taskplane 0.24.5 → 0.24.6

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.
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Spawns pi in RPC mode, sends a prompt, captures RPC events to a sidecar JSONL
7
7
  * file, and writes a final exit summary JSON on process exit. Displays minimal
8
- * live progress on stderr for tmux pane visibility.
8
+ * live progress on stderr for dashboard/session visibility.
9
9
  *
10
10
  * Usage:
11
11
  * node bin/rpc-wrapper.mjs \
@@ -271,7 +271,7 @@ function writeSidecarEvent(sidecarPath, event) {
271
271
  // ── Progress Display ─────────────────────────────────────────────────
272
272
 
273
273
  /**
274
- * Display minimal progress on stderr for tmux pane visibility.
274
+ * Display minimal progress on stderr for dashboard/session visibility.
275
275
  */
276
276
  function displayProgress(state) {
277
277
  const parts = [];
@@ -774,8 +774,8 @@ piArgs.push(...args.passthrough);
774
774
  // Windows CreateProcess has a ~32K command line limit. Orchestrated worker
775
775
  // system prompts routinely exceed this (PROMPT.md + context docs + steps).
776
776
  // When the system prompt is large, write it to a temp file and use shell
777
- // expansion `$(cat file)` to pass it. This works in MSYS2/Git Bash tmux
778
- // (where the lane sessions run) without hitting the Win32 limit.
777
+ // expansion `$(cat file)` to pass it. This works in MSYS2/Git Bash shells
778
+ // used by lane sessions without hitting the Win32 limit.
779
779
  //
780
780
  // For small system prompts (< 8K), pass inline for simplicity.
781
781
  const SYSTEM_PROMPT_FILE_THRESHOLD = 8192;
@@ -126,6 +126,20 @@ function tokenSummaryFromLaneState(ls) {
126
126
  return s;
127
127
  }
128
128
 
129
+ function tokenSummaryFromReviewerLaneState(ls) {
130
+ if (!ls) return "";
131
+ const inp = ls.reviewerInputTokens || 0;
132
+ const out = ls.reviewerOutputTokens || 0;
133
+ const cr = ls.reviewerCacheReadTokens || 0;
134
+ const cw = ls.reviewerCacheWriteTokens || 0;
135
+ const cost = ls.reviewerCostUsd || 0;
136
+ const totalIn = inp + cr; // uncached + cached = total input processed
137
+ if (totalIn === 0 && out === 0) return "";
138
+ let s = `↑${formatTokens(totalIn)} ↓${formatTokens(out)}`;
139
+ if (cost > 0) s += ` ${formatCost(cost)}`;
140
+ return s;
141
+ }
142
+
129
143
  /** Build compact telemetry badge HTML for retry/compaction indicators.
130
144
  * Only shows badges when telemetry data has meaningful values.
131
145
  * @param {object|null} tel - Telemetry data for a lane (from currentData.telemetry[prefix])
@@ -311,10 +325,10 @@ $repoFilter.addEventListener("change", (e) => {
311
325
  // Re-render with current data
312
326
  if (currentData) {
313
327
  const batch = currentData.batch;
314
- const tmux = currentData.tmuxSessions || [];
328
+ const sessions = currentData.sessions ?? currentData.tmuxSessions ?? [];
315
329
  if (batch) {
316
- renderLanesTasks(batch, tmux);
317
- renderMergeAgents(batch, tmux);
330
+ renderLanesTasks(batch, sessions);
331
+ renderMergeAgents(batch, sessions);
318
332
  }
319
333
  }
320
334
  });
@@ -496,14 +510,14 @@ function renderSummary(batch) {
496
510
 
497
511
  // ─── Render: Lanes + Tasks (integrated) ─────────────────────────────────────
498
512
 
499
- function renderLanesTasks(batch, tmuxSessions) {
513
+ function renderLanesTasks(batch, sessions) {
500
514
  if (!batch || !batch.lanes || batch.lanes.length === 0) {
501
515
  $lanesTasksBody.innerHTML = '<div class="empty-state">No lanes</div>';
502
516
  return;
503
517
  }
504
518
 
505
519
  const tasks = batch.tasks || [];
506
- const tmuxSet = new Set(tmuxSessions || []);
520
+ const sessionSet = new Set(sessions || []);
507
521
  const laneStates = currentData?.laneStates || {};
508
522
  const telemetry = currentData?.telemetry || {};
509
523
  // TP-107: V2 lane snapshots take precedence over legacy lane states when present
@@ -520,10 +534,10 @@ function renderLanesTasks(batch, tmuxSessions) {
520
534
  if (!laneMatchesRepo) continue;
521
535
  }
522
536
 
523
- // TP-107: check V2 registry for liveness first, fall back to tmux
537
+ // TP-107: check Runtime V2 registry for liveness first, fall back to session list
524
538
  const laneSessionId = lane.laneSessionId;
525
539
  const v2Alive = isLaneAliveV2(lane.laneNumber);
526
- const alive = v2Alive !== null ? v2Alive : tmuxSet.has(laneSessionId);
540
+ const alive = v2Alive !== null ? v2Alive : sessionSet.has(laneSessionId);
527
541
  const sessionChip = `session: ${laneSessionId}`;
528
542
 
529
543
  // Lane header
@@ -538,14 +552,14 @@ function renderLanesTasks(batch, tmuxSessions) {
538
552
  }
539
553
  html += ` </div>`;
540
554
  html += ` <div class="lane-right">`;
541
- html += ` <span class="tmux-dot ${alive ? "alive" : "dead"}" title="${alive ? "session alive" : "session not active"}"></span>`;
555
+ html += ` <span class="session-dot ${alive ? "alive" : "dead"}" title="${alive ? "session alive" : "session not active"}"></span>`;
542
556
  // View button: shows conversation stream when available
543
557
  const isViewingConv = viewerMode === 'conversation' && viewerTarget === laneSessionId;
544
- html += ` <button class="tmux-view-btn${isViewingConv ? ' active' : ''}" onclick="viewConversation('${escapeHtml(laneSessionId)}')" title="View worker conversation">👁 View</button>`;
558
+ html += ` <button class="session-view-btn${isViewingConv ? ' active' : ''}" onclick="viewConversation('${escapeHtml(laneSessionId)}')" title="View worker conversation">👁 View</button>`;
545
559
  if (alive) {
546
- html += ` <span class="tmux-cmd" data-session="${escapeHtml(laneSessionId)}" onclick="copySessionId('${escapeHtml(laneSessionId)}')" title="Copy session ID">${escapeHtml(sessionChip)}</span>`;
560
+ html += ` <span class="session-cmd" data-session="${escapeHtml(laneSessionId)}" onclick="copySessionId('${escapeHtml(laneSessionId)}')" title="Copy session ID">${escapeHtml(sessionChip)}</span>`;
547
561
  } else {
548
- html += ` <span class="tmux-cmd dead-session">${escapeHtml(sessionChip)}</span>`;
562
+ html += ` <span class="session-cmd dead-session">${escapeHtml(sessionChip)}</span>`;
549
563
  }
550
564
  html += ` </div>`;
551
565
  html += `</div>`;
@@ -659,7 +673,7 @@ function renderLanesTasks(batch, tmuxSessions) {
659
673
  const rTools = ls.reviewerToolCount || 0;
660
674
  const rCtx = ls.reviewerContextPct ? `${Math.round(ls.reviewerContextPct)}%` : "";
661
675
  const rLastTool = ls.reviewerLastTool || "";
662
- const rCost = ls.reviewerCostUsd ? `$${ls.reviewerCostUsd.toFixed(2)}` : "";
676
+ const rTokenStr = tokenSummaryFromReviewerLaneState(ls);
663
677
  const rType = ls.reviewerType || "review";
664
678
  const rStep = ls.reviewerStep || "?";
665
679
  reviewerRowHtml = `
@@ -668,13 +682,14 @@ function renderLanesTasks(batch, tmuxSessions) {
668
682
  <span class="task-actions"></span>
669
683
  <span class="reviewer-label">📋 Reviewer</span>
670
684
  <span class="reviewer-type">${escapeHtml(rType)} · Step ${rStep}</span>
671
- <span class="task-duration">${rElapsed}</span>
685
+ <span class="task-duration"></span>
672
686
  <span></span>
673
687
  <span class="task-step">
674
688
  <div class="worker-stats reviewer-stats">
689
+ <span class="worker-stat" title="Reviewer elapsed">⏱ ${rElapsed}</span>
675
690
  <span class="worker-stat" title="Reviewer tool calls">🔧 ${rTools}</span>
676
691
  ${rCtx ? `<span class="worker-stat" title="Reviewer context used">📊 ${rCtx}</span>` : ""}
677
- ${rCost ? `<span class="worker-stat" title="Reviewer cost">${rCost}</span>` : ""}
692
+ ${rTokenStr ? `<span class="worker-stat" title="Reviewer tokens: input↑ output↓ cacheRead(R) cacheWrite(W)">🪙 ${rTokenStr}</span>` : ""}
678
693
  ${rLastTool ? `<span class="worker-stat worker-last-tool" title="Reviewer last tool">${escapeHtml(rLastTool)}</span>` : ""}
679
694
  </div>
680
695
  </span>
@@ -759,14 +774,14 @@ function mergeTelemetryHtml(tel, alive) {
759
774
  return html;
760
775
  }
761
776
 
762
- function renderMergeAgents(batch, tmuxSessions) {
777
+ function renderMergeAgents(batch, sessions) {
763
778
  const mergeResults = batch?.mergeResults || [];
764
- const tmuxSet = new Set(tmuxSessions || []);
779
+ const sessionSet = new Set(sessions || []);
765
780
  const showRepos = knownRepos.length >= 2;
766
781
  const telemetry = currentData?.telemetry || {};
767
782
 
768
783
  // Check for active merge sessions (convention: {prefix}-{opId}-merge-{N})
769
- const mergeSessions = (tmuxSessions || []).filter(s => s.includes("-merge-"));
784
+ const mergeSessions = (sessions || []).filter(s => s.includes("-merge-"));
770
785
 
771
786
  // Derive merge session name from lane session naming pattern.
772
787
  // Lane sessions: "{prefix}-{opId}-lane-{N}", merge sessions: "{prefix}-{opId}-merge-{N}".
@@ -830,14 +845,14 @@ function renderMergeAgents(batch, tmuxSessions) {
830
845
  let effectiveSession = null;
831
846
  for (const ln of waveLaneNums) {
832
847
  const candidate = getMergeSessionName(ln);
833
- if (tmuxSet.has(candidate) && !shownSessions.has(candidate)) {
848
+ if (sessionSet.has(candidate) && !shownSessions.has(candidate)) {
834
849
  effectiveSession = candidate;
835
850
  break;
836
851
  }
837
852
  }
838
853
  // Fallback: any unshown alive merge session
839
854
  if (!effectiveSession) {
840
- effectiveSession = mergeSessions.find(s => tmuxSet.has(s) && !shownSessions.has(s)) || null;
855
+ effectiveSession = mergeSessions.find(s => sessionSet.has(s) && !shownSessions.has(s)) || null;
841
856
  }
842
857
  const effectiveAlive = !!effectiveSession;
843
858
  if (effectiveSession) shownSessions.add(effectiveSession);
@@ -861,7 +876,7 @@ function renderMergeAgents(batch, tmuxSessions) {
861
876
  html += `<td>`;
862
877
  if (effectiveAlive) {
863
878
  const sessionChip = `session: ${effectiveSession}`;
864
- html += `<span class="tmux-cmd" data-session="${escapeHtml(effectiveSession)}" onclick="copySessionId('${escapeHtml(effectiveSession)}')" title="Copy session ID">${escapeHtml(sessionChip)}</span>`;
879
+ html += `<span class="session-cmd" data-session="${escapeHtml(effectiveSession)}" onclick="copySessionId('${escapeHtml(effectiveSession)}')" title="Copy session ID">${escapeHtml(sessionChip)}</span>`;
865
880
  } else {
866
881
  html += '<span class="merge-no-data">—</span>';
867
882
  }
@@ -906,7 +921,7 @@ function renderMergeAgents(batch, tmuxSessions) {
906
921
  html += `<td class="merge-session-cell">${escapeHtml(sess)}</td>`;
907
922
  // Full telemetry cell for active merge session
908
923
  html += `<td class="merge-telemetry-cell">${mergeTelemetryHtml(sessTel, true)}</td>`;
909
- html += `<td><span class="tmux-cmd" data-session="${escapeHtml(sess)}" onclick="copySessionId('${escapeHtml(sess)}')" title="Copy session ID">${escapeHtml(sessionChip)}</span></td>`;
924
+ html += `<td><span class="session-cmd" data-session="${escapeHtml(sess)}" onclick="copySessionId('${escapeHtml(sess)}')" title="Copy session ID">${escapeHtml(sessionChip)}</span></td>`;
910
925
  html += `<td>—</td>`;
911
926
  html += `</tr>`;
912
927
  }
@@ -1374,7 +1389,7 @@ let currentData = null;
1374
1389
  function render(data) {
1375
1390
  currentData = data;
1376
1391
  const batch = data.batch;
1377
- const tmux = data.tmuxSessions || [];
1392
+ const sessions = data.sessions ?? data.tmuxSessions ?? [];
1378
1393
 
1379
1394
  $lastUpdate.textContent = new Date().toLocaleTimeString();
1380
1395
 
@@ -1408,8 +1423,8 @@ function render(data) {
1408
1423
  updateRepoFilter(repos);
1409
1424
 
1410
1425
  renderSupervisor(data);
1411
- renderLanesTasks(batch, tmux);
1412
- renderMergeAgents(batch, tmux);
1426
+ renderLanesTasks(batch, sessions);
1427
+ renderMergeAgents(batch, sessions);
1413
1428
  // TP-107: Runtime V2 panels
1414
1429
  renderAgentsPanel(data.runtimeRegistry);
1415
1430
  renderMessagesPanel(data.mailbox);
@@ -1477,7 +1492,7 @@ let lastStatusMdText = "";
1477
1492
  // ── Open conversation viewer (TP-107: V2 events preferred, legacy fallback) ──
1478
1493
 
1479
1494
  /**
1480
- * Resolve a lane's tmux session name to a Runtime V2 agent ID via the registry.
1495
+ * Resolve a lane session ID to a Runtime V2 agent ID via the registry.
1481
1496
  * Returns null if no V2 registry data is available.
1482
1497
  */
1483
1498
  function resolveV2AgentId(sessionName) {
@@ -1485,7 +1500,7 @@ function resolveV2AgentId(sessionName) {
1485
1500
  const agents = currentData.runtimeRegistry.agents;
1486
1501
  // Direct match on agentId
1487
1502
  if (agents[sessionName]) return sessionName;
1488
- // Match by tmux session prefix + "-worker" suffix (common V2 naming)
1503
+ // Match by session ID prefix + "-worker" suffix (common V2 naming)
1489
1504
  const workerKey = sessionName + '-worker';
1490
1505
  if (agents[workerKey]) return workerKey;
1491
1506
  // Search by laneNumber match from lane snapshots
@@ -134,8 +134,8 @@ body {
134
134
  .status-badge,
135
135
  .count-chip,
136
136
  .wave-chip,
137
- .tmux-view-btn,
138
- .tmux-cmd,
137
+ .session-view-btn,
138
+ .session-cmd,
139
139
  .repo-filter-select,
140
140
  .history-select,
141
141
  .task-row,
@@ -513,17 +513,17 @@ body {
513
513
  gap: 10px;
514
514
  }
515
515
 
516
- .tmux-dot {
516
+ .session-dot {
517
517
  width: 8px;
518
518
  height: 8px;
519
519
  border-radius: 50%;
520
520
  flex-shrink: 0;
521
521
  }
522
522
 
523
- .tmux-dot.alive { background: var(--green); }
524
- .tmux-dot.dead { background: var(--red); opacity: 0.6; }
523
+ .session-dot.alive { background: var(--green); }
524
+ .session-dot.dead { background: var(--red); opacity: 0.6; }
525
525
 
526
- .tmux-view-btn {
526
+ .session-view-btn {
527
527
  font-family: var(--font-sans);
528
528
  font-size: 0.72rem;
529
529
  font-weight: 600;
@@ -537,12 +537,12 @@ body {
537
537
  transition: background 0.2s, border-color 0.2s;
538
538
  }
539
539
 
540
- .tmux-view-btn:hover {
540
+ .session-view-btn:hover {
541
541
  background: var(--accent);
542
542
  border-color: var(--accent);
543
543
  }
544
544
 
545
- .tmux-cmd {
545
+ .session-cmd {
546
546
  font-family: var(--font-mono);
547
547
  font-size: 0.7rem;
548
548
  padding: 3px 8px;
@@ -556,17 +556,17 @@ body {
556
556
  transition: border-color 0.2s, color 0.2s;
557
557
  }
558
558
 
559
- .tmux-cmd:hover {
559
+ .session-cmd:hover {
560
560
  border-color: var(--accent-dim);
561
561
  color: var(--accent);
562
562
  }
563
563
 
564
- .tmux-cmd.copied {
564
+ .session-cmd.copied {
565
565
  border-color: var(--green-dim);
566
566
  color: var(--green);
567
567
  }
568
568
 
569
- .tmux-cmd.dead-session {
569
+ .session-cmd.dead-session {
570
570
  color: var(--text-muted);
571
571
  cursor: default;
572
572
  }
@@ -945,7 +945,7 @@ body {
945
945
  box-shadow: 0 0 0 1px var(--accent-dim);
946
946
  }
947
947
 
948
- .tmux-view-btn.active {
948
+ .session-view-btn.active {
949
949
  background: var(--accent);
950
950
  border-color: var(--accent);
951
951
  }
@@ -75,6 +75,8 @@ function normalizeBatchStateIngress(state) {
75
75
 
76
76
  for (const lane of state.lanes) {
77
77
  if (!lane || typeof lane !== "object") continue;
78
+ // Legacy compatibility: older persisted states stored lane session IDs under
79
+ // `tmuxSessionName`. Normalize to `laneSessionId` at ingress and drop legacy key.
78
80
  const laneSessionId = typeof lane.laneSessionId === "string"
79
81
  ? lane.laneSessionId
80
82
  : (typeof lane.tmuxSessionName === "string" ? lane.tmuxSessionName : undefined);
@@ -191,9 +193,9 @@ function parseStatusMd(taskFolder) {
191
193
  return null;
192
194
  }
193
195
 
194
- function getTmuxSessions() {
195
- // Runtime V2 no longer relies on TMUX sessions.
196
- // Keep field shape stable for dashboard clients that still read `tmuxSessions`.
196
+ function getActiveSessions() {
197
+ // Runtime V2 no longer relies on external terminal multiplexers for liveness.
198
+ // Dashboard session data is driven by runtime registry + persisted state.
197
199
  return [];
198
200
  }
199
201
 
@@ -236,15 +238,15 @@ function loadLaneStates() {
236
238
  const telemetryTailStates = new Map();
237
239
 
238
240
  /**
239
- * Module-level accumulated telemetry per tmux prefix.
241
+ * Module-level accumulated telemetry per session prefix.
240
242
  * Persists across poll ticks so incremental tail reads accumulate correctly.
241
- * Key: tmux prefix → { inputTokens, outputTokens, ... }
243
+ * Key: session prefix → { inputTokens, outputTokens, ... }
242
244
  */
243
245
  const telemetryAccumulators = new Map();
244
246
 
245
247
  /**
246
248
  * Tracks which files are currently contributing to each prefix.
247
- * Key: tmux prefix → Set of absolute file paths
249
+ * Key: session prefix → Set of absolute file paths
248
250
  * Used to detect file rotation: when files change, accumulator is reset.
249
251
  */
250
252
  const telemetryPrefixFiles = new Map();
@@ -530,7 +532,7 @@ function loadTelemetryData(batchState) {
530
532
  const telemetryDir = path.join(REPO_ROOT, ".pi", "telemetry");
531
533
  const result = {};
532
534
 
533
- // Build lane number → tmux prefix mapping from batch state
535
+ // Build lane number → session prefix mapping from batch state
534
536
  const laneToPrefix = {};
535
537
  if (batchState && batchState.lanes) {
536
538
  for (const lane of batchState.lanes) {
@@ -563,11 +565,11 @@ function loadTelemetryData(batchState) {
563
565
  const parsed = parseTelemetryFilename(file);
564
566
  if (!parsed) continue;
565
567
 
566
- // Determine the key (tmux prefix)
568
+ // Determine the key (session prefix)
567
569
  let prefix;
568
570
  if (parsed.role === "merger") {
569
- // Merge agent — derive prefix from lane naming so it matches the tmux
570
- // session name used by the client (e.g. "orch-henrylach-merge-1").
571
+ // Merge agent — derive prefix from lane naming so it matches the
572
+ // session ID used by the client (e.g. "orch-henrylach-merge-1").
571
573
  // Lane sessions: "orch-{opId}-lane-{N}" → merge sessions: "orch-{opId}-merge-{N}".
572
574
  const firstLanePrefix = Object.values(laneToPrefix)[0]; // e.g. "orch-henrylach-lane-1"
573
575
  const opPrefix = firstLanePrefix?.replace(/-lane-\d+$/, ""); // "orch-henrylach"
@@ -1029,7 +1031,7 @@ function synthesizeLaneStateFromSnapshot(key, snap, fallbackBatchId) {
1029
1031
  /** Build full dashboard state object for the frontend. */
1030
1032
  function buildDashboardState() {
1031
1033
  const state = loadBatchState();
1032
- const tmuxSessions = getTmuxSessions();
1034
+ const sessions = getActiveSessions();
1033
1035
  const rawLaneStates = loadLaneStates();
1034
1036
  // Filter stale lane states from previous batches.
1035
1037
  // Lane state files persist across batches (same filename), so without
@@ -1046,7 +1048,16 @@ function buildDashboardState() {
1046
1048
  const supervisor = loadSupervisorData(state);
1047
1049
 
1048
1050
  if (!state) {
1049
- return { batch: null, tmuxSessions, laneStates: {}, telemetry: {}, batchTotalCost: 0, supervisor: null, timestamp: Date.now() };
1051
+ return {
1052
+ batch: null,
1053
+ sessions,
1054
+ tmuxSessions: sessions, // Legacy compatibility field for older dashboard clients
1055
+ laneStates: {},
1056
+ telemetry: {},
1057
+ batchTotalCost: 0,
1058
+ supervisor: null,
1059
+ timestamp: Date.now(),
1060
+ };
1050
1061
  }
1051
1062
 
1052
1063
  const tasks = (state.tasks || []).map((task) => {
@@ -1108,7 +1119,8 @@ function buildDashboardState() {
1108
1119
  // Additive field — absent in v1 state files, frontend must default to "repo".
1109
1120
  mode: state.mode || "repo",
1110
1121
  },
1111
- tmuxSessions,
1122
+ sessions,
1123
+ tmuxSessions: sessions, // Legacy compatibility field for older dashboard clients
1112
1124
  timestamp: Date.now(),
1113
1125
  };
1114
1126
  }
@@ -1157,68 +1169,6 @@ function serveStatic(req, res) {
1157
1169
 
1158
1170
  const sseClients = new Set();
1159
1171
 
1160
- // ─── Pane Capture SSE ───────────────────────────────────────────────────
1161
-
1162
- const paneClients = new Map(); // sessionName → Set<res>
1163
-
1164
- function handlePaneSSE(req, res, sessionName) {
1165
- // Validate session name (alphanumeric, dashes, underscores only)
1166
- if (!/^[\w-]+$/.test(sessionName)) {
1167
- res.writeHead(400, { "Content-Type": "text/plain" });
1168
- res.end("Invalid session name");
1169
- return;
1170
- }
1171
-
1172
- res.writeHead(200, {
1173
- "Content-Type": "text/event-stream",
1174
- "Cache-Control": "no-cache",
1175
- Connection: "keep-alive",
1176
- "Access-Control-Allow-Origin": "*",
1177
- });
1178
-
1179
- if (!paneClients.has(sessionName)) {
1180
- paneClients.set(sessionName, new Set());
1181
- }
1182
- paneClients.get(sessionName).add(res);
1183
-
1184
- // Send initial capture immediately
1185
- const initial = captureTmuxPane(sessionName);
1186
- if (initial !== null) {
1187
- res.write(`data: ${JSON.stringify({ output: initial, session: sessionName })}\n\n`);
1188
- } else {
1189
- res.write(`data: ${JSON.stringify({ error: "Session not found or not accessible", session: sessionName })}\n\n`);
1190
- }
1191
-
1192
- req.on("close", () => {
1193
- const clients = paneClients.get(sessionName);
1194
- if (clients) {
1195
- clients.delete(res);
1196
- if (clients.size === 0) paneClients.delete(sessionName);
1197
- }
1198
- });
1199
- }
1200
-
1201
- function captureTmuxPane(_sessionName) {
1202
- // Runtime V2 no longer captures TMUX panes.
1203
- return null;
1204
- }
1205
-
1206
- function broadcastPaneCaptures() {
1207
- for (const [sessionName, clients] of paneClients) {
1208
- if (clients.size === 0) continue;
1209
- const output = captureTmuxPane(sessionName);
1210
- if (output === null) continue;
1211
- const payload = `data: ${JSON.stringify({ output, session: sessionName })}\n\n`;
1212
- for (const client of clients) {
1213
- try {
1214
- client.write(payload);
1215
- } catch {
1216
- clients.delete(client);
1217
- }
1218
- }
1219
- }
1220
- }
1221
-
1222
1172
  // ─── Conversation JSONL ─────────────────────────────────────────────────
1223
1173
 
1224
1174
  function serveConversation(req, res, prefix) {
@@ -1441,9 +1391,6 @@ function createServer() {
1441
1391
 
1442
1392
  if (pathname === "/api/stream" && req.method === "GET") {
1443
1393
  handleSSE(req, res);
1444
- } else if (pathname.startsWith("/api/pane/") && req.method === "GET") {
1445
- const sessionName = pathname.slice("/api/pane/".length);
1446
- handlePaneSSE(req, res, sessionName);
1447
1394
  } else if (pathname.startsWith("/api/conversation/") && req.method === "GET") {
1448
1395
  const prefix = pathname.slice("/api/conversation/".length);
1449
1396
  serveConversation(req, res, prefix);
@@ -1594,9 +1541,6 @@ async function main() {
1594
1541
  // Broadcast state to all SSE clients on interval
1595
1542
  const pollTimer = setInterval(broadcastState, POLL_INTERVAL);
1596
1543
 
1597
- // Broadcast pane captures more frequently (1s) for smooth terminal viewing
1598
- const paneTimer = setInterval(broadcastPaneCaptures, 1000);
1599
-
1600
1544
  // Also watch batch-state.json for immediate push on change
1601
1545
  try {
1602
1546
  const batchDir = path.dirname(BATCH_STATE_PATH);
@@ -1621,12 +1565,6 @@ async function main() {
1621
1565
  // Graceful shutdown
1622
1566
  function cleanup() {
1623
1567
  clearInterval(pollTimer);
1624
- clearInterval(paneTimer);
1625
- for (const [, clients] of paneClients) {
1626
- for (const client of clients) {
1627
- try { client.end(); } catch {}
1628
- }
1629
- }
1630
1568
  for (const client of sseClients) {
1631
1569
  try { client.end(); } catch {}
1632
1570
  }
@@ -12,7 +12,7 @@
12
12
  * /orch-resume — Resume a paused batch
13
13
  * /orch-abort [--hard] — Abort batch (graceful or immediate)
14
14
  * /orch-deps <areas|paths|all> — Show dependency graph
15
- * /orch-sessions — List active TMUX sessions
15
+ * /orch-sessions — List active lane sessions
16
16
  *
17
17
  * Configuration:
18
18
  * .pi/task-orchestrator.yaml — orchestrator-specific settings
@@ -314,7 +314,8 @@ export function spawnAgent(
314
314
  let timedOut = false;
315
315
  let agentEnded = false;
316
316
  let stdinClosed = false;
317
- let statsRequested = false;
317
+ let assistantMessageEnds = 0;
318
+ const STATS_REFRESH_EVERY_ASSISTANT_MESSAGES = 5;
318
319
  let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0;
319
320
  let costUsd = 0, toolCalls = 0, retries = 0, compactions = 0;
320
321
  let lastTool = "", error: string | null = null;
@@ -586,10 +587,13 @@ export function spawnAgent(
586
587
  emitEvent("assistant_message", { text: truncatePayload(content, MAX_CONV_PAYLOAD_CHARS) });
587
588
  }
588
589
  }
589
- // Request session stats after first assistant message
590
- if (!statsRequested && event.message?.role === "assistant") {
591
- statsRequested = true;
592
- try { proc.stdin?.write(JSON.stringify({ type: "get_session_stats" }) + "\n"); } catch { /* ignore */ }
590
+ // Request session stats immediately on first assistant message,
591
+ // then periodically at a bounded cadence to refresh context usage.
592
+ if (event.message?.role === "assistant") {
593
+ assistantMessageEnds += 1;
594
+ if (assistantMessageEnds === 1 || assistantMessageEnds % STATS_REFRESH_EVERY_ASSISTANT_MESSAGES === 0) {
595
+ try { proc.stdin?.write(JSON.stringify({ type: "get_session_stats" }) + "\n"); } catch { /* ignore */ }
596
+ }
593
597
  }
594
598
  // Check mailbox
595
599
  checkMailbox();
@@ -636,6 +640,8 @@ export function spawnAgent(
636
640
  if (event.success === true && event.data?.contextUsage) {
637
641
  contextUsage = event.data.contextUsage;
638
642
  emitEvent("context_usage", { ...event.data.contextUsage });
643
+ // Emit telemetry immediately so context % is live in dashboard
644
+ onTelemetry({ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, costUsd, toolCalls, lastTool, contextUsage });
639
645
  }
640
646
  break;
641
647
  }
@@ -32,6 +32,8 @@ import type {
32
32
  /**
33
33
  * Messages sent FROM the worker TO the main thread.
34
34
  */
35
+ export type WorkerErrorSource = "enginePromise" | "uncaughtException" | "unhandledRejection";
36
+
35
37
  export type WorkerToMainMessage =
36
38
  | { type: "notify"; msg: string; level: "info" | "warning" | "error" }
37
39
  | { type: "monitor-update"; state: MonitorState }
@@ -39,7 +41,7 @@ export type WorkerToMainMessage =
39
41
  | { type: "supervisor-alert"; alert: SupervisorAlert }
40
42
  | { type: "state-sync"; state: SerializedBatchState }
41
43
  | { type: "complete"; state: SerializedBatchState }
42
- | { type: "error"; message: string };
44
+ | { type: "error"; message: string; stack?: string; source?: WorkerErrorSource };
43
45
 
44
46
  /**
45
47
  * Messages sent FROM the main thread TO the worker.
@@ -194,12 +196,73 @@ export function applySerializedState(
194
196
 
195
197
  // Guard: only run engine main when launched via fork() with the sentinel env var.
196
198
  if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "function") {
197
- const send = (msg: WorkerToMainMessage) => process.send!(msg);
199
+ const send = (msg: WorkerToMainMessage) => {
200
+ try {
201
+ process.send?.(msg);
202
+ } catch {
203
+ // best effort only
204
+ }
205
+ };
206
+
207
+ const sendWithAck = (msg: WorkerToMainMessage, onFlushed: () => void) => {
208
+ if (typeof process.send !== "function" || !process.connected) {
209
+ onFlushed();
210
+ return;
211
+ }
212
+
213
+ let flushed = false;
214
+ const done = () => {
215
+ if (flushed) return;
216
+ flushed = true;
217
+ onFlushed();
218
+ };
219
+
220
+ try {
221
+ (process.send as (
222
+ message: WorkerToMainMessage,
223
+ sendHandle?: unknown,
224
+ options?: unknown,
225
+ callback?: (error: Error | null) => void,
226
+ ) => boolean)(msg, undefined, undefined, () => done());
227
+ setTimeout(done, 75).unref();
228
+ } catch {
229
+ done();
230
+ }
231
+ };
232
+
233
+ const normalizeError = (err: unknown): { message: string; stack?: string } => {
234
+ if (err instanceof Error) return { message: err.message, stack: err.stack };
235
+ return { message: String(err) };
236
+ };
198
237
 
199
238
  // Wait for the init message carrying workerData, then start the engine.
200
239
  process.once("message", async (initMsg: { type: string; data: EngineWorkerData }) => {
201
240
  if (initMsg?.type !== "init") return;
202
241
 
242
+ let batchState: OrchBatchRuntimeState | null = null;
243
+ let fatalHandled = false;
244
+ const reportFatalAndExit = (source: WorkerErrorSource, err: unknown) => {
245
+ if (fatalHandled) return;
246
+ fatalHandled = true;
247
+
248
+ const normalized = normalizeError(err);
249
+ if (batchState && batchState.phase !== "completed" && batchState.phase !== "failed") {
250
+ batchState.phase = "failed";
251
+ batchState.endedAt = Date.now();
252
+ batchState.errors.push(`[${source}] ${normalized.message}`);
253
+ }
254
+
255
+ if (batchState) send({ type: "state-sync", state: serializeBatchState(batchState) });
256
+ sendWithAck(
257
+ { type: "error", source, message: normalized.message, stack: normalized.stack },
258
+ () => process.exit(1),
259
+ );
260
+ setTimeout(() => process.exit(1), 200).unref();
261
+ };
262
+
263
+ process.once("uncaughtException", (err: unknown) => reportFatalAndExit("uncaughtException", err));
264
+ process.once("unhandledRejection", (reason: unknown) => reportFatalAndExit("unhandledRejection", reason));
265
+
203
266
  // Dynamic imports — only loaded in engine context to avoid circular
204
267
  // dependencies when this module is imported from extension.ts
205
268
  const { executeOrchBatch } = await import("./engine.ts");
@@ -209,7 +272,7 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
209
272
  const data = initMsg.data;
210
273
 
211
274
  // Create a fresh batch state for this process
212
- const batchState: OrchBatchRuntimeState = freshOrchBatchState();
275
+ batchState = freshOrchBatchState();
213
276
  batchState.phase = "launching";
214
277
  batchState.startedAt = Date.now();
215
278
 
@@ -220,6 +283,7 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
220
283
  // Main process sends pause/resume/abort signals via IPC.
221
284
  // We apply them to the in-process batchState.pauseSignal.
222
285
  process.on("message", (msg: WorkerInMessage) => {
286
+ if (!batchState) return;
223
287
  switch (msg.type) {
224
288
  case "pause":
225
289
  batchState.pauseSignal.paused = true;
@@ -236,6 +300,7 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
236
300
  // ── Callback factories (replace ctx-dependent callbacks) ─────
237
301
  const onNotify = (message: string, level: "info" | "warning" | "error") => {
238
302
  send({ type: "notify", msg: message, level });
303
+ if (!batchState) return;
239
304
  // Sync batch state on every notify (lightweight — just the summary fields)
240
305
  send({ type: "state-sync", state: serializeBatchState(batchState) });
241
306
  };
@@ -292,15 +357,15 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
292
357
  process.disconnect?.();
293
358
  })
294
359
  .catch((err: unknown) => {
295
- const errMsg = err instanceof Error ? err.message : String(err);
360
+ const normalized = normalizeError(err);
296
361
  // Ensure batch state reflects the failure
297
362
  if (batchState.phase !== "completed" && batchState.phase !== "failed") {
298
363
  batchState.phase = "failed";
299
364
  batchState.endedAt = Date.now();
300
- batchState.errors.push(`Unhandled engine error: ${errMsg}`);
365
+ batchState.errors.push(`Unhandled engine error: ${normalized.message}`);
301
366
  }
302
367
  send({ type: "state-sync", state: serializeBatchState(batchState) });
303
- send({ type: "error", message: errMsg });
368
+ send({ type: "error", source: "enginePromise", message: normalized.message, stack: normalized.stack });
304
369
  process.disconnect?.();
305
370
  });
306
371
  });
@@ -2,7 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-age
2
2
  import { Type } from "@mariozechner/pi-ai";
3
3
 
4
4
  import { execSync, execFileSync } from "child_process";
5
- import { writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync, readFileSync, statSync } from "fs";
5
+ import { writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync, readFileSync, statSync, createWriteStream } from "fs";
6
6
  import { join, dirname } from "path";
7
7
  import { fileURLToPath } from "url";
8
8
  import { fork, type ChildProcess } from "child_process";
@@ -964,6 +964,7 @@ export function startBatchInWorker(
964
964
  child = fork(workerPath, [], {
965
965
  env: { ...process.env, TASKPLANE_ENGINE_FORK: "1" },
966
966
  serialization: "advanced",
967
+ stdio: ["inherit", "inherit", "pipe", "ipc"],
967
968
  });
968
969
  } catch (spawnErr: unknown) {
969
970
  const errMsg = spawnErr instanceof Error ? spawnErr.message : String(spawnErr);
@@ -1007,11 +1008,78 @@ export function startBatchInWorker(
1007
1008
  return null;
1008
1009
  }
1009
1010
 
1011
+ const telemetryDir = join(wkData.cwd, ".pi", "telemetry");
1012
+ if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
1013
+ const pendingBatchId = `pending-${Date.now()}`;
1014
+ const toSafeBatchId = (batchId: string) => batchId.replace(/[^a-zA-Z0-9._-]/g, "_");
1015
+ let stderrBatchId = toSafeBatchId(batchState.batchId || pendingBatchId);
1016
+ let stderrLogPath = join(telemetryDir, `${stderrBatchId}-engine-worker-stderr.log`);
1017
+ let stderrLogStream = createWriteStream(stderrLogPath, { flags: "a" });
1018
+ stderrLogStream.on("error", () => { /* non-fatal: telemetry stream */ });
1019
+ let stderrTailBuffer = "";
1020
+
1021
+ const appendStderr = (chunk: Buffer | string) => {
1022
+ const text = Buffer.isBuffer(chunk) ? chunk.toString("utf-8") : chunk;
1023
+ if (!text) return;
1024
+ process.stderr.write(text);
1025
+ stderrLogStream.write(text);
1026
+ stderrTailBuffer = (stderrTailBuffer + text).slice(-24_000);
1027
+ };
1028
+
1029
+ const rotateStderrLogToBatch = (batchId: string | undefined) => {
1030
+ if (!batchId) return;
1031
+ const resolvedBatchId = toSafeBatchId(batchId.trim());
1032
+ if (!resolvedBatchId || resolvedBatchId === stderrBatchId) return;
1033
+
1034
+ const nextPath = join(telemetryDir, `${resolvedBatchId}-engine-worker-stderr.log`);
1035
+ try {
1036
+ // Flush pending writes before rotating. cork→uncork ensures buffered
1037
+ // data is flushed synchronously before we read the file contents.
1038
+ stderrLogStream.cork();
1039
+ stderrLogStream.uncork();
1040
+ stderrLogStream.end();
1041
+ const pendingContent = existsSync(stderrLogPath) ? readFileSync(stderrLogPath, "utf-8") : "";
1042
+ if (pendingContent) writeFileSync(nextPath, pendingContent, { flag: "a" });
1043
+ if (existsSync(stderrLogPath)) unlinkSync(stderrLogPath);
1044
+ } catch {
1045
+ // best effort rename/copy
1046
+ }
1047
+
1048
+ stderrBatchId = resolvedBatchId;
1049
+ stderrLogPath = nextPath;
1050
+ stderrLogStream = createWriteStream(stderrLogPath, { flags: "a" });
1051
+ stderrLogStream.on("error", () => { /* non-fatal: telemetry stream */ });
1052
+ };
1053
+
1054
+ const readStderrTail = (lineCount = 25): string => {
1055
+ // Prefer in-memory tail buffer (always has the freshest unflushed data)
1056
+ // over the disk file (which may lag behind due to stream buffering).
1057
+ let content = stderrTailBuffer;
1058
+ if (!content) {
1059
+ try {
1060
+ if (existsSync(stderrLogPath)) content = readFileSync(stderrLogPath, "utf-8");
1061
+ } catch { /* fallback: empty */ }
1062
+ }
1063
+ const lines = content.split(/\r?\n/).filter(Boolean);
1064
+ if (lines.length === 0) return "(no stderr output captured)";
1065
+ return lines.slice(-lineCount).join("\n");
1066
+ };
1067
+
1068
+ child.stderr?.on("data", appendStderr);
1069
+ child.on("close", () => {
1070
+ try {
1071
+ stderrLogStream.end();
1072
+ } catch {
1073
+ // ignore
1074
+ }
1075
+ });
1076
+
1010
1077
  // Send workerData as first IPC message
1011
1078
  child.send({ type: "init", data: wkData });
1012
1079
 
1013
1080
  // Terminal settlement guard (R001 §3): ensures onTerminal fires at most once.
1014
1081
  let settled = false;
1082
+ let errorReceivedViaIpc = false;
1015
1083
  const settle = () => {
1016
1084
  if (settled) return;
1017
1085
  settled = true;
@@ -1039,32 +1107,42 @@ export function startBatchInWorker(
1039
1107
 
1040
1108
  case "state-sync":
1041
1109
  applySerializedState(batchState, msg.state);
1110
+ rotateStderrLogToBatch(msg.state.batchId);
1042
1111
  updateWidget();
1043
1112
  break;
1044
1113
 
1045
1114
  case "complete":
1046
1115
  applySerializedState(batchState, msg.state);
1116
+ rotateStderrLogToBatch(msg.state.batchId);
1047
1117
  updateWidget();
1048
1118
  settle();
1049
1119
  break;
1050
1120
 
1051
- case "error":
1121
+ case "error": {
1122
+ errorReceivedViaIpc = true;
1123
+ const sourceLabel = msg.source ? ` (${msg.source})` : "";
1124
+ const stackLine = msg.stack?.split("\n")[0]?.trim();
1052
1125
  if (batchState.phase !== "completed" && batchState.phase !== "failed") {
1053
1126
  batchState.phase = "failed";
1054
1127
  batchState.endedAt = Date.now();
1055
- batchState.errors.push(`Unhandled engine error: ${msg.message}`);
1128
+ batchState.errors.push(`Unhandled engine error${sourceLabel}: ${msg.message}`);
1129
+ if (stackLine) batchState.errors.push(`Engine stack: ${stackLine}`);
1056
1130
  }
1057
1131
  ctx.ui.notify(
1058
- `❌ Engine crashed with unhandled error: ${msg.message}\n` +
1132
+ `❌ Engine crashed with unhandled error${sourceLabel}: ${msg.message}\n` +
1133
+ (stackLine ? ` ${stackLine}\n` : "") +
1059
1134
  ` Batch ${batchState.batchId} marked as failed.`,
1060
1135
  "error",
1061
1136
  );
1062
1137
  updateWidget();
1063
1138
  break;
1139
+ }
1064
1140
  }
1065
1141
  });
1066
1142
 
1067
1143
  child.on("error", (err: Error) => {
1144
+ rotateStderrLogToBatch(batchState.batchId || undefined);
1145
+ const stderrTail = readStderrTail();
1068
1146
  if (batchState.phase !== "completed" && batchState.phase !== "failed") {
1069
1147
  batchState.phase = "failed";
1070
1148
  batchState.endedAt = Date.now();
@@ -1082,6 +1160,7 @@ export function startBatchInWorker(
1082
1160
  summary:
1083
1161
  `🔴 Engine process error — batch ${batchState.batchId} marked as failed\n` +
1084
1162
  ` Error: ${err.message}\n\n` +
1163
+ `Engine stderr tail (${stderrLogPath}):\n${stderrTail}\n\n` +
1085
1164
  `This is a critical engine failure. The batch cannot continue.\n` +
1086
1165
  `Available actions:\n` +
1087
1166
  ` - orch_status() to inspect state\n` +
@@ -1103,6 +1182,14 @@ export function startBatchInWorker(
1103
1182
 
1104
1183
  child.on("exit", (code: number | null) => {
1105
1184
  if (code !== 0 && !settled) {
1185
+ // If we already received an error IPC with diagnostics, the exit is expected
1186
+ // (the worker calls process.exit(1) after sending the error). Skip duplicate alert.
1187
+ if (errorReceivedViaIpc) {
1188
+ settle();
1189
+ return;
1190
+ }
1191
+ rotateStderrLogToBatch(batchState.batchId || undefined);
1192
+ const stderrTail = readStderrTail();
1106
1193
  if (batchState.phase !== "completed" && batchState.phase !== "failed") {
1107
1194
  batchState.phase = "failed";
1108
1195
  batchState.endedAt = Date.now();
@@ -1119,6 +1206,7 @@ export function startBatchInWorker(
1119
1206
  summary:
1120
1207
  `🔴 Engine process died unexpectedly (exit code ${code})\n` +
1121
1208
  ` Batch ${batchState.batchId} marked as failed.\n\n` +
1209
+ `Engine stderr tail (${stderrLogPath}):\n${stderrTail}\n\n` +
1122
1210
  `This is a critical engine failure. The batch cannot continue.\n` +
1123
1211
  `Available actions:\n` +
1124
1212
  ` - orch_status() to inspect state\n` +
@@ -312,8 +312,24 @@ export async function executeTaskV2(
312
312
  // Reviewer telemetry is written by the worker bridge during review_step.
313
313
  // Poll snapshot refresh independently from worker message_end cadence so
314
314
  // the dashboard sees reviewer activity while tool calls are in-flight.
315
+ let reviewerSnapshotFailures = 0;
316
+ const reviewerRefreshFailureThreshold = 5;
315
317
  const reviewerRefresh = setInterval(() => {
316
- try { emitSnapshot(config, taskId, "running", iterationTelemetry, statusPath); } catch { /* non-fatal */ }
318
+ const ok = emitSnapshot(config, taskId, "running", iterationTelemetry, statusPath);
319
+ if (ok) {
320
+ reviewerSnapshotFailures = 0;
321
+ return;
322
+ }
323
+
324
+ reviewerSnapshotFailures += 1;
325
+ if (reviewerSnapshotFailures >= reviewerRefreshFailureThreshold) {
326
+ clearInterval(reviewerRefresh);
327
+ logExecution(
328
+ statusPath,
329
+ "Snapshot refresh disabled",
330
+ `Lane ${config.laneNumber}, task ${taskId}: ${reviewerSnapshotFailures} consecutive emitSnapshot failures`,
331
+ );
332
+ }
317
333
  }, 1000);
318
334
 
319
335
  let workerResult: AgentHostResult;
@@ -614,6 +630,8 @@ export function readReviewerTelemetrySnapshot(
614
630
  * caught and logged. This function is called from setInterval callbacks
615
631
  * and onTelemetry callbacks where an unhandled throw would trigger
616
632
  * uncaughtException and crash the engine-worker process.
633
+ *
634
+ * @returns true when snapshot write succeeds, false when it fails.
617
635
  */
618
636
  function emitSnapshot(
619
637
  config: LaneRunnerConfig,
@@ -621,7 +639,7 @@ function emitSnapshot(
621
639
  status: "running" | "idle" | "complete" | "failed",
622
640
  telemetry: Partial<AgentHostResult>,
623
641
  statusPath: string,
624
- ): void {
642
+ ): boolean {
625
643
  try {
626
644
  // Parse progress from STATUS.md
627
645
  let progress: RuntimeTaskProgress | null = null;
@@ -669,9 +687,11 @@ function emitSnapshot(
669
687
  };
670
688
 
671
689
  writeLaneSnapshot(config.stateRoot, config.batchId, config.laneNumber, snapshot as any);
690
+ return true;
672
691
  } catch {
673
692
  // Non-fatal: snapshot is telemetry, not execution-critical.
674
693
  // Swallow to prevent uncaughtException crash in setInterval/callback contexts.
694
+ return false;
675
695
  }
676
696
  }
677
697
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.24.5",
3
+ "version": "0.24.6",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -48,8 +48,7 @@ worker:
48
48
  model: "" # empty = inherit from parent pi session
49
49
  tools: "read,write,edit,bash,grep,find,ls"
50
50
  thinking: "off"
51
- # spawn_mode: "subprocess" # "subprocess" (default) or "tmux"
52
- # tmux_prefix: "task" # used only in tmux mode
51
+ # spawn_mode: "subprocess" # currently supported runtime mode
53
52
 
54
53
  reviewer:
55
54
  model: ""
@@ -63,7 +62,7 @@ context:
63
62
  max_worker_iterations: 20
64
63
  max_review_cycles: 2
65
64
  no_progress_limit: 3
66
- # max_worker_minutes: 30 # used only in tmux mode
65
+ # max_worker_minutes: 30 # optional wall-clock guard for long worker runs
67
66
 
68
67
  # ── Task Creation / Discovery ─────────────────────────────────────────
69
68