taskplane 0.22.11 → 0.22.13

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.
@@ -28,8 +28,8 @@
28
28
  */
29
29
 
30
30
  import { spawn } from "node:child_process";
31
- import { readFileSync, writeFileSync, appendFileSync, mkdirSync } from "node:fs";
32
- import { dirname, resolve } from "node:path";
31
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync, renameSync } from "node:fs";
32
+ import { dirname, resolve, join, basename } from "node:path";
33
33
  import { StringDecoder } from "node:string_decoder";
34
34
 
35
35
  // ── CLI Argument Parsing ─────────────────────────────────────────────
@@ -45,6 +45,7 @@ function parseArgs(argv) {
45
45
  extensions: [],
46
46
  passthrough: [],
47
47
  help: false,
48
+ mailboxDir: null,
48
49
  };
49
50
 
50
51
  let i = 2; // skip "node" and script path
@@ -74,6 +75,9 @@ function parseArgs(argv) {
74
75
  } else if (arg === "--extensions" && i + 1 < argv.length) {
75
76
  args.extensions = argv[++i].split(",").map((e) => e.trim()).filter(Boolean);
76
77
  i++;
78
+ } else if (arg === "--mailbox-dir" && i + 1 < argv.length) {
79
+ args.mailboxDir = argv[++i];
80
+ i++;
77
81
  } else if (arg === "--") {
78
82
  args.passthrough = argv.slice(i + 1);
79
83
  break;
@@ -103,6 +107,7 @@ Optional:
103
107
  --system-prompt-file <path> Path to system prompt file
104
108
  --tools <t1,t2,...> Comma-separated tool names
105
109
  --extensions <e1,e2,...> Comma-separated extension paths
110
+ --mailbox-dir <path> Mailbox directory for agent steering (TP-089)
106
111
  -h, --help Show this help
107
112
  `
108
113
  );
@@ -487,6 +492,160 @@ function createSingleWriteGuard(writer) {
487
492
  };
488
493
  }
489
494
 
495
+ // ── Agent Mailbox Check (TP-089) ─────────────────────────────────────
496
+
497
+ /**
498
+ * Valid mailbox message types (must match MailboxMessageType in types.ts).
499
+ */
500
+ const MAILBOX_MESSAGE_TYPES = new Set(["steer", "query", "abort", "info", "reply", "escalate"]);
501
+
502
+ /**
503
+ * Check the agent's mailbox inbox for pending messages and inject them
504
+ * into the pi process via the `steer` RPC command.
505
+ *
506
+ * Called on every `message_end` event when `--mailbox-dir` is provided.
507
+ * Messages are validated (batchId, to, shape), sorted deterministically,
508
+ * injected via steer, and moved from inbox/ to ack/.
509
+ *
510
+ * @param {string} mailboxDir - Session mailbox directory (e.g., .pi/mailbox/{batchId}/{session})
511
+ * @param {object} proc - The spawned pi process (must have writable stdin)
512
+ * @returns {{ delivered: number, skipped: number }} Delivery stats
513
+ */
514
+ function checkMailboxAndSteer(mailboxDir, proc) {
515
+ const stats = { delivered: 0, skipped: 0 };
516
+
517
+ // Derive expected values from path structure:
518
+ // mailboxDir = .pi/mailbox/{batchId}/{sessionName}
519
+ const expectedSessionName = basename(mailboxDir);
520
+ const expectedBatchId = basename(dirname(mailboxDir));
521
+
522
+ const inboxDir = join(mailboxDir, "inbox");
523
+
524
+ // Read inbox — ENOENT is quiet no-op (inbox may not exist yet)
525
+ let entries;
526
+ try {
527
+ entries = readdirSync(inboxDir);
528
+ } catch (err) {
529
+ if (err.code === "ENOENT") return stats;
530
+ process.stderr.write(`\n[STEERING] WARNING: failed to read inbox: ${err.message}\n`);
531
+ return stats;
532
+ }
533
+
534
+ // Filter: only *.msg.json files (excludes .msg.json.tmp temp files)
535
+ const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp"));
536
+ if (msgFiles.length === 0) return stats;
537
+
538
+ // Read and validate all messages
539
+ const validMessages = [];
540
+
541
+ for (const filename of msgFiles) {
542
+ const filePath = join(inboxDir, filename);
543
+ let raw;
544
+ try {
545
+ raw = readFileSync(filePath, "utf-8");
546
+ } catch (err) {
547
+ process.stderr.write(`\n[STEERING] WARNING: failed to read ${filename}: ${err.message}\n`);
548
+ stats.skipped++;
549
+ continue;
550
+ }
551
+
552
+ let msg;
553
+ try {
554
+ msg = JSON.parse(raw);
555
+ } catch {
556
+ process.stderr.write(`\n[STEERING] WARNING: malformed JSON in ${filename}, skipping\n`);
557
+ stats.skipped++;
558
+ continue;
559
+ }
560
+
561
+ // Validate shape
562
+ if (!isValidMailboxMessageShape(msg)) {
563
+ process.stderr.write(`\n[STEERING] WARNING: invalid message shape in ${filename}, skipping\n`);
564
+ stats.skipped++;
565
+ continue;
566
+ }
567
+
568
+ // Validate batchId (derived from path, not message content)
569
+ if (msg.batchId !== expectedBatchId) {
570
+ process.stderr.write(`\n[STEERING] WARNING: batchId mismatch in ${filename} (expected ${expectedBatchId}, got ${msg.batchId}), skipping\n`);
571
+ stats.skipped++;
572
+ continue;
573
+ }
574
+
575
+ // Validate to (no misdelivery)
576
+ if (msg.to !== expectedSessionName) {
577
+ process.stderr.write(`\n[STEERING] WARNING: misdelivery in ${filename} (to=${msg.to}, expected ${expectedSessionName}), skipping\n`);
578
+ stats.skipped++;
579
+ continue;
580
+ }
581
+
582
+ validMessages.push({ filename, message: msg });
583
+ }
584
+
585
+ // Sort: primary by timestamp ascending, tie-break by filename lexical
586
+ validMessages.sort((a, b) => {
587
+ const tsDiff = a.message.timestamp - b.message.timestamp;
588
+ if (tsDiff !== 0) return tsDiff;
589
+ return a.filename.localeCompare(b.filename);
590
+ });
591
+
592
+ // Inject each message via steer RPC command and move to ack/
593
+ for (const { filename, message } of validMessages) {
594
+ try {
595
+ // Precondition: stdin must be available for injection.
596
+ // If stdin is closed/destroyed, keep message in inbox (no false ack).
597
+ if (!proc.stdin || proc.stdin.destroyed) {
598
+ stats.skipped++;
599
+ continue;
600
+ }
601
+
602
+ // Inject via steer RPC command
603
+ proc.stdin.write(JSON.stringify({ type: "steer", message: message.content }) + "\n");
604
+
605
+ // Move to ack/ (delivery proof)
606
+ const ackDir = join(mailboxDir, "ack");
607
+ try { mkdirSync(ackDir, { recursive: true }); } catch { /* exists */ }
608
+ try {
609
+ renameSync(join(inboxDir, filename), join(ackDir, filename));
610
+ } catch (err) {
611
+ // ENOENT race is harmless (another process acked it)
612
+ if (err.code !== "ENOENT") {
613
+ process.stderr.write(`\n[STEERING] WARNING: failed to ack ${filename}: ${err.message}\n`);
614
+ }
615
+ }
616
+
617
+ stats.delivered++;
618
+ process.stderr.write(`\n[STEERING] Delivered message ${message.id}\n`);
619
+ } catch (err) {
620
+ process.stderr.write(`\n[STEERING] WARNING: failed to deliver ${filename}: ${err.message}\n`);
621
+ stats.skipped++;
622
+ }
623
+ }
624
+
625
+ return stats;
626
+ }
627
+
628
+ /**
629
+ * Runtime validation for mailbox message shape in rpc-wrapper.
630
+ * Mirrors isValidMailboxMessage() from mailbox.ts but as a standalone
631
+ * function (rpc-wrapper.mjs is a plain .mjs module, not TypeScript).
632
+ *
633
+ * @param {any} obj - Parsed JSON value
634
+ * @returns {boolean} true if valid shape
635
+ */
636
+ function isValidMailboxMessageShape(obj) {
637
+ if (!obj || typeof obj !== "object") return false;
638
+ return (
639
+ typeof obj.id === "string" &&
640
+ typeof obj.batchId === "string" &&
641
+ typeof obj.from === "string" &&
642
+ typeof obj.to === "string" &&
643
+ typeof obj.timestamp === "number" && Number.isFinite(obj.timestamp) &&
644
+ typeof obj.type === "string" && MAILBOX_MESSAGE_TYPES.has(obj.type) &&
645
+ typeof obj.content === "string"
646
+ );
647
+ }
648
+
490
649
  // ── Exports for Testing ──────────────────────────────────────────────
491
650
 
492
651
  // Export pure functions so tests can import them without triggering side effects.
@@ -503,6 +662,9 @@ export {
503
662
  applyEvent,
504
663
  buildExitSummary,
505
664
  createSingleWriteGuard,
665
+ checkMailboxAndSteer,
666
+ isValidMailboxMessageShape,
667
+ MAILBOX_MESSAGE_TYPES,
506
668
  };
507
669
 
508
670
  // ── Main ─────────────────────────────────────────────────────────────
@@ -602,6 +764,15 @@ const proc = spawn("pi", piArgs, {
602
764
  const promptCmd = { type: "prompt", message: promptContent };
603
765
  proc.stdin.write(JSON.stringify(promptCmd) + "\n");
604
766
 
767
+ // ── Agent Mailbox Steering Setup (TP-089) ────────────────────────────
768
+ // When mailbox-dir is provided, set steering mode to "all" so queued
769
+ // steering messages are delivered together at the next turn boundary.
770
+ // Must be sent after prompt but before any agent processing begins.
771
+ if (args.mailboxDir) {
772
+ proc.stdin.write(JSON.stringify({ type: "set_steering_mode", mode: "all" }) + "\n");
773
+ process.stderr.write(`[rpc-wrapper] mailbox enabled: ${args.mailboxDir}\n`);
774
+ }
775
+
605
776
  // ── Stdin Lifecycle ──────────────────────────────────────────────────
606
777
 
607
778
  /**
@@ -677,6 +848,16 @@ function handleEvent(event) {
677
848
  // Falls back gracefully: older pi versions ignore the command
678
849
  // or return a response without contextUsage — state.contextUsage stays null.
679
850
  querySessionStats();
851
+ // Check mailbox for pending steering messages (TP-089).
852
+ // Only active when --mailbox-dir is provided (backward compatible).
853
+ if (args.mailboxDir) {
854
+ try {
855
+ checkMailboxAndSteer(args.mailboxDir, proc);
856
+ } catch (err) {
857
+ // Never crash on mailbox I/O errors
858
+ process.stderr.write(`\n[STEERING] ERROR: ${err.message}\n`);
859
+ }
860
+ }
680
861
  break;
681
862
 
682
863
  case "tool_execution_start":
@@ -621,6 +621,58 @@ function renderLanesTasks(batch, tmuxSessions) {
621
621
 
622
622
  // ─── Render: Merge Agents ───────────────────────────────────────────────────
623
623
 
624
+ /** Build full telemetry HTML for a merge agent (parity with worker stats).
625
+ * Shows: elapsed, tool count, context %, cost, current tool, retry/compaction badges.
626
+ * Returns empty string if no meaningful telemetry exists.
627
+ */
628
+ function mergeTelemetryHtml(tel, alive) {
629
+ if (!tel) return '<span class="merge-no-data">—</span>';
630
+ const hasData = (tel.inputTokens || 0) > 0 || (tel.outputTokens || 0) > 0 ||
631
+ (tel.toolCalls || 0) > 0 || (tel.cost || 0) > 0;
632
+ if (!hasData) return '<span class="merge-no-data">—</span>';
633
+
634
+ let html = '<div class="merge-stats">';
635
+
636
+ // Elapsed time
637
+ if (tel.startedAt) {
638
+ const elapsed = Date.now() - tel.startedAt;
639
+ html += `<span class="worker-stat" title="Merge elapsed">⏱ ${formatDuration(elapsed)}</span>`;
640
+ }
641
+
642
+ // Tool calls
643
+ if (tel.toolCalls > 0) {
644
+ html += `<span class="worker-stat" title="Tool calls">🔧 ${tel.toolCalls}</span>`;
645
+ }
646
+
647
+ // Context %
648
+ if (tel.contextPct > 0) {
649
+ html += `<span class="worker-stat" title="Context window used">📊 ${Math.round(tel.contextPct)}%</span>`;
650
+ }
651
+
652
+ // Tokens + cost
653
+ const inp = (tel.inputTokens || 0) + (tel.cacheReadTokens || 0);
654
+ const out = tel.outputTokens || 0;
655
+ const cost = tel.cost || 0;
656
+ if (inp > 0 || out > 0) {
657
+ let tokenStr = `↑${formatTokens(inp)} ↓${formatTokens(out)}`;
658
+ if (cost > 0) tokenStr += ` ${formatCost(cost)}`;
659
+ html += `<span class="worker-stat" title="Tokens">🪙 ${tokenStr}</span>`;
660
+ }
661
+
662
+ // Current tool (if alive/active) or last tool (completed merges)
663
+ if (alive && tel.currentTool) {
664
+ html += `<span class="worker-stat worker-last-tool" title="Current tool">${escapeHtml(tel.currentTool)}</span>`;
665
+ } else if (!alive && tel.lastTool) {
666
+ html += `<span class="worker-stat worker-last-tool" title="Last tool">${escapeHtml(tel.lastTool)}</span>`;
667
+ }
668
+
669
+ // Retry/compaction badges (reuse shared helper)
670
+ html += telemetryBadgesHtml(tel);
671
+
672
+ html += '</div>';
673
+ return html;
674
+ }
675
+
624
676
  function renderMergeAgents(batch, tmuxSessions) {
625
677
  const mergeResults = batch?.mergeResults || [];
626
678
  const tmuxSet = new Set(tmuxSessions || []);
@@ -642,8 +694,8 @@ function renderMergeAgents(batch, tmuxSessions) {
642
694
  mergePrefix = laneMatch[1] + "-merge";
643
695
  }
644
696
  }
645
- // Helper: get merge session name for a lane number
646
- const getMergeSessionName = (laneNum) => `${mergePrefix}-${laneNum}`;
697
+ // Helper: get merge session name for a merge number
698
+ const getMergeSessionName = (mergeNum) => `${mergePrefix}-${mergeNum}`;
647
699
 
648
700
  if (mergeResults.length === 0 && mergeSessions.length === 0) {
649
701
  $mergeBody.innerHTML = '<div class="empty-state">No merge agents active</div>';
@@ -671,49 +723,64 @@ function renderMergeAgents(batch, tmuxSessions) {
671
723
  : mr.status === "partial" ? "status-stalled"
672
724
  : "status-failed";
673
725
 
674
- // Look for matching tmux merge sessions for this wave result.
675
- // Merge sessions follow the naming pattern: {prefix}-{opId}-merge-{laneNumber}
676
- // (e.g., "orch-henrylach-merge-1"). Find any alive merge sessions.
677
- const waveMergeSessions = mergeSessions.filter(s => tmuxSet.has(s));
678
- const sessionName = waveMergeSessions.length > 0 ? waveMergeSessions[0] : null;
679
- const alive = sessionName !== null;
680
- if (alive) shownSessions.add(sessionName);
726
+ // Merge session mapping: derive from lane numbers involved in this wave.
727
+ // Merge sessions are named by lane number (e.g., ...-merge-1), not wave index.
728
+ // Extract lane numbers from repoResults or from batch tasks for this wave.
729
+ const waveLaneNums = new Set();
730
+ const repoResults2 = mr.repoResults || [];
731
+ for (const rr of repoResults2) {
732
+ for (const ln of (rr.laneNumbers || [])) waveLaneNums.add(ln);
733
+ }
734
+ // Fallback: find lane numbers from tasks assigned to this wave
735
+ if (waveLaneNums.size === 0 && batch.wavePlan && batch.wavePlan[mr.waveIndex]) {
736
+ const waveTaskIds = new Set(batch.wavePlan[mr.waveIndex]);
737
+ for (const t of (batch.tasks || [])) {
738
+ if (waveTaskIds.has(t.taskId) && t.laneNumber != null) {
739
+ waveLaneNums.add(t.laneNumber);
740
+ }
741
+ }
742
+ }
743
+ // Find alive merge sessions matching the wave's lane numbers
744
+ let effectiveSession = null;
745
+ for (const ln of waveLaneNums) {
746
+ const candidate = getMergeSessionName(ln);
747
+ if (tmuxSet.has(candidate) && !shownSessions.has(candidate)) {
748
+ effectiveSession = candidate;
749
+ break;
750
+ }
751
+ }
752
+ // Fallback: any unshown alive merge session
753
+ if (!effectiveSession) {
754
+ effectiveSession = mergeSessions.find(s => tmuxSet.has(s) && !shownSessions.has(s)) || null;
755
+ }
756
+ const effectiveAlive = !!effectiveSession;
757
+ if (effectiveSession) shownSessions.add(effectiveSession);
681
758
 
682
- // Look for merge telemetry data check all merge sessions
759
+ // Find merge telemetry: try sessions by lane number first
683
760
  let mergeTel = null;
684
- for (const ms of mergeSessions) {
685
- if (telemetry[ms]) { mergeTel = telemetry[ms]; break; }
761
+ for (const ln of waveLaneNums) {
762
+ const candidate = getMergeSessionName(ln);
763
+ if (telemetry[candidate]) { mergeTel = telemetry[candidate]; break; }
686
764
  }
765
+ // Fallback: effective session telemetry or any merge session
766
+ if (!mergeTel && effectiveSession) mergeTel = telemetry[effectiveSession] || null;
767
+ if (!mergeTel) mergeTel = mergeSessions.reduce((found, ms) => found || telemetry[ms] || null, null);
687
768
 
688
769
  html += `<tr>`;
689
- html += `<td style="font-family:var(--font-mono);">Wave ${mr.waveIndex + 1}</td>`;
770
+ html += `<td class="merge-wave-cell">Wave ${mr.waveIndex + 1}</td>`;
690
771
  html += `<td><span class="status-badge ${statusCls}">${mr.status}</span></td>`;
691
- html += `<td style="font-family:var(--font-mono);font-size:0.8rem;">${alive ? escapeHtml(sessionName) : "—"}</td>`;
692
- // Telemetry cell
693
- html += `<td style="font-size:0.75rem;">`;
694
- if (mergeTel) {
695
- const totalTok = (mergeTel.inputTokens || 0) + (mergeTel.outputTokens || 0);
696
- const cost = mergeTel.cost || 0;
697
- if (totalTok > 0 || cost > 0) {
698
- html += `<span style="color:var(--text-muted);">${totalTok > 0 ? totalTok.toLocaleString() + " tok" : ""}`;
699
- if (cost > 0) html += ` · $${cost.toFixed(4)}`;
700
- html += `</span>`;
701
- } else {
702
- html += '<span style="color:var(--text-faint);">—</span>';
703
- }
704
- } else {
705
- html += '<span style="color:var(--text-faint);">—</span>';
706
- }
707
- html += `</td>`;
772
+ html += `<td class="merge-session-cell">${effectiveAlive ? escapeHtml(effectiveSession) : "—"}</td>`;
773
+ // Full telemetry cell
774
+ html += `<td class="merge-telemetry-cell">${mergeTelemetryHtml(mergeTel, effectiveAlive)}</td>`;
708
775
  html += `<td>`;
709
- if (alive) {
710
- const cmd = `tmux attach -t ${sessionName}`;
711
- html += `<span class="tmux-cmd" data-tmux="${escapeHtml(sessionName)}" onclick="copyTmuxCmd('${escapeHtml(sessionName)}')" title="Click to copy">${escapeHtml(cmd)}</span>`;
776
+ if (effectiveAlive) {
777
+ const cmd = `tmux attach -t ${effectiveSession}`;
778
+ html += `<span class="tmux-cmd" data-tmux="${escapeHtml(effectiveSession)}" onclick="copyTmuxCmd('${escapeHtml(effectiveSession)}')" title="Click to copy">${escapeHtml(cmd)}</span>`;
712
779
  } else {
713
- html += '<span style="color:var(--text-faint);">—</span>';
780
+ html += '<span class="merge-no-data">—</span>';
714
781
  }
715
782
  html += `</td>`;
716
- html += `<td style="font-size:0.8rem;color:var(--text-muted);">${mr.failureReason ? escapeHtml(mr.failureReason) : "—"}</td>`;
783
+ html += `<td class="merge-detail-cell">${mr.failureReason ? escapeHtml(mr.failureReason) : "—"}</td>`;
717
784
  html += `</tr>`;
718
785
 
719
786
  // Per-repo sub-rows: show when workspace mode has repo results
@@ -732,10 +799,10 @@ function renderMergeAgents(batch, tmuxSessions) {
732
799
  html += `<tr class="merge-repo-row">`;
733
800
  html += `<td>${repoBadgeHtml(rr.repoId)}</td>`;
734
801
  html += `<td><span class="status-badge ${rrStatusCls}">${rr.status}</span></td>`;
735
- html += `<td style="font-family:var(--font-mono);font-size:0.75rem;color:var(--text-faint);">${rrLanes}</td>`;
802
+ html += `<td class="merge-session-cell">${rrLanes}</td>`;
736
803
  html += `<td></td>`; /* telemetry placeholder */
737
804
  html += `<td></td>`; /* attach placeholder */
738
- html += `<td style="font-size:0.75rem;color:var(--text-faint);">${rrDetail}</td>`;
805
+ html += `<td class="merge-detail-cell">${rrDetail}</td>`;
739
806
  html += `</tr>`;
740
807
  }
741
808
  }
@@ -748,25 +815,11 @@ function renderMergeAgents(batch, tmuxSessions) {
748
815
  const sessTel = telemetry[sess] || null;
749
816
  const cmd = `tmux attach -t ${sess}`;
750
817
  html += `<tr>`;
751
- html += `<td style="font-family:var(--font-mono);">—</td>`;
818
+ html += `<td class="merge-wave-cell">—</td>`;
752
819
  html += `<td><span class="status-badge status-running"><span class="status-dot running"></span> merging</span></td>`;
753
- html += `<td style="font-family:var(--font-mono);font-size:0.8rem;">${escapeHtml(sess)}</td>`;
754
- // Telemetry cell for active merge session
755
- html += `<td style="font-size:0.75rem;">`;
756
- if (sessTel) {
757
- const totalTok = (sessTel.inputTokens || 0) + (sessTel.outputTokens || 0);
758
- const cost = sessTel.cost || 0;
759
- if (totalTok > 0 || cost > 0) {
760
- html += `<span style="color:var(--text-muted);">${totalTok > 0 ? totalTok.toLocaleString() + " tok" : ""}`;
761
- if (cost > 0) html += ` · $${cost.toFixed(4)}`;
762
- html += `</span>`;
763
- } else {
764
- html += '<span style="color:var(--text-faint);">—</span>';
765
- }
766
- } else {
767
- html += '<span style="color:var(--text-faint);">—</span>';
768
- }
769
- html += `</td>`;
820
+ html += `<td class="merge-session-cell">${escapeHtml(sess)}</td>`;
821
+ // Full telemetry cell for active merge session
822
+ html += `<td class="merge-telemetry-cell">${mergeTelemetryHtml(sessTel, true)}</td>`;
770
823
  html += `<td><span class="tmux-cmd" data-tmux="${escapeHtml(sess)}" onclick="copyTmuxCmd('${escapeHtml(sess)}')" title="Click to copy">${escapeHtml(cmd)}</span></td>`;
771
824
  html += `<td>—</td>`;
772
825
  html += `</tr>`;
@@ -712,6 +712,24 @@ body {
712
712
  .merge-table tr:last-child td { border-bottom: none; }
713
713
  .merge-table tr:hover td { background: var(--bg-surface-hover); }
714
714
 
715
+ .merge-wave-cell { font-family: var(--font-mono); }
716
+ .merge-session-cell { font-family: var(--font-mono); font-size: 0.8rem; }
717
+ .merge-detail-cell { font-size: 0.8rem; color: var(--text-muted); }
718
+ .merge-no-data { color: var(--text-faint); }
719
+
720
+ .merge-stats {
721
+ display: flex;
722
+ flex-wrap: wrap;
723
+ gap: 4px 8px;
724
+ align-items: center;
725
+ font-size: 0.75rem;
726
+ }
727
+
728
+ .merge-telemetry-cell {
729
+ font-size: 0.75rem;
730
+ min-width: 120px;
731
+ }
732
+
715
733
  /* ─── Terminal Panel ────────────────────────────────────────────────────── */
716
734
 
717
735
  .terminal-panel .panel-header {
@@ -427,8 +427,9 @@ function loadTelemetryData(batchState) {
427
427
  const fresh = {
428
428
  inputTokens: 0, outputTokens: 0, cacheReadTokens: 0,
429
429
  cacheWriteTokens: 0, cost: 0, toolCalls: 0,
430
- lastTool: "", retries: 0, retryActive: false,
430
+ lastTool: "", currentTool: "", retries: 0, retryActive: false,
431
431
  lastRetryError: "", compactions: 0, latestTotalTokens: 0,
432
+ contextPct: 0, startedAt: 0,
432
433
  };
433
434
  telemetryAccumulators.set(prefix, fresh);
434
435
  // Also reset tail states for ALL files of this prefix to re-read from beginning
@@ -450,8 +451,9 @@ function loadTelemetryData(batchState) {
450
451
  if (ts && ts.wasReset) {
451
452
  acc.inputTokens = 0; acc.outputTokens = 0; acc.cacheReadTokens = 0;
452
453
  acc.cacheWriteTokens = 0; acc.cost = 0; acc.toolCalls = 0;
453
- acc.lastTool = ""; acc.retries = 0; acc.retryActive = false;
454
+ acc.lastTool = ""; acc.currentTool = ""; acc.retries = 0; acc.retryActive = false;
454
455
  acc.lastRetryError = ""; acc.compactions = 0; acc.latestTotalTokens = 0;
456
+ acc.contextPct = 0; acc.startedAt = 0;
455
457
  ts.wasReset = false;
456
458
  }
457
459
  for (const event of events) {
@@ -497,7 +499,31 @@ function loadTelemetryData(batchState) {
497
499
  }
498
500
  }
499
501
  }
500
- acc.lastTool = argPreview ? `${toolDesc} ${argPreview}` : toolDesc;
502
+ const toolLabel = argPreview ? `${toolDesc} ${argPreview}` : toolDesc;
503
+ acc.lastTool = toolLabel;
504
+ acc.currentTool = toolLabel;
505
+ break;
506
+ }
507
+ case "tool_execution_end": {
508
+ acc.currentTool = "";
509
+ break;
510
+ }
511
+ case "agent_start": {
512
+ if (event.ts && !acc.startedAt) {
513
+ acc.startedAt = event.ts;
514
+ }
515
+ break;
516
+ }
517
+ case "response": {
518
+ // Extract context usage from get_session_stats responses
519
+ const ctxUsage = event.data?.contextUsage;
520
+ if (ctxUsage) {
521
+ // Support both percent (current) and percentUsed (legacy pi versions)
522
+ const pct = typeof ctxUsage.percent === "number" ? ctxUsage.percent
523
+ : typeof ctxUsage.percentUsed === "number" ? ctxUsage.percentUsed
524
+ : null;
525
+ if (pct !== null) acc.contextPct = pct;
526
+ }
501
527
  break;
502
528
  }
503
529
  case "auto_retry_start": {