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.
@@ -456,6 +456,34 @@ function writeLaneState(state: TaskState): void {
456
456
  }
457
457
  }
458
458
 
459
+ /**
460
+ * Write a context % snapshot at worker iteration boundary (TP-094).
461
+ * Best-effort JSONL append to `.pi/context-snapshots/{batchId}/{sessionName}.jsonl`.
462
+ * Non-fatal on any failure — never blocks execution.
463
+ */
464
+ function writeContextSnapshot(state: TaskState, contextWindow: number): void {
465
+ const batchId = process.env.ORCH_BATCH_ID || "standalone";
466
+ const sessionName = isOrchestratedMode() ? `${getTmuxPrefix()}-worker` : "task-worker";
467
+ try {
468
+ const dir = join(getSidecarDir(), "context-snapshots", batchId);
469
+ mkdirSync(dir, { recursive: true });
470
+ const filePath = join(dir, `${sessionName}.jsonl`);
471
+ const snapshot = {
472
+ iteration: state.totalIterations,
473
+ contextPct: state.workerContextPct,
474
+ tokens: state.workerInputTokens + state.workerOutputTokens + state.workerCacheReadTokens + state.workerCacheWriteTokens,
475
+ contextWindow,
476
+ cost: state.workerCostUsd,
477
+ toolCalls: state.workerToolCount,
478
+ exitReason: state.workerExitDiagnostic?.classification || null,
479
+ timestamp: Date.now(),
480
+ };
481
+ appendFileSync(filePath, JSON.stringify(snapshot) + "\n");
482
+ } catch {
483
+ // Best effort — don't crash the runner
484
+ }
485
+ }
486
+
459
487
  /**
460
488
  * Append a JSON event to the conversation JSONL log file.
461
489
  * Used in orchestrated mode to capture the full worker conversation for the web dashboard.
@@ -944,6 +972,16 @@ function logExecution(statusPath: string, action: string, outcome: string): void
944
972
  appendTableRow(statusPath, "Execution Log", `| ${ts} | ${action} | ${outcome} |`);
945
973
  }
946
974
 
975
+ /**
976
+ * TP-090: Sanitize steering message content for safe injection into a markdown table row.
977
+ * Collapses newlines to " / ", escapes pipe characters, and truncates to 200 chars.
978
+ */
979
+ function sanitizeSteeringContent(content: string): string {
980
+ let s = content.replace(/\r?\n/g, " / ").replace(/\|/g, "\\|");
981
+ if (s.length > 200) s = s.slice(0, 197) + "...";
982
+ return s;
983
+ }
984
+
947
985
  function logReview(statusPath: string, num: string, type: string, stepNum: number, verdict: string, file: string): void {
948
986
  appendTableRow(statusPath, "Reviews", `| ${num} | ${type} | Step ${stepNum} | ${verdict} | ${file} |`);
949
987
  }
@@ -1371,7 +1409,9 @@ interface SidecarTelemetryDelta {
1371
1409
  /** Whether any sidecar events were parsed in this tick (used for callback gating) */
1372
1410
  hadEvents: boolean;
1373
1411
  /** Authoritative context usage from pi get_session_stats (pi ≥ 0.63.0, null if unavailable) */
1374
- contextUsage: { percentUsed: number; totalTokens: number; maxTokens: number } | null;
1412
+ contextUsage: { percent: number; totalTokens: number; maxTokens: number } | null;
1413
+ /** True when a get_session_stats response was seen but lacked contextUsage (older pi) */
1414
+ sawStatsResponseWithoutContextUsage: boolean;
1375
1415
  }
1376
1416
 
