taskplane 0.5.12 β†’ 0.6.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.
@@ -73,6 +73,26 @@ function tokenSummaryFromLaneState(ls) {
73
73
  return s;
74
74
  }
75
75
 
76
+ /** Build compact telemetry badge HTML for retry/compaction indicators.
77
+ * Only shows badges when telemetry data has meaningful values.
78
+ * @param {object|null} tel - Telemetry data for a lane (from currentData.telemetry[prefix])
79
+ * @returns {string} HTML string with badges, or "" if nothing to show
80
+ */
81
+ function telemetryBadgesHtml(tel) {
82
+ if (!tel) return "";
83
+ let badges = "";
84
+ if (tel.retryActive) {
85
+ const err = tel.lastRetryError ? ` β€” ${tel.lastRetryError}` : "";
86
+ badges += `<span class="telem-badge telem-retry-active" title="Retry in progress${escapeHtml(err)}">πŸ”„ retrying</span>`;
87
+ } else if (tel.retries > 0) {
88
+ badges += `<span class="telem-badge telem-retry" title="${tel.retries} auto-retry event(s)">πŸ”„ ${tel.retries}</span>`;
89
+ }
90
+ if (tel.compactions > 0) {
91
+ badges += `<span class="telem-badge telem-compaction" title="${tel.compactions} context compaction(s)">πŸ—œ ${tel.compactions}</span>`;
92
+ }
93
+ return badges;
94
+ }
95
+
76
96
  // ─── Copy to Clipboard ──────────────────────────────────────────────────────
77
97
 
78
98
  let toastEl = null;
