taskplane 0.24.1 → 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,
@@ -283,6 +283,7 @@ export async function executeTaskV2(
283
283
 
284
284
  // Context pressure: write wrap-up signal before kill
285
285
  let workerKillReason: "context" | "timer" | null = null;
286
+ let iterationTelemetry: Partial<AgentHostResult> = {};
286
287
 
287
288
  const spawned = spawnAgent(hostOpts, undefined, (telemetry) => {
288
289
  // Context pressure check
@@ -298,12 +299,25 @@ export async function executeTaskV2(
298
299
  }
299
300
  }
300
301
 
302
+ iterationTelemetry = telemetry;
301
303
  lastTelemetry = telemetry;
302
304
  // Emit lane snapshot
303
305
  emitSnapshot(config, taskId, "running", telemetry, statusPath);
304
306
  });
305
307
 
306
- const workerResult = await spawned.promise;
308
+ // Reviewer telemetry is written by the worker bridge during review_step.
309
+ // Poll snapshot refresh independently from worker message_end cadence so
310
+ // the dashboard sees reviewer activity while tool calls are in-flight.
311
+ const reviewerRefresh = setInterval(() => {
312
+ emitSnapshot(config, taskId, "running", iterationTelemetry, statusPath);
313
+ }, 1000);
314
+
315
+ let workerResult: AgentHostResult;
316
+ try {
317
+ workerResult = await spawned.promise;
318
+ } finally {
319
+ clearInterval(reviewerRefresh);
320
+ }
307
321
 
308
322
  // TP-115: Update lastTelemetry with definitive final values from AgentHostResult
309
323
  lastTelemetry = workerResult;
@@ -538,6 +552,59 @@ function makeResult(
538
552
  return result;
539
553
  }
540
554
 
555
+ /** Max age for reviewer state file before it's considered stale (2 minutes). */
556
+ const REVIEWER_STATE_STALE_MS = 120_000;
557
+
558
+ export function readReviewerTelemetrySnapshot(
559
+ config: LaneRunnerConfig,
560
+ statusPath: string,
561
+ ): (RuntimeAgentTelemetrySnapshot & { reviewType?: string; reviewStep?: number }) | null {
562
+ const reviewerPath = join(dirname(statusPath), ".reviewer-state.json");
563
+ if (!existsSync(reviewerPath)) return null;
564
+
565
+ try {
566
+ const raw = readFileSync(reviewerPath, "utf-8");
567
+ const parsed = JSON.parse(raw) as Partial<{
568
+ status: string;
569
+ elapsedMs: number;
570
+ toolCalls: number;
571
+ contextPct: number;
572
+ costUsd: number;
573
+ lastTool: string;
574
+ inputTokens: number;
575
+ outputTokens: number;
576
+ cacheReadTokens: number;
577
+ cacheWriteTokens: number;
578
+ updatedAt: number;
579
+ reviewType: string;
580
+ reviewStep: number;
581
+ }>;
582
+
583
+ if (parsed.status !== "running") return null;
584
+
585
+ // Stale guard: if updatedAt is present and older than threshold, ignore
586
+ if (parsed.updatedAt && (Date.now() - parsed.updatedAt) > REVIEWER_STATE_STALE_MS) return null;
587
+
588
+ return {
589
+ agentId: buildRuntimeAgentId(config.agentIdPrefix, config.laneNumber, "reviewer"),
590
+ status: "running",
591
+ elapsedMs: Number.isFinite(parsed.elapsedMs) ? Number(parsed.elapsedMs) : 0,
592
+ toolCalls: Number.isFinite(parsed.toolCalls) ? Number(parsed.toolCalls) : 0,
593
+ contextPct: Number.isFinite(parsed.contextPct) ? Number(parsed.contextPct) : 0,
594
+ costUsd: Number.isFinite(parsed.costUsd) ? Number(parsed.costUsd) : 0,
595
+ lastTool: typeof parsed.lastTool === "string" ? parsed.lastTool : "",
596
+ inputTokens: Number.isFinite(parsed.inputTokens) ? Number(parsed.inputTokens) : 0,
597
+ outputTokens: Number.isFinite(parsed.outputTokens) ? Number(parsed.outputTokens) : 0,
598
+ cacheReadTokens: Number.isFinite(parsed.cacheReadTokens) ? Number(parsed.cacheReadTokens) : 0,
599
+ cacheWriteTokens: Number.isFinite(parsed.cacheWriteTokens) ? Number(parsed.cacheWriteTokens) : 0,
600
+ reviewType: typeof parsed.reviewType === "string" ? parsed.reviewType : undefined,
601
+ reviewStep: Number.isFinite(parsed.reviewStep) ? Number(parsed.reviewStep) : undefined,
602
+ };
603
+ } catch {
604
+ return null;
605
+ }
606
+ }
607
+
541
608
  function emitSnapshot(
542
609
  config: LaneRunnerConfig,
543
610
  taskId: string,
@@ -562,6 +629,8 @@ function emitSnapshot(
562
629
  };
563
630
  } catch { /* best effort */ }
564
631
 
632
+ const reviewerSnapshot = readReviewerTelemetrySnapshot(config, statusPath);
633
+
565
634
  const snapshot: RuntimeLaneSnapshot = {
566
635
  batchId: config.batchId,
567
636
  laneNumber: config.laneNumber,
@@ -583,7 +652,7 @@ function emitSnapshot(
583
652
  cacheReadTokens: telemetry.cacheReadTokens ?? 0,
584
653
  cacheWriteTokens: telemetry.cacheWriteTokens ?? 0,
585
654
  },
586
- reviewer: null,
655
+ reviewer: reviewerSnapshot,
587
656
  progress,
588
657
  updatedAt: Date.now(),
589
658
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.24.1",
3
+ "version": "0.24.2",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",