1377
1417
  /**
@@ -1390,7 +1430,7 @@ function tailSidecarJsonl(filePath: string, tailState: SidecarTailState): Sideca
1390
1430
  inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
1391
1431
  cost: 0, latestTotalTokens: 0, toolCalls: 0, lastTool: "",
1392
1432
  retryActive: tailState.retryActive, retriesStarted: 0, lastRetryError: "",
1393
- hadEvents: false, contextUsage: null,
1433
+ hadEvents: false, contextUsage: null, sawStatsResponseWithoutContextUsage: false,
1394
1434
  };
1395
1435
 
1396
1436
  // Gracefully handle missing file (wrapper hasn't written yet)
@@ -1506,13 +1546,18 @@ function tailSidecarJsonl(filePath: string, tailState: SidecarTailState): Sideca
1506
1546
  // get_session_stats response from pi ≥ 0.63.0 — authoritative context usage
1507
1547
  if (event.success === true && event.data?.contextUsage) {
1508
1548
  const cu = event.data.contextUsage;
1509
- if (typeof cu.percentUsed === "number") {
1549
+ // pi sends `percent` (pi ≥ 0.63.0); accept `percentUsed` as legacy fallback
1550
+ const pctValue = cu.percent ?? cu.percentUsed;
1551
+ if (typeof pctValue === "number") {
1510
1552
  delta.contextUsage = {
1511
- percentUsed: cu.percentUsed,
1553
+ percent: pctValue,
1512
1554
  totalTokens: cu.totalTokens || 0,
1513
1555
  maxTokens: cu.maxTokens || 0,
1514
1556
  };
1515
1557
  }
1558
+ } else if (event.success === true && event.data && !event.data.contextUsage) {
1559
+ // Successful get_session_stats response but no contextUsage — older pi
1560
+ delta.sawStatsResponseWithoutContextUsage = true;
1516
1561
  }
1517
1562
  break;
1518
1563
  }
@@ -1532,6 +1577,68 @@ export const _resolveContextWindow = resolveContextWindow;
1532
1577
  export const _FALLBACK_CONTEXT_WINDOW = FALLBACK_CONTEXT_WINDOW;
1533
1578
  export type { SidecarTailState, SidecarTelemetryDelta };
1534
1579
 
1580
+ // ── Stable Sidecar Path Generation (TP-097) ─────────────────────────
1581
+
1582
+ /**
1583
+ * Generate a deterministic telemetry basename for sidecar/exit-summary files.
1584
+ *
1585
+ * The basename is stable per session (not per spawn attempt). When called
1586
+ * once before the iteration loop and reused, it ensures that:
1587
+ * - Crash recovery writes to the same sidecar file (tailing resumes)
1588
+ * - Exit summaries overwrite the same file (latest wins)
1589
+ *
1590
+ * Naming contract:
1591
+ * {opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}
1592
+ *
1593
+ * @param sessionName — TMUX session name (e.g., "orch-lane-1-worker")
1594
+ * @param taskId — Optional task ID for enrichment (e.g., "TP-097")
1595
+ * @returns Object with sidecarPath and exitSummaryPath
1596
+ */
1597
+ function generateStableSidecarPaths(sessionName: string, taskId?: string): {
1598
+ sidecarPath: string;
1599
+ exitSummaryPath: string;
1600
+ } {
1601
+ // Resolve opId: same priority chain as naming.ts resolveOperatorId()
1602
+ let opId = "op";
1603
+ const envOpId = process.env.TASKPLANE_OPERATOR_ID;
1604
+ if (envOpId?.trim()) {
1605
+ opId = envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
1606
+ } else {
1607
+ try {
1608
+ const username = userInfo().username;
1609
+ if (username?.trim()) {
1610
+ opId = username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
1611
+ }
1612
+ } catch { /* userInfo() can throw on some platforms */ }
1613
+ }
1614
+
1615
+ // Use ORCH_BATCH_ID if available (orchestrated mode), otherwise fallback to timestamp
1616
+ const batchId = process.env.ORCH_BATCH_ID || String(Date.now());
1617
+ const repoId = process.env.TASKPLANE_REPO_ID || "default";
1618
+
1619
+ // Extract role (worker/reviewer) from sessionName, and optional lane component
1620
+ const role = sessionName.endsWith("-reviewer") ? "reviewer" : "worker";
1621
+ const laneMatch = sessionName.match(/lane-(\d+)/);
1622
+ const laneSuffix = laneMatch ? `-lane-${laneMatch[1]}` : "";
1623
+
1624
+ // Include taskId when available — sanitize to filesystem-safe characters
1625
+ const taskIdSegment = taskId
1626
+ ? `-${taskId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30)}`
1627
+ : "";
1628
+
1629
+ const telemetryBasename = `${opId}-${batchId}-${repoId}${taskIdSegment}${laneSuffix}-${role}`;
1630
+ const telemetryDir = join(getSidecarDir(), "telemetry");
1631
+ if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
1632
+
1633
+ return {
1634
+ sidecarPath: join(telemetryDir, `${telemetryBasename}.jsonl`),
1635
+ exitSummaryPath: join(telemetryDir, `${telemetryBasename}-exit.json`),
1636
+ };
1637
+ }
1638
+
1639
+ /** Expose for testing. */
1640
+ export const _generateStableSidecarPaths = generateStableSidecarPaths;
1641
+
1535
1642
  // ── Exit Summary & Diagnostic ────────────────────────────────────────
1536
1643
 
1537
1644
  /**
@@ -1656,6 +1763,25 @@ export function isLowRiskStep(stepNumber: number, totalSteps: number): boolean {
1656
1763
 
1657
1764
  // ── TMUX Agent Spawner ───────────────────────────────────────────────
1658
1765
 
1766
+ /**
1767
+ * Synchronous sleep helper for tmux spawn stabilization checks.
1768
+ *
1769
+ * Uses Atomics.wait for cross-platform blocking delays without relying on
1770
+ * shell `sleep` availability (important on Windows environments).
1771
+ */
1772
+ function sleepSyncMs(ms: number): void {
1773
+ if (!Number.isFinite(ms) || ms <= 0) return;
1774
+ try {
1775
+ const arr = new Int32Array(new SharedArrayBuffer(4));
1776
+ Atomics.wait(arr, 0, 0, Math.floor(ms));
1777
+ } catch {
1778
+ const start = Date.now();
1779
+ while (Date.now() - start < ms) {
1780
+ // Busy-wait fallback (rare path)
1781
+ }
1782
+ }
1783
+ }
1784
+
1659
1785
  /**
1660
1786
  * Spawns a Pi agent in a named TMUX session instead of a headless subprocess.
1661
1787
  * Returns the same interface shape as `spawnAgent()` for drop-in compatibility.
@@ -1721,6 +1847,19 @@ function spawnAgentTmux(opts: {
1721
1847
  * Enables the tmux poll loop to update TaskState (tokens, cost, context%, tools, retries)
1722
1848
  * with the same signals that subprocess mode gets from onTokenUpdate/onContextPct/onToolCall. */
1723
1849
  onTelemetry?: (delta: SidecarTelemetryDelta) => void;