@@ -342,14 +362,19 @@ function renderSummary(batch) {
342
362
 
343
363
  // Aggregate tokens across all active lane states
344
364
  const laneStates = currentData?.laneStates || {};
345
- let batchInput = 0, batchOutput = 0, batchCacheRead = 0, batchCacheWrite = 0, batchCost = 0;
365
+ let batchInput = 0, batchOutput = 0, batchCacheRead = 0, batchCacheWrite = 0, batchCostFromLanes = 0;
346
366
  for (const ls of Object.values(laneStates)) {
347
367
  batchInput += ls.workerInputTokens || 0;
348
368
  batchOutput += ls.workerOutputTokens || 0;
349
369
  batchCacheRead += ls.workerCacheReadTokens || 0;
350
370
  batchCacheWrite += ls.workerCacheWriteTokens || 0;
351
- batchCost += ls.workerCostUsd || 0;
371
+ batchCostFromLanes += ls.workerCostUsd || 0;
352
372
  }
373
+ // Use server-computed batchTotalCost (includes telemetry for uncovered lanes);
374
+ // fallback to lane-state-only sum for backward compatibility (pre-telemetry server)
375
+ const batchCost = (currentData?.batchTotalCost != null && currentData.batchTotalCost > 0)
376
+ ? currentData.batchTotalCost
377
+ : batchCostFromLanes;
353
378
  const batchTotalIn = batchInput + batchCacheRead;
354
379
  if (batchTotalIn > 0 || batchOutput > 0) {
355
380
  let tokenStr = ` Β· tokens: ↑${formatTokens(batchTotalIn)} ↓${formatTokens(batchOutput)}`;
@@ -386,6 +411,7 @@ function renderLanesTasks(batch, tmuxSessions) {
386
411
  const tasks = batch.tasks || [];
387
412
  const tmuxSet = new Set(tmuxSessions || []);
388
413
  const laneStates = currentData?.laneStates || {};
414
+ const telemetry = currentData?.telemetry || {};
389
415
  const showRepos = knownRepos.length >= 2;
390
416
  let html = "";
391
417
 
@@ -432,8 +458,9 @@ function renderLanesTasks(batch, tmuxSessions) {
432
458
  html += `<div class="task-row"><span class="task-icon"></span><span style="color:var(--text-faint);grid-column:2/-1;">No tasks assigned</span></div>`;
433
459
  }
434
460
 
435
- // Get lane state for worker stats
461
+ // Get lane state and telemetry for worker stats
436
462
  const ls = laneStates[lane.tmuxSessionName] || null;
463
+ const tel = telemetry[lane.tmuxSessionName] || null;
437
464
 
438
465
  for (const task of laneTasks) {
439
466
  // Repo filtering at task level
@@ -486,8 +513,9 @@ function renderLanesTasks(batch, tmuxSessions) {
486
513
  stepHtml = `<span style="color:var(--text-faint)">${escapeHtml(task.exitReason || "β€”")}</span>`;
487
514
  }
488
515
 
489
- // Worker stats from lane state sidecar β€” only show for the active (running) task
516
+ // Worker stats from lane state sidecar + telemetry badges
490
517
  let workerHtml = "";
518
+ const telemBadges = task.status !== "pending" ? telemetryBadgesHtml(tel) : "";
491
519
  if (ls && ls.workerStatus === "running" && task.status === "running") {
492
520
  const elapsed = ls.workerElapsed ? `${Math.round(ls.workerElapsed / 1000)}s` : "";
493
521
  const tools = ls.workerToolCount || 0;
@@ -500,11 +528,22 @@ function renderLanesTasks(batch, tmuxSessions) {
500
528
  if (ctx) workerHtml += `<span class="worker-stat" title="Context window used">πŸ“Š ${ctx}</span>`;
501
529
  if (tokenStr) workerHtml += `<span class="worker-stat" title="Tokens: input↑ output↓ cacheRead(R) cacheWrite(W)">πŸͺ™ ${tokenStr}</span>`;
502
530
  if (lastTool) workerHtml += `<span class="worker-stat worker-last-tool" title="Last tool call">${escapeHtml(lastTool)}</span>`;
531
+ workerHtml += telemBadges;
532
+ workerHtml += `</div>`;
533
+ } else if (!ls && tel && task.status === "running") {
534
+ // Running task with telemetry but no lane-state yet (early startup)
535
+ const lastTool = tel.lastTool || "";
536
+ workerHtml = `<div class="worker-stats">`;
537
+ if (lastTool) workerHtml += `<span class="worker-stat worker-last-tool" title="Last tool call">${escapeHtml(lastTool)}</span>`;
538
+ workerHtml += telemBadges;
503
539
  workerHtml += `</div>`;
504
540
  } else if (ls && ls.workerStatus === "done" && task.status !== "pending") {
505
- workerHtml = `<div class="worker-stats"><span class="worker-stat" style="color:var(--green)">βœ“ Worker done</span></div>`;
541
+ workerHtml = `<div class="worker-stats"><span class="worker-stat" style="color:var(--green)">βœ“ Worker done</span>${telemBadges}</div>`;
506
542
  } else if (ls && ls.workerStatus === "error" && task.status !== "pending") {
507
- workerHtml = `<div class="worker-stats"><span class="worker-stat" style="color:var(--red)">βœ— Worker error</span></div>`;
543
+ workerHtml = `<div class="worker-stats"><span class="worker-stat" style="color:var(--red)">βœ— Worker error</span>${telemBadges}</div>`;
544
+ } else if (telemBadges && task.status !== "pending") {
545
+ // No lane-state but telemetry exists (done/error lane without sidecar)
546
+ workerHtml = `<div class="worker-stats">${telemBadges}</div>`;
508
547
  }
509
548
 
510
549
  const isViewingStatus = viewerMode === 'status-md' && viewerTarget === task.taskId;
@@ -631,6 +631,37 @@ body {
631
631
  text-overflow: ellipsis;
632
632
  }
633
633
 
634
+ /* ─── Telemetry Badges (retry, compaction) ─────────────────────────────── */
635
+
636
+ .telem-badge {
637
+ display: inline-flex;
638
+ align-items: center;
639
+ gap: 3px;
640
+ font-family: var(--font-mono);
641
+ font-size: 0.65rem;
642
+ font-weight: 500;
643
+ padding: 1px 7px;
644
+ border-radius: 8px;
645
+ white-space: nowrap;
646
+ margin-left: 4px;
647
+ }
648
+
649
+ .telem-retry {
650
+ background: rgba(210,153,34,0.15);
651
+ color: var(--yellow);
652
+ }
653
+
654
+ .telem-retry-active {
655
+ background: rgba(210,153,34,0.25);
656
+ color: var(--yellow);
657
+ animation: pulse 1.5s infinite;
658
+ }
659
+
660
+ .telem-compaction {
661
+ background: rgba(188,140,255,0.15);
662
+ color: var(--magenta);
663
+ }
664
+
634
665
  /* ─── Conversation Viewer ──────────────────────────────────────────────── */
635
666
 
636
667
  .conv-stream {
@@ -213,14 +213,337 @@ function loadLaneStates() {
213
213
  return states;
214
214
  }
215
215
 
216
+ // ─── Telemetry JSONL Tailing ────────────────────────────────────────────────
217
+
218
+ /**
219
+ * Module-level tail state for incremental JSONL reading.
220
+ * Persists across poll ticks within this server process.
221
+ * Key: absolute file path β†’ { offset, partial }
222
+ */
223
+ const telemetryTailStates = new Map();
224
+
225
+ /**
226
+ * Module-level accumulated telemetry per tmux prefix.
227
+ * Persists across poll ticks so incremental tail reads accumulate correctly.
228
+ * Key: tmux prefix β†’ { inputTokens, outputTokens, ... }
229
+ */
230
+ const telemetryAccumulators = new Map();
231
+
232
+ /**
233
+ * Tracks which files are currently contributing to each prefix.
234
+ * Key: tmux prefix β†’ Set of absolute file paths
235
+ * Used to detect file rotation: when files change, accumulator is reset.
236
+ */
237
+ const telemetryPrefixFiles = new Map();
238
+
239
+ /**
240
+ * Parse a telemetry JSONL filename to extract lane number and role.
241
+ * Pattern: {opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}.jsonl
242
+ * Returns { laneNumber: number|null, role: string } or null if unparseable.
243
+ */
244
+ function parseTelemetryFilename(filename) {
245
+ // Remove .jsonl extension
246
+ const base = filename.replace(/\.jsonl$/, "");
247
+ // Role is always the last segment
248
+ const lastDash = base.lastIndexOf("-");
249
+ if (lastDash < 0) return null;
250
+ const role = base.slice(lastDash + 1);
251
+ if (role !== "worker" && role !== "reviewer") return null;
252
+
253
+ // Extract lane number from -lane-{N}- pattern
254
+ const laneMatch = base.match(/-lane-(\d+)-/);
255
+ const laneNumber = laneMatch ? parseInt(laneMatch[1], 10) : null;
256
+
257
+ return { laneNumber, role };
258
+ }
259
+
260
+ /**
261
+ * Incrementally read new bytes from a JSONL file, parse events, and return them.
262
+ * Handles: file not yet created, empty reads, partial trailing lines, malformed JSON.
263
+ * @param {string} filePath - Absolute path to the JSONL file
264
+ * @returns {object[]} Array of parsed event objects from new data
265
+ */
266
+ function tailJsonlFile(filePath) {
267
+ // Get or create tail state for this file
268
+ let tailState = telemetryTailStates.get(filePath);
269
+ if (!tailState) {
270
+ tailState = { offset: 0, partial: "" };
271
+ telemetryTailStates.set(filePath, tailState);
272
+ }
273
+
274
+ // Check file size
275
+ let fileSize;
276
+ try {
277
+ fileSize = fs.statSync(filePath).size;
278
+ } catch {
279
+ return []; // File doesn't exist yet
280
+ }
281
+
282
+ // Handle file truncation/recreation (offset beyond current size)
283
+ if (fileSize < tailState.offset) {
284
+ tailState.offset = 0;
285
+ tailState.partial = "";
286
+ tailState.wasReset = true; // Signal to caller that accumulator should be reset
287
+ }
288
+
289
+ if (fileSize <= tailState.offset) {
290
+ return []; // No new data
291
+ }
292
+
293
+ // Read new bytes from offset to end of file
294
+ const bytesToRead = fileSize - tailState.offset;
295
+ const buf = Buffer.alloc(bytesToRead);
296
+ let fd;
297
+ try {
298
+ fd = fs.openSync(filePath, "r");
299
+ } catch {
300
+ return []; // File became inaccessible
301
+ }
302
+ try {
303
+ fs.readSync(fd, buf, 0, bytesToRead, tailState.offset);
304
+ } catch {
305
+ fs.closeSync(fd);
306
+ return []; // Read error β€” try again next tick
307
+ }
308
+ fs.closeSync(fd);
309
+ tailState.offset = fileSize;
310
+
311
+ // Split into lines, preserving partial trailing line
312
+ const chunk = tailState.partial + buf.toString("utf-8");
313
+ const lines = chunk.split("\n");
314
+ tailState.partial = lines.pop() || "";
315
+
316
+ const events = [];
317
+ for (const line of lines) {
318
+ const trimmed = line.trim();
319
+ if (!trimmed) continue;
320
+ try {
321
+ const event = JSON.parse(trimmed);
322
+ if (event && event.type) events.push(event);
323
+ } catch {
324
+ // Malformed JSON β€” skip (concurrent write race, truncated line)
325
+ }
326
+ }
327
+ return events;
328
+ }
329
+
330
+ /**
331
+ * Load and accumulate telemetry from .pi/telemetry/*.jsonl files.
332
+ * Returns telemetry keyed by tmux session prefix (e.g., "orch-lane-1").
333
+ *
334
+ * Uses batch-state lanes to map lane numbers β†’ tmux prefixes.
335
+ * For standalone /task mode (no lane number in filename), data is keyed as "standalone".
336
+ *
337
+ * @param {object|null} batchState - The batch state from batch-state.json
338
+ * @returns {object} Map of tmuxPrefix β†’ accumulated telemetry
339
+ */
340
+ function loadTelemetryData(batchState) {
341
+ const telemetryDir = path.join(REPO_ROOT, ".pi", "telemetry");
342
+ const result = {};
343
+
344
+ // Build lane number β†’ tmux prefix mapping from batch state
345
+ const laneToPrefix = {};
346
+ if (batchState && batchState.lanes) {
347
+ for (const lane of batchState.lanes) {
348
+ if (lane.laneNumber != null && lane.tmuxSessionName) {
349
+ laneToPrefix[lane.laneNumber] = lane.tmuxSessionName;
350
+ }
351
+ }
352
+ }
353
+
354
+ // Scan telemetry directory for JSONL files
355
+ let files;
356
+ try {
357
+ files = fs.readdirSync(telemetryDir).filter(f => f.endsWith(".jsonl"));
358
+ } catch {
359
+ // .pi/telemetry/ may not exist (pre-RPC sessions) β€” degrade gracefully
360
+ return result;
361
+ }
362
+
363
+ // Track which files still exist for tail-state cleanup
364
+ const currentFiles = new Set();
365
+ // Track current file→prefix mapping to detect file rotation
366
+ const currentPrefixFiles = new Map(); // prefix β†’ Set<filePath>
367
+
368
+ for (const file of files) {
369
+ const filePath = path.join(telemetryDir, file);
370
+ currentFiles.add(filePath);
371
+
372
+ // Parse filename to get lane number and role
373
+ const parsed = parseTelemetryFilename(file);
374
+ if (!parsed) continue;
375
+
376
+ // Determine the key (tmux prefix)
377
+ let prefix;
378
+ if (parsed.laneNumber != null && laneToPrefix[parsed.laneNumber]) {
379
+ prefix = laneToPrefix[parsed.laneNumber];
380
+ } else if (parsed.laneNumber != null) {
381
+ // Lane number found but no batch-state mapping β€” use heuristic
382
+ prefix = `orch-lane-${parsed.laneNumber}`;
383
+ } else {
384
+ // Standalone /task mode
385
+ prefix = "standalone";
386
+ }
387
+
388
+ // Track file→prefix mapping
389
+ if (!currentPrefixFiles.has(prefix)) currentPrefixFiles.set(prefix, new Set());
390
+ currentPrefixFiles.get(prefix).add(filePath);
391
+
392
+ // Check if file set for this prefix has changed (file rotation)
393
+ const prevFiles = telemetryPrefixFiles.get(prefix);
394
+ const isNewFile = !prevFiles || !prevFiles.has(filePath);
395
+
396
+ // Initialize persistent accumulator for this prefix if needed,
397
+ // or reset if files changed (new file appeared for same prefix)
398
+ if (!telemetryAccumulators.has(prefix) || (isNewFile && !telemetryTailStates.has(filePath))) {
399
+ const fresh = {
400
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0,
401
+ cacheWriteTokens: 0, cost: 0, toolCalls: 0,
402
+ lastTool: "", retries: 0, retryActive: false,
403
+ lastRetryError: "", compactions: 0, latestTotalTokens: 0,
404
+ };
405
+ telemetryAccumulators.set(prefix, fresh);
406
+ // Also reset tail states for ALL files of this prefix to re-read from beginning
407
+ if (prevFiles) {
408
+ for (const pf of prevFiles) {
409
+ telemetryTailStates.delete(pf);
410
+ }
411
+ }
412
+ }
413
+
414
+ const acc = telemetryAccumulators.get(prefix);
415
+ result[prefix] = acc; // expose the persistent accumulator in the result
416
+
417
+ // Tail the file for new events
418
+ const events = tailJsonlFile(filePath);
419
+
420
+ // Check if file was truncated β€” reset accumulator
421
+ const ts = telemetryTailStates.get(filePath);
422
+ if (ts && ts.wasReset) {
423
+ acc.inputTokens = 0; acc.outputTokens = 0; acc.cacheReadTokens = 0;
424
+ acc.cacheWriteTokens = 0; acc.cost = 0; acc.toolCalls = 0;
425
+ acc.lastTool = ""; acc.retries = 0; acc.retryActive = false;
426
+ acc.lastRetryError = ""; acc.compactions = 0; acc.latestTotalTokens = 0;
427
+ ts.wasReset = false;
428
+ }
429
+ for (const event of events) {
430
+ switch (event.type) {
431
+ case "message_end": {
432
+ const usage = event.message?.usage;
433
+ if (usage) {
434
+ acc.inputTokens += usage.input || 0;
435
+ acc.outputTokens += usage.output || 0;
436
+ acc.cacheReadTokens += usage.cacheRead || 0;
437
+ acc.cacheWriteTokens += usage.cacheWrite || 0;
438
+ if (usage.cost) {
439
+ acc.cost += typeof usage.cost === "object"
440
+ ? (usage.cost.total || 0)
441
+ : (typeof usage.cost === "number" ? usage.cost : 0);
442
+ }
443
+ const totalTokens = usage.totalTokens
444
+ || ((usage.input || 0) + (usage.output || 0));
445
+ if (totalTokens > acc.latestTotalTokens) {
446
+ acc.latestTotalTokens = totalTokens;
447
+ }
448
+ }
449
+ break;
450
+ }
451
+ case "tool_execution_start": {
452
+ acc.toolCalls++;
453
+ const toolDesc = event.toolName || "unknown";
454
+ let argPreview = "";
455
+ if (event.args) {
456
+ if (typeof event.args === "string") {
457
+ argPreview = event.args.slice(0, 80);
458
+ } else if (typeof event.args === "object") {
459
+ const firstVal = Object.values(event.args)[0];
460
+ if (typeof firstVal === "string") {
461
+ argPreview = firstVal.slice(0, 80);
462
+ }
463
+ }
464
+ }
465
+ acc.lastTool = argPreview ? `${toolDesc} ${argPreview}` : toolDesc;
466
+ break;
467
+ }
468
+ case "auto_retry_start": {
469
+ acc.retries++;
470
+ acc.retryActive = true;
471
+ acc.lastRetryError = event.errorMessage || event.error || "unknown";
472
+ break;
473
+ }
474
+ case "auto_retry_end": {
475
+ acc.retryActive = false;
476
+ break;
477
+ }
478
+ case "auto_compaction_start": {
479
+ acc.compactions++;
480
+ break;
481
+ }
482
+ }
483
+ }
484
+ }
485
+
486
+ // Clean up tail states for files that no longer exist
487
+ for (const [filePath] of telemetryTailStates) {
488
+ if (filePath.startsWith(telemetryDir) && !currentFiles.has(filePath)) {
489
+ telemetryTailStates.delete(filePath);
490
+ }
491
+ }
492
+
493
+ // Update prefix→files tracking for next call
494
+ // Clean up accumulators and tracking for prefixes that have no remaining files
495
+ const activePrefixes = new Set(Object.keys(result));
496
+ for (const [prefix] of telemetryAccumulators) {
497
+ if (!activePrefixes.has(prefix)) {
498
+ telemetryAccumulators.delete(prefix);
499
+ telemetryPrefixFiles.delete(prefix);
500
+ }
501
+ }
502
+ // Store current file mappings for next call's rotation detection
503
+ for (const [prefix, fileSet] of currentPrefixFiles) {
504
+ telemetryPrefixFiles.set(prefix, fileSet);
505
+ }
506
+
507
+ return result;
508
+ }
509
+
510
+ /**
511
+ * Compute batch total cost from lane states (primary) and telemetry (supplementary).
512
+ * Lane states are authoritative β€” telemetry provides additional data only for lanes
513
+ * that have no lane-state entry (e.g., very early in session startup).
514
+ */
515
+ function computeBatchTotalCost(laneStates, telemetry) {
516
+ let totalCost = 0;
517
+ const coveredPrefixes = new Set();
518
+
519
+ // Primary: sum cost from lane states
520
+ for (const [prefix, ls] of Object.entries(laneStates)) {
521
+ if (ls.workerCostUsd) {
522
+ totalCost += ls.workerCostUsd;
523
+ coveredPrefixes.add(prefix);
524
+ }
525
+ }
526
+
527
+ // Supplementary: add cost from telemetry for uncovered lanes only
528
+ for (const [prefix, tel] of Object.entries(telemetry)) {
529
+ if (!coveredPrefixes.has(prefix) && tel.cost > 0) {
530
+ totalCost += tel.cost;
531
+ }
532
+ }
533
+
534
+ return totalCost;
535
+ }
536
+
216
537
  /** Build full dashboard state object for the frontend. */
217
538
  function buildDashboardState() {
218
539
  const state = loadBatchState();
219
540
  const tmuxSessions = getTmuxSessions();
220
541
  const laneStates = loadLaneStates();
542
+ const telemetry = loadTelemetryData(state);
543
+ const batchTotalCost = computeBatchTotalCost(laneStates, telemetry);
221
544
 
222
545
  if (!state) {
223
- return { batch: null, tmuxSessions, laneStates: {}, timestamp: Date.now() };
546
+ return { batch: null, tmuxSessions, laneStates: {}, telemetry: {}, batchTotalCost: 0, timestamp: Date.now() };
224
547
  }
225
548
 
226
549
  const tasks = (state.tasks || []).map((task) => {
@@ -237,6 +560,8 @@ function buildDashboardState() {
237
560
 
238
561
  return {
239
562
  laneStates,
563
+ telemetry,
564
+ batchTotalCost,
240
565
  batch: {
241
566
  batchId: state.batchId,
242
567
  phase: state.phase,