taskplane 0.22.12 → 0.22.14

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,7 +28,7 @@
28
28
  */
29
29
 
30
30
  import { spawn } from "node:child_process";
31
- import { readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync, renameSync } from "node:fs";
31
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync, renameSync, unlinkSync } from "node:fs";
32
32
  import { dirname, resolve, join, basename } from "node:path";
33
33
  import { StringDecoder } from "node:string_decoder";
34
34
 
@@ -46,6 +46,7 @@ function parseArgs(argv) {
46
46
  passthrough: [],
47
47
  help: false,
48
48
  mailboxDir: null,
49
+ steeringPendingPath: null,
49
50
  };
50
51
 
51
52
  let i = 2; // skip "node" and script path
@@ -78,6 +79,9 @@ function parseArgs(argv) {
78
79
  } else if (arg === "--mailbox-dir" && i + 1 < argv.length) {
79
80
  args.mailboxDir = argv[++i];
80
81
  i++;
82
+ } else if (arg === "--steering-pending-path" && i + 1 < argv.length) {
83
+ args.steeringPendingPath = argv[++i];
84
+ i++;
81
85
  } else if (arg === "--") {
82
86
  args.passthrough = argv.slice(i + 1);
83
87
  break;
@@ -108,6 +112,7 @@ Optional:
108
112
  --tools <t1,t2,...> Comma-separated tool names
109
113
  --extensions <e1,e2,...> Comma-separated extension paths
110
114
  --mailbox-dir <path> Mailbox directory for agent steering (TP-089)
115
+ --steering-pending-path <p> Path to .steering-pending JSONL flag file (TP-090)
111
116
  -h, --help Show this help
112
117
  `
113
118
  );
@@ -509,9 +514,10 @@ const MAILBOX_MESSAGE_TYPES = new Set(["steer", "query", "abort", "info", "reply
509
514
  *
510
515
  * @param {string} mailboxDir - Session mailbox directory (e.g., .pi/mailbox/{batchId}/{session})
511
516
  * @param {object} proc - The spawned pi process (must have writable stdin)
517
+ * @param {string|null} steeringPendingPath - Path to .steering-pending JSONL flag file (TP-090, worker-only)
512
518
  * @returns {{ delivered: number, skipped: number }} Delivery stats
513
519
  */
514
- function checkMailboxAndSteer(mailboxDir, proc) {
520
+ function checkMailboxAndSteer(mailboxDir, proc, steeringPendingPath) {
515
521
  const stats = { delivered: 0, skipped: 0 };
516
522
 
517
523
  // Derive expected values from path structure:
@@ -616,6 +622,17 @@ function checkMailboxAndSteer(mailboxDir, proc) {
616
622
 
617
623
  stats.delivered++;
618
624
  process.stderr.write(`\n[STEERING] Delivered message ${message.id}\n`);
625
+
626
+ // TP-090: Append to .steering-pending JSONL flag for task-runner STATUS.md annotation.
627
+ // Worker-only: steeringPendingPath is only set for worker sessions.
628
+ if (steeringPendingPath) {
629
+ try {
630
+ const entry = JSON.stringify({ ts: message.timestamp, content: message.content, id: message.id }) + "\n";
631
+ appendFileSync(steeringPendingPath, entry, "utf-8");
632
+ } catch (err) {
633
+ process.stderr.write(`\n[STEERING] WARNING: failed to write .steering-pending: ${err.message}\n`);
634
+ }
635
+ }
619
636
  } catch (err) {
620
637
  process.stderr.write(`\n[STEERING] WARNING: failed to deliver ${filename}: ${err.message}\n`);
621
638
  stats.skipped++;
@@ -759,6 +776,29 @@ const proc = spawn("pi", piArgs, {
759
776
  shell: true, // Required for Windows: resolves pi.cmd shim. Matches task-runner.ts pattern.
760
777
  });
761
778
 
779
+ // ── TP-097: Write PID file for orphan cleanup ──────────────────
780
+ // Write both the wrapper PID and the pi child PID alongside the sidecar file.
781
+ // The task-runner reads this on session end to kill orphan processes.
782
+ // Format: JSON with wrapperPid and childPid fields.
783
+ const pidFilePath = args.sidecarPath + ".pid";
784
+ try {
785
+ const pidData = {
786
+ wrapperPid: process.pid,
787
+ childPid: proc.pid ?? null,
788
+ startedAt: Date.now(),
789
+ };
790
+ writeFileSync(pidFilePath, JSON.stringify(pidData) + "\n", "utf-8");
791
+ process.stderr.write(`[rpc-wrapper] PID file written: ${pidFilePath} (wrapper=${process.pid}, child=${proc.pid})\n`);
792
+ } catch (err) {
793
+ process.stderr.write(`[rpc-wrapper] WARNING: failed to write PID file: ${err.message}\n`);
794
+ }
795
+
796
+ // Clean up PID file on process exit (best-effort)
797
+ function cleanupPidFile() {
798
+ try { unlinkSync(pidFilePath); } catch { /* ignore */ }
799
+ }
800
+ process.on("exit", cleanupPidFile);
801
+
762
802
  // ── Send prompt via JSONL stdin ──────────────────────────────────────
763
803
 
764
804
  const promptCmd = { type: "prompt", message: promptContent };
@@ -852,7 +892,7 @@ function handleEvent(event) {
852
892
  // Only active when --mailbox-dir is provided (backward compatible).
853
893
  if (args.mailboxDir) {
854
894
  try {
855
- checkMailboxAndSteer(args.mailboxDir, proc);
895
+ checkMailboxAndSteer(args.mailboxDir, proc, args.steeringPendingPath || null);
856
896
  } catch (err) {
857
897
  // Never crash on mailbox I/O errors
858
898
  process.stderr.write(`\n[STEERING] ERROR: ${err.message}\n`);
@@ -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": {