1850
+ /** TP-090: Path to .steering-pending JSONL flag file for STATUS.md annotation.
1851
+ * Only set for worker sessions (not reviewer/merger). */
1852
+ steeringPendingPath?: string;
1853
+ /** TP-097: Caller-provided sidecar path for stable identity across iterations.
1854
+ * When provided, spawnAgentTmux() skips internal path generation and uses this path.
1855
+ * The caller (runWorker) generates this ONCE before the iteration loop. */
1856
+ sidecarPath?: string;
1857
+ /** TP-097: Caller-provided exit summary path (paired with sidecarPath). */
1858
+ exitSummaryPath?: string;
1859
+ /** TP-097: Caller-provided tail state for resuming sidecar tailing across iterations.
1860
+ * When provided, the poll loop reuses this state instead of creating a fresh one.
1861
+ * This ensures tailing resumes from the last byte position after crash recovery. */
1862
+ tailState?: SidecarTailState;
1724
1863
  }): {
1725
1864
  promise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
1726
1865
  kill: () => void;
@@ -1738,59 +1877,55 @@ function spawnAgentTmux(opts: {
1738
1877
  );
1739
1878
  }
1740
1879
 
1741
- // ── Generate telemetry file paths ───────────────────────────────
1742
- // Naming contract from resilience roadmap:
1743
- // .pi/telemetry/{opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}.{ext}
1744
- //
1745
- // In standalone /task mode (no orchestrator):
1746
- // opId → TASKPLANE_OPERATOR_ID env, or OS username, or "op"
1747
- // batchId → timestamp (no batch concept in standalone mode)
1748
- // repoId → "default" (single-repo mode)
1749
- // taskId → from opts.taskId when provided (e.g., "tp-026"), omitted if absent
1750
- // lane → omitted (no lanes)
1751
- // role → derived from sessionName suffix (worker/reviewer)
1752
- //
1753
- // getSidecarDir() respects ORCH_SIDECAR_DIR for workspace mode.
1754
- const telemetryTs = Date.now();
1755
-
1756
- // Resolve opId: same priority chain as naming.ts resolveOperatorId()
1757
- let opId = "op";
1758
- const envOpId = process.env.TASKPLANE_OPERATOR_ID;
1759
- if (envOpId?.trim()) {
1760
- opId = envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
1880
+ // ── Resolve telemetry file paths ───────────────────────────────
1881
+ // TP-097: When the caller provides sidecarPath + exitSummaryPath, reuse them
1882
+ // for stable identity across crash recovery iterations. Otherwise, generate
1883
+ // paths internally (backward compatible for reviewer/quality-gate/standalone).
1884
+ let sidecarPath: string;
1885
+ let exitSummaryPath: string;
1886
+ if (opts.sidecarPath && opts.exitSummaryPath) {
1887
+ // TP-097: Caller provided stable paths (worker iteration flow)
1888
+ sidecarPath = opts.sidecarPath;
1889
+ exitSummaryPath = opts.exitSummaryPath;
1761
1890
  } else {
1762
- try {
1763
- const username = userInfo().username;
1764
- if (username?.trim()) {
1765
- opId = username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
1766
- }
1767
- } catch { /* userInfo() can throw on some platforms */ }
1891
+ // Internal generation: use unique per-spawn paths (Date.now-based batchId)
1892
+ // to prevent reviewer/QG sessions from replaying old telemetry on respawn.
1893
+ // Only the worker iteration flow uses ORCH_BATCH_ID for stable identity.
1894
+ const telemetryTs = Date.now();
1895
+ let opId = "op";
1896
+ const envOpId = process.env.TASKPLANE_OPERATOR_ID;
1897
+ if (envOpId?.trim()) {
1898
+ opId = envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
1899
+ } else {
1900
+ try {
1901
+ const username = userInfo().username;
1902
+ if (username?.trim()) {
1903
+ opId = username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
1904
+ }
1905
+ } catch { /* userInfo() can throw on some platforms */ }
1906
+ }
1907
+ const batchId = String(telemetryTs);
1908
+ const repoId = "default";
1909
+ const role = opts.sessionName.endsWith("-reviewer") ? "reviewer" : "worker";
1910
+ const laneMatch = opts.sessionName.match(/lane-(\d+)/);
1911
+ const laneSuffix = laneMatch ? `-lane-${laneMatch[1]}` : "";
1912
+ const taskIdSegment = opts.taskId
1913
+ ? `-${opts.taskId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30)}`
1914
+ : "";
1915
+ const telemetryBasename = `${opId}-${batchId}-${repoId}${taskIdSegment}${laneSuffix}-${role}`;
1916
+ const internalTelemetryDir = join(getSidecarDir(), "telemetry");
1917
+ if (!existsSync(internalTelemetryDir)) mkdirSync(internalTelemetryDir, { recursive: true });
1918
+ sidecarPath = join(internalTelemetryDir, `${telemetryBasename}.jsonl`);
1919
+ exitSummaryPath = join(internalTelemetryDir, `${telemetryBasename}-exit.json`);
1768
1920
  }
1769
-
1770
- const batchId = String(telemetryTs);
1771
- const repoId = "default";
1772
-
1773
- // Extract role (worker/reviewer) from sessionName, and optional lane component
1774
- // sessionName patterns: "task-worker", "task-reviewer", "orch-lane-1-worker"
1775
- const role = opts.sessionName.endsWith("-reviewer") ? "reviewer" : "worker";
1776
- const laneMatch = opts.sessionName.match(/lane-(\d+)/);
1777
- const laneSuffix = laneMatch ? `-lane-${laneMatch[1]}` : "";
1778
-
1779
- // Include taskId when available — sanitize to filesystem-safe characters.
1780
- // Pattern: {opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}
1781
- const taskIdSegment = opts.taskId
1782
- ? `-${opts.taskId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30)}`
1783
- : "";
1784
- const telemetryBasename = `${opId}-${batchId}-${repoId}${taskIdSegment}${laneSuffix}-${role}`;
1785
- const telemetryDir = join(getSidecarDir(), "telemetry");
1921
+ // Ensure telemetry directory exists
1922
+ const telemetryDir = dirname(sidecarPath);
1786
1923
  if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
1787
- const sidecarPath = join(telemetryDir, `${telemetryBasename}.jsonl`);
1788
- const exitSummaryPath = join(telemetryDir, `${telemetryBasename}-exit.json`);
1789
1924
 
1790
1925
  // ── Write prompts to temp files ─────────────────────────────────
1791
1926
  // Same pattern as spawnAgent() — avoids shell escaping issues with
1792
1927
  // backticks, quotes, and special characters in markdown content.
1793
- const id = `${telemetryTs}-${Math.random().toString(36).slice(2, 8)}`;
1928
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1794
1929
  const sysTmpFile = join(tmpdir(), `pi-task-sys-${id}.txt`);
1795
1930
  const promptTmpFile = join(tmpdir(), `pi-task-prompt-${id}.txt`);
1796
1931
  writeFileSync(sysTmpFile, opts.systemPrompt);
@@ -1844,6 +1979,10 @@ function spawnAgentTmux(opts: {
1844
1979
  mkdirSync(join(mailboxDir, "inbox"), { recursive: true });
1845
1980
  wrapperArgs.push("--mailbox-dir", quoteArg(mailboxDir));
1846
1981
  }
1982
+ // TP-090: Pass steering-pending path to rpc-wrapper (worker-only).
1983
+ if (opts.steeringPendingPath) {
1984
+ wrapperArgs.push("--steering-pending-path", quoteArg(opts.steeringPendingPath));
1985
+ }
1847
1986
  // Passthrough pi args: flags forwarded to the underlying pi --mode rpc process.
1848
1987
  // Note: --no-session is NOT passed here — rpc-wrapper.mjs already injects it.
1849
1988
  wrapperArgs.push("--");
@@ -1890,13 +2029,93 @@ function spawnAgentTmux(opts: {
1890
2029
  );
1891
2030
  }
1892
2031
 
2032
+ // ── TP-095: Post-spawn verification with retry (#335) ──────────
2033
+ // On Windows/MSYS2, rapid sequential tmux session creation is unreliable.
2034
+ // Pi process can exit with code 1 in 0 seconds on the first 3-5 attempts.
2035
+ // Verify the session is alive after a brief delay, and retry if it died.
2036
+ // TP-097: Increased from 300→500ms and 2→5 retries for reliability (#335)
2037
+ const SPAWN_VERIFY_DELAY_MS = 500;
2038
+ const SPAWN_VERIFY_POLL_ATTEMPTS = 3;
2039
+ const SPAWN_VERIFY_POLL_INTERVAL_MS = 200;
2040
+ const SPAWN_MAX_RETRIES = 5;
2041
+
2042
+ const verifySessionAlive = (): boolean => {
2043
+ for (let poll = 0; poll < SPAWN_VERIFY_POLL_ATTEMPTS; poll++) {
2044
+ const check = spawnSync("tmux", ["has-session", "-t", opts.sessionName]);
2045
+ if (check.status === 0) return true;
2046
+ if (poll < SPAWN_VERIFY_POLL_ATTEMPTS - 1) {
2047
+ sleepSyncMs(SPAWN_VERIFY_POLL_INTERVAL_MS);
2048
+ }
2049
+ }
2050
+ return false;
2051
+ };
2052
+
2053
+ // Wait briefly for session to stabilize, then verify
2054
+ sleepSyncMs(SPAWN_VERIFY_DELAY_MS);
2055
+
2056
+ // Derive the stderr log path for diagnostic messages (mirrors execution.ts convention)
2057
+ const stderrLogHint = `${sidecarPath.replace(/\.jsonl$/, "-stderr.log")}`;
2058
+
2059
+ let spawnRetries = 0;
2060
+ while (!verifySessionAlive() && spawnRetries < SPAWN_MAX_RETRIES) {
2061
+ spawnRetries++;
2062
+ // TP-097: Log stderr from the failed session for diagnostics
2063
+ let failedStderr = "";
2064
+ try {
2065
+ if (existsSync(stderrLogHint)) {
2066
+ const raw = readFileSync(stderrLogHint, "utf-8");
2067
+ // Take last 500 chars to capture the most recent error
2068
+ failedStderr = raw.length > 500 ? "..." + raw.slice(-500) : raw;
2069
+ }
2070
+ } catch { /* best effort */ }
2071
+ console.error(`[task-runner] tmux: session '${opts.sessionName}' died on startup — retrying (${spawnRetries}/${SPAWN_MAX_RETRIES}).${failedStderr ? ` Last stderr: ${failedStderr.trim().slice(0, 200)}` : ""} Stderr log: ${stderrLogHint}`);
2072
+
2073
+ // Brief delay before retry (increases with each attempt: 500ms, 1000ms, 1500ms, ...)
2074
+ const retryDelay = spawnRetries * 500;
2075
+ sleepSyncMs(retryDelay);
2076
+
2077
+ // Kill any remnant and re-create
2078
+ spawnSync("tmux", ["kill-session", "-t", opts.sessionName]);
2079
+
2080
+ const retryResult = spawnSync("tmux", [
2081
+ "new-session", "-d",
2082
+ "-s", opts.sessionName,
2083
+ wrappedCommand,
2084
+ ]);
2085
+
2086
+ if (retryResult.status !== 0) {
2087
+ const retryStderr = retryResult.stderr?.toString().trim() || "unknown error";
2088
+ console.error(`[task-runner] tmux: retry ${spawnRetries} session creation failed: ${retryStderr}`);
2089
+ continue;
2090
+ }
2091
+
2092
+ // Wait for the retried session to stabilize
2093
+ sleepSyncMs(SPAWN_VERIFY_DELAY_MS);
2094
+ }
2095
+
2096
+ if (spawnRetries > 0) {
2097
+ const finalAlive = verifySessionAlive();
2098
+ if (!finalAlive) {
2099
+ cleanupTmp();
2100
+ console.error(`[task-runner] tmux: session '${opts.sessionName}' failed after ${SPAWN_MAX_RETRIES} retries. Stderr log: ${stderrLogHint}`);
2101
+ throw new Error(
2102
+ `TMUX session '${opts.sessionName}' died on startup after ${SPAWN_MAX_RETRIES} retries. ` +
2103
+ `Stderr log: ${stderrLogHint}`
2104
+ );
2105
+ }
2106
+ console.error(`[task-runner] tmux: session '${opts.sessionName}' alive after ${spawnRetries} retry(ies)`);
2107
+ }
2108
+
1893
2109
  console.error(`[task-runner] tmux: session '${opts.sessionName}' created (cwd: ${opts.cwd})`);
1894
2110
 
1895
2111
 
1896
2112
  // ── Poll until session ends ─────────────────────────────────────
1897
2113
  let killed = false;
1898
2114
  const startTime = Date.now();
1899
- const tailState = createSidecarTailState();
2115
+ // TP-097: Reuse caller-provided tailState for cross-iteration tailing resume.
2116
+ // When the caller (runWorker) passes tailState, byte offset is preserved
2117
+ // across iterations so tailing resumes from the last position after crash recovery.
2118
+ const tailState = opts.tailState ?? createSidecarTailState();
1900
2119
 
1901
2120
  const promise = (async (): Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }> => {
1902
2121
  try {
@@ -1938,10 +2157,12 @@ function spawnAgentTmux(opts: {
1938
2157
  };
1939
2158
  }
1940
2159
 
1941
- // Normal completion — clean up temp files
2160
+ // Normal completion — clean up temp files and orphan processes
1942
2161
  const elapsed = Date.now() - startTime;
1943
2162
  console.error(`[task-runner] tmux: session '${opts.sessionName}' ended after ${Math.round(elapsed / 1000)}s${killed ? " (killed)" : ""}`);
1944
2163
  cleanupTmp();
2164
+ // TP-097: Clean up orphan rpc-wrapper/pi processes after session ends
2165
+ cleanupOrphanProcesses(sidecarPath);
1945
2166
  console.error(`[task-runner] tmux: cleanup done for '${opts.sessionName}'`);
1946
2167
  return {
1947
2168
  output: "", // No captured output in TMUX mode
@@ -1960,6 +2181,8 @@ function spawnAgentTmux(opts: {
1960
2181
  // Session may have already exited — not an error
1961
2182
  console.error(`[task-runner] tmux: session '${opts.sessionName}' already exited (kill was no-op)`);
1962
2183
  }
2184
+ // TP-097: Clean up orphan rpc-wrapper/pi processes on explicit kill
2185
+ cleanupOrphanProcesses(sidecarPath);
1963
2186
  cleanupTmp();
1964
2187
  console.error(`[task-runner] tmux: cleanup done for '${opts.sessionName}' (killed)`);
1965
2188
  };
@@ -1967,6 +2190,68 @@ function spawnAgentTmux(opts: {
1967
2190
  return { promise, kill, sidecarPath, exitSummaryPath };
1968
2191
  }
1969
2192
 
2193
+ // ── Orphan Process Cleanup (TP-097) ───────────────────────────────────
2194
+
2195
+ /**
2196
+ * Read the PID file written by rpc-wrapper.mjs and kill orphan processes.
2197
+ *
2198
+ * The PID file is at `{sidecarPath}.pid` and contains JSON with wrapperPid
2199
+ * and childPid fields. After a tmux session ends, the rpc-wrapper child
2200
+ * process may still be alive (e.g., if the tmux session was killed externally
2201
+ * or the wrapper didn't get a clean shutdown signal).
2202
+ *
2203
+ * Best-effort: failures are logged but never throw.
2204
+ *
2205
+ * @param sidecarPath - Path to the sidecar JSONL file (PID file is at sidecarPath + ".pid")
2206
+ */
2207
+ function cleanupOrphanProcesses(sidecarPath: string): void {
2208
+ const pidFilePath = sidecarPath + ".pid";
2209
+ try {
2210
+ if (!existsSync(pidFilePath)) return;
2211
+
2212
+ const raw = readFileSync(pidFilePath, "utf-8").trim();
2213
+ if (!raw) return;
2214
+
2215
+ const pidData = JSON.parse(raw);
2216
+ const pidsToCheck = new Set<number>();
2217
+ if (typeof pidData.childPid === "number" && pidData.childPid > 0) {
2218
+ pidsToCheck.add(pidData.childPid);
2219
+ }
2220
+ if (typeof pidData.wrapperPid === "number" && pidData.wrapperPid > 0) {
2221
+ pidsToCheck.add(pidData.wrapperPid);
2222
+ }
2223
+
2224
+ // Safety: never kill ourselves or PID 1 (init)
2225
+ const selfPid = process.pid;
2226
+ pidsToCheck.delete(selfPid);
2227
+ pidsToCheck.delete(1);
2228
+
2229
+ for (const pid of pidsToCheck) {
2230
+ try {
2231
+ // Check if process is still alive (signal 0 = no-op, just check existence)
2232
+ process.kill(pid, 0);
2233
+ // Process is alive — send SIGTERM
2234
+ console.error(`[task-runner] TP-097: killing orphan process PID ${pid}`);
2235
+ try {
2236
+ process.kill(pid, "SIGTERM");
2237
+ } catch (killErr: any) {
2238
+ console.error(`[task-runner] TP-097: failed to kill PID ${pid}: ${killErr?.message}`);
2239
+ }
2240
+ } catch {
2241
+ // Process already dead — expected path
2242
+ }
2243
+ }
2244
+
2245
+ // Clean up the PID file
2246
+ try { unlinkSync(pidFilePath); } catch {}
2247
+ } catch (err: any) {
2248
+ console.error(`[task-runner] TP-097: orphan cleanup error: ${err?.message}`);
2249
+ }
2250
+ }
2251
+
2252
+ /** Expose for testing. */
2253
+ export const _cleanupOrphanProcesses = cleanupOrphanProcesses;
2254
+
1970
2255
  // ── Display Helpers ──────────────────────────────────────────────────
1971
2256
 
1972
2257
  function displayName(name: string): string {
@@ -2461,11 +2746,9 @@ export default function (pi: ExtensionAPI) {
2461
2746
  state.reviewerLastTool = delta.lastTool;
2462
2747
  }
2463
2748
 
2464
- // Context % — prefer authoritative contextUsage (pi ≥ 0.63.0)
2749
+ // Context % — authoritative contextUsage only (pi ≥ 0.63.0, TP-094)
2465
2750
  if (delta.contextUsage) {
2466
- state.reviewerContextPct = delta.contextUsage.percentUsed;
2467
- } else if (delta.latestTotalTokens > 0 && contextWindow > 0) {
2468
- state.reviewerContextPct = (delta.latestTotalTokens / contextWindow) * 100;
2751
+ state.reviewerContextPct = delta.contextUsage.percent;
2469
2752
  }
2470
2753
 
2471
2754
  writeLaneState(state);
@@ -2668,11 +2951,9 @@ export default function (pi: ExtensionAPI) {
2668
2951
  state.reviewerCostUsd += delta.cost;
2669
2952
  state.reviewerToolCount += delta.toolCalls;
2670
2953
  if (delta.lastTool) state.reviewerLastTool = delta.lastTool;
2671
- // Context % — prefer authoritative contextUsage (pi ≥ 0.63.0)
2954
+ // Context % — authoritative contextUsage only (pi ≥ 0.63.0, TP-094)
2672
2955
  if (delta.contextUsage) {
2673
- state.reviewerContextPct = delta.contextUsage.percentUsed;
2674
- } else if (delta.latestTotalTokens > 0 && contextWindow > 0) {
2675
- state.reviewerContextPct = (delta.latestTotalTokens / contextWindow) * 100;
2956
+ state.reviewerContextPct = delta.contextUsage.percent;
2676
2957
  }
2677
2958
  writeLaneState(state);
2678
2959
  updateWidgets();
@@ -2749,7 +3030,14 @@ export default function (pi: ExtensionAPI) {
2749
3030
 
2750
3031
  updateStatusField(statusPath, "Status", "🟡 In Progress");
2751
3032
  updateStatusField(statusPath, "Last Updated", new Date().toISOString().slice(0, 10));
2752
- logExecution(statusPath, "Task started", "Extension-driven execution");
3033
+
3034
+ // TP-098: Distinguish first start from restart/resume to prevent
3035
+ // duplicate "Task started" entries in the execution log (#348).
3036
+ if (state.totalIterations === 0) {
3037
+ logExecution(statusPath, "Task started", "Extension-driven execution");
3038
+ } else {
3039
+ logExecution(statusPath, "Task resumed", `Resuming from iteration ${state.totalIterations}`);
3040
+ }
2753
3041
 
2754
3042
  // ── Per-task worker loop ─────────────────────────────────────
2755
3043
  // Spawn one worker per iteration; each worker handles ALL remaining
@@ -2766,10 +3054,14 @@ export default function (pi: ExtensionAPI) {
2766
3054
  const ss = currentStatus.steps.find(s => s.number === step.number);
2767
3055
  if (ss?.status === "complete") continue;
2768
3056
 
2769
- if (!foundFirstIncomplete) {
3057
+ if (!foundFirstIncomplete) {
2770
3058
  // Mark the first incomplete step as in-progress
2771
- updateStepStatus(statusPath, step.number, "in-progress");
2772
- logExecution(statusPath, `Step ${step.number} started`, step.name);
3059
+ // TP-098: Only log "Step N started" if the step was not already
3060
+ // in-progress, preventing duplicate entries on restart (#348).
3061
+ if (ss?.status !== "in-progress") {
3062
+ updateStepStatus(statusPath, step.number, "in-progress");
3063
+ logExecution(statusPath, `Step ${step.number} started`, step.name);
3064
+ }
2773
3065
  foundFirstIncomplete = true;
2774
3066
  } else {
2775
3067
  // Ensure future steps show as not-started, not in-progress
@@ -2788,11 +3080,26 @@ export default function (pi: ExtensionAPI) {
2788
3080
  return ss.totalChecked === ss.totalItems && ss.totalItems > 0;
2789
3081
  }
2790
3082
 
3083
+ // ── TP-097: Generate stable sidecar paths ONCE before the iteration loop ──
3084
+ // These paths are reused across all worker iterations so that:
3085
+ // 1. After crash recovery, the new worker writes to the SAME sidecar file
3086
+ // 2. tailState preserves byte offset, so tailing resumes from last position
3087
+ // 3. Exit summary overwrites the same file (latest iteration wins)
3088
+ const spawnMode = getSpawnMode(config);
3089
+ let workerStableSidecar: { sidecarPath: string; exitSummaryPath: string } | null = null;
3090
+ let workerTailState: SidecarTailState | null = null;
3091
+ if (spawnMode === "tmux") {
3092
+ const sessionName = `${getTmuxPrefix()}-worker`;
3093
+ workerStableSidecar = generateStableSidecarPaths(sessionName, task.taskId);
3094
+ workerTailState = createSidecarTailState();
3095
+ console.error(`[task-runner] TP-097: stable sidecar path: ${workerStableSidecar.sidecarPath}`);
3096
+ }
3097
+
2791
3098
  let noProgressCount = 0;
2792
3099
  for (let iter = 0; iter < config.context.max_worker_iterations; iter++) {
2793
3100
  if (state.phase === "paused") {
2794
- logExecution(statusPath, "Paused", `User paused at iteration ${iter + 1}`);
2795
- ctx.ui.notify(`Task paused at iteration ${iter + 1}`, "info");
3101
+ logExecution(statusPath, "Paused", `User paused at iteration ${state.totalIterations}`);
3102
+ ctx.ui.notify(`Task paused at iteration ${state.totalIterations}`, "info");
2796
3103
  await shutdownPersistentReviewer("task paused");
2797
3104
  return;
2798
3105
  }
@@ -2823,7 +3130,61 @@ export default function (pi: ExtensionAPI) {
2823
3130
  if (isStepComplete(ss)) completedBefore.add(ss.number);
2824
3131
  }
2825
3132
 
2826
- await runWorker(remainingSteps, ctx);
3133
+ // ── TP-095: Reset stale lane-state fields before new worker spawn (#333) ──
3134
+ // When a worker crashes and restarts, the lane-state JSON retains stale
3135
+ // values (workerStatus: "done", phase: "error", workerExitDiagnostic from
3136
+ // the crash). Reset STATUS fields BEFORE the new worker spawns so the
3137
+ // dashboard immediately reflects the new running state.
3138
+ // IMPORTANT: Do NOT reset telemetry counters (tokens, cost) here — they
3139
+ // accumulate across worker iterations via += in onTelemetry (#334).
3140
+ if (state.totalIterations > 1) {
3141
+ state.phase = "running";
3142
+ state.workerStatus = "idle"; // Will be set to "running" by runWorker()
3143
+ state.workerExitDiagnostic = null;
3144
+ state.workerElapsed = 0;
3145
+ state.workerContextPct = 0;
3146
+ state.workerLastTool = "";
3147
+ state.workerRetryActive = false;
3148
+ state.workerRetryCount = 0;
3149
+ state.workerLastRetryError = "";
3150
+ // Note: workerToolCount, workerInputTokens, workerOutputTokens,
3151
+ // workerCacheReadTokens, workerCacheWriteTokens, workerCostUsd
3152
+ // are intentionally NOT reset — they persist across iterations.
3153
+ writeLaneState(state);
3154
+ }
3155
+
3156
+ await runWorker(remainingSteps, ctx, workerStableSidecar, workerTailState);
3157
+
3158
+ // Write context % snapshot at iteration boundary (TP-094)
3159
+ const { contextWindow: snapshotContextWindow } = resolveContextWindow(config, ctx);
3160
+ writeContextSnapshot(state, snapshotContextWindow);
3161
+
3162
+ // ── TP-090: Annotate STATUS.md with delivered steering messages ──
3163
+ // Check for .steering-pending JSONL flag written by rpc-wrapper.
3164
+ // Must happen BEFORE the error-return so messages are not dropped.
3165
+ const steeringFlagPath = join(task.taskFolder, ".steering-pending");
3166
+ try {
3167
+ if (existsSync(steeringFlagPath)) {
3168
+ const raw = readFileSync(steeringFlagPath, "utf-8");
3169
+ const lines = raw.split("\n").filter(l => l.trim());
3170
+ for (const line of lines) {
3171
+ try {
3172
+ const entry = JSON.parse(line) as { ts: number; content: string; id: string };
3173
+ const sanitized = sanitizeSteeringContent(entry.content);
3174
+ // Use the delivered message timestamp, not current time
3175
+ const ts = new Date(entry.ts).toISOString().slice(0, 16).replace("T", " ");
3176
+ appendTableRow(statusPath, "Execution Log", `| ${ts} | \u26a0\ufe0f Steering | ${sanitized} |`);
3177
+ console.error(`[task-runner] steering message annotated: ${entry.id}`);
3178
+ } catch {
3179
+ // Skip malformed JSONL lines
3180
+ }
3181
+ }
3182
+ unlinkSync(steeringFlagPath);
3183
+ }
3184
+ } catch (err: any) {
3185
+ // Non-fatal: steering annotation is supplementary
3186
+ console.error(`[task-runner] steering-pending annotation error: ${err?.message || err}`);
3187
+ }
2827
3188
 
2828
3189
  if (state.phase === "error") {
2829
3190
  await shutdownPersistentReviewer("worker error");
@@ -2838,8 +3199,10 @@ export default function (pi: ExtensionAPI) {
2838
3199
  const progressDelta = afterTotalChecked - prevTotalChecked;
2839
3200
  if (progressDelta <= 0) {
2840
3201
  noProgressCount++;
2841
- logExecution(statusPath, "No progress", `Iteration ${iter + 1}: 0 new checkboxes (${noProgressCount}/${config.context.no_progress_limit} stall limit)`);
2842
- ctx.ui.notify(`⚠️ No progress in iteration ${iter + 1} (${noProgressCount}/${config.context.no_progress_limit})`, "warning");
3202
+ // TP-098: Use state.totalIterations (global) instead of iter+1
3203
+ // (loop-local) to avoid label collision across restarts (#348).
3204
+ logExecution(statusPath, "No progress", `Iteration ${state.totalIterations}: 0 new checkboxes (${noProgressCount}/${config.context.no_progress_limit} stall limit)`);
3205
+ ctx.ui.notify(`⚠️ No progress in iteration ${state.totalIterations} (${noProgressCount}/${config.context.no_progress_limit})`, "warning");
2843
3206
  if (noProgressCount >= config.context.no_progress_limit) {
2844
3207
  logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
2845
3208
  ctx.ui.notify(`⚠️ Task blocked — no progress after ${noProgressCount} iterations`, "error");
@@ -2886,11 +3249,13 @@ export default function (pi: ExtensionAPI) {
2886
3249
  // Log iteration summary with progress delta and completed steps
2887
3250
  const completedNames = newlyCompleted.map(s => `Step ${s.number}`).join(", ");
2888
3251
  if (newlyCompleted.length > 0) {
2889
- logExecution(statusPath, `Iteration ${iter + 1} summary`, `+${progressDelta} checkboxes, completed: ${completedNames}`);
2890
- ctx.ui.notify(`Iteration ${iter + 1}: completed ${completedNames} (+${progressDelta} checkboxes)`, "info");
3252
+ // TP-098: Use state.totalIterations (global) instead of iter+1
3253
+ // (loop-local) to avoid label collision across restarts (#348).
3254
+ logExecution(statusPath, `Iteration ${state.totalIterations} summary`, `+${progressDelta} checkboxes, completed: ${completedNames}`);
3255
+ ctx.ui.notify(`Iteration ${state.totalIterations}: completed ${completedNames} (+${progressDelta} checkboxes)`, "info");
2891
3256
  } else if (progressDelta > 0) {
2892
- logExecution(statusPath, `Iteration ${iter + 1} summary`, `+${progressDelta} checkboxes, no steps fully completed`);
2893
- ctx.ui.notify(`Iteration ${iter + 1}: +${progressDelta} checkboxes (no steps fully completed)`, "info");
3257
+ logExecution(statusPath, `Iteration ${state.totalIterations} summary`, `+${progressDelta} checkboxes, no steps fully completed`);
3258
+ ctx.ui.notify(`Iteration ${state.totalIterations}: +${progressDelta} checkboxes (no steps fully completed)`, "info");
2894
3259
  }
2895
3260
 
2896
3261
  // Reviews are now driven inline by the worker via the review_step
@@ -3113,25 +3478,29 @@ export default function (pi: ExtensionAPI) {
3113
3478
 
3114
3479
  // ── Worker ───────────────────────────────────────────────────────
3115
3480
 
3116
- async function runWorker(remainingSteps: StepInfo[], ctx: ExtensionContext): Promise<void> {
3481
+ /** Pre-generated sidecar paths for stable identity across iterations (TP-097). */
3482
+ type StableSidecarPaths = { sidecarPath: string; exitSummaryPath: string };
3483
+
3484
+ async function runWorker(
3485
+ remainingSteps: StepInfo[],
3486
+ ctx: ExtensionContext,
3487
+ stableSidecar?: StableSidecarPaths | null,
3488
+ sharedTailState?: SidecarTailState | null,
3489
+ ): Promise<void> {
3117
3490
  if (!state.task || !state.config) return;
3118
3491
 
3119
3492
  const task = state.task;
3120
3493
  const config = state.config;
3121
3494
  const statusPath = join(task.taskFolder, "STATUS.md");
3122
3495
  const wrapUpFile = join(task.taskFolder, ".task-wrap-up");
3123
- const legacyWrapUpFile = join(task.taskFolder, ".wiggum-wrap-up");
3124
3496
 
3125
3497
  const clearWrapUpSignals = () => {
3126
3498
  if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch {}
3127
- if (existsSync(legacyWrapUpFile)) try { unlinkSync(legacyWrapUpFile); } catch {}
3128
3499
  };
3129
3500
 
3130
3501
  const writeWrapUpSignal = (reason: string) => {
3131
3502
  const msg = `${reason} at ${new Date().toISOString()}`;
3132
3503
  if (!existsSync(wrapUpFile)) writeFileSync(wrapUpFile, msg);
3133
- // Backward compatibility: write legacy signal too until all workers migrate.
3134
- if (!existsSync(legacyWrapUpFile)) writeFileSync(legacyWrapUpFile, msg);
3135
3504
  };
3136
3505
 
3137
3506
  clearWrapUpSignals();
@@ -3219,8 +3588,8 @@ export default function (pi: ExtensionAPI) {
3219
3588
  `5. Check for wrap-up signal files before starting the next step`,
3220
3589
  `6. Proceed to the next incomplete step`,
3221
3590
  ``,
3222
- `Wrap-up signal files: ${wrapUpFile} (primary), ${legacyWrapUpFile} (legacy)`,
3223
- `Check for either file after each checkpoint. If one exists, stop.`,
3591
+ `Wrap-up signal file: ${wrapUpFile}`,
3592
+ `Check for this file after each checkpoint. If it exists, stop.`,
3224
3593
  archiveSuppression,
3225
3594
  contextDocsList,
3226
3595
  ].join("\n");
@@ -3229,7 +3598,10 @@ export default function (pi: ExtensionAPI) {
3229
3598
  state.workerElapsed = 0;
3230
3599
  state.workerContextPct = 0;
3231
3600
  state.workerLastTool = "";
3232
- state.workerToolCount = 0;
3601
+ // TP-095: Don't reset workerToolCount accumulate across iterations (#334).
3602
+ // Previous behavior zeroed the counter on each iteration, losing totals
3603
+ // when a worker crashed and restarted. Token/cost counters already
3604
+ // accumulate via += in onTelemetry and were never reset here.
3233
3605
  state.workerRetryActive = false;
3234
3606
  state.workerRetryCount = 0;
3235
3607
  state.workerLastRetryError = "";
@@ -3257,6 +3629,8 @@ export default function (pi: ExtensionAPI) {
3257
3629
  const warnPct = config.context.warn_percent;
3258
3630
  const killPct = config.context.kill_percent;
3259
3631
  console.error(`[task-runner] worker context window: ${contextWindow} (${contextWindowSource})`);
3632
+ // One-shot warning when pi doesn't provide authoritative contextUsage (TP-094)
3633
+ let warnedNoContextUsage = false;
3260
3634
 
3261
3635
  if (spawnMode === "tmux") {
3262
3636
  // ── TMUX mode ────────────────────────────────────────
@@ -3265,6 +3639,12 @@ export default function (pi: ExtensionAPI) {
3265
3639
  // Kill via wall-clock timeout (context-% wrap-up also available via sidecar).
3266
3640
  const sessionName = `${getTmuxPrefix()}-worker`;
3267
3641
 
3642
+ // TP-090: Construct .steering-pending path for worker-only STATUS.md annotation.
3643
+ // Only set when running under orchestrator (mailbox is orch-only).
3644
+ const steeringPendingPath = isOrchestratedMode()
3645
+ ? join(task.taskFolder, ".steering-pending")
3646
+ : undefined;
3647
+
3268
3648
  const spawned = spawnAgentTmux({
3269
3649
  sessionName,
3270
3650
  cwd: ctx.cwd,
@@ -3274,6 +3654,11 @@ export default function (pi: ExtensionAPI) {
3274
3654
  tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
3275
3655
  thinking: config.worker.thinking || "off",
3276
3656
  taskId: task.taskId,
3657
+ steeringPendingPath,
3658
+ // TP-097: Pass stable sidecar paths and shared tailState for cross-iteration identity
3659
+ sidecarPath: stableSidecar?.sidecarPath,
3660
+ exitSummaryPath: stableSidecar?.exitSummaryPath,
3661
+ tailState: sharedTailState ?? undefined,
3277
3662
  onTelemetry: (delta) => {
3278
3663
  // Accumulate tokens and cost (same as subprocess onTokenUpdate)
3279
3664
  state.workerInputTokens += delta.inputTokens;
@@ -3295,14 +3680,10 @@ export default function (pi: ExtensionAPI) {
3295
3680
  state.workerLastRetryError = delta.lastRetryError;
3296
3681
  }
3297
3682
 
3298
- // Context % — prefer authoritative contextUsage from pi ≥ 0.63.0,
3299
- // fall back to manual calculation from totalTokens + cacheRead.
3300
- {
3301
- const pct = delta.contextUsage
3302
- ? delta.contextUsage.percentUsed
3303
- : (delta.latestTotalTokens > 0 && contextWindow > 0)
3304
- ? (delta.latestTotalTokens / contextWindow) * 100
3305
- : 0;
3683
+ // Context % — authoritative contextUsage only (pi ≥ 0.63.0, TP-094)
3684
+ // Manual token-based fallback removed: avoids false thresholds on older pi.
3685
+ if (delta.contextUsage) {
3686
+ const pct = delta.contextUsage.percent;
3306
3687
  if (pct > 0) {
3307
3688
  state.workerContextPct = pct;
3308
3689
  if (pct >= warnPct) {
@@ -3314,6 +3695,10 @@ export default function (pi: ExtensionAPI) {
3314
3695
  spawned.kill();
3315
3696
  }
3316
3697
  }
3698
+ } else if (delta.sawStatsResponseWithoutContextUsage && !warnedNoContextUsage) {
3699
+ // One-shot warning: pi responded to get_session_stats but omitted contextUsage (older pi)
3700
+ warnedNoContextUsage = true;
3701
+ console.error(`[task-runner] warning: pi did not provide contextUsage — context pressure thresholds disabled`);
3317
3702
  }
3318
3703
 
3319
3704
  updateWidgets();