From 1439f5e8380330d31e08bcd9ade632d1a27d3d38 Mon Sep 17 00:00:00 2001 From: Issue Reproducer Date: Thu, 18 Jun 2026 15:00:44 +0000 Subject: [PATCH 001/405] reproduce(issue-1720): add reproduction script for brew opencode detection on macOS --- scripts/reproduce-issue-1720.mjs | 322 +++++++++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 scripts/reproduce-issue-1720.mjs diff --git a/scripts/reproduce-issue-1720.mjs b/scripts/reproduce-issue-1720.mjs new file mode 100644 index 00000000..0844bbb8 --- /dev/null +++ b/scripts/reproduce-issue-1720.mjs @@ -0,0 +1,322 @@ +#!/usr/bin/env node +/** + * Reproduction script for issue #1720: + * Mac initial setup process does not detect opencode installed via brew. + * + * This script simulates the EXACT opencode binary detection chain that + * runs during initial setup (first launch) of the OpenChamber desktop app + * on macOS. + * + * Detection chain in packages/web/server/lib/opencode/env-runtime.js: + * Step 1: Check env vars (OPENCODE_BINARY, OPENCODE_PATH, etc.) + * Step 2: searchPathFor('opencode') — walks process.env.PATH + * Step 3: Hardcoded fallback paths (includes /opt/homebrew/bin/opencode, + * /usr/local/bin/opencode, etc.) + * Step 4: Shell probing — $SHELL -lic 'command -v opencode' + * (⚠️ NO TIMEOUT on spawnSync) + * + * On macOS, apps launched from the Dock/Finder inherit a minimal PATH: + * /usr/bin:/bin:/usr/sbin:/sbin + * This does NOT include any brew bin directories. + * + * The Electron main process (main.mjs) tries to augment PATH by probing + * the user's login shell with: + * spawnSync($SHELL, ['-il', '-c', 'env -0'], { timeout: 5000 }) + * + * If the shell startup is slow (>5s due to nvm, pyenv, etc.), this times + * out and PATH stays minimal. The server-side probing (in index.js:652) + * also calls spawnSync but with NO timeout at all — potentially hanging + * the module initialization indefinitely. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); + +// For accurate reproduction, try to find the actual env-runtime.js +const ENV_RUNTIME_PATH = path.resolve( + path.dirname(__filename), + '..', + 'packages/web/server/lib/opencode/env-runtime.js' +); + +// ── Helpers ────────────────────────────────────────────────── + +function isExecutable(filePath) { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) return false; + if (process.platform === 'win32') { + const ext = path.extname(filePath).toLowerCase(); + if (!ext) return true; + return ['.exe', '.cmd', '.bat', '.com'].includes(ext); + } + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function searchPathFor(binaryName, envPath) { + const trimmed = typeof binaryName === 'string' ? binaryName.trim() : ''; + if (!trimmed) return null; + const current = typeof envPath === 'string' ? envPath : ''; + const parts = current.split(path.delimiter).filter(Boolean); + const candidateNames = [trimmed]; + for (const dir of parts) { + for (const candidateName of candidateNames) { + const candidate = path.join(dir, candidateName); + if (isExecutable(candidate)) return candidate; + } + } + return null; +} + +// ── Print section header ───────────────────────────────────── + +function section(title) { + console.log(`\n${'─'.repeat(60)}`); + console.log(` ${title}`); + console.log(`${'─'.repeat(60)}`); +} + +// ── Main reproduction ──────────────────────────────────────── + +async function main() { + console.log(`🔍 Issue #1720 Reproduction`); + console.log(` Platform: ${process.platform} (${process.arch})`); + console.log(` Node: ${process.version}`); + console.log(` env-runtime.js: ${fs.existsSync(ENV_RUNTIME_PATH) ? 'found' : 'not found'}`); + console.log(` Timestamp: ${new Date().toISOString()}`); + console.log(); + + // ── Step 0: Find actual opencode binary on system ───────── + + section('Step 0: Locate opencode on this system'); + + let whichPath = null; + try { + const r = spawnSync('which', ['opencode'], { encoding: 'utf8' }); + if (r.status === 0 && r.stdout.trim()) { + whichPath = r.stdout.trim(); + console.log(` 'which opencode' → ${whichPath}`); + console.log(` Is executable: ${isExecutable(whichPath)}`); + if (isExecutable(whichPath)) { + const realPath = fs.realpathSync(whichPath); + console.log(` Real path (resolved symlinks): ${realPath}`); + } + } else { + console.log(` 'which opencode' failed (status ${r.status})`); + } + } catch (err) { + console.log(` 'which opencode' error: ${err.message}`); + } + + // Also try 'command -v opencode' + try { + const r = spawnSync('sh', ['-c', 'command -v opencode'], { encoding: 'utf8' }); + if (r.status === 0 && r.stdout.trim()) { + console.log(` 'command -v opencode' → ${r.stdout.trim()}`); + } + } catch {} + + console.log(); + + // ── Step 1: Check brew prefix ───────────────────────────── + + section('Step 1: Determine brew installation path'); + + for (const cmd of ['/opt/homebrew/bin/brew', '/usr/local/bin/brew']) { + if (isExecutable(cmd)) { + try { + const r = spawnSync(cmd, ['--prefix'], { encoding: 'utf8' }); + if (r.status === 0) { + const prefix = r.stdout.trim(); + console.log(` Brew at ${cmd}, prefix: ${prefix}`); + console.log(` Expected opencode path: ${path.join(prefix, 'bin', 'opencode')}`); + console.log(` Exists & executable: ${isExecutable(path.join(prefix, 'bin', 'opencode'))}`); + } + } catch {} + } + } + + // Check if opencode exists at known brew paths + for (const candidate of ['/opt/homebrew/bin/opencode', '/usr/local/bin/opencode']) { + const marker = isExecutable(candidate) ? '✓ EXISTS' : '✗ NOT FOUND'; + console.log(` ${marker} ${candidate}`); + } + + // ── Step 2: Simulate Dock-launched environment ──────────── + + section('Step 2: Simulate macOS Dock/Finder-launched environment'); + + // On macOS, the Dock gives a minimal PATH + const DOCK_PATH = '/usr/bin:/bin:/usr/sbin:/sbin'; + const RUNNER_PATH = process.env.PATH || ''; + const isDockLike = RUNNER_PATH === DOCK_PATH; + + console.log(` Actual PATH ${isDockLike ? '=' : '≠'} Dock PATH`); + console.log(` Actual: ${RUNNER_PATH}`); + console.log(` Dock: ${DOCK_PATH}`); + console.log(` SHELL: ${process.env.SHELL || '(not set)'}`); + + // ── Step 3: Run the detection chain ─────────────────────── + + section('Step 3: Run opencode binary detection chain'); + + // 3a. Env vars (fresh install: none set) + console.log('\n 🔹 Step 3a: Environment variables'); + const envVars = ['OPENCODE_BINARY', 'OPENCODE_PATH', 'OPENCHAMBER_OPENCODE_PATH', 'OPENCHAMBER_OPENCODE_BIN']; + for (const v of envVars) { + console.log(` ${v}=${process.env[v] || '(not set)'}`); + } + + // 3b. PATH search + console.log('\n 🔹 Step 3b: PATH search (searchPathFor)'); + const envPath = process.env.PATH || DOCK_PATH; + const pathResult = searchPathFor('opencode', envPath); + if (pathResult) { + console.log(` ✓ Found: ${pathResult}`); + } else { + console.log(` ✗ Not found in PATH`); + console.log(` (PATH=${envPath})`); + } + + // 3c. Hardcoded fallbacks + console.log('\n 🔹 Step 3c: Hardcoded fallback paths'); + const home = os.homedir(); + const unixFallbacks = [ + path.join(home, '.opencode', 'bin', 'opencode'), + path.join(home, '.bun', 'bin', 'opencode'), + path.join(home, '.local', 'bin', 'opencode'), + path.join(home, 'bin', 'opencode'), + '/opt/homebrew/bin/opencode', + '/usr/local/bin/opencode', + '/usr/bin/opencode', + '/bin/opencode', + ]; + + let foundInFallbacks = false; + for (const candidate of unixFallbacks) { + const ok = isExecutable(candidate); + console.log(` ${ok ? '✓' : '✗'} ${candidate}`); + if (ok) foundInFallbacks = true; + } + + // 3d. Shell probing (last resort) + console.log('\n 🔹 Step 3d: Shell probing (last resort)'); + const shellCandidates = [ + process.env.SHELL || '', + '/bin/zsh', + '/bin/bash', + '/bin/sh', + ].filter(Boolean).filter((s) => isExecutable(s)); + + console.log(` Available shells: ${shellCandidates.length > 0 ? shellCandidates.join(', ') : 'NONE'}`); + + let shellFound = false; + for (const shell of shellCandidates) { + try { + console.log(` Probing: ${shell} -lic 'command -v opencode'`); + const result = spawnSync(shell, ['-lic', 'command -v opencode'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + timeout: 10000, + }); + if (result.status === 0) { + const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + console.log(` ✓ Found: ${found}`); + shellFound = true; + break; + } + } + if (result.error) { + console.log(` ⚠ Error: ${result.error.message}`); + } else if (result.status !== 0) { + console.log(` ✗ Status ${result.status}`); + } + } catch (err) { + console.log(` ⚠ Exception: ${err.message}`); + } + } + + if (!shellFound) { + console.log(' ✗ Not found via shell probing'); + } + + // ── Summary ────────────────────────────────────────────── + + section('Root Cause Analysis'); + + const found = pathResult || foundInFallbacks || shellFound || (whichPath && isExecutable(whichPath)); + + if (found) { + console.log(` + ✅ The binary CAN be found by at least one detection method on + this system. However, this does not rule out the bug on all + macOS configurations. + + ⚠️ POTENTIAL ISSUES (macOS-specific): + + 1. SHELL PROBING HAS NO TIMEOUT (env-runtime.js:366): + The shell probing step at line 366 calls spawnSync without + a timeout. If the user's shell startup is slow (>5s due to + nvm, pyenv, etc.), this blocks indefinitely. + + 2. ELECTRON PROBE HAS 5s TIMEOUT (main.mjs:1023): + The Electron main process probes the shell with a 5-second + timeout. If the shell takes >5s, PATH is NOT augmented. + + 3. SERVER-SIDE SHELL PROBE HAS NO TIMEOUT (env-runtime.js:205): + The server's getLoginShellEnvSnapshot() also has no timeout, + potentially blocking module initialization at index.js:652. + + 4. FILE-SYSTEM PERMISSION ISSUE: + On macOS with Full Disk Protection, the Electron sandbox + may restrict access to files outside the app container. + + 5. BREW INSTALLATION PATH NOT COVERED: + If brew is installed at a custom prefix (not /opt/homebrew + or /usr/local), the hardcoded fallbacks won't match. +`); + } else { + console.log(` + ❌ The binary was NOT found by ANY detection method. This + confirms the root cause: the detection chain failed. + + The hardcoded fallback paths only cover: + - /opt/homebrew/bin/opencode (Apple Silicon standard) + - /usr/local/bin/opencode (Intel standard) + + If opencode is at a different location, it won't be found. +`); + } + + console.log(` Binary path (from 'which opencode'): ${whichPath || '(not found)'}`); + if (whichPath && !unixFallbacks.includes(whichPath)) { + console.log(` ⚠️ NOT in hardcoded fallbacks! + The detected path ${whichPath} is not among the hardcoded + fallback paths. This is likely the root cause.`); + } + + console.log(` + ────────────────────────────────────────────────────────── + Recommended fixes: + 1. Add a timeout to ALL spawnSync calls in the detection chain + (especially env-runtime.js lines 205, 366) + 2. Consider using 'which opencode' or 'command -v opencode' + as a standalone fallback with a short timeout + 3. The existing hardcoded paths DO cover standard brew paths, + so if the binary is at /opt/homebrew/bin/opencode or + /usr/local/bin/opencode, detection should work +`); +} + +main().catch(console.error); From 13caa698f3a00b13f7f69dc70fd6853cd06e15b3 Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:14:25 +0100 Subject: [PATCH 002/405] server: add 5s timeout to all shell probes in env-runtime.js --- packages/web/server/lib/opencode/env-runtime.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 998128d3..892fb1ba 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -4,6 +4,8 @@ import os from 'node:os'; import path from 'node:path'; import { mergePathValues } from './path-utils.js'; +const SHELL_PROBE_TIMEOUT_MS = 5_000; + export const createOpenCodeEnvRuntime = (deps) => { const { state, @@ -207,6 +209,7 @@ export const createOpenCodeEnvRuntime = (deps) => { stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 10 * 1024 * 1024, windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status !== 0) { @@ -367,6 +370,7 @@ export const createOpenCodeEnvRuntime = (deps) => { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; @@ -434,6 +438,7 @@ export const createOpenCodeEnvRuntime = (deps) => { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; @@ -515,6 +520,7 @@ export const createOpenCodeEnvRuntime = (deps) => { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; From 3d436d88e84dbc7de256d0216b776bd293090db8 Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:05:24 +0100 Subject: [PATCH 003/405] server: add fast command -v path before login shell probing --- .../web/server/lib/opencode/env-runtime.js | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 892fb1ba..a9b919cf 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -362,6 +362,30 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } + // Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). + // This is much faster than the full login shell probe below and catches + // standard brew paths even when launched with minimal PATH. + if (process.platform !== 'win32') { + try { + const fastResult = runSpawnSync('/bin/sh', ['-c', 'command -v opencode'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, + }); + if (fastResult.status === 0) { + const found = (fastResult.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + clearWslOpencodeResolution(); + state.resolvedOpencodeBinarySource = 'shell'; + return found; + } + } + } catch { + // Fall through to login shell probe + } + } + const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); for (const shell of shells) { if (!isExecutable(shell)) continue; @@ -430,6 +454,28 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } + // Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). + // This is much faster than the full login shell probe below and catches + // standard brew paths even when launched with minimal PATH. + if (process.platform !== 'win32') { + try { + const fastResult = runSpawnSync('/bin/sh', ['-c', 'command -v node'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, + }); + if (fastResult.status === 0) { + const found = (fastResult.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + return found; + } + } + } catch { + // Fall through to login shell probe + } + } + const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); for (const shell of shells) { if (!isExecutable(shell)) continue; @@ -512,6 +558,28 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } + // Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). + // This is much faster than the full login shell probe below and catches + // standard brew paths even when launched with minimal PATH. + if (process.platform !== 'win32') { + try { + const fastResult = runSpawnSync('/bin/sh', ['-c', 'command -v bun'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, + }); + if (fastResult.status === 0) { + const found = (fastResult.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + return found; + } + } + } catch { + // Fall through to login shell probe + } + } + const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); for (const shell of shells) { if (!isExecutable(shell)) continue; From d781c302e207be395b6a5c5c25c05adde8b44280 Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:09:15 +0100 Subject: [PATCH 004/405] server+vsce: fix brew path coverage (TOOLCHAIN_SEGMENTS, fallback order) --- packages/vscode/src/opencode.ts | 2 +- packages/web/server/lib/opencode/path-utils.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index edcb47a9..a85ef453 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -317,8 +317,8 @@ function resolveOpencodeCliPath(): string | null { path.join(home, '.opencode', 'bin', 'opencode'), path.join(home, '.bun', 'bin', 'opencode'), path.join(home, '.local', 'bin', 'opencode'), - '/usr/local/bin/opencode', '/opt/homebrew/bin/opencode', + '/usr/local/bin/opencode', path.join(home, 'bin', 'opencode'), ]; diff --git a/packages/web/server/lib/opencode/path-utils.js b/packages/web/server/lib/opencode/path-utils.js index 4803593a..732c4921 100644 --- a/packages/web/server/lib/opencode/path-utils.js +++ b/packages/web/server/lib/opencode/path-utils.js @@ -12,6 +12,7 @@ const TOOLCHAIN_SEGMENTS = [ '/opt/pkg/', '/opt/pmk/', '/snap/', + '/usr/local/', ]; const TOOLCHAIN_BASENAMES = new Set([ From 390a7d0a66f00f9fa79860846f26f1d0b4cf1b14 Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:11:18 +0100 Subject: [PATCH 005/405] scripts: update reproduction script with fix documentation --- scripts/reproduce-issue-1720.mjs | 95 +++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 31 deletions(-) diff --git a/scripts/reproduce-issue-1720.mjs b/scripts/reproduce-issue-1720.mjs index 0844bbb8..603a52bf 100644 --- a/scripts/reproduce-issue-1720.mjs +++ b/scripts/reproduce-issue-1720.mjs @@ -10,10 +10,11 @@ * Detection chain in packages/web/server/lib/opencode/env-runtime.js: * Step 1: Check env vars (OPENCODE_BINARY, OPENCODE_PATH, etc.) * Step 2: searchPathFor('opencode') — walks process.env.PATH - * Step 3: Hardcoded fallback paths (includes /opt/homebrew/bin/opencode, + * Step 3a: Hardcoded fallback paths (includes /opt/homebrew/bin/opencode, * /usr/local/bin/opencode, etc.) + * Step 3b: Fast-path: /bin/sh -c 'command -v opencode' (with timeout) * Step 4: Shell probing — $SHELL -lic 'command -v opencode' - * (⚠️ NO TIMEOUT on spawnSync) + * (with timeout, fixed) * * On macOS, apps launched from the Dock/Finder inherit a minimal PATH: * /usr/bin:/bin:/usr/sbin:/sbin @@ -24,9 +25,9 @@ * spawnSync($SHELL, ['-il', '-c', 'env -0'], { timeout: 5000 }) * * If the shell startup is slow (>5s due to nvm, pyenv, etc.), this times - * out and PATH stays minimal. The server-side probing (in index.js:652) - * also calls spawnSync but with NO timeout at all — potentially hanging - * the module initialization indefinitely. + * out and PATH stays minimal. The fast-path (Step 3b) catches standard brew + * paths even with minimal PATH, and all shell probes now have a 5s timeout + * to prevent blocking startup indefinitely. */ import fs from 'node:fs'; @@ -208,6 +209,30 @@ async function main() { if (ok) foundInFallbacks = true; } + // 3c-fp. Fast-path (command -v via /bin/sh) + console.log('\n 🔹 Step 3c-fp: Fast-path (command -v via /bin/sh)'); + let fastPathFound = false; + try { + const fastResult = spawnSync('/bin/sh', ['-c', 'command -v opencode'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + }); + if (fastResult.status === 0) { + const found = (fastResult.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + console.log(` ✓ Found: ${found}`); + fastPathFound = true; + } else { + console.log(` ✗ Not found (${found || 'empty'})`); + } + } else { + console.log(` ✗ Status ${fastResult.status} (${fastResult.error?.message || ''})`); + } + } catch (err) { + console.log(` ⚠ Exception: ${err.message}`); + } + // 3d. Shell probing (last resort) console.log('\n 🔹 Step 3d: Shell probing (last resort)'); const shellCandidates = [ @@ -255,7 +280,7 @@ async function main() { section('Root Cause Analysis'); - const found = pathResult || foundInFallbacks || shellFound || (whichPath && isExecutable(whichPath)); + const found = pathResult || foundInFallbacks || fastPathFound || shellFound || (whichPath && isExecutable(whichPath)); if (found) { console.log(` @@ -263,28 +288,36 @@ async function main() { this system. However, this does not rule out the bug on all macOS configurations. - ⚠️ POTENTIAL ISSUES (macOS-specific): + 🔧 FIXES APPLIED (issue #1720): - 1. SHELL PROBING HAS NO TIMEOUT (env-runtime.js:366): - The shell probing step at line 366 calls spawnSync without - a timeout. If the user's shell startup is slow (>5s due to - nvm, pyenv, etc.), this blocks indefinitely. + 1. ✅ SHELL PROBING TIMEOUT (env-runtime.js): + All 4 shell probe spawnSync calls now have a 5-second + timeout. Prevents indefinite blocking on slow shells. - 2. ELECTRON PROBE HAS 5s TIMEOUT (main.mjs:1023): - The Electron main process probes the shell with a 5-second - timeout. If the shell takes >5s, PATH is NOT augmented. + 2. ✅ FAST-PATH PROBE (env-runtime.js): + Added /bin/sh -c 'command -v opencode' before the full + login shell probe. Catches brew binaries at ~10ms instead + of potentially seconds. (Standard brew paths are also + covered by hardcoded fallbacks.) - 3. SERVER-SIDE SHELL PROBE HAS NO TIMEOUT (env-runtime.js:205): - The server's getLoginShellEnvSnapshot() also has no timeout, - potentially blocking module initialization at index.js:652. + 3. ✅ TOOLCHAIN_SEGMENTS (path-utils.js): + Added '/usr/local/' to TOOLCHAIN_SEGMENTS so a PATH + containing /usr/local/bin is recognized as user-configured. - 4. FILE-SYSTEM PERMISSION ISSUE: - On macOS with Full Disk Protection, the Electron sandbox - may restrict access to files outside the app container. + 4. ✅ VS CODE FALLBACK ORDER (opencode.ts): + Brew fallback order now matches server: /opt/homebrew + (Apple Silicon) before /usr/local (Intel). - 5. BREW INSTALLATION PATH NOT COVERED: - If brew is installed at a custom prefix (not /opt/homebrew - or /usr/local), the hardcoded fallbacks won't match. + ⚠️ REMAINING ISSUES (not addressed by this fix): + + 1. FILE-SYSTEM PERMISSION ISSUE: + On macOS with Full Disk Protection, the Electron sandbox + may restrict access to files outside the app container. + + 2. CUSTOM BREW PREFIX: + If brew is installed at a custom prefix (not /opt/homebrew + or /usr/local), the hardcoded fallbacks won't match — but + the fast-path (command -v via sh) may still find it. `); } else { console.log(` @@ -308,14 +341,14 @@ async function main() { console.log(` ────────────────────────────────────────────────────────── - Recommended fixes: - 1. Add a timeout to ALL spawnSync calls in the detection chain - (especially env-runtime.js lines 205, 366) - 2. Consider using 'which opencode' or 'command -v opencode' - as a standalone fallback with a short timeout - 3. The existing hardcoded paths DO cover standard brew paths, - so if the binary is at /opt/homebrew/bin/opencode or - /usr/local/bin/opencode, detection should work + Fixes applied: + 1. ✅ All 4 shell probe spawnSync calls now have a 5s timeout + (SHELL_PROBE_TIMEOUT_MS, added at env-runtime.js module level) + 2. ✅ Fast-path via /bin/sh -c 'command -v opencode' added + before login shell probing in all 3 resolvers + 3. ✅ /usr/local/ added to TOOLCHAIN_SEGMENTS in path-utils.js + 4. ✅ Brew fallback order fixed in VS Code extension + (/opt/homebrew before /usr/local) `); } From df669a4ded4f4429d7115ed0adac63052f734a9b Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:12:15 +0100 Subject: [PATCH 006/405] changelog: document brew opencode detection fix --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c18a394..17bd9515 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Desktop: opencode installed via Homebrew on macOS is now found even when the app is launched from the Dock — shell probes won't block startup, a fast `command -v` catch catches brew paths without sourcing shell config, and brew path ordering is consistent across runtimes (issue #1720). + ## [1.13.2] - 2026-06-18 - Chat/Performance: long conversations and large session lists now stay smooth and responsive while a response is streaming (thanks to @bashrusakh). From 2cbbf3ab153b574a0fc2c8314779f7d345153405 Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:16:49 +0100 Subject: [PATCH 007/405] review fixes: align reproduction script timeout, tighten comments --- .../web/server/lib/opencode/env-runtime.js | 21 +++++++++++-------- scripts/reproduce-issue-1720.mjs | 2 +- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index a9b919cf..1ee396c7 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -362,9 +362,10 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } - // Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). - // This is much faster than the full login shell probe below and catches - // standard brew paths even when launched with minimal PATH. +// Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). +// This is much faster than the full login shell probe below and catches +// brew paths when the Electron login shell env merge already augmented PATH +// or when /bin/sh has a broader default PATH than the process. if (process.platform !== 'win32') { try { const fastResult = runSpawnSync('/bin/sh', ['-c', 'command -v opencode'], { @@ -454,9 +455,10 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } - // Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). - // This is much faster than the full login shell probe below and catches - // standard brew paths even when launched with minimal PATH. +// Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). +// This is much faster than the full login shell probe below and catches +// brew paths when the Electron login shell env merge already augmented PATH +// or when /bin/sh has a broader default PATH than the process. if (process.platform !== 'win32') { try { const fastResult = runSpawnSync('/bin/sh', ['-c', 'command -v node'], { @@ -558,9 +560,10 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } - // Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). - // This is much faster than the full login shell probe below and catches - // standard brew paths even when launched with minimal PATH. +// Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). +// This is much faster than the full login shell probe below and catches +// brew paths when the Electron login shell env merge already augmented PATH +// or when /bin/sh has a broader default PATH than the process. if (process.platform !== 'win32') { try { const fastResult = runSpawnSync('/bin/sh', ['-c', 'command -v bun'], { diff --git a/scripts/reproduce-issue-1720.mjs b/scripts/reproduce-issue-1720.mjs index 603a52bf..e4c60002 100644 --- a/scripts/reproduce-issue-1720.mjs +++ b/scripts/reproduce-issue-1720.mjs @@ -252,7 +252,7 @@ async function main() { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, - timeout: 10000, + timeout: 5000, }); if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; From 474ec6c0378aa0271010a157f04da2fc90a0373d Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:35:59 +0100 Subject: [PATCH 008/405] gitignore: exclude internal planning docs from upstream --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index f69072c8..0a6e6dca 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,6 @@ data/ workspaces/ *.pid .worktrees/ + +# Internal superpower planning docs (not for upstream) +docs/superpowers/ From 1ad470cead5f2bce109c222afecb4249fb8c1594 Mon Sep 17 00:00:00 2001 From: Leonid Skorobogatyy Date: Mon, 22 Jun 2026 02:09:50 +1100 Subject: [PATCH 009/405] fix(worktree): hide non-matching branches during search --- .../src/components/session/NewWorktreeDialog.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index 70be749d..363c91c4 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -1142,7 +1142,7 @@ export function NewWorktreeDialog({ )} - {existingBranchRankedGroups.otherLocal.length > 0 && ( + {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && (
{hasExistingBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')} @@ -1174,7 +1174,7 @@ export function NewWorktreeDialog({
)} - {existingBranchRankedGroups.otherRemote.length > 0 && ( + {!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && (
{hasExistingBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')} @@ -1401,7 +1401,7 @@ export function NewWorktreeDialog({
)} - {sourceBranchRankedGroups.otherLocal.length > 0 && ( + {!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && (
{hasSourceBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')} @@ -1428,7 +1428,7 @@ export function NewWorktreeDialog({
)} - {sourceBranchRankedGroups.otherRemote.length > 0 && ( + {!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && (
{hasSourceBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')} @@ -1607,7 +1607,7 @@ export function NewWorktreeDialog({
)} - {existingBranchRankedGroups.otherLocal.length > 0 && ( + {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && ( <> {hasExistingBranchQuery && } @@ -1632,7 +1632,7 @@ export function NewWorktreeDialog({ )} - {existingBranchRankedGroups.otherRemote.length > 0 && ( + {!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && ( <> {(existingBranchRankedGroups.otherLocal.length > 0 || hasExistingBranchQuery) && ( @@ -1843,7 +1843,7 @@ export function NewWorktreeDialog({
)} - {sourceBranchRankedGroups.otherLocal.length > 0 && ( + {!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && ( <> {hasSourceBranchQuery && } @@ -1863,7 +1863,7 @@ export function NewWorktreeDialog({ )} - {sourceBranchRankedGroups.otherRemote.length > 0 && ( + {!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && ( <> {(sourceBranchRankedGroups.otherLocal.length > 0 || hasSourceBranchQuery) && ( From 20f2a2635baf2d5f7dc013cb00700a661c9c0aa7 Mon Sep 17 00:00:00 2001 From: Leonid Skorobogatyy Date: Wed, 24 Jun 2026 03:51:56 +1100 Subject: [PATCH 010/405] chore(worktree): drop dead otherBranches labels after search guard The mobile and desktop (cmdk) pickers in NewWorktreeDialog now hide non-matching branches during search via a !hasExistingBranchQuery / !hasSourceBranchQuery outer guard. That makes the inner heading ternaries (which switched between 'localBranches' and 'otherLocalBranches' depending on the search state) unreachable: they always resolve to the non-query label, and the 'hasExistingBranchQuery && ' lines were dead code. This commit: - Replaces 8 heading ternaries with static non-query labels (mobile and desktop, existing/source, local/remote). - Drops the two unreachable renders in the otherLocal blocks. - Simplifies the otherRemote separator conditions by removing the always-false '|| hasExistingBranchQuery' / '|| hasSourceBranchQuery' terms. - Removes the now-unused 'session.newWorktree.otherLocalBranches' and 'session.newWorktree.otherRemoteBranches' keys from all 9 locales. Behavior is unchanged; this is dead-branch cleanup only. --- .../components/session/NewWorktreeDialog.tsx | 22 +++++++++---------- packages/ui/src/lib/i18n/messages/en.ts | 2 -- packages/ui/src/lib/i18n/messages/es.ts | 2 -- packages/ui/src/lib/i18n/messages/fr.ts | 2 -- packages/ui/src/lib/i18n/messages/ko.ts | 2 -- packages/ui/src/lib/i18n/messages/pl.ts | 2 -- packages/ui/src/lib/i18n/messages/pt-BR.ts | 2 -- packages/ui/src/lib/i18n/messages/uk.ts | 2 -- packages/ui/src/lib/i18n/messages/zh-CN.ts | 2 -- packages/ui/src/lib/i18n/messages/zh-TW.ts | 2 -- 10 files changed, 10 insertions(+), 30 deletions(-) diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index 363c91c4..8d4c16f5 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -1145,7 +1145,7 @@ export function NewWorktreeDialog({ {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && (
- {hasExistingBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')} + {t('session.newWorktree.localBranches')}
{existingBranchRankedGroups.otherLocal.map((branch) => ( @@ -1177,7 +1177,7 @@ export function NewWorktreeDialog({ {!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && (
- {hasExistingBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')} + {t('session.newWorktree.remoteBranches')}
{existingBranchRankedGroups.otherRemote.map((branch) => ( @@ -1404,7 +1404,7 @@ export function NewWorktreeDialog({ {!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && (
- {hasSourceBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')} + {t('session.newWorktree.localBranches')}
{sourceBranchRankedGroups.otherLocal.map((branch) => ( @@ -1431,7 +1431,7 @@ export function NewWorktreeDialog({ {!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && (
- {hasSourceBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')} + {t('session.newWorktree.remoteBranches')}
{sourceBranchRankedGroups.otherRemote.map((branch) => ( @@ -1609,8 +1609,7 @@ export function NewWorktreeDialog({ {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && ( <> - {hasExistingBranchQuery && } - + {existingBranchRankedGroups.otherLocal.map((branch) => ( 0 && ( <> - {(existingBranchRankedGroups.otherLocal.length > 0 || hasExistingBranchQuery) && ( + {existingBranchRankedGroups.otherLocal.length > 0 && ( )} - + {existingBranchRankedGroups.otherRemote.map((branch) => ( 0 && ( <> - {hasSourceBranchQuery && } - + {sourceBranchRankedGroups.otherLocal.map((branch) => ( 0 && ( <> - {(sourceBranchRankedGroups.otherLocal.length > 0 || hasSourceBranchQuery) && ( + {sourceBranchRankedGroups.otherLocal.length > 0 && ( )} - + {sourceBranchRankedGroups.otherRemote.map((branch) => ( = { "session.newWorktree.noMatchingBranches": "No hay ramas coincidentes", "session.newWorktree.localBranches": "Ramas locales", "session.newWorktree.remoteBranches": "Ramas remotas", - "session.newWorktree.otherLocalBranches": "Otras ramas locales", - "session.newWorktree.otherRemoteBranches": "Otras ramas remotas", "session.newWorktree.branchName": "Nombre de la rama", "session.newWorktree.branchNamePlaceholder": "feature/my-awesome-feature", "session.newWorktree.actions.change": "Cambiar", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 9d66af07..a37fb929 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1414,8 +1414,6 @@ export const dict = { 'session.newWorktree.noMatchingBranches': 'Aucune branche correspondante', 'session.newWorktree.localBranches': 'Branches locales', 'session.newWorktree.remoteBranches': 'Branches du dépôt distant', - 'session.newWorktree.otherLocalBranches': 'Autres branches locales', - 'session.newWorktree.otherRemoteBranches': 'Autres branches du remote', 'session.newWorktree.branchName': 'Nom de la branche', 'session.newWorktree.branchNamePlaceholder': 'fonctionnalité/ma-fonctionnalité-géniale', 'session.newWorktree.actions.change': 'Changement', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 06532993..1e41603f 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1551,8 +1551,6 @@ export const dict: Record = { 'session.newWorktree.noMatchingBranches': '일치하는 브랜치가 없습니다', 'session.newWorktree.localBranches': '로컬 브랜치', 'session.newWorktree.remoteBranches': '리모트 브랜치', - 'session.newWorktree.otherLocalBranches': '기타 로컬 브랜치', - 'session.newWorktree.otherRemoteBranches': '기타 리모트 브랜치', 'session.newWorktree.branchName': '브랜치 이름', 'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature', 'session.newWorktree.actions.change': '변경', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 664f62ed..b0bb914e 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2354,8 +2354,6 @@ export const dict: Record = { 'session.newWorktree.newSessionTitle': 'Nowa sesja', 'session.newWorktree.noBranchesFound': 'Nie znaleziono gałęzi', 'session.newWorktree.noMatchingBranches': 'Brak pasujących gałęzi', - 'session.newWorktree.otherLocalBranches': 'Pozostałe lokalne gałęzie', - 'session.newWorktree.otherRemoteBranches': 'Pozostałe zdalne gałęzie', 'session.newWorktree.prNumber': 'PR #{number}', 'session.newWorktree.remoteBranches': 'Zdalne gałęzie', 'session.newWorktree.resetToMatchBranchName': 'Zresetuj do nazwy zgodnej z gałęzią', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 8f345e85..c86dd4a6 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1515,8 +1515,6 @@ export const dict: Record = { "session.newWorktree.noMatchingBranches": "Não há branches coincidentes", "session.newWorktree.localBranches": "Branches locais", "session.newWorktree.remoteBranches": "Branches remotas", - "session.newWorktree.otherLocalBranches": "Outras branches locais", - "session.newWorktree.otherRemoteBranches": "Outras branches remotas", "session.newWorktree.branchName": "Nome da branch", "session.newWorktree.branchNamePlaceholder": "feature/my-awesome-feature", "session.newWorktree.actions.change": "Alterar", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 42c09339..ce5b08e2 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1515,8 +1515,6 @@ export const dict: Record = { "session.newWorktree.noMatchingBranches": "Немає відповідних гілок", "session.newWorktree.localBranches": "Локальні гілки", "session.newWorktree.remoteBranches": "Віддалені гілки", - "session.newWorktree.otherLocalBranches": "Інші локальні гілки", - "session.newWorktree.otherRemoteBranches": "Інші віддалені гілки", "session.newWorktree.branchName": "Назва гілки", "session.newWorktree.branchNamePlaceholder": "feature/my-awesome-feature", "session.newWorktree.actions.change": "Змінити", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 1ffd934d..5e4c71b5 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1515,8 +1515,6 @@ export const dict: Record = { 'session.newWorktree.noMatchingBranches': '没有匹配分支', 'session.newWorktree.localBranches': '本地分支', 'session.newWorktree.remoteBranches': '远程分支', - 'session.newWorktree.otherLocalBranches': '其他本地分支', - 'session.newWorktree.otherRemoteBranches': '其他远程分支', 'session.newWorktree.branchName': '分支名', 'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature', 'session.newWorktree.actions.change': '更改', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 08d30254..825fad27 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1519,8 +1519,6 @@ export const dict: Record = { 'session.newWorktree.noMatchingBranches': '沒有符合分支', 'session.newWorktree.localBranches': '本地分支', 'session.newWorktree.remoteBranches': '遠端分支', - 'session.newWorktree.otherLocalBranches': '其他本地分支', - 'session.newWorktree.otherRemoteBranches': '其他遠端分支', 'session.newWorktree.branchName': '分支名稱', 'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature', 'session.newWorktree.actions.change': '變更', From ecc70fa15d967bf0a43abed519a05a574b29d80f Mon Sep 17 00:00:00 2001 From: Tom Rochette Date: Wed, 24 Jun 2026 05:24:48 +0000 Subject: [PATCH 011/405] feat(debug): add fetch requests-in-flight tracker with age percentiles Adds a 'Requests' tab to the debug panel (Ctrl/Cmd+Shift+D) that wraps window.fetch while the panel is open to track every request as in-flight from call to promise settle, sampled once per second over a 5-minute rolling window. Charts: - in-flight request count over time (current + peak) - p50/p90/p99/max age distribution of currently in-flight requests, with the legend below the chart and a y-axis max label for scale Tracking is fully gated behind the panel: closing it stops the sampling interval, unwraps window.fetch, and drops all state (zero overhead when not debugging). i18n keys added to all 9 locales. --- packages/ui/src/App.tsx | 8 + .../ui/src/components/ui/MemoryDebugPanel.tsx | 180 +++++++++- packages/ui/src/lib/i18n/messages/en.ts | 11 + packages/ui/src/lib/i18n/messages/es.ts | 11 + packages/ui/src/lib/i18n/messages/fr.ts | 11 + packages/ui/src/lib/i18n/messages/ko.ts | 11 + packages/ui/src/lib/i18n/messages/pl.ts | 11 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 11 + packages/ui/src/lib/i18n/messages/uk.ts | 11 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 11 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 11 + .../ui/src/stores/utils/requestsInFlight.ts | 314 ++++++++++++++++++ 12 files changed, 594 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/stores/utils/requestsInFlight.ts diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 583d4b9d..a0c977ce 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -6,6 +6,7 @@ import { Toaster } from '@/components/ui/sonner'; import { Button } from '@/components/ui/button'; import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel'; import { setStreamPerfEnabled } from '@/stores/utils/streamDebug'; +import { setRequestsInFlightTrackingEnabled } from '@/stores/utils/requestsInFlight'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; // useEventStream removed — replaced by SyncProvider + SyncBridge import { useMenuActions } from '@/hooks/useMenuActions'; @@ -256,6 +257,13 @@ function App({ apis }: AppProps) { }; }, [showMemoryDebug]); + React.useEffect(() => { + setRequestsInFlightTrackingEnabled(showMemoryDebug); + return () => { + setRequestsInFlightTrackingEnabled(false); + }; + }, [showMemoryDebug]); + React.useEffect(() => { applyMobileKeyboardMode(mobileKeyboardMode); }, [mobileKeyboardMode]); diff --git a/packages/ui/src/components/ui/MemoryDebugPanel.tsx b/packages/ui/src/components/ui/MemoryDebugPanel.tsx index 901d8f80..957e654b 100644 --- a/packages/ui/src/components/ui/MemoryDebugPanel.tsx +++ b/packages/ui/src/components/ui/MemoryDebugPanel.tsx @@ -7,6 +7,7 @@ import { MEMORY_LIMITS } from '@/stores/types/sessionTypes'; import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { getBackgroundTrimLimit } from '@/stores/types/sessionTypes'; import { getStreamPerfSnapshot, getVsCodeStreamPerfSnapshot, resetStreamPerf, type StreamPerfSnapshot } from '@/stores/utils/streamDebug'; +import { getRequestsInFlightSnapshot, resetRequestsInFlight, type RequestsInFlightSnapshot } from '@/stores/utils/requestsInFlight'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; @@ -18,7 +19,7 @@ interface DebugPanelProps { onClose?: () => void; } -type DebugTab = 'memory' | 'streaming'; +type DebugTab = 'memory' | 'streaming' | 'requests'; const formatDuration = (durationMs: number): string => { if (durationMs < 1000) { @@ -35,6 +36,10 @@ const formatDuration = (durationMs: number): string => { return `${minutes}m ${remainderSeconds}s`; }; +// Fixed-width seconds format ("XX.XX s") for the percentile series so the +// legend/labels don't jitter as values change. Pair with `tabular-nums`. +const formatSeconds = (durationMs: number): string => `${(durationMs / 1000).toFixed(2)} s`; + const MetricCard: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => { return (
= ({ series, peak, windowSeconds, ariaLabel, maxLabel }) => { + const width = windowSeconds; + const height = 56; + const padTop = 4; + const n = series.reduce((max, s) => Math.max(max, s.samples.length), 0); + const scale = peak > 0 ? (height - padTop) / peak : 0; + const xFor = (i: number): number => width - n + i; + const yFor = (v: number): number => height - v * scale; + const baseline = height; + + return ( +
+ {maxLabel} + + + {series.map((s, si) => { + const sn = s.samples.length; + if (sn === 0) return null; + const points = s.samples.map((v, i) => `${xFor(i)},${yFor(v).toFixed(2)}`); + const linePath = `M ${points.join(' L ')}`; + return ( + + {s.filled ? ( + + ) : null} + + + ); + })} + +
+ ); +}; + export const DebugPanel: React.FC = ({ onClose }) => { const { t } = useI18n(); const [activeTab, setActiveTab] = React.useState('memory'); @@ -110,6 +183,15 @@ export const DebugPanel: React.FC = ({ onClose }) => { const totalGitHubRequests = useGitHubPrStatusStore((state) => state.totalRequestCount); const [streamSnapshot, setStreamSnapshot] = React.useState(() => getStreamPerfSnapshot()); const [vscodeStreamSnapshot, setVsCodeStreamSnapshot] = React.useState(() => getVsCodeStreamPerfSnapshot()); + const [requestsSnapshot, setRequestsSnapshot] = React.useState(() => getRequestsInFlightSnapshot()); + const ageLines = [ + { label: 'p50', current: requestsSnapshot.ageP50, samples: requestsSnapshot.p50Samples, color: 'var(--status-success)' }, + { label: 'p90', current: requestsSnapshot.ageP90, samples: requestsSnapshot.p90Samples, color: 'var(--status-info)' }, + { label: 'p99', current: requestsSnapshot.ageP99, samples: requestsSnapshot.p99Samples, color: 'var(--status-warning)' }, + { label: 'max', current: requestsSnapshot.ageMax, samples: requestsSnapshot.maxSamples, color: 'var(--status-error)' }, + ]; + const countMax = requestsSnapshot.samples.reduce((m, v) => Math.max(m, v), 0); + const percentileMax = ageLines.reduce((m, l) => l.samples.reduce((mm, v) => Math.max(mm, v), m), 0); const streamMetricCounts = React.useMemo(() => { const counts = new Map(); streamSnapshot.entries.forEach((entry) => { @@ -130,6 +212,7 @@ export const DebugPanel: React.FC = ({ onClose }) => { const refresh = () => { setStreamSnapshot(getStreamPerfSnapshot()); setVsCodeStreamSnapshot(getVsCodeStreamPerfSnapshot()); + setRequestsSnapshot(getRequestsInFlightSnapshot()); }; refresh(); @@ -218,11 +301,10 @@ export const DebugPanel: React.FC = ({ onClose }) => { >
- {activeTab === 'memory' ? ( - - ) : ( - - )} +

{t('memoryDebugPanel.title')}

@@ -244,6 +326,18 @@ export const DebugPanel: React.FC = ({ onClose }) => { ) : null} + {activeTab === 'requests' ? ( + + ) : null} {onClose ? ( +
{activeTab === 'memory' ? ( @@ -366,7 +468,7 @@ export const DebugPanel: React.FC = ({ onClose }) => {
- ) : ( + ) : activeTab === 'streaming' ? (
@@ -409,6 +511,70 @@ export const DebugPanel: React.FC = ({ onClose }) => { /> ) : null}
+ ) : ( +
+
+ + +
+ + {requestsSnapshot.samples.length === 0 ? ( +
+ {t('memoryDebugPanel.requests.noSamples')} +
+ ) : ( +
+
+ {t('memoryDebugPanel.requests.inFlight')} + + {requestsSnapshot.inFlight} + · {t('memoryDebugPanel.requests.peak')} + {requestsSnapshot.peak} + +
+ + +
+ {t('memoryDebugPanel.requests.duration')} + {formatSeconds(requestsSnapshot.peakAgeMs)} +
+ ({ samples: line.samples, color: line.color }))} + peak={percentileMax} + windowSeconds={requestsSnapshot.windowSeconds} + ariaLabel={t('memoryDebugPanel.requests.percentileChartLabel')} + maxLabel={formatSeconds(percentileMax)} + /> + +
+ {ageLines.map((line) => ( + + + {line.label} + {formatSeconds(line.current)} + + ))} +
+ +
+ {t('memoryDebugPanel.requests.windowHint', { seconds: requestsSnapshot.windowSeconds })} + {t('memoryDebugPanel.requests.now')} +
+
+ )} +
)} ); diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index dba49cb5..c0f6b69e 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -2537,6 +2537,7 @@ export const dict = { 'memoryDebugPanel.title': 'Debug Panel', 'memoryDebugPanel.tabs.memory': 'Memory', 'memoryDebugPanel.tabs.streaming': 'Streaming', + 'memoryDebugPanel.tabs.requests': 'Requests', 'memoryDebugPanel.section.sessionsInMemory': 'Sessions in Memory', 'memoryDebugPanel.section.uiStreamingMetrics': 'UI Streaming Metrics', 'memoryDebugPanel.section.vscodeBridgeMetrics': 'VS Code Bridge Metrics', @@ -2574,6 +2575,16 @@ export const dict = { 'memoryDebugPanel.streaming.copy.copied': 'Streaming debug JSON copied', 'memoryDebugPanel.streaming.copy.failed': 'Failed to copy JSON', 'memoryDebugPanel.streaming.copy.hint': 'Copy exports both UI and VS Code streaming metrics as JSON', + 'memoryDebugPanel.requests.inFlight': 'In flight', + 'memoryDebugPanel.requests.peak': 'Peak', + 'memoryDebugPanel.requests.duration': 'Duration', + 'memoryDebugPanel.requests.totalRequests': 'Total Requests', + 'memoryDebugPanel.requests.tracking': 'Tracking', + 'memoryDebugPanel.requests.now': 'now', + 'memoryDebugPanel.requests.noSamples': 'No requests tracked yet. Keep this panel open to record fetch activity.', + 'memoryDebugPanel.requests.chartLabel': 'Fetch requests in flight over time, peak {peak}', + 'memoryDebugPanel.requests.windowHint': 'last {seconds}s', + 'memoryDebugPanel.requests.percentileChartLabel': 'In-flight request age percentiles (p50, p90, p99, max) over time', 'memoryDebugPanel.common.idle': 'idle', 'memoryDebugPanel.common.live': 'live', 'memoryDebugPanel.common.notAvailable': 'n/a', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index a92e4127..4df60271 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2503,6 +2503,7 @@ export const dict: Record = { "memoryDebugPanel.title": "Panel de depuración", "memoryDebugPanel.tabs.memory": "Memoria", "memoryDebugPanel.tabs.streaming": "Transmisión", + "memoryDebugPanel.tabs.requests": "Solicitudes", "memoryDebugPanel.section.sessionsInMemory": "Sesiones en memoria", "memoryDebugPanel.section.uiStreamingMetrics": "Métricas de streaming de UI", "memoryDebugPanel.section.vscodeBridgeMetrics": "Métricas del puente de VS Code", @@ -2540,6 +2541,16 @@ export const dict: Record = { "memoryDebugPanel.streaming.copy.copied": "JSON de depuración en streaming copiado", "memoryDebugPanel.streaming.copy.failed": "No se pudo copiar JSON", "memoryDebugPanel.streaming.copy.hint": "Copia exportaciones de métricas de UI como de métricas de VS Code en formato JSON", + "memoryDebugPanel.requests.inFlight": "En curso", + "memoryDebugPanel.requests.peak": "Pico", + "memoryDebugPanel.requests.duration": "Duración", + "memoryDebugPanel.requests.totalRequests": "Solicitudes totales", + "memoryDebugPanel.requests.tracking": "Seguimiento", + "memoryDebugPanel.requests.now": "ahora", + "memoryDebugPanel.requests.noSamples": "Aún no se han registrado solicitudes. Mantén este panel abierto para registrar la actividad de fetch.", + "memoryDebugPanel.requests.chartLabel": "Solicitudes fetch en curso a lo largo del tiempo, pico {peak}", + "memoryDebugPanel.requests.windowHint": "últimos {seconds}s", + "memoryDebugPanel.requests.percentileChartLabel": "Percentiles de antigüedad de solicitudes en curso (p50, p90, p99, máx) a lo largo del tiempo", "memoryDebugPanel.common.idle": "inactivo", "memoryDebugPanel.common.live": "en vivo", "memoryDebugPanel.common.notAvailable": "n/a", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 9d66af07..08026779 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -2346,6 +2346,7 @@ export const dict = { 'memoryDebugPanel.title': 'Panneau de débogage', 'memoryDebugPanel.tabs.memory': 'Mémoire', 'memoryDebugPanel.tabs.streaming': 'Streaming', + 'memoryDebugPanel.tabs.requests': 'Requêtes', 'memoryDebugPanel.section.sessionsInMemory': 'Sessions en mémoire', 'memoryDebugPanel.section.uiStreamingMetrics': 'Métriques de streaming de l\'interface utilisateur', 'memoryDebugPanel.section.vscodeBridgeMetrics': 'Métriques du pont VS Code', @@ -2383,6 +2384,16 @@ export const dict = { 'memoryDebugPanel.streaming.copy.copied': 'Débogage en streaming JSON copié', 'memoryDebugPanel.streaming.copy.failed': 'Échec de la copie de JSON', 'memoryDebugPanel.streaming.copy.hint': 'La copie exporte les métriques de streaming de l\'interface utilisateur et de VS Code en tant que JSON.', + 'memoryDebugPanel.requests.inFlight': 'En cours', + 'memoryDebugPanel.requests.peak': 'Pic', + 'memoryDebugPanel.requests.duration': 'Durée', + 'memoryDebugPanel.requests.totalRequests': 'Requêtes totales', + 'memoryDebugPanel.requests.tracking': 'Suivi', + 'memoryDebugPanel.requests.now': 'maintenant', + 'memoryDebugPanel.requests.noSamples': 'Aucune requête enregistrée. Gardez ce panneau ouvert pour enregistrer l\'activité fetch.', + 'memoryDebugPanel.requests.chartLabel': 'Requêtes fetch en cours dans le temps, pic {peak}', + 'memoryDebugPanel.requests.windowHint': '{seconds}s dernières', + 'memoryDebugPanel.requests.percentileChartLabel': 'Percentiles d\'âge des requêtes en cours (p50, p90, p99, max) dans le temps', 'memoryDebugPanel.common.idle': 'inactif', 'memoryDebugPanel.common.live': 'en direct', 'memoryDebugPanel.common.notAvailable': 'n / A', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 06532993..d0d69989 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2537,6 +2537,7 @@ export const dict: Record = { 'memoryDebugPanel.title': '디버그 패널', 'memoryDebugPanel.tabs.memory': '메모리', 'memoryDebugPanel.tabs.streaming': '스트리밍', + 'memoryDebugPanel.tabs.requests': '요청', 'memoryDebugPanel.section.sessionsInMemory': '메모리 내 세션', 'memoryDebugPanel.section.uiStreamingMetrics': 'UI 스트리밍 지표', 'memoryDebugPanel.section.vscodeBridgeMetrics': 'VS Code 브리지 지표', @@ -2574,6 +2575,16 @@ export const dict: Record = { 'memoryDebugPanel.streaming.copy.copied': '스트리밍 디버그 JSON 복사 완료', 'memoryDebugPanel.streaming.copy.failed': 'JSON 복사 실패', 'memoryDebugPanel.streaming.copy.hint': 'UI와 VS Code 스트리밍 메트릭을 JSON으로 복사합니다', + 'memoryDebugPanel.requests.inFlight': '진행 중', + 'memoryDebugPanel.requests.peak': '최대', + 'memoryDebugPanel.requests.duration': '지속 시간', + 'memoryDebugPanel.requests.totalRequests': '전체 요청', + 'memoryDebugPanel.requests.tracking': '추적 중', + 'memoryDebugPanel.requests.now': '현재', + 'memoryDebugPanel.requests.noSamples': '아직 기록된 요청이 없습니다. fetch 활동을 기록하려면 이 패널을 열어 두세요.', + 'memoryDebugPanel.requests.chartLabel': '시간에 따른 진행 중인 fetch 요청, 최대 {peak}', + 'memoryDebugPanel.requests.windowHint': '최근 {seconds}초', + 'memoryDebugPanel.requests.percentileChartLabel': '진행 중 요청 수명 백분위수(p50, p90, p99, max)의 시간별 변화', 'memoryDebugPanel.common.idle': '유휴', 'memoryDebugPanel.common.live': '실시간', 'memoryDebugPanel.common.notAvailable': 'n/a', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 664f62ed..cf228b44 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2135,8 +2135,19 @@ export const dict: Record = { 'memoryDebugPanel.streaming.copy.copied': 'Skopiowano JSON debugowania streamingu', 'memoryDebugPanel.streaming.copy.failed': 'Nie udało się skopiować JSON', 'memoryDebugPanel.streaming.copy.hint': 'Kopiowanie eksportuje metryki streamingu zarówno UI, jak i VS Code w formacie JSON', + 'memoryDebugPanel.requests.inFlight': 'W trakcie', + 'memoryDebugPanel.requests.peak': 'Szczyt', + 'memoryDebugPanel.requests.duration': 'Czas trwania', + 'memoryDebugPanel.requests.totalRequests': 'Łączne żądania', + 'memoryDebugPanel.requests.tracking': 'Śledzenie', + 'memoryDebugPanel.requests.now': 'teraz', + 'memoryDebugPanel.requests.noSamples': 'Brak żądań. Utrzymuj ten panel otwarty, aby rejestrować aktywność fetch.', + 'memoryDebugPanel.requests.chartLabel': 'Żądania fetch w trakcie w czasie, szczyt {peak}', + 'memoryDebugPanel.requests.windowHint': 'ostatnie {seconds}s', + 'memoryDebugPanel.requests.percentileChartLabel': 'Percentyle wieku żądań w trakcie (p50, p90, p99, max) w czasie', 'memoryDebugPanel.tabs.memory': 'Pamięć', 'memoryDebugPanel.tabs.streaming': 'Streaming', + 'memoryDebugPanel.tabs.requests': 'Żądania', 'memoryDebugPanel.title': 'Panel debugowania', 'memoryDebugPanel.tooltip.logCurrentState': 'Zaloguj bieżący stan pamięci do konsoli przeglądarki', 'openChamberLogo.aria.logo': 'Logo OpenChamber', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 8f345e85..08591730 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2503,6 +2503,7 @@ export const dict: Record = { "memoryDebugPanel.title": "Painel de depuração", "memoryDebugPanel.tabs.memory": "Memória", "memoryDebugPanel.tabs.streaming": "Transmissão", + "memoryDebugPanel.tabs.requests": "Solicitações", "memoryDebugPanel.section.sessionsInMemory": "Sessões em memória", "memoryDebugPanel.section.uiStreamingMetrics": "Métricas de streaming de UI", "memoryDebugPanel.section.vscodeBridgeMetrics": "Métricas da ponte do VS Code", @@ -2540,6 +2541,16 @@ export const dict: Record = { "memoryDebugPanel.streaming.copy.copied": "JSON de depuração em streaming copiado", "memoryDebugPanel.streaming.copy.failed": "Não foi possível copiar JSON", "memoryDebugPanel.streaming.copy.hint": "Copia exportações de métricas da UI e do VS Code em formato JSON", + "memoryDebugPanel.requests.inFlight": "Em curso", + "memoryDebugPanel.requests.peak": "Pico", + "memoryDebugPanel.requests.duration": "Duração", + "memoryDebugPanel.requests.totalRequests": "Solicitações totais", + "memoryDebugPanel.requests.tracking": "Rastreamento", + "memoryDebugPanel.requests.now": "agora", + "memoryDebugPanel.requests.noSamples": "Nenhuma solicitação registrada. Mantenha este painel aberto para registrar a atividade de fetch.", + "memoryDebugPanel.requests.chartLabel": "Solicitações fetch em curso ao longo do tempo, pico {peak}", + "memoryDebugPanel.requests.windowHint": "últimos {seconds}s", + "memoryDebugPanel.requests.percentileChartLabel": "Percentis de idade das solicitações em curso (p50, p90, p99, máx) ao longo do tempo", "memoryDebugPanel.common.idle": "inativo", "memoryDebugPanel.common.live": "ao vivo", "memoryDebugPanel.common.notAvailable": "n/a", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 42c09339..d2232dae 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2503,6 +2503,7 @@ export const dict: Record = { "memoryDebugPanel.title": "Панель налагодження", "memoryDebugPanel.tabs.memory": "Пам'ять", "memoryDebugPanel.tabs.streaming": "Потокове передавання", + "memoryDebugPanel.tabs.requests": "Запити", "memoryDebugPanel.section.sessionsInMemory": "Сесії в пам'яті", "memoryDebugPanel.section.uiStreamingMetrics": "Потокові показники інтерфейсу користувача", "memoryDebugPanel.section.vscodeBridgeMetrics": "Метрики мосту VS Code", @@ -2540,6 +2541,16 @@ export const dict: Record = { "memoryDebugPanel.streaming.copy.copied": "Потокове налагодження JSON скопійовано", "memoryDebugPanel.streaming.copy.failed": "Не вдалося скопіювати JSON", "memoryDebugPanel.streaming.copy.hint": "Копіювання експортує метрики потокового інтерфейсу користувача та VS Code як JSON", + "memoryDebugPanel.requests.inFlight": "Виконуються", + "memoryDebugPanel.requests.peak": "Пік", + "memoryDebugPanel.requests.duration": "Тривалість", + "memoryDebugPanel.requests.totalRequests": "Усього запитів", + "memoryDebugPanel.requests.tracking": "Відстеження", + "memoryDebugPanel.requests.now": "зараз", + "memoryDebugPanel.requests.noSamples": "Запитів ще немає. Тримайте цю панель відкритою, щоб фіксувати активність fetch.", + "memoryDebugPanel.requests.chartLabel": "Запити fetch у виконанні з часом, пік {peak}", + "memoryDebugPanel.requests.windowHint": "останні {seconds}s", + "memoryDebugPanel.requests.percentileChartLabel": "Перцентилі віку запитів у виконанні (p50, p90, p99, max) з часом", "memoryDebugPanel.common.idle": "очікування", "memoryDebugPanel.common.live": "live", "memoryDebugPanel.common.notAvailable": "n/a", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 1ffd934d..3fd91956 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2503,6 +2503,7 @@ export const dict: Record = { 'memoryDebugPanel.title': '调试面板', 'memoryDebugPanel.tabs.memory': '内存', 'memoryDebugPanel.tabs.streaming': '流式', + 'memoryDebugPanel.tabs.requests': '请求', 'memoryDebugPanel.section.sessionsInMemory': '内存中的会话', 'memoryDebugPanel.section.uiStreamingMetrics': 'UI 流式指标', 'memoryDebugPanel.section.vscodeBridgeMetrics': 'VS Code 桥接指标', @@ -2540,6 +2541,16 @@ export const dict: Record = { 'memoryDebugPanel.streaming.copy.copied': '流式调试 JSON 已复制', 'memoryDebugPanel.streaming.copy.failed': '复制 JSON 失败', 'memoryDebugPanel.streaming.copy.hint': '复制会导出 UI 与 VS Code 的流式指标 JSON', + 'memoryDebugPanel.requests.inFlight': '进行中', + 'memoryDebugPanel.requests.peak': '峰值', + 'memoryDebugPanel.requests.duration': '时长', + 'memoryDebugPanel.requests.totalRequests': '请求总数', + 'memoryDebugPanel.requests.tracking': '跟踪', + 'memoryDebugPanel.requests.now': '当前', + 'memoryDebugPanel.requests.noSamples': '尚未记录请求。保持此面板打开以记录 fetch 活动。', + 'memoryDebugPanel.requests.chartLabel': '随时间变化的进行中 fetch 请求,峰值 {peak}', + 'memoryDebugPanel.requests.windowHint': '最近 {seconds}秒', + 'memoryDebugPanel.requests.percentileChartLabel': '进行中请求年龄百分位(p50、p90、p99、最大值)随时间的变化', 'memoryDebugPanel.common.idle': '空闲', 'memoryDebugPanel.common.live': '实时', 'memoryDebugPanel.common.notAvailable': '无', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 08d30254..407ed0e1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2500,6 +2500,7 @@ export const dict: Record = { 'memoryDebugPanel.title': '偵錯面板', 'memoryDebugPanel.tabs.memory': '記憶體', 'memoryDebugPanel.tabs.streaming': '串流', + 'memoryDebugPanel.tabs.requests': '請求', 'memoryDebugPanel.section.sessionsInMemory': '記憶體中的會話', 'memoryDebugPanel.section.uiStreamingMetrics': 'UI 串流指標', 'memoryDebugPanel.section.vscodeBridgeMetrics': 'VS Code 橋接指標', @@ -2537,6 +2538,16 @@ export const dict: Record = { 'memoryDebugPanel.streaming.copy.copied': '串流偵錯 JSON 已複製', 'memoryDebugPanel.streaming.copy.failed': '複製 JSON 失敗', 'memoryDebugPanel.streaming.copy.hint': '複製會匯出 UI 與 VS Code 的串流指標 JSON', + 'memoryDebugPanel.requests.inFlight': '進行中', + 'memoryDebugPanel.requests.peak': '峰值', + 'memoryDebugPanel.requests.duration': '時長', + 'memoryDebugPanel.requests.totalRequests': '請求總數', + 'memoryDebugPanel.requests.tracking': '追蹤', + 'memoryDebugPanel.requests.now': '目前', + 'memoryDebugPanel.requests.noSamples': '尚未記錄請求。保持此面板開啟以記錄 fetch 活動。', + 'memoryDebugPanel.requests.chartLabel': '隨時間變化的進行中 fetch 請求,峰值 {peak}', + 'memoryDebugPanel.requests.windowHint': '最近 {seconds}秒', + 'memoryDebugPanel.requests.percentileChartLabel': '進行中請求年齡百分位(p50、p90、p99、最大值)隨時間的變化', 'memoryDebugPanel.common.idle': '閒置', 'memoryDebugPanel.common.live': '即時', 'memoryDebugPanel.common.notAvailable': '無', diff --git a/packages/ui/src/stores/utils/requestsInFlight.ts b/packages/ui/src/stores/utils/requestsInFlight.ts new file mode 100644 index 00000000..9b263f93 --- /dev/null +++ b/packages/ui/src/stores/utils/requestsInFlight.ts @@ -0,0 +1,314 @@ +// Tracks every fetch() request as "in flight" from call to promise settle, +// samples two series once per second, and keeps a 5-minute rolling window for +// plotting: +// 1. in-flight request count +// 2. percentile distribution of currently in-flight request ages: p50, p90, +// p99, max (ms since each unsettled fetch started; 0 when nothing is in +// flight) +// Mirrors the streamDebug.ts pattern: collection is gated behind an +// enable/disable toggle (driven by the debug panel), state lives on `window` +// to survive HMR, and the UI polls a serializable snapshot instead of +// subscribing to a store (this is high-frequency debug data, see stores docs). + +const STORAGE_KEY = 'openchamber_requests_in_flight'; +const SAMPLE_INTERVAL_MS = 1000; +const WINDOW_MS = 5 * 60 * 1000; +const MAX_SAMPLES = Math.ceil(WINDOW_MS / SAMPLE_INTERVAL_MS); + +type RequestsInFlightState = { + enabled: boolean; + startedAt: number; + inFlight: number; + peak: number; + totalStarted: number; + totalSettled: number; + samples: number[]; + p50Samples: number[]; + p90Samples: number[]; + p99Samples: number[]; + maxSamples: number[]; + peakAgeMs: number; + inFlightStarts: Map; + sampleCount: number; + lastSampleAt: number | null; + fetchWrapped: boolean; + originalFetch: typeof window.fetch | null; + sampleTimer: number | null; +}; + +export type RequestsInFlightSnapshot = { + enabled: boolean; + startedAt: number | null; + durationMs: number; + inFlight: number; + peak: number; + totalStarted: number; + totalSettled: number; + samples: number[]; + ageP50: number; + ageP90: number; + ageP99: number; + ageMax: number; + peakAgeMs: number; + p50Samples: number[]; + p90Samples: number[]; + p99Samples: number[]; + maxSamples: number[]; + sampleCount: number; + lastSampleAt: number | null; + windowSeconds: number; +}; + +declare global { + interface Window { + __openchamberRequestsInFlight__?: RequestsInFlightState; + } +} + +export const requestsInFlightEnabled = (): boolean => { + if (typeof window === 'undefined') return false; + try { + return window.localStorage.getItem(STORAGE_KEY) === '1'; + } catch { + return false; + } +}; + +const createState = (): RequestsInFlightState => { + const startedAt = Date.now(); + return { + enabled: true, + startedAt, + inFlight: 0, + peak: 0, + totalStarted: 0, + totalSettled: 0, + samples: [], + p50Samples: [], + p90Samples: [], + p99Samples: [], + maxSamples: [], + peakAgeMs: 0, + inFlightStarts: new Map(), + sampleCount: 0, + lastSampleAt: null, + fetchWrapped: false, + originalFetch: null, + sampleTimer: null, + }; +}; + +let nextRequestId = 1; + +const recordStart = (id: number, startMs: number): void => { + const state = window.__openchamberRequestsInFlight__; + if (!state || !state.enabled) return; + state.inFlight += 1; + state.totalStarted += 1; + if (state.inFlight > state.peak) state.peak = state.inFlight; + state.inFlightStarts.set(id, startMs); +}; + +const recordSettle = (id: number): void => { + const state = window.__openchamberRequestsInFlight__; + if (!state || !state.enabled) return; + state.inFlight = Math.max(0, state.inFlight - 1); + state.totalSettled += 1; + state.inFlightStarts.delete(id); +}; + +// Sorted ages (ms) of every currently in-flight request. Empty when nothing +// is in flight. Used both for live snapshot reporting and per-second sampling. +const currentAges = (state: RequestsInFlightState): number[] => { + if (state.inFlightStarts.size === 0) return []; + const now = Date.now(); + const ages: number[] = []; + for (const start of state.inFlightStarts.values()) { + ages.push(Math.max(0, now - start)); + } + ages.sort((a, b) => a - b); + return ages; +}; + +// Linear-interpolation percentile of a pre-sorted array. +const percentile = (sorted: number[], p: number): number => { + const n = sorted.length; + if (n === 0) return 0; + if (n === 1) return sorted[0]; + const rank = (p / 100) * (n - 1); + const lo = Math.floor(rank); + const hi = Math.ceil(rank); + if (lo === hi) return sorted[lo]; + return sorted[lo] + (sorted[hi] - sorted[lo]) * (rank - lo); +}; + +const installFetchTracker = (): void => { + if (typeof window === 'undefined') return; + const state = window.__openchamberRequestsInFlight__; + if (!state || state.fetchWrapped) return; + const original = window.fetch.bind(window); + state.originalFetch = original; + state.fetchWrapped = true; + const tracker = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const id = nextRequestId++; + recordStart(id, Date.now()); + try { + return await original(input, init); + } finally { + recordSettle(id); + } + }; + window.fetch = tracker as typeof window.fetch; +}; + +const uninstallFetchTracker = (): void => { + if (typeof window === 'undefined') return; + const state = window.__openchamberRequestsInFlight__; + if (!state || !state.fetchWrapped || !state.originalFetch) return; + window.fetch = state.originalFetch; + state.fetchWrapped = false; + state.originalFetch = null; +}; + +const trimSamples = (arr: number[]): void => { + if (arr.length > MAX_SAMPLES) arr.splice(0, arr.length - MAX_SAMPLES); +}; + +const pushSample = (): void => { + const state = window.__openchamberRequestsInFlight__; + if (!state || !state.enabled) return; + state.samples.push(state.inFlight); + const ages = currentAges(state); + const mx = ages.length > 0 ? ages[ages.length - 1] : 0; + state.p50Samples.push(percentile(ages, 50)); + state.p90Samples.push(percentile(ages, 90)); + state.p99Samples.push(percentile(ages, 99)); + state.maxSamples.push(mx); + if (mx > state.peakAgeMs) state.peakAgeMs = mx; + state.sampleCount += 1; + trimSamples(state.samples); + trimSamples(state.p50Samples); + trimSamples(state.p90Samples); + trimSamples(state.p99Samples); + trimSamples(state.maxSamples); + state.lastSampleAt = Date.now(); +}; + +const startSampling = (): void => { + if (typeof window === 'undefined') return; + const state = window.__openchamberRequestsInFlight__; + if (!state || state.sampleTimer != null) return; + state.sampleTimer = window.setInterval(pushSample, SAMPLE_INTERVAL_MS); +}; + +const stopSampling = (): void => { + if (typeof window === 'undefined') return; + const state = window.__openchamberRequestsInFlight__; + if (!state || state.sampleTimer == null) return; + window.clearInterval(state.sampleTimer); + state.sampleTimer = null; +}; + +export const setRequestsInFlightTrackingEnabled = (enabled: boolean): void => { + if (typeof window === 'undefined') return; + + try { + if (enabled) { + // Idempotent: tear down any prior tracking first so a repeated + // enable can never wrap window.fetch twice (which would double-count). + stopSampling(); + uninstallFetchTracker(); + window.localStorage.setItem(STORAGE_KEY, '1'); + window.__openchamberRequestsInFlight__ = createState(); + installFetchTracker(); + startSampling(); + return; + } + + window.localStorage.removeItem(STORAGE_KEY); + stopSampling(); + uninstallFetchTracker(); + delete window.__openchamberRequestsInFlight__; + } catch { + // ignore storage failures in debug helper + } +}; + +export const resetRequestsInFlight = (): void => { + if (typeof window === 'undefined') return; + const state = window.__openchamberRequestsInFlight__; + if (!state) return; + const fresh = createState(); + state.startedAt = fresh.startedAt; + state.inFlight = fresh.inFlight; + state.peak = fresh.peak; + state.totalStarted = fresh.totalStarted; + state.totalSettled = fresh.totalSettled; + state.samples = fresh.samples; + state.p50Samples = fresh.p50Samples; + state.p90Samples = fresh.p90Samples; + state.p99Samples = fresh.p99Samples; + state.maxSamples = fresh.maxSamples; + state.peakAgeMs = fresh.peakAgeMs; + state.inFlightStarts = fresh.inFlightStarts; + state.sampleCount = fresh.sampleCount; + state.lastSampleAt = fresh.lastSampleAt; +}; + +export const getRequestsInFlightSnapshot = (): RequestsInFlightSnapshot => { + if (typeof window === 'undefined') { + return emptySnapshot(); + } + + const state = window.__openchamberRequestsInFlight__; + if (!requestsInFlightEnabled() || !state) { + return emptySnapshot(); + } + + const ages = currentAges(state); + return { + enabled: true, + startedAt: state.startedAt, + durationMs: Math.max(0, Date.now() - state.startedAt), + inFlight: state.inFlight, + peak: state.peak, + totalStarted: state.totalStarted, + totalSettled: state.totalSettled, + samples: state.samples.slice(), + ageP50: percentile(ages, 50), + ageP90: percentile(ages, 90), + ageP99: percentile(ages, 99), + ageMax: ages.length > 0 ? ages[ages.length - 1] : 0, + peakAgeMs: state.peakAgeMs, + p50Samples: state.p50Samples.slice(), + p90Samples: state.p90Samples.slice(), + p99Samples: state.p99Samples.slice(), + maxSamples: state.maxSamples.slice(), + sampleCount: state.sampleCount, + lastSampleAt: state.lastSampleAt, + windowSeconds: MAX_SAMPLES, + }; +}; + +const emptySnapshot = (): RequestsInFlightSnapshot => ({ + enabled: false, + startedAt: null, + durationMs: 0, + inFlight: 0, + peak: 0, + totalStarted: 0, + totalSettled: 0, + samples: [], + ageP50: 0, + ageP90: 0, + ageP99: 0, + ageMax: 0, + peakAgeMs: 0, + p50Samples: [], + p90Samples: [], + p99Samples: [], + maxSamples: [], + sampleCount: 0, + lastSampleAt: null, + windowSeconds: MAX_SAMPLES, +}); From c6c9338acacba2c1fa9237199640854556b7cae0 Mon Sep 17 00:00:00 2001 From: bashrusakh Date: Sat, 27 Jun 2026 05:50:54 +1100 Subject: [PATCH 012/405] fix(web): make managed process reaper async --- packages/web/server/lib/opencode/lifecycle.js | 4 +- .../web/server/lib/opencode/lifecycle.test.js | 5 + .../lib/opencode/managed-process-registry.js | 93 ++++-- .../managed-process-registry.test.mjs | 306 ++++++++++++++++++ 4 files changed, 377 insertions(+), 31 deletions(-) create mode 100644 packages/web/server/lib/opencode/managed-process-registry.test.mjs diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js index d01a9fd3..31f3341f 100644 --- a/packages/web/server/lib/opencode/lifecycle.js +++ b/packages/web/server/lib/opencode/lifecycle.js @@ -221,7 +221,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => { // Drop it from the registry only once it has actually exited, so a child // that survived teardown stays eligible for the next run's reaper. if (Number.isInteger(pid) && hasChildProcessExited(child)) { - unregisterManagedProcess(pid); + await unregisterManagedProcess(pid); } } }; @@ -343,7 +343,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => { // actual host (Electron sets OPENCHAMBER_RUNTIME='desktop'; the standalone // web CLI leaves it unset → 'web'; SSH remote → 'ssh-remote') rather than a // hardcoded label, matching the server's existing runtimeName convention. - registerManagedProcess({ + await registerManagedProcess({ pid: child.pid, ownerPid: process.pid, port, diff --git a/packages/web/server/lib/opencode/lifecycle.test.js b/packages/web/server/lib/opencode/lifecycle.test.js index 19dbe18d..39ebcdc7 100644 --- a/packages/web/server/lib/opencode/lifecycle.test.js +++ b/packages/web/server/lib/opencode/lifecycle.test.js @@ -6,6 +6,11 @@ const spawnMock = vi.fn(); vi.mock('node:child_process', () => ({ spawn: spawnMock, spawnSync: vi.fn(), + // `managed-process-registry.js` (imported transitively via lifecycle.js) + // calls `promisify(execFile)` at module load, so the mock must expose a + // function here. Lifecycle tests don't exercise the reaper path, so a plain + // stub is enough; the registry's best-effort writes are no-ops on errors. + execFile: vi.fn(), })); const { createOpenCodeLifecycleRuntime } = await import('./lifecycle.js'); diff --git a/packages/web/server/lib/opencode/managed-process-registry.js b/packages/web/server/lib/opencode/managed-process-registry.js index 2e225bce..a6b6927b 100644 --- a/packages/web/server/lib/opencode/managed-process-registry.js +++ b/packages/web/server/lib/opencode/managed-process-registry.js @@ -32,13 +32,26 @@ // been reparented to init/pid 1, or the recorded owner pid is dead. A // child still owned by a live instance is left untouched. // +// All filesystem and child-process operations here are ASYNCHRONOUS. The web +// server runs in-process inside the Electron main event loop (and other hosts), +// so any `spawnSync`/`*Sync` FS call blocks the single event loop — which also +// serves UI asset requests and realtime SSE traffic. The startup reaper can +// iterate several registry entries and, on Windows, each one spawns `tasklist` +// (100-500ms) and possibly `taskkill`; doing that synchronously stalls the +// whole process and is what caused the 1.13.3 `openchamber-ui://` lag +// regression (#1841). `execFile`/`fsp.*` keep the event loop responsive while +// the reaper waits on the kernel. +// // The VS Code extension cannot import this module (it does not bundle the web // package); it carries a parity implementation that reads/writes the SAME dir. -import fs from 'node:fs'; +import fsp from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); const resolveRegistryDir = () => { const override = process.env.OPENCHAMBER_MANAGED_PROCESS_REGISTRY; @@ -48,49 +61,53 @@ const resolveRegistryDir = () => { const entryFilePath = (pid) => path.join(resolveRegistryDir(), `${pid}.json`); -const writeEntryFile = (entry) => { +const writeEntryFile = async (entry) => { const dir = resolveRegistryDir(); try { - fs.mkdirSync(dir, { recursive: true }); + await fsp.mkdir(dir, { recursive: true }); const filePath = path.join(dir, `${entry.pid}.json`); const tmp = `${filePath}.tmp-${process.pid}`; - fs.writeFileSync(tmp, JSON.stringify(entry, null, 2)); - fs.renameSync(tmp, filePath); + await fsp.writeFile(tmp, JSON.stringify(entry, null, 2)); + await fsp.rename(tmp, filePath); } catch { // Best-effort: a failed registry write must never break spawn/shutdown. } }; -const readAllEntries = () => { +const readAllEntries = async () => { const dir = resolveRegistryDir(); let names = []; try { - names = fs.readdirSync(dir).filter((name) => name.endsWith('.json')); + names = await fsp.readdir(dir); } catch { return []; } const out = []; - for (const name of names) { + for (const name of names.filter((value) => value.endsWith('.json'))) { const filePath = path.join(dir, name); try { - const entry = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const entry = JSON.parse(await fsp.readFile(filePath, 'utf8')); if (entry && Number.isInteger(entry.pid)) { out.push({ entry, filePath }); } else { - fs.rmSync(filePath, { force: true }); + await fsp.rm(filePath, { force: true }); } } catch { // Corrupt/partial file — drop it. - try { fs.rmSync(filePath, { force: true }); } catch {} + try { + await fsp.rm(filePath, { force: true }); + } catch { + // ignore + } } } return out; }; /** Record an OpenCode process WE spawned so a future run can reap it if orphaned. */ -export const registerManagedProcess = ({ pid, ownerPid, port, binary, runtime } = {}) => { +export const registerManagedProcess = async ({ pid, ownerPid, port, binary, runtime } = {}) => { if (!Number.isInteger(pid)) return; - writeEntryFile({ + await writeEntryFile({ pid, ownerPid: Number.isInteger(ownerPid) ? ownerPid : process.pid, port: Number.isInteger(port) ? port : null, @@ -101,11 +118,12 @@ export const registerManagedProcess = ({ pid, ownerPid, port, binary, runtime } }; /** Drop a pid from the registry (after we have killed/closed it ourselves). */ -export const unregisterManagedProcess = (pid) => { +export const unregisterManagedProcess = async (pid) => { if (!Number.isInteger(pid)) return; try { - fs.rmSync(entryFilePath(pid), { force: true }); + await fsp.rm(entryFilePath(pid), { force: true }); } catch { + // Best-effort: dropping a missing file is not an error. } }; @@ -123,14 +141,14 @@ const isPidAlive = (pid) => { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // Returns { ppid, command } for a live pid on Unix, or null if it can't be read. -const readUnixProcInfo = (pid) => { +const readUnixProcInfo = async (pid) => { try { - const result = spawnSync('ps', ['-p', String(pid), '-o', 'ppid=,command='], { + const { stdout } = await execFileAsync('ps', ['-p', String(pid), '-o', 'ppid=,command='], { encoding: 'utf8', timeout: 3000, windowsHide: true, }); - const line = (result.stdout || '').trim(); + const line = (stdout || '').trim(); if (!line) return null; const match = line.match(/^\s*(\d+)\s+(.*)$/); if (!match) return null; @@ -141,14 +159,14 @@ const readUnixProcInfo = (pid) => { }; // Windows image name for a pid (e.g. "opencode.exe"), or null. -const readWindowsImageName = (pid) => { +const readWindowsImageName = async (pid) => { try { - const result = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], { + const { stdout } = await execFileAsync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], { encoding: 'utf8', timeout: 3000, windowsHide: true, }); - return (result.stdout || '').trim() || null; + return (stdout || '').trim() || null; } catch { return null; } @@ -167,15 +185,28 @@ const commandIdentifiesOurServer = (command, entry) => { const killOrphan = async (pid) => { if (process.platform === 'win32') { try { - spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', timeout: 5000, windowsHide: true }); + await execFileAsync('taskkill', ['/PID', String(pid), '/T', '/F'], { + stdio: 'ignore', + timeout: 5000, + windowsHide: true, + }); } catch { + // Best-effort: a failed kill is not fatal (startup reaper is a backstop). } return; } const signalTree = (signal) => { - try { process.kill(-pid, signal); } catch {} - try { process.kill(pid, signal); } catch {} + try { + process.kill(-pid, signal); + } catch { + // process group may already be gone + } + try { + process.kill(pid, signal); + } catch { + // pid may already be gone + } }; signalTree('SIGTERM'); @@ -196,7 +227,7 @@ const processEntry = async (entry, { log }) => { const ownerGone = Number.isInteger(entry.ownerPid) && !isPidAlive(entry.ownerPid); if (process.platform === 'win32') { - const image = readWindowsImageName(entry.pid); + const image = await readWindowsImageName(entry.pid); const looksLikeOpencode = typeof image === 'string' && image.toLowerCase().includes('opencode'); // Windows lacks reliable reparent-to-1 semantics (job objects usually kill // children with the parent), so we reap only when the owner is provably dead @@ -209,7 +240,7 @@ const processEntry = async (entry, { log }) => { return false; } - const info = readUnixProcInfo(entry.pid); + const info = await readUnixProcInfo(entry.pid); // Can't verify identity (or it's not our server) → leave it alone. if (!info || !commandIdentifiesOurServer(info.command, entry)) return false; @@ -227,7 +258,7 @@ const processEntry = async (entry, { log }) => { * server. Returns { inspected, reaped }. */ export const reapOrphanedProcesses = async ({ log } = {}) => { - const records = readAllEntries(); + const records = await readAllEntries(); if (records.length === 0) return { inspected: 0, reaped: 0 }; let reaped = 0; @@ -243,7 +274,11 @@ export const reapOrphanedProcesses = async ({ log } = {}) => { log?.(`[lifecycle] reap check failed for pid ${entry.pid}: ${error?.message ?? error}`); } if (drop) { - try { fs.rmSync(filePath, { force: true }); } catch {} + try { + await fsp.rm(filePath, { force: true }); + } catch { + // best-effort + } } } diff --git a/packages/web/server/lib/opencode/managed-process-registry.test.mjs b/packages/web/server/lib/opencode/managed-process-registry.test.mjs new file mode 100644 index 00000000..7f1891c7 --- /dev/null +++ b/packages/web/server/lib/opencode/managed-process-registry.test.mjs @@ -0,0 +1,306 @@ +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mocks must be in place before the module under test is imported, because the +// module calls `promisify(execFile)` at module-load time and binds `fsp.*` at +// call time. +// +// NOTE on `promisify.custom`: the real `child_process.execFile` carries a +// `[util.promisify.custom]` symbol so that `promisify(execFile)` resolves to +// `{ stdout, stderr }` (not the generic multi-arg array). A plain `vi.fn()` +// mock lacks that symbol, so `const { stdout } = await execFileAsync(...)` +// would destructure `undefined`. We attach the symbol to the mock so the +// promisified helper used by the module resolves to the same `{ stdout, +// stderr }` shape. + +const readdirMock = vi.fn(); +const readFileMock = vi.fn(); +const rmMock = vi.fn(); +const mkdirMock = vi.fn(); +const writeFileMock = vi.fn(); +const renameMock = vi.fn(); + +vi.mock('node:fs/promises', () => ({ + default: { + readdir: readdirMock, + readFile: readFileMock, + rm: rmMock, + mkdir: mkdirMock, + writeFile: writeFileMock, + rename: renameMock, + }, +})); + +// `execFileImpl` is the swappable per-test implementation; `execFileMock` is +// what the mocked module sees. `promisify(execFileMock)` returns the custom +// function, which delegates to `execFileImpl` with a (err, stdout, stderr) +// callback and resolves to `{ stdout, stderr }`. +const execFileImpl = vi.fn(); +const execFileMock = vi.fn(); +execFileMock[promisify.custom] = (cmd, args, opts) => + new Promise((resolve, reject) => { + execFileImpl(cmd, args, opts, (err, stdout, stderr) => + err ? reject(err) : resolve({ stdout: stdout ?? '', stderr: stderr ?? '' })); + }); + +vi.mock('node:child_process', () => ({ + execFile: execFileMock, +})); + +const { + registerManagedProcess, + unregisterManagedProcess, + reapOrphanedProcesses, +} = await import('./managed-process-registry.js'); + +const ORIGINAL_PLATFORM = Object.getOwnPropertyDescriptor(process, 'platform'); +const ORIGINAL_KILL = process.kill; +const killMock = vi.fn(); + +const setPlatform = (platform) => { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); +}; + +const restorePlatform = () => { + if (ORIGINAL_PLATFORM) { + Object.defineProperty(process, 'platform', ORIGINAL_PLATFORM); + } +}; + +const installKillMock = () => { + Object.defineProperty(process, 'kill', { value: killMock, configurable: true }); +}; + +const restoreKill = () => { + Object.defineProperty(process, 'kill', { value: ORIGINAL_KILL, configurable: true }); +}; + +// Helper to make given pids look alive on signal-0 (returns true); any other +// pid throws ESRCH (dead). Non-zero signals always "succeed" so `killOrphan`'s +// signalTree is inert under test. +const killAliveFor = (alivePids) => + killMock.mockImplementation((pid, signal) => { + if (signal === 0 || signal === undefined) { + if (alivePids.includes(pid)) return true; + const error = new Error('ESRCH'); + error.code = 'ESRCH'; + throw error; + } + return true; + }); + +// Configure `execFileImpl` with a (cmd, args, opts, cb) dispatcher. +const execFileYields = (dispatch) => + execFileImpl.mockImplementation((cmd, args, opts, cb) => dispatch(cmd, args, opts, cb)); + +beforeEach(() => { + readdirMock.mockReset(); + readFileMock.mockReset(); + rmMock.mockReset(); + mkdirMock.mockReset(); + writeFileMock.mockReset(); + renameMock.mockReset(); + execFileImpl.mockReset(); + killMock.mockReset(); + installKillMock(); +}); + +afterEach(() => { + restoreKill(); + restorePlatform(); +}); + +describe('reapOrphanedProcesses', () => { + it('returns zero counts when the registry directory is missing', async () => { + readdirMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const result = await reapOrphanedProcesses(); + + expect(result).toEqual({ inspected: 0, reaped: 0 }); + expect(execFileImpl).not.toHaveBeenCalled(); + }); + + it('drops registry entries whose pid is already dead, without spawning anything', async () => { + readdirMock.mockResolvedValue(['99999.json']); + readFileMock.mockResolvedValue( + JSON.stringify({ pid: 99999, ownerPid: 12345, port: 4096, binary: '/opencode', runtime: 'web' }), + ); + killMock.mockImplementation(() => { + const error = new Error('ESRCH'); + error.code = 'ESRCH'; + throw error; + }); + rmMock.mockResolvedValue(); + + const result = await reapOrphanedProcesses(); + + expect(result).toEqual({ inspected: 1, reaped: 0 }); + expect(rmMock).toHaveBeenCalledTimes(1); + expect(execFileImpl).not.toHaveBeenCalled(); + }); + + describe('on Windows', () => { + beforeEach(() => setPlatform('win32')); + + it('reaps an opencode image whose owner is gone', async () => { + readdirMock.mockResolvedValue(['777.json']); + readFileMock.mockResolvedValue( + JSON.stringify({ pid: 777, ownerPid: 12345, port: 4096, binary: 'opencode.exe', runtime: 'desktop' }), + ); + // pid 777 alive, owner 12345 dead. + killAliveFor([777]); + execFileYields((cmd, _args, _opts, cb) => { + if (cmd === 'tasklist') return cb(null, 'opencode.exe', ''); + if (cmd === 'taskkill') return cb(null, '', ''); + cb(new Error(`unexpected cmd: ${cmd}`)); + }); + rmMock.mockResolvedValue(); + + const result = await reapOrphanedProcesses({ log: () => {} }); + + expect(result).toEqual({ inspected: 1, reaped: 1 }); + expect(execFileImpl).toHaveBeenCalledWith( + 'tasklist', + expect.any(Array), + expect.objectContaining({ windowsHide: true }), + expect.any(Function), + ); + expect(execFileImpl).toHaveBeenCalledWith( + 'taskkill', + expect.any(Array), + expect.objectContaining({ windowsHide: true }), + expect.any(Function), + ); + }); + + it('leaves a non-opencode image alone even if the owner is gone', async () => { + readdirMock.mockResolvedValue(['777.json']); + readFileMock.mockResolvedValue( + JSON.stringify({ pid: 777, ownerPid: 12345, port: 4096, binary: 'opencode.exe', runtime: 'desktop' }), + ); + killAliveFor([777]); + execFileYields((_cmd, _args, _opts, cb) => cb(null, 'notepad.exe', '')); + rmMock.mockResolvedValue(); + + const result = await reapOrphanedProcesses({ log: () => {} }); + + expect(result).toEqual({ inspected: 1, reaped: 0 }); + const calls = execFileImpl.mock.calls.filter(([cmd]) => cmd === 'taskkill'); + expect(calls).toHaveLength(0); + }); + + it('leaves an opencode image whose owner is still alive', async () => { + readdirMock.mockResolvedValue(['777.json']); + readFileMock.mockResolvedValue( + JSON.stringify({ pid: 777, ownerPid: 12345, port: 4096, binary: 'opencode.exe', runtime: 'desktop' }), + ); + // Both alive. + killAliveFor([777, 12345]); + execFileYields((_cmd, _args, _opts, cb) => cb(null, 'opencode.exe', '')); + + const result = await reapOrphanedProcesses({ log: () => {} }); + + expect(result).toEqual({ inspected: 1, reaped: 0 }); + const calls = execFileImpl.mock.calls.filter(([cmd]) => cmd === 'taskkill'); + expect(calls).toHaveLength(0); + }); + }); + + describe('on Unix', () => { + beforeEach(() => setPlatform('linux')); + + it('reaps a reparented opencode serve matching the recorded port', async () => { + readdirMock.mockResolvedValue(['777.json']); + readFileMock.mockResolvedValue( + JSON.stringify({ pid: 777, ownerPid: 12345, port: 4096, binary: '/opencode', runtime: 'web' }), + ); + // pid 777 stays "alive"; killOrphan's signalTree is inert (mock returns + // true for non-zero signals), and its wait loop sees isPidAlive true so + // it exhausts the SIGTERM wait then sends SIGKILL and sleeps 300ms. + killAliveFor([777]); + execFileYields((cmd, _args, _opts, cb) => { + if (cmd === 'ps') return cb(null, '1 /usr/bin/opencode serve --port 4096\n', ''); + cb(new Error(`unexpected cmd: ${cmd}`)); + }); + rmMock.mockResolvedValue(); + + const result = await reapOrphanedProcesses({ log: () => {} }); + + expect(result).toEqual({ inspected: 1, reaped: 1 }); + }); + + it('leaves a process whose command is not our opencode serve', async () => { + readdirMock.mockResolvedValue(['777.json']); + readFileMock.mockResolvedValue( + JSON.stringify({ pid: 777, ownerPid: 12345, port: 4096, binary: '/opencode', runtime: 'web' }), + ); + killAliveFor([777]); + execFileYields((cmd, _args, _opts, cb) => { + if (cmd === 'ps') return cb(null, '1 /some/other/binary serve\n', ''); + cb(new Error(`unexpected cmd: ${cmd}`)); + }); + + const result = await reapOrphanedProcesses({ log: () => {} }); + + expect(result).toEqual({ inspected: 1, reaped: 0 }); + }); + + it('leaves a process still owned by a live owner (not reparented)', async () => { + readdirMock.mockResolvedValue(['777.json']); + readFileMock.mockResolvedValue( + JSON.stringify({ pid: 777, ownerPid: 12345, port: 4096, binary: '/opencode', runtime: 'web' }), + ); + killAliveFor([777, 12345]); + execFileYields((cmd, _args, _opts, cb) => { + if (cmd === 'ps') return cb(null, '12345 /usr/bin/opencode serve --port 4096\n', ''); + cb(new Error(`unexpected cmd: ${cmd}`)); + }); + + const result = await reapOrphanedProcesses({ log: () => {} }); + + expect(result).toEqual({ inspected: 1, reaped: 0 }); + }); + }); +}); + +describe('registerManagedProcess', () => { + it('writes an entry file atomically via tmp + rename', async () => { + mkdirMock.mockResolvedValue(); + writeFileMock.mockResolvedValue(); + renameMock.mockResolvedValue(); + + await registerManagedProcess({ pid: 4242, ownerPid: 12345, port: 4096, binary: '/opencode', runtime: 'desktop' }); + + expect(mkdirMock).toHaveBeenCalledWith(expect.any(String), { recursive: true }); + expect(writeFileMock).toHaveBeenCalledWith( + expect.stringContaining('4242.json.tmp-'), + expect.any(String), + ); + expect(renameMock).toHaveBeenCalledWith( + expect.stringContaining('4242.json.tmp-'), + expect.stringContaining('4242.json'), + ); + }); + + it('is a no-op for a non-integer pid', async () => { + await registerManagedProcess({ pid: 'not-a-pid' }); + + expect(writeFileMock).not.toHaveBeenCalled(); + }); +}); + +describe('unregisterManagedProcess', () => { + it('removes the entry file', async () => { + rmMock.mockResolvedValue(); + + await unregisterManagedProcess(4242); + + expect(rmMock).toHaveBeenCalledWith(expect.stringContaining('4242.json'), { force: true }); + }); + + it('is a no-op for a non-integer pid', async () => { + await unregisterManagedProcess(undefined); + + expect(rmMock).not.toHaveBeenCalled(); + }); +}); From 94e4bce990c888c401725dabaa558b9825564927 Mon Sep 17 00:00:00 2001 From: "PC2\\micha" Date: Sun, 28 Jun 2026 20:07:39 +0800 Subject: [PATCH 013/405] fix: kill process tree on Windows via taskkill before SIGTERM fallback On Windows, child.kill('SIGTERM') only terminates the cmd.exe wrapper, leaving the inner opencode.exe serve running as an orphan. This adds killProcessTree() which runs taskkill /PID /T /F first, then falls back to child.kill('SIGTERM') for the close() method. Fixes #1889 --- packages/vscode/src/opencode.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index 540a9aba..95f7f239 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -133,6 +133,19 @@ function shouldUseWindowsShell(binary: string): boolean { return !ext && !trimmed.includes('\\') && !trimmed.includes('/'); } +function killProcessTree(pid: number | undefined): void { + if (!Number.isInteger(pid)) return; + if (process.platform === 'win32') { + try { + spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { + stdio: 'ignore', timeout: 5000, windowsHide: true, + }); + } catch { + // ignore + } + } +} + function appendToPath(dir: string) { const trimmed = (dir || '').trim(); if (!trimmed) return; @@ -695,6 +708,7 @@ async function spawnManagedOpenCodeServer( return { url, close: () => { + killProcessTree(child.pid); try { child.kill('SIGTERM'); } catch { From 224a948693febbf977ac87878d27d0c3d2a4e6ae Mon Sep 17 00:00:00 2001 From: Divyam <47589864+divyam234@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:41:07 +0530 Subject: [PATCH 014/405] fix: load symlinked custom themes --- .../web/server/lib/opencode/theme-runtime.js | 2 +- .../server/lib/opencode/theme-runtime.test.js | 129 ++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 packages/web/server/lib/opencode/theme-runtime.test.js diff --git a/packages/web/server/lib/opencode/theme-runtime.js b/packages/web/server/lib/opencode/theme-runtime.js index df2639e3..2655f826 100644 --- a/packages/web/server/lib/opencode/theme-runtime.js +++ b/packages/web/server/lib/opencode/theme-runtime.js @@ -116,7 +116,7 @@ export const createThemeRuntime = (dependencies) => { const seen = new Set(); for (const entry of entries) { - if (!entry.isFile()) continue; + if (!entry.isFile() && !entry.isSymbolicLink()) continue; if (!entry.name.toLowerCase().endsWith('.json')) continue; const filePath = path.join(themesDir, entry.name); diff --git a/packages/web/server/lib/opencode/theme-runtime.test.js b/packages/web/server/lib/opencode/theme-runtime.test.js new file mode 100644 index 00000000..38316a14 --- /dev/null +++ b/packages/web/server/lib/opencode/theme-runtime.test.js @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; + +import { createThemeRuntime } from './theme-runtime.js'; + +const validTheme = (id = 'custom-theme') => ({ + metadata: { + id, + name: 'Custom Theme', + variant: 'dark', + }, + colors: { + primary: { + base: '#ffffff', + foreground: '#000000', + }, + surface: { + background: '#000000', + foreground: '#ffffff', + muted: '#111111', + mutedForeground: '#eeeeee', + elevated: '#222222', + elevatedForeground: '#dddddd', + subtle: '#333333', + }, + interactive: { + border: '#444444', + selection: '#555555', + selectionForeground: '#ffffff', + focusRing: '#666666', + hover: '#777777', + }, + status: { + error: '#ff0000', + errorForeground: '#ffffff', + errorBackground: '#330000', + errorBorder: '#660000', + warning: '#ffaa00', + warningForeground: '#000000', + warningBackground: '#332200', + warningBorder: '#664400', + success: '#00ff00', + successForeground: '#000000', + successBackground: '#003300', + successBorder: '#006600', + info: '#0000ff', + infoForeground: '#ffffff', + infoBackground: '#000033', + infoBorder: '#000066', + }, + syntax: { + base: { + background: '#000000', + foreground: '#ffffff', + keyword: '#ff00ff', + string: '#00ff00', + number: '#ffaa00', + function: '#00ffff', + variable: '#ffffff', + type: '#ffff00', + comment: '#888888', + operator: '#ffffff', + }, + highlights: { + diffAdded: '#003300', + diffRemoved: '#330000', + lineNumber: '#888888', + }, + }, + }, +}); + +const fileEntry = (name, type = 'file') => ({ + name, + isFile: () => type === 'file', + isDirectory: () => type === 'directory', + isSymbolicLink: () => type === 'symlink', +}); + +const createTestRuntime = ({ entries, files, stats }) => createThemeRuntime({ + fsPromises: { + readdir: async () => entries, + stat: async (filePath) => stats[filePath], + readFile: async (filePath) => files[filePath], + }, + path: { join: (...parts) => parts.join('/') }, + themesDir: '/themes', + maxThemeJsonBytes: 512 * 1024, + logger: { warn: () => {} }, +}); + +describe('theme runtime', () => { + describe('readCustomThemesFromDisk', () => { + it('loads valid theme files', async () => { + const runtime = createTestRuntime({ + entries: [fileEntry('direct.json')], + files: { '/themes/direct.json': JSON.stringify(validTheme('direct-theme')) }, + stats: { '/themes/direct.json': { isFile: () => true, size: 1024 } }, + }); + + const themes = await runtime.readCustomThemesFromDisk(); + + expect(themes.map((theme) => theme.metadata.id)).toEqual(['direct-theme']); + }); + + it('loads JSON themes whose directory entry is a symbolic link', async () => { + const runtime = createTestRuntime({ + entries: [fileEntry('linked.json', 'symlink')], + files: { '/themes/linked.json': JSON.stringify(validTheme('linked-theme')) }, + stats: { '/themes/linked.json': { isFile: () => true, size: 1024 } }, + }); + + const themes = await runtime.readCustomThemesFromDisk(); + + expect(themes.map((theme) => theme.metadata.id)).toEqual(['linked-theme']); + }); + + it('skips JSON directories after stat resolution', async () => { + const runtime = createTestRuntime({ + entries: [fileEntry('directory.json', 'directory')], + files: { '/themes/directory.json': JSON.stringify(validTheme('directory-theme')) }, + stats: { '/themes/directory.json': { isFile: () => false, size: 1024 } }, + }); + + const themes = await runtime.readCustomThemesFromDisk(); + + expect(themes).toEqual([]); + }); + }); +}); From 72a24c388f6dbb6eb4ccc93619edf91b4dcf9c88 Mon Sep 17 00:00:00 2001 From: Brian Ketelsen Date: Tue, 30 Jun 2026 23:26:51 -0400 Subject: [PATCH 015/405] fix(pwa): focus existing window on notification click The service worker's notificationclick handler called self.clients.openWindow(url) unconditionally, spawning a new window/PWA instance on every notification click even when one was already open. Focus an existing window client and navigate it to the (relative) deep-link, resolved against self.location.origin, falling back to openWindow only when no window is available. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/web/src/sw.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/web/src/sw.ts b/packages/web/src/sw.ts index 7ed2a8cc..3583d41b 100644 --- a/packages/web/src/sw.ts +++ b/packages/web/src/sw.ts @@ -67,5 +67,30 @@ self.addEventListener('notificationclick', (event) => { const data = (event.notification.data ?? null) as { url?: string } | null; const url = data?.url ?? '/'; - event.waitUntil(self.clients.openWindow(url)); + event.waitUntil((async () => { + // Prefer focusing an already-open window (e.g. the installed PWA) and + // navigating it to the target, instead of always spawning a new window. + const target = new URL(url, self.location.origin).href; + const windowClients = await self.clients.matchAll({ + type: 'window', + includeUncontrolled: true, + }); + + for (const client of windowClients) { + try { + if ('navigate' in client) { + await client.navigate(target); + } + } catch { + // navigate() can reject for uncontrolled clients; fall back to focus. + } + if ('focus' in client) { + return client.focus(); + } + } + + if (self.clients.openWindow) { + return self.clients.openWindow(target); + } + })()); }); From adb6ca2b08686fe043a325643c71cec38845a6ab Mon Sep 17 00:00:00 2001 From: Tang <1024830255@qq.com> Date: Sat, 4 Jul 2026 10:32:19 +0800 Subject: [PATCH 016/405] fix(cli): externalize Windows startup PowerShell to .ps1 wrapper (schtasks /TR 261-char limit) The inline PowerShell env-parsing script exceeded the Task Scheduler /TR 261-char limit, causing startup enable to fail on Windows. Extract the script into a .ps1 wrapper file and reduce /TR to a short powershell.exe -File command (~115 chars). Mirrors the macOS writeMacosStartupWrapper pattern. Adds regression tests pinning /TR < 200 (default) and < 261 (worst-case). Apply fix to refactored lib/cli-startup.js (was cli.js before refactor). --- packages/web/bin/cli.test.js | 35 ++++++++++++++++++++++++++ packages/web/bin/lib/cli-startup.js | 38 ++++++++++++++++++++++------- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/packages/web/bin/cli.test.js b/packages/web/bin/cli.test.js index 9faa2100..ff7e291d 100644 --- a/packages/web/bin/cli.test.js +++ b/packages/web/bin/cli.test.js @@ -30,6 +30,7 @@ import { parseArgs, resolveServeHost, } from './cli.js'; +import { buildWindowsStartupTaskCommand } from './lib/cli-startup.js'; async function withTempOpenChamberDataDir(fn) { const previous = process.env.OPENCHAMBER_DATA_DIR; @@ -884,3 +885,37 @@ describe('lifecycle commands with unmanaged explicit ports', () => { }); }); }); + +describe('Windows startup task command builder', () => { + it('default-path length stays under 200 chars', () => { + const cmd = buildWindowsStartupTaskCommand( + 'C:\\Users\\test\\.config\\openchamber\\bin\\OpenChamber.ps1' + ); + expect(cmd).toMatch(/^powershell\.exe -NoProfile -ExecutionPolicy Bypass -File /); + expect(cmd.length).toBeLessThan(200); + }); + + it('worst-case long path stays under 261-char Task Scheduler ceiling', () => { + // Build a wrapper path >= 180 chars (simulates long OPENCHAMBER_DATA_DIR) + // Overhead = 57 chars (prefix + closing quote), so max wrapper for <261 total is 203 + const longPath = + 'C:\\Users\\' + + 'a'.repeat(139) + + '\\.config\\openchamber\\bin\\OpenChamber.ps1'; + expect(longPath.length).toBeGreaterThanOrEqual(180); + + const cmd = buildWindowsStartupTaskCommand(longPath); + expect(cmd.length).toBeLessThan(261); + }); + + it('does NOT inline SetEnvironmentVariable (externalization invariant)', () => { + const cmd = buildWindowsStartupTaskCommand('C:\\wrapper.ps1'); + expect(cmd).not.toContain('SetEnvironmentVariable'); + }); + + it('uses -File form, not -Command', () => { + const cmd = buildWindowsStartupTaskCommand('C:\\wrapper.ps1'); + expect(cmd).toContain('-File '); + expect(cmd).not.toContain('-Command '); + }); +}); diff --git a/packages/web/bin/lib/cli-startup.js b/packages/web/bin/lib/cli-startup.js index 8a2df874..a7a5f929 100644 --- a/packages/web/bin/lib/cli-startup.js +++ b/packages/web/bin/lib/cli-startup.js @@ -74,6 +74,10 @@ function getMacosStartupWrapperPath() { return path.join(getDataDir(), 'bin', 'OpenChamber'); } +function getWindowsStartupWrapperPath() { + return path.join(getDataDir(), 'bin', 'OpenChamber.ps1'); +} + function collectStartupEnv(options = {}) { const env = options.envSnapshot === false ? {} : Object.fromEntries( Object.entries(process.env) @@ -189,6 +193,24 @@ exec ${startupShellQuote(process.execPath)} ${args} return wrapperPath; } +function writeWindowsStartupWrapper(options = {}) { + const wrapperPath = getWindowsStartupWrapperPath(); + const envFilePath = getStartupEnvFilePath(); + const startupArgs = buildStartupArgs(options).map(powershellQuote).join(' '); + const ps1Content = [ + `$envFile=${powershellQuote(envFilePath)}`, + `if (Test-Path $envFile) { Get-Content $envFile | ForEach-Object { if ($_ -match '^([^=]+)=(.*)$') { $v=$matches[2]; if ($v.StartsWith("'") -and $v.EndsWith("'")) { $v=$v.Substring(1,$v.Length-2).Replace("'\\''","'") }; [Environment]::SetEnvironmentVariable($matches[1], $v, 'Process') } } }`, + `& ${powershellQuote(process.execPath)} ${startupArgs}`, + ].join('; '); + fs.mkdirSync(path.dirname(wrapperPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(wrapperPath, ps1Content, { mode: 0o700 }); + return wrapperPath; +} + +function buildWindowsStartupTaskCommand(wrapperPath) { + return `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${wrapperPath}"`; +} + function buildMacosLaunchAgent(options = {}) { const wrapperPath = writeMacosStartupWrapper(options); const args = [wrapperPath]; @@ -318,21 +340,16 @@ function enableStartupService(options = {}) { return getStartupStatus(); } - const envFilePath = writeStartupEnvFile(options); - const startupArgs = buildStartupArgs(options).map(powershellQuote).join(', '); - const powerShellCommand = [ - `$envFile=${powershellQuote(envFilePath)}`, - `if (Test-Path $envFile) { Get-Content $envFile | ForEach-Object { if ($_ -match '^([^=]+)=(.*)$') { $v=$matches[2]; if ($v.StartsWith("'") -and $v.EndsWith("'")) { $v=$v.Substring(1,$v.Length-2).Replace("'\\''","'") }; [Environment]::SetEnvironmentVariable($matches[1], $v, 'Process') } } }`, - `& ${powershellQuote(process.execPath)} ${startupArgs}`, - ].join('; '); - const taskArgs = `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "${powerShellCommand.replace(/"/g, '\\"')}"`; + writeStartupEnvFile(options); + const wrapperPath = writeWindowsStartupWrapper(options); + const taskCommand = buildWindowsStartupTaskCommand(wrapperPath); runStartupCommand('schtasks.exe', [ '/Create', '/TN', STARTUP_SERVICE_ID, '/SC', 'ONLOGON', '/RL', 'LIMITED', '/F', - '/TR', taskArgs, + '/TR', taskCommand, ]); runStartupCommand('schtasks.exe', ['/Run', '/TN', STARTUP_SERVICE_ID], { allowFailure: true }); return getStartupStatus(); @@ -359,6 +376,8 @@ function disableStartupService() { runStartupCommand('schtasks.exe', ['/End', '/TN', STARTUP_SERVICE_ID], { allowFailure: true }); runStartupCommand('schtasks.exe', ['/Delete', '/TN', STARTUP_SERVICE_ID, '/F'], { allowFailure: true }); + try { fs.unlinkSync(getWindowsStartupWrapperPath()); } catch {} + removeStartupEnvFile(); return getStartupStatus(); } @@ -367,4 +386,5 @@ export { getStartupStatus, enableStartupService, disableStartupService, + buildWindowsStartupTaskCommand, }; From 83d4bc7b59c015e672eac63c2e0f3a145cb461c1 Mon Sep 17 00:00:00 2001 From: herjarsa Date: Thu, 9 Jul 2026 21:55:57 +0200 Subject: [PATCH 017/405] fix(chat): save previous-session anchor in microtask with bail check When switching sessions, the previous session's viewport anchor save was deferred via setTimeout(..., 0). This races with the new session's restoreSnapshot effect: the timer can fire after React has flushed the new session's render and before the restore effect runs, leaving the saved anchor and the restored scroll position fighting over the same viewport store entry. The save reads messages (can be expensive) on the same tick as the new session's skeleton render. Replace setTimeout(..., 0) with queueMicrotask() so the save runs immediately after the current synchronous call stack and before the next macrotask / paint. This guarantees the save completes before the new session's restoreSnapshot effect fires. Add a bail check: if the user switched sessions again between the microtask scheduling and execution (rapid switching), the save is now stale. Comparing the captured newId to the current currentSessionId at microtask runtime avoids clobbering the in-flight session's anchor with data from a session that is no longer "previous". This is the queueMicrotask + bail change acknowledged as 'great' in the review of #1675, extracted as a focused single-file PR. --- packages/ui/src/sync/session-ui-store.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 397fb484..154ae984 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -617,7 +617,16 @@ export const useSessionUIStore = create()((set, get) => ({ // skeleton to render and reads messages which can be expensive. if (previousSessionId && previousSessionId !== id) { const prevId = previousSessionId - setTimeout(() => { + const newId = id + // queueMicrotask runs after the current synchronous call stack (and + // before the next macrotask / setTimeout(0) / paint), so the previous + // session's anchor is saved before the new session's restoreSnapshot + // effect fires. This eliminates the race where save and restore + // interleave against the same viewport store entry. + queueMicrotask(() => { + // Bail if the user already switched again — save is now stale. + const current = get().currentSessionId + if (current !== newId) return const memState = getViewportSessionMemory(prevId) if (!memState?.isStreaming) { const prevMessages = getSyncMessages(prevId) @@ -625,7 +634,7 @@ export const useSessionUIStore = create()((set, get) => ({ useViewportStore.getState().updateViewportAnchor(prevId, prevMessages.length - 1) } } - }, 0) + }); } // Mark session viewed in notification store + update active session ref From 128122fdd92bbd02d4eb26fb07648d1a2caa3365 Mon Sep 17 00:00:00 2001 From: Greg Haynes Date: Sun, 12 Jul 2026 19:54:35 -0700 Subject: [PATCH 018/405] fix(web): shorten PWA install app name --- packages/web/index.html | 2 +- packages/web/public/site.webmanifest | 2 +- packages/web/server/lib/opencode/pwa-manifest-routes.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/web/index.html b/packages/web/index.html index 2434decd..9ee0b466 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -24,7 +24,7 @@