From 1439f5e8380330d31e08bcd9ade632d1a27d3d38 Mon Sep 17 00:00:00 2001 From: Issue Reproducer Date: Thu, 18 Jun 2026 15:00:44 +0000 Subject: [PATCH 001/282] 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/282] 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/282] 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/282] 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/282] 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/282] 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/282] 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/282] 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/282] 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/282] 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/282] 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/282] 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/282] 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/282] 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/282] 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/282] 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/282] 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/282] 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 @@ ` : ''} + + +`; +} + +async function storeAuthorizationResult(libraries, result) { + const { setLinearAuth, fetchLinearIdentity } = libraries; + let user = null; + let organization = null; + try { + const identity = await fetchLinearIdentity(result.accessToken); + user = identity.user; + organization = identity.organization; + } catch (error) { + console.error('Failed to load Linear identity after OAuth:', error); + } + return setLinearAuth({ + accessToken: result.accessToken, + refreshToken: result.refreshToken, + tokenType: result.tokenType, + expiresAt: result.expiresAt, + scope: result.scope, + user, + organization, + }); +} + +export function registerLinearRoutes(app) { + let linearLibraries = null; + const getLinearLibraries = async () => { + if (!linearLibraries) { + linearLibraries = await import('./index.js'); + } + return linearLibraries; + }; + + app.get('/linear/oauth/callback', async (req, res) => { + const finish = (status, { title, message, desktopReturn = false }) => { + res.status(status).type('html').send(renderLinearOAuthCallbackPage({ title, message, desktopReturn })); + }; + + try { + const libraries = await getLinearLibraries(); + const { consumeAuthorizationCallback } = libraries; + const result = await consumeAuthorizationCallback({ + code: queryValue(req, 'code'), + state: queryValue(req, 'state'), + error: queryValue(req, 'error'), + errorDescription: queryValue(req, 'error_description'), + }); + + await storeAuthorizationResult(libraries, result); + + return finish(200, { + title: 'Authorization Complete', + message: 'You can close this tab and return to OpenChamber.', + desktopReturn: result.origin === 'desktop', + }); + } catch (error) { + const code = error instanceof Error ? error.code : ''; + const status = code === 'UNKNOWN_STATE' || code === 'MISSING_CODE' || code === 'ACCESS_DENIED' + ? 400 + : 502; + return finish(status, { + title: 'Authorization Failed', + message: error instanceof Error ? error.message : 'Linear authorization failed. Return to OpenChamber and click Connect again.', + desktopReturn: error?.origin === 'desktop', + }); + } + }); + + app.get('/api/linear/auth/status', async (_req, res) => { + try { + const libraries = await getLinearLibraries(); + const { + getLinearAuth, + getLinearAuthWorkspaces, + getValidLinearAccessToken, + fetchLinearIdentity, + setLinearAuth, + clearLinearAuth, + toLinearPublicStatus, + pollAuthorizationBroker, + completeAuthorizationBroker, + } = libraries; + + try { + const result = await pollAuthorizationBroker(); + if (result) { + await storeAuthorizationResult(libraries, result); + await completeAuthorizationBroker(result.brokerReceipt).catch((error) => { + console.warn('Failed to acknowledge Linear authorization broker result:', error); + }); + } + } catch (error) { + console.error('Failed to complete Linear authorization through broker:', error); + } + + const accessToken = await getValidLinearAccessToken(); + if (!accessToken) { + return res.json({ connected: false }); + } + + const auth = getLinearAuth(); + try { + const identity = await fetchLinearIdentity(accessToken); + const next = setLinearAuth({ + accessToken, + refreshToken: auth?.refreshToken, + tokenType: auth?.tokenType, + expiresAt: auth?.expiresAt, + scope: auth?.scope, + user: identity.user, + organization: identity.organization, + workspaceId: auth?.workspaceId, + }, { activate: false }); + return res.json(toLinearPublicStatus(next, getLinearAuthWorkspaces())); + } catch (error) { + if (error?.status === 401) { + clearLinearAuth(auth?.workspaceId); + const remaining = getLinearAuth(); + if (!remaining) { + return res.json({ connected: false }); + } + return res.json(toLinearPublicStatus(remaining, getLinearAuthWorkspaces())); + } + if (auth) { + return res.json(toLinearPublicStatus(auth, getLinearAuthWorkspaces())); + } + throw error; + } + } catch (error) { + console.error('Failed to get Linear auth status:', error); + return res.status(500).json({ error: error.message || 'Failed to get Linear auth status' }); + } + }); + + app.post('/api/linear/auth/start', parseJsonBody, async (req, res) => { + try { + const { startAuthorization } = await getLinearLibraries(); + const origin = req.body?.origin === 'desktop' ? 'desktop' : 'web'; + const payload = await startAuthorization({ origin }); + return res.json(payload); + } catch (error) { + const status = error?.code === 'LINEAR_CLIENT_ID_MISSING' ? 400 : 500; + console.error('Failed to start Linear authorization:', error); + return res.status(status).json({ error: error.message || 'Failed to start Linear authorization' }); + } + }); + + app.get('/api/linear/issues/list', async (req, res) => { + try { + const { listLinearIssues } = await getLinearLibraries(); + const result = await listLinearIssues({ + query: queryValue(req, 'query'), + cursor: queryValue(req, 'cursor'), + status: queryValue(req, 'status'), + assignee: queryValue(req, 'assignee'), + teamId: queryValue(req, 'teamId'), + priority: queryValue(req, 'priority'), + }); + return res.json(result); + } catch (error) { + console.error('Failed to list Linear issues:', error); + return res.status(500).json({ error: error.message || 'Failed to list Linear issues' }); + } + }); + + app.get('/api/linear/issues/get', async (req, res) => { + try { + const id = queryValue(req, 'id'); + if (!id) { + return res.status(400).json({ error: 'id is required' }); + } + const { getLinearIssue } = await getLinearLibraries(); + const result = await getLinearIssue(id); + return res.json(result); + } catch (error) { + console.error('Failed to load Linear issue:', error); + return res.status(500).json({ error: error.message || 'Failed to load Linear issue' }); + } + }); + + app.get('/api/linear/issues/states', async (req, res) => { + try { + const teamId = queryValue(req, 'teamId'); + if (!teamId) { + return res.status(400).json({ error: 'teamId is required' }); + } + const { listLinearIssueStates } = await getLinearLibraries(); + const result = await listLinearIssueStates(teamId); + return res.json(result); + } catch (error) { + if (isLinearUserError(error)) { + return res.status(400).json({ error: error.message }); + } + console.error('Failed to load Linear workflow states:', error); + return res.status(500).json({ error: error.message || 'Failed to load Linear workflow states' }); + } + }); + + app.post('/api/linear/issues/update', parseJsonBody, async (req, res) => { + try { + const { updateLinearIssue } = await getLinearLibraries(); + const result = await updateLinearIssue({ + id: req.body?.id, + stateId: req.body?.stateId, + }); + return res.json(result); + } catch (error) { + if (isLinearUserError(error)) { + return res.status(400).json({ error: error.message }); + } + console.error('Failed to update Linear issue:', error); + return res.status(500).json({ error: error.message || 'Failed to update Linear issue' }); + } + }); + + app.get('/api/linear/mapping', async (_req, res) => { + try { + const { + listLinearTeams, + readStoredLinearMapping, + mergeLinearMappingView, + LinearMappingError, + } = await getLinearLibraries(); + const teamsResult = await listLinearTeams(); + if (teamsResult.connected === false) { + return res.json({ connected: false }); + } + let stored; + try { + stored = readStoredLinearMapping(); + } catch (error) { + if (error instanceof LinearMappingError && error.code === 'MALFORMED') { + return res.status(500).json({ error: error.message }); + } + throw error; + } + return res.json({ + connected: true, + ...mergeLinearMappingView(stored, teamsResult.teams), + }); + } catch (error) { + console.error('Failed to load Linear mapping:', error); + return res.status(500).json({ error: error.message || 'Failed to load Linear mapping' }); + } + }); + + app.put('/api/linear/mapping', parseJsonBody, async (req, res) => { + try { + const { + getValidLinearAccessToken, + listLinearTeams, + setStoredLinearMapping, + mergeLinearMappingView, + LinearMappingError, + } = await getLinearLibraries(); + const accessToken = await getValidLinearAccessToken(); + if (!accessToken) { + return res.json({ connected: false }); + } + let stored; + try { + stored = setStoredLinearMapping(req.body); + } catch (error) { + if (error instanceof LinearMappingError && error.code === 'INVALID') { + return res.status(400).json({ error: error.message }); + } + throw error; + } + const teamsResult = await listLinearTeams(); + if (teamsResult.connected === false) { + return res.json({ + connected: true, + ...mergeLinearMappingView(stored, []), + }); + } + return res.json({ + connected: true, + ...mergeLinearMappingView(stored, teamsResult.teams), + }); + } catch (error) { + console.error('Failed to save Linear mapping:', error); + return res.status(500).json({ error: error.message || 'Failed to save Linear mapping' }); + } + }); + + app.post('/api/linear/session-status', parseJsonBody, async (req, res) => { + try { + const { postLinearSessionStatus, LinearSessionStatusError } = await getLinearLibraries(); + try { + const result = await postLinearSessionStatus({ + kind: req.body?.kind, + sessionId: req.body?.sessionId, + issueIdentifier: req.body?.issueIdentifier, + sessionOrigin: req.body?.sessionOrigin, + }); + return res.json(result); + } catch (error) { + if (error instanceof LinearSessionStatusError && error.code === 'INVALID') { + return res.status(400).json({ error: error.message }); + } + if (error instanceof LinearSessionStatusError && error.code === 'MALFORMED') { + return res.status(500).json({ error: error.message }); + } + throw error; + } + } catch (error) { + console.error('Failed to post Linear session status:', error); + return res.status(500).json({ error: error.message || 'Failed to post Linear session status' }); + } + }); + + app.get('/api/linear/preferences', async (_req, res) => { + try { + const { getLinearSessionCommentsEnabled } = await getLinearLibraries(); + return res.json({ sessionComments: getLinearSessionCommentsEnabled() }); + } catch (error) { + console.error('Failed to load Linear preferences:', error); + return res.status(500).json({ error: error.message || 'Failed to load Linear preferences' }); + } + }); + + app.put('/api/linear/preferences', parseJsonBody, async (req, res) => { + try { + const sessionComments = req.body?.sessionComments; + if (sessionComments !== true && sessionComments !== false) { + return res.status(400).json({ error: 'sessionComments must be a boolean' }); + } + const { setLinearSessionCommentsEnabled } = await getLinearLibraries(); + return res.json({ sessionComments: setLinearSessionCommentsEnabled(sessionComments) }); + } catch (error) { + console.error('Failed to save Linear preferences:', error); + return res.status(500).json({ error: error.message || 'Failed to save Linear preferences' }); + } + }); + + app.post('/api/linear/auth/activate', parseJsonBody, async (req, res) => { + try { + const { + activateLinearAuth, + getLinearAuth, + getLinearAuthWorkspaces, + toLinearPublicStatus, + } = await getLinearLibraries(); + const organizationId = readTrimmedString(req.body?.organizationId); + if (!organizationId) { + return res.status(400).json({ error: 'organizationId is required' }); + } + const activated = activateLinearAuth(organizationId); + if (!activated) { + return res.status(404).json({ error: 'Linear workspace not found' }); + } + const auth = getLinearAuth(); + if (!auth) { + return res.json({ connected: false }); + } + return res.json(toLinearPublicStatus(auth, getLinearAuthWorkspaces())); + } catch (error) { + console.error('Failed to switch Linear workspace:', error); + return res.status(500).json({ error: error.message || 'Failed to switch Linear workspace' }); + } + }); + + app.delete('/api/linear/auth', async (_req, res) => { + try { + const { getLinearAuth, clearLinearAuth, revokeToken } = await getLinearLibraries(); + const auth = getLinearAuth(); + if (auth?.refreshToken) { + await revokeToken(auth.refreshToken, 'refresh_token'); + } else if (auth?.accessToken) { + await revokeToken(auth.accessToken, 'access_token'); + } + const removed = clearLinearAuth(auth?.workspaceId); + return res.json({ success: true, removed }); + } catch (error) { + console.error('Failed to disconnect Linear:', error); + return res.status(500).json({ error: error.message || 'Failed to disconnect Linear' }); + } + }); +} diff --git a/packages/web/server/lib/linear/routes.test.js b/packages/web/server/lib/linear/routes.test.js new file mode 100644 index 00000000..a6d87112 --- /dev/null +++ b/packages/web/server/lib/linear/routes.test.js @@ -0,0 +1,661 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { registerLinearRoutes } from './routes.js'; +import { setLinearAuth, setLinearSessionCommentsEnabled } from './auth.js'; + +const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-routes-')); + +const createApp = () => { + const app = express(); + registerLinearRoutes(app); + return app; +}; + +const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, +}); + +describe('Linear auth routes', () => { + let dataDir; + let previousDataDir; + + beforeEach(() => { + previousDataDir = process.env.OPENCHAMBER_DATA_DIR; + dataDir = makeTempDir(); + process.env.OPENCHAMBER_DATA_DIR = dataDir; + process.env.OPENCHAMBER_PORT = '3001'; + process.env.OPENCHAMBER_LINEAR_REDIRECT_URI = 'http://127.0.0.1:3001/linear/oauth/callback'; + delete process.env.OPENCHAMBER_LINEAR_CLIENT_ID; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + if (previousDataDir === undefined) { + delete process.env.OPENCHAMBER_DATA_DIR; + } else { + process.env.OPENCHAMBER_DATA_DIR = previousDataDir; + } + delete process.env.OPENCHAMBER_PORT; + delete process.env.OPENCHAMBER_LINEAR_REDIRECT_URI; + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('starts authorization and completes it from the public callback', async () => { + const app = createApp(); + const start = await request(app) + .post('/api/linear/auth/start') + .send({ origin: 'desktop' }) + .expect(200); + + expect(start.body.authorizationUrl).toContain('https://linear.app/oauth/authorize'); + const state = new URL(start.body.authorizationUrl).searchParams.get('state'); + + vi.stubGlobal('fetch', vi.fn(async (url) => { + const target = String(url); + if (target === 'https://api.linear.app/oauth/token') { + return jsonResponse({ + access_token: 'access-1', + refresh_token: 'refresh-1', + token_type: 'Bearer', + expires_in: 86399, + scope: 'read,write,comments:create', + }); + } + if (target === 'https://api.linear.app/graphql') { + return jsonResponse({ + data: { + viewer: { + id: 'user-1', + name: 'Ada', + displayName: 'Ada Lovelace', + email: 'ada@example.com', + avatarUrl: 'https://example.com/a.png', + }, + organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' }, + }, + }); + } + throw new Error(`unexpected fetch: ${target}`); + })); + + const callback = await request(app) + .get('/linear/oauth/callback') + .query({ state, code: 'auth-code' }) + .expect(200); + + expect(callback.text).toContain('Authorization Complete'); + expect(callback.text).toContain('openchamber://focus/linear-auth'); + + const status = await request(app).get('/api/linear/auth/status').expect(200); + expect(status.body.connected).toBe(true); + expect(status.body.user).toEqual({ + id: 'user-1', + name: 'Ada', + displayName: 'Ada Lovelace', + email: 'ada@example.com', + avatarUrl: 'https://example.com/a.png', + }); + expect(status.body.organization).toEqual({ id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' }); + expect(status.body.scope).toBe('read,write,comments:create'); + expect(status.body.workspaces).toEqual([{ + id: 'org-1', + name: 'OpenChamber', + urlKey: 'openchamber', + current: true, + user: { + id: 'user-1', + name: 'Ada', + displayName: 'Ada Lovelace', + email: 'ada@example.com', + avatarUrl: 'https://example.com/a.png', + }, + authorizedAt: expect.any(Number), + }]); + expect(JSON.stringify(status.body)).not.toContain('access-1'); + expect(JSON.stringify(status.body)).not.toContain('refresh-1'); + + const again = await request(app).get('/api/linear/auth/status').expect(200); + expect(again.body.workspaces[0].authorizedAt).toBe(status.body.workspaces[0].authorizedAt); + }); + + it('never exchanges a code whose state is unknown', async () => { + const tokenFetch = vi.fn(); + vi.stubGlobal('fetch', tokenFetch); + const app = createApp(); + const response = await request(app) + .get('/linear/oauth/callback') + .query({ state: 'forged', code: 'attacker-code' }) + .expect(400); + expect(tokenFetch).not.toHaveBeenCalled(); + expect(response.text).toContain('Authorization Failed'); + expect(response.text).not.toContain('openchamber://'); + }); + + it('omits the desktop deep link for flows started outside the desktop shell', async () => { + const app = createApp(); + const start = await request(app) + .post('/api/linear/auth/start') + .send({ origin: 'web' }) + .expect(200); + const state = new URL(start.body.authorizationUrl).searchParams.get('state'); + + vi.stubGlobal('fetch', vi.fn(async (url) => { + const target = String(url); + if (target.includes('/oauth/token')) { + return jsonResponse({ + access_token: 'access-1', + refresh_token: 'refresh-1', + expires_in: 86399, + }); + } + return jsonResponse({ + data: { viewer: { id: 'user-1', name: 'Ada' }, organization: null }, + }); + })); + + const response = await request(app) + .get('/linear/oauth/callback') + .query({ state, code: 'auth-code' }) + .expect(200); + expect(response.text).not.toContain('openchamber://'); + }); + + it('disconnects and revokes the refresh token', async () => { + const app = createApp(); + const start = await request(app).post('/api/linear/auth/start').send({}).expect(200); + const state = new URL(start.body.authorizationUrl).searchParams.get('state'); + const fetchMock = vi.fn(async (url) => { + const target = String(url); + if (target.includes('/oauth/token')) { + return jsonResponse({ + access_token: 'access-1', + refresh_token: 'refresh-1', + expires_in: 86399, + }); + } + if (target.includes('/graphql')) { + return jsonResponse({ data: { viewer: { id: 'user-1', name: 'Ada' } } }); + } + if (target.includes('/oauth/revoke')) { + return new Response('', { status: 200 }); + } + throw new Error(`unexpected fetch: ${target}`); + }); + vi.stubGlobal('fetch', fetchMock); + + await request(app).get('/linear/oauth/callback').query({ state, code: 'auth-code' }).expect(200); + await request(app).delete('/api/linear/auth').expect(200); + + const revokeCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/oauth/revoke')); + expect(revokeCall).toBeTruthy(); + const body = new URLSearchParams(revokeCall[1].body); + expect(body.get('token')).toBe('refresh-1'); + expect(body.get('token_type_hint')).toBe('refresh_token'); + + const status = await request(app).get('/api/linear/auth/status').expect(200); + expect(status.body).toEqual({ connected: false }); + }); + + it('stores a second workspace, switches current, and disconnects only that one', async () => { + const app = createApp(); + + const startA = await request(app).post('/api/linear/auth/start').send({}).expect(200); + const stateA = new URL(startA.body.authorizationUrl).searchParams.get('state'); + vi.stubGlobal('fetch', vi.fn(async (url) => { + const target = String(url); + if (target.includes('/oauth/token')) { + return jsonResponse({ + access_token: 'access-a', + refresh_token: 'refresh-a', + expires_in: 86399, + scope: 'read,write,comments:create', + }); + } + if (target.includes('/graphql')) { + return jsonResponse({ + data: { + viewer: { id: 'user-a', name: 'Ada' }, + organization: { id: 'org-a', name: 'Alpha', urlKey: 'alpha' }, + }, + }); + } + throw new Error(`unexpected fetch: ${target}`); + })); + await request(app).get('/linear/oauth/callback').query({ state: stateA, code: 'code-a' }).expect(200); + + const startB = await request(app).post('/api/linear/auth/start').send({}).expect(200); + const stateB = new URL(startB.body.authorizationUrl).searchParams.get('state'); + vi.stubGlobal('fetch', vi.fn(async (url) => { + const target = String(url); + if (target.includes('/oauth/token')) { + return jsonResponse({ + access_token: 'access-b', + refresh_token: 'refresh-b', + expires_in: 86399, + scope: 'read,write,comments:create', + }); + } + if (target.includes('/graphql')) { + return jsonResponse({ + data: { + viewer: { id: 'user-b', name: 'Ben' }, + organization: { id: 'org-b', name: 'Beta', urlKey: 'beta' }, + }, + }); + } + if (target.includes('/oauth/revoke')) { + return new Response('', { status: 200 }); + } + throw new Error(`unexpected fetch: ${target}`); + })); + await request(app).get('/linear/oauth/callback').query({ state: stateB, code: 'code-b' }).expect(200); + + const both = await request(app).get('/api/linear/auth/status').expect(200); + expect(both.body.organization.id).toBe('org-b'); + expect(both.body.workspaces).toHaveLength(2); + + await request(app).post('/api/linear/auth/activate').send({}).expect(400); + await request(app).post('/api/linear/auth/activate').send({ organizationId: 'missing' }).expect(404); + + const activated = await request(app) + .post('/api/linear/auth/activate') + .send({ organizationId: 'org-a' }) + .expect(200); + expect(activated.body.organization.id).toBe('org-a'); + expect(activated.body.workspaces.find((entry) => entry.id === 'org-a').current).toBe(true); + expect(activated.body.workspaces.find((entry) => entry.id === 'org-b').current).toBe(false); + + await request(app).delete('/api/linear/auth').expect(200); + const remaining = await request(app).get('/api/linear/auth/status').expect(200); + expect(remaining.body.connected).toBe(true); + expect(remaining.body.organization.id).toBe('org-b'); + expect(remaining.body.workspaces).toHaveLength(1); + expect(remaining.body.workspaces[0].id).toBe('org-b'); + }); + + it('lists and gets issues through authenticated routes without leaking tokens', async () => { + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read', + }); + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('GetLinearIssue')) { + return jsonResponse({ + data: { + issue: { + id: 'issue-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + state: { name: 'Todo', type: 'unstarted' }, + assignee: null, + description: 'Users cannot sign in.', + comments: { nodes: [] }, + }, + }, + }); + } + return jsonResponse({ + data: { + issues: { + nodes: [{ + id: 'issue-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + state: { name: 'Todo', type: 'unstarted' }, + assignee: null, + }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + })); + + const app = createApp(); + const list = await request(app).get('/api/linear/issues/list').expect(200); + expect(list.body.connected).toBe(true); + expect(list.body.issues).toHaveLength(1); + expect(JSON.stringify(list.body)).not.toContain('access-1'); + + const missing = await request(app).get('/api/linear/issues/get').expect(400); + expect(missing.body.error).toBe('id is required'); + + const got = await request(app).get('/api/linear/issues/get').query({ id: 'ENG-12' }).expect(200); + expect(got.body.issue.identifier).toBe('ENG-12'); + expect(got.body.issue.description).toBe('Users cannot sign in.'); + expect(got.body.issue.state).toEqual({ id: null, name: 'Todo', type: 'unstarted' }); + }); + + it('passes list filters from query params to Linear', async () => { + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read', + }); + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.variables.filter).toEqual({ + state: { type: { eq: 'completed' } }, + assignee: { isMe: { eq: true } }, + team: { id: { eq: 'team-eng' } }, + priority: { eq: 1 }, + }); + return jsonResponse({ + data: { + issues: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + })); + + const app = createApp(); + const list = await request(app).get('/api/linear/issues/list').query({ + status: 'completed', + assignee: 'me', + teamId: 'team-eng', + priority: 'urgent', + }).expect(200); + expect(list.body.connected).toBe(true); + expect(list.body.issues).toEqual([]); + }); + + it('lists workflow states and updates issue status without leaking tokens', async () => { + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'write', + }); + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('TeamWorkflowStates')) { + expect(body.variables.id).toBe('team-eng'); + expect(options.headers.Authorization).toBe('Bearer access-1'); + return jsonResponse({ + data: { + team: { + states: { + nodes: [ + { id: 'state-todo', name: 'Todo', type: 'unstarted', position: 1 }, + { id: 'state-done', name: 'Done', type: 'completed', position: 2 }, + ], + }, + }, + }, + }); + } + expect(body.query).toContain('mutation IssueUpdate'); + expect(body.variables).toEqual({ + id: 'issue-uuid-1', + input: { stateId: 'state-done' }, + }); + return jsonResponse({ + data: { + issueUpdate: { + success: true, + issue: { + id: 'issue-uuid-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + state: { id: 'state-done', name: 'Done', type: 'completed' }, + assignee: null, + description: null, + comments: { nodes: [] }, + }, + }, + }, + }); + })); + + const app = createApp(); + const missingTeam = await request(app).get('/api/linear/issues/states').expect(400); + expect(missingTeam.body.error).toBe('teamId is required'); + + const states = await request(app).get('/api/linear/issues/states').query({ teamId: 'team-eng' }).expect(200); + expect(states.body.connected).toBe(true); + expect(states.body.states).toEqual([ + { id: 'state-todo', name: 'Todo', type: 'unstarted', position: 1 }, + { id: 'state-done', name: 'Done', type: 'completed', position: 2 }, + ]); + expect(JSON.stringify(states.body)).not.toContain('access-1'); + + const missingBody = await request(app).post('/api/linear/issues/update').send({}).expect(400); + expect(missingBody.body.error).toBe('id and stateId are required'); + + const updated = await request(app).post('/api/linear/issues/update').send({ + id: 'issue-uuid-1', + stateId: 'state-done', + }).expect(200); + expect(updated.body.connected).toBe(true); + expect(updated.body.issue.identifier).toBe('ENG-12'); + expect(updated.body.issue.state).toEqual({ id: 'state-done', name: 'Done', type: 'completed' }); + expect(JSON.stringify(updated.body)).not.toContain('access-1'); + }); + + it('returns 400 for Linear validation and not-found GraphQL errors', async () => { + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'write', + }); + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('TeamWorkflowStates')) { + return jsonResponse({ + data: null, + errors: [{ + message: 'Entity not found: Team', + extensions: { + code: 'INPUT_ERROR', + userError: true, + userPresentableMessage: 'Could not find referenced Team.', + }, + }], + }); + } + return jsonResponse({ + data: null, + errors: [{ + message: 'Argument Validation Error', + extensions: { + code: 'INVALID_INPUT', + userError: true, + userPresentableMessage: 'stateId must be a UUID.', + }, + }], + }); + })); + + const app = createApp(); + const states = await request(app).get('/api/linear/issues/states').query({ teamId: 'missing-team' }).expect(400); + expect(states.body.error).toBe('Could not find referenced Team.'); + + const updated = await request(app).post('/api/linear/issues/update').send({ + id: 'issue-uuid-1', + stateId: 'not-a-uuid', + }).expect(400); + expect(updated.body.error).toBe('stateId must be a UUID.'); + }); + + it('returns disconnected for issue routes when Linear is not connected', async () => { + const app = createApp(); + const list = await request(app).get('/api/linear/issues/list').expect(200); + expect(list.body).toEqual({ connected: false }); + const got = await request(app).get('/api/linear/issues/get').query({ id: 'ENG-12' }).expect(200); + expect(got.body).toEqual({ connected: false }); + const states = await request(app).get('/api/linear/issues/states').query({ teamId: 'team-eng' }).expect(200); + expect(states.body).toEqual({ connected: false }); + const updated = await request(app).post('/api/linear/issues/update').send({ + id: 'issue-1', + stateId: 'state-done', + }).expect(200); + expect(updated.body).toEqual({ connected: false }); + }); + + it('returns disconnected mapping when Linear is not connected', async () => { + const app = createApp(); + const mapping = await request(app).get('/api/linear/mapping').expect(200); + expect(mapping.body).toEqual({ connected: false }); + const saved = await request(app).put('/api/linear/mapping').send({ + defaultProjectPath: '/tmp/project', + teamProjectPaths: {}, + }).expect(200); + expect(saved.body).toEqual({ connected: false }); + }); + + it('saves and reads Linear team-to-project mapping without leaking tokens', async () => { + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read', + }); + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.query).toContain('query ListLinearTeams'); + expect(options.headers.Authorization).toBe('Bearer access-1'); + return jsonResponse({ + data: { + teams: { + nodes: [ + { id: 'team-eng', key: 'ENG', name: 'Engineering' }, + { id: 'team-des', key: 'DES', name: 'Design' }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + })); + + const app = createApp(); + const empty = await request(app).get('/api/linear/mapping').expect(200); + expect(empty.body).toEqual({ + connected: true, + defaultProjectPath: null, + teams: [ + { id: 'team-eng', key: 'ENG', name: 'Engineering', projectPath: null }, + { id: 'team-des', key: 'DES', name: 'Design', projectPath: null }, + ], + }); + expect(JSON.stringify(empty.body)).not.toContain('access-1'); + + const saved = await request(app).put('/api/linear/mapping').send({ + defaultProjectPath: '/Users/ada/openchamber', + teamProjectPaths: { 'team-eng': '/Users/ada/eng' }, + }).expect(200); + expect(saved.body).toEqual({ + connected: true, + defaultProjectPath: '/Users/ada/openchamber', + teams: [ + { id: 'team-eng', key: 'ENG', name: 'Engineering', projectPath: '/Users/ada/eng' }, + { id: 'team-des', key: 'DES', name: 'Design', projectPath: null }, + ], + }); + expect(JSON.stringify(saved.body)).not.toContain('access-1'); + + const reread = await request(app).get('/api/linear/mapping').expect(200); + expect(reread.body.defaultProjectPath).toBe('/Users/ada/openchamber'); + expect(reread.body.teams[0].projectPath).toBe('/Users/ada/eng'); + }); + + it('posts a session status comment and never leaks the token', async () => { + setLinearSessionCommentsEnabled(true); + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read,write,comments:create', + }); + vi.stubGlobal('fetch', vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('query GetLinearIssue')) { + return jsonResponse({ + data: { + issue: { + id: 'issue-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + state: { name: 'Todo', type: 'unstarted' }, + assignee: null, + description: null, + comments: { nodes: [] }, + }, + }, + }); + } + expect(body.query).toContain('mutation CommentCreate'); + return jsonResponse({ + data: { + commentCreate: { + success: true, + comment: { id: 'comment-1' }, + }, + }, + }); + })); + + const app = createApp(); + const missing = await request(app).post('/api/linear/session-status').send({ + kind: 'started', + }).expect(400); + expect(missing.body.error).toBe('kind and sessionId are required'); + + const posted = await request(app).post('/api/linear/session-status').send({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + }).expect(200); + expect(posted.body).toEqual({ + connected: true, + posted: true, + commentId: 'comment-1', + }); + expect(JSON.stringify(posted.body)).not.toContain('access-1'); + }); + + it('reads and writes the session-comment preference', async () => { + const app = createApp(); + const initial = await request(app).get('/api/linear/preferences').expect(200); + expect(initial.body).toEqual({ sessionComments: false }); + + const invalid = await request(app).put('/api/linear/preferences').send({ sessionComments: 'yes' }).expect(400); + expect(invalid.body.error).toBe('sessionComments must be a boolean'); + + const enabled = await request(app).put('/api/linear/preferences').send({ sessionComments: true }).expect(200); + expect(enabled.body).toEqual({ sessionComments: true }); + const reread = await request(app).get('/api/linear/preferences').expect(200); + expect(reread.body).toEqual({ sessionComments: true }); + }); + + it('returns disconnected session-status when Linear is not connected', async () => { + const app = createApp(); + const response = await request(app).post('/api/linear/session-status').send({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + }).expect(200); + expect(response.body).toEqual({ connected: false }); + }); +}); diff --git a/packages/web/server/lib/linear/status-runtime.js b/packages/web/server/lib/linear/status-runtime.js new file mode 100644 index 00000000..6b2a1e67 --- /dev/null +++ b/packages/web/server/lib/linear/status-runtime.js @@ -0,0 +1,64 @@ +import { isPlainObject, readTrimmedString } from './parse.js'; +import { postLinearSessionStatus } from './status.js'; + +function readProperties(payload) { + if (!isPlainObject(payload)) return {}; + return isPlainObject(payload.properties) ? payload.properties : {}; +} + +function readNested(properties, key) { + return isPlainObject(properties[key]) ? properties[key] : {}; +} + +function extractSessionId(payload) { + const properties = readProperties(payload); + const info = readNested(properties, 'info'); + return readTrimmedString(info.sessionID) + || readTrimmedString(info.sessionId) + || readTrimmedString(properties.sessionID) + || readTrimmedString(properties.sessionId) + || readTrimmedString(properties.session); +} + +function extractStatusType(payload) { + if (!isPlainObject(payload) || payload.type !== 'session.status') return ''; + const properties = readProperties(payload); + const status = readNested(properties, 'status'); + const info = readNested(properties, 'info'); + return readTrimmedString(status.type) || readTrimmedString(info.type); +} + +function extractErrorName(payload) { + if (!isPlainObject(payload) || payload.type !== 'session.error') return ''; + const properties = readProperties(payload); + return readTrimmedString(readNested(properties, 'error').name); +} + +export function createLinearSessionStatusRuntime() { + let stopped = false; + + const processPayload = (payload) => { + if (stopped) return; + const sessionId = extractSessionId(payload); + if (!sessionId) return; + + if (isPlainObject(payload) && payload.type === 'session.error') { + if (extractErrorName(payload) === 'MessageAbortedError') return; + void postLinearSessionStatus({ kind: 'failure', sessionId }).catch((error) => { + console.warn('[linear] failed to post session failure comment:', error?.message || error); + }); + return; + } + + if (extractStatusType(payload) !== 'idle') return; + void postLinearSessionStatus({ kind: 'completed', sessionId }).catch((error) => { + console.warn('[linear] failed to post session completed comment:', error?.message || error); + }); + }; + + const stop = () => { + stopped = true; + }; + + return { processPayload, stop }; +} diff --git a/packages/web/server/lib/linear/status-runtime.test.js b/packages/web/server/lib/linear/status-runtime.test.js new file mode 100644 index 00000000..36bc6856 --- /dev/null +++ b/packages/web/server/lib/linear/status-runtime.test.js @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { setLinearAuth, clearLinearAuth, setLinearSessionCommentsEnabled } from './auth.js'; +import { createLinearSessionStatusRuntime } from './status-runtime.js'; + +const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-status-runtime-')); + +const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, +}); + +const issueNode = { + id: 'issue-uuid-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + state: { name: 'In Progress', type: 'started' }, + assignee: null, + team: { id: 'team-eng', key: 'ENG', name: 'Engineering' }, + description: null, + comments: { nodes: [] }, +}; + +function stubLinearGraphql({ commentId = 'comment-1' } = {}) { + return vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('query GetLinearIssue')) { + return jsonResponse({ data: { issue: issueNode } }); + } + if (body.query.includes('mutation CommentCreate')) { + return jsonResponse({ + data: { + commentCreate: { + success: true, + comment: { id: commentId }, + }, + }, + }); + } + throw new Error(`unexpected query: ${body.query}`); + }); +} + +describe('Linear session status runtime', () => { + let dataDir; + let previousDataDir; + let previousPort; + + beforeEach(() => { + previousDataDir = process.env.OPENCHAMBER_DATA_DIR; + previousPort = process.env.OPENCHAMBER_PORT; + dataDir = makeTempDir(); + process.env.OPENCHAMBER_DATA_DIR = dataDir; + process.env.OPENCHAMBER_PORT = '3001'; + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read,write,comments:create', + }); + setLinearSessionCommentsEnabled(true); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + clearLinearAuth(); + if (previousDataDir === undefined) { + delete process.env.OPENCHAMBER_DATA_DIR; + } else { + process.env.OPENCHAMBER_DATA_DIR = previousDataDir; + } + if (previousPort === undefined) { + delete process.env.OPENCHAMBER_PORT; + } else { + process.env.OPENCHAMBER_PORT = previousPort; + } + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('posts completed on the first idle after started, then ignores later idles', async () => { + const { postLinearSessionStatus } = await import('./status.js'); + vi.stubGlobal('fetch', stubLinearGraphql({ commentId: 'started' })); + await postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + }); + + const graphql = stubLinearGraphql({ commentId: 'done' }); + vi.stubGlobal('fetch', graphql); + const runtime = createLinearSessionStatusRuntime(); + runtime.processPayload({ + type: 'session.status', + properties: { sessionID: 'ses_1', status: { type: 'idle' } }, + }); + runtime.processPayload({ + type: 'session.status', + properties: { sessionID: 'ses_1', status: { type: 'idle' } }, + }); + await vi.waitFor(() => { + const commentCalls = graphql.mock.calls.filter(([, options]) => { + return JSON.parse(options.body).query.includes('mutation CommentCreate'); + }); + expect(commentCalls).toHaveLength(1); + }); + runtime.stop(); + }); + + it('posts failure on session.error and skips user abort', async () => { + const { postLinearSessionStatus } = await import('./status.js'); + vi.stubGlobal('fetch', stubLinearGraphql({ commentId: 'started' })); + await postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + }); + + const graphql = stubLinearGraphql({ commentId: 'fail' }); + vi.stubGlobal('fetch', graphql); + const runtime = createLinearSessionStatusRuntime(); + runtime.processPayload({ + type: 'session.error', + properties: { + sessionID: 'ses_1', + error: { name: 'MessageAbortedError', message: 'stopped' }, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(graphql).not.toHaveBeenCalled(); + + runtime.processPayload({ + type: 'session.error', + properties: { + sessionID: 'ses_1', + error: { name: 'ProviderError', message: 'boom' }, + }, + }); + await vi.waitFor(() => { + const commentCalls = graphql.mock.calls.filter(([, options]) => { + return JSON.parse(options.body).query.includes('mutation CommentCreate'); + }); + expect(commentCalls).toHaveLength(1); + const body = JSON.parse(commentCalls[0][1].body).variables.input.body; + expect(body).toContain('OpenChamber session failed'); + }); + runtime.stop(); + }); + + it('does not treat busy as completed', async () => { + const graphql = stubLinearGraphql(); + vi.stubGlobal('fetch', graphql); + const runtime = createLinearSessionStatusRuntime(); + runtime.processPayload({ + type: 'session.status', + properties: { sessionID: 'ses_1', status: { type: 'busy' } }, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(graphql).not.toHaveBeenCalled(); + runtime.stop(); + }); +}); diff --git a/packages/web/server/lib/linear/status.js b/packages/web/server/lib/linear/status.js new file mode 100644 index 00000000..9ba19457 --- /dev/null +++ b/packages/web/server/lib/linear/status.js @@ -0,0 +1,280 @@ +import fs from 'fs'; +import path from 'path'; +import { getLinearAuth, getLinearAuthFilePath, getLinearSessionCommentsEnabled } from './auth.js'; +import { createLinearIssueComment } from './issues.js'; +import { isPlainObject, readTrimmedString } from './parse.js'; + +const LINEAR_SESSION_STATUS_KINDS = ['started', 'completed', 'failure']; +const MAX_SESSION_STATUS_RECORDS = 500; + +export class LinearSessionStatusError extends Error { + constructor(message, code) { + super(message); + this.name = 'LinearSessionStatusError'; + this.code = code; + } +} + +const inflight = new Map(); + +function statusFile() { + return path.join(path.dirname(getLinearAuthFilePath()), 'linear-session-status.json'); +} + +function writeJsonFile(filePath, payload) { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const tmpFile = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmpFile, JSON.stringify(payload, null, 2), 'utf8'); + try { + fs.chmodSync(tmpFile, 0o600); + } catch { + // best-effort + } + fs.renameSync(tmpFile, filePath); + try { + fs.chmodSync(filePath, 0o600); + } catch { + // best-effort + } +} + +const PRIVATE_HOST_SUFFIXES = ['.local', '.localhost', '.internal', '.lan', '.home.arpa']; + +function isPrivateIpv4(hostname) { + const parts = hostname.split('.'); + if (parts.length !== 4) return false; + const octets = parts.map((part) => (/^\d{1,3}$/.test(part) ? Number(part) : -1)); + if (octets.some((octet) => octet < 0 || octet > 255)) return false; + const [a, b] = octets; + if (a === 0 || a === 10 || a === 127) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + // 100.64.0.0/10 is carrier-grade NAT, which Tailscale and similar overlays use. + if (a === 100 && b >= 64 && b <= 127) return true; + return false; +} + +function isPrivateIpv6(hostname) { + const address = hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase(); + if (address === '::1' || address === '::') return true; + // fc00::/7 (unique local) and fe80::/10 (link local). + return /^f[cd]/.test(address) || /^fe[89ab]/.test(address); +} + +/** + * A session link is only worth writing into Linear when somebody other than the + * person who started the session can open it. Loopback, private LAN and + * overlay-network addresses reach nobody else, so they do not qualify. + */ +export function isPublicSessionOrigin(value) { + const origin = readSessionOrigin(value); + if (!origin) return false; + let hostname; + try { + hostname = new URL(origin).hostname.toLowerCase(); + } catch { + return false; + } + if (!hostname || hostname === 'localhost') return false; + if (PRIVATE_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix))) return false; + if (hostname.includes(':') || hostname.startsWith('[')) return !isPrivateIpv6(hostname); + if (/^[\d.]+$/.test(hostname)) return !isPrivateIpv4(hostname); + // A bare single-label host is a LAN machine name, not a routable address. + return hostname.includes('.'); +} + +export function readSessionOrigin(value) { + const trimmed = readTrimmedString(value); + if (!trimmed) return ''; + try { + const url = new URL(trimmed); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''; + if (url.username || url.password) return ''; + if (url.search || url.hash) return ''; + if (url.pathname && url.pathname !== '/') return ''; + return url.origin; + } catch { + return ''; + } +} + +export function buildLinearSessionOpenUrl(sessionId, sessionOrigin) { + const id = readTrimmedString(sessionId); + const origin = readSessionOrigin(sessionOrigin); + if (!origin) return ''; + return `${origin}/?session=${encodeURIComponent(id)}`; +} + +function statusWord(kind) { + if (kind === 'started') return 'started'; + if (kind === 'completed') return 'completed'; + return 'failed'; +} + +export function buildLinearSessionStatusComment({ kind, sessionUrl }) { + const url = readTrimmedString(sessionUrl); + const label = `OpenChamber session ${statusWord(kind)}`; + if (!url) return label; + // The comment already lives on the issue, so it says only what happened and + // links to the session. Issue titles routinely contain brackets ("[Bug] …"), + // which would break this markdown link if they were repeated in the label. + return `[${label}](${url})`; +} + +function readBooleanFlag(value) { + return value === true; +} + +function readRecord(value) { + if (!isPlainObject(value)) return null; + const issueIdentifier = readTrimmedString(value.issueIdentifier); + if (!issueIdentifier) return null; + return { + issueIdentifier, + sessionOrigin: readSessionOrigin(value.sessionOrigin) || null, + organizationId: readTrimmedString(value.organizationId) || null, + started: readBooleanFlag(value.started), + completed: readBooleanFlag(value.completed), + failure: readBooleanFlag(value.failure), + }; +} + +function readRecords() { + const filePath = statusFile(); + if (!fs.existsSync(filePath)) { + return {}; + } + let parsed; + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const trimmed = raw.trim(); + if (!trimmed) { + return {}; + } + parsed = JSON.parse(trimmed); + } catch { + throw new LinearSessionStatusError('Linear session status file is malformed', 'MALFORMED'); + } + if (!isPlainObject(parsed)) { + throw new LinearSessionStatusError('Linear session status file is malformed', 'MALFORMED'); + } + const next = {}; + for (const key of Object.keys(parsed)) { + const sessionId = readTrimmedString(key); + const record = readRecord(parsed[key]); + if (sessionId && record) { + next[sessionId] = record; + } + } + return next; +} + +/** + * The file only exists to dedupe comments, so it does not need to remember + * every session ever started. Keep the newest entries and drop the tail. + */ +export function pruneSessionStatusRecords(records, limit = MAX_SESSION_STATUS_RECORDS) { + const keys = Object.keys(records); + if (keys.length <= limit) { + return records; + } + const kept = {}; + for (const key of keys.slice(keys.length - limit)) { + kept[key] = records[key]; + } + return kept; +} + +function writeRecords(records) { + writeJsonFile(statusFile(), pruneSessionStatusRecords(records)); +} + +async function postOnce(input) { + const kind = readTrimmedString(input?.kind); + const sessionId = readTrimmedString(input?.sessionId); + if (!LINEAR_SESSION_STATUS_KINDS.includes(kind) || !sessionId) { + throw new LinearSessionStatusError('kind and sessionId are required', 'INVALID'); + } + + // Disconnected answers first so the picker and panel keep showing their + // "connect Linear" state whatever the comment preference says. + if (!getLinearAuth()) { + return { connected: false }; + } + if (!getLinearSessionCommentsEnabled()) { + return { connected: true, posted: false, skipped: 'disabled' }; + } + + const records = readRecords(); + const existing = records[sessionId] || null; + if (existing?.[kind] === true) { + return { connected: true, posted: false, skipped: 'already-posted' }; + } + if (kind !== 'started' && existing?.started !== true) { + return { connected: true, posted: false, skipped: 'not-started' }; + } + + const issueIdentifier = readTrimmedString(input?.issueIdentifier) + || readTrimmedString(existing?.issueIdentifier); + if (!issueIdentifier) { + throw new LinearSessionStatusError('issueIdentifier is required', 'INVALID'); + } + + const sessionOrigin = readSessionOrigin(input?.sessionOrigin) + || readTrimmedString(existing?.sessionOrigin); + // Without an origin other people can reach, the comment would carry a link + // only its author could open. Say nothing rather than publish a dead link. + if (!isPublicSessionOrigin(sessionOrigin)) { + return { connected: true, posted: false, skipped: 'origin-not-public' }; + } + const sessionUrl = buildLinearSessionOpenUrl(sessionId, sessionOrigin); + const organizationId = readTrimmedString(input?.organizationId) + || readTrimmedString(existing?.organizationId) + || readTrimmedString(getLinearAuth()?.workspaceId); + const body = buildLinearSessionStatusComment({ kind, sessionUrl }); + const commentResult = await createLinearIssueComment({ + issueId: issueIdentifier, + body, + organizationId, + }); + if (commentResult.connected === false) { + return { connected: false }; + } + if (!commentResult.comment) { + return { connected: true, posted: false, skipped: 'issue-not-found' }; + } + + records[sessionId] = { + issueIdentifier, + sessionOrigin: sessionOrigin || null, + organizationId: organizationId || null, + started: existing?.started === true || kind === 'started', + completed: existing?.completed === true || kind === 'completed', + failure: existing?.failure === true || kind === 'failure', + }; + writeRecords(records); + return { + connected: true, + posted: true, + commentId: commentResult.comment.id, + }; +} + +export async function postLinearSessionStatus(input) { + const kind = readTrimmedString(input?.kind); + const sessionId = readTrimmedString(input?.sessionId); + const key = `${sessionId}:${kind}`; + const pending = inflight.get(key); + if (pending) { + return pending; + } + const promise = postOnce(input).finally(() => { + inflight.delete(key); + }); + inflight.set(key, promise); + return promise; +} diff --git a/packages/web/server/lib/linear/status.test.js b/packages/web/server/lib/linear/status.test.js new file mode 100644 index 00000000..d2c9fb8a --- /dev/null +++ b/packages/web/server/lib/linear/status.test.js @@ -0,0 +1,271 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { setLinearAuth, clearLinearAuth, setLinearSessionCommentsEnabled } from './auth.js'; +import { + buildLinearSessionOpenUrl, + buildLinearSessionStatusComment, + isPublicSessionOrigin, + postLinearSessionStatus, + pruneSessionStatusRecords, + readSessionOrigin, +} from './status.js'; + +const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-status-')); + +const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, +}); + +const issueNode = { + id: 'issue-uuid-1', + identifier: 'ENG-12', + title: 'Broken login', + url: 'https://linear.app/openchamber/issue/ENG-12', + state: { name: 'In Progress', type: 'started' }, + assignee: null, + team: { id: 'team-eng', key: 'ENG', name: 'Engineering' }, + description: null, + comments: { nodes: [] }, +}; + +function stubLinearGraphql({ commentId = 'comment-1' } = {}) { + return vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + if (body.query.includes('query GetLinearIssue')) { + return jsonResponse({ data: { issue: issueNode } }); + } + if (body.query.includes('mutation CommentCreate')) { + expect(body.variables.input.issueId).toBe('issue-uuid-1'); + expect(body.variables.input.body).toContain('/?session=ses_1'); + return jsonResponse({ + data: { + commentCreate: { + success: true, + comment: { id: commentId }, + }, + }, + }); + } + throw new Error(`unexpected query: ${body.query}`); + }); +} + +describe('Linear session status comments', () => { + let dataDir; + let previousDataDir; + let previousPort; + + beforeEach(() => { + previousDataDir = process.env.OPENCHAMBER_DATA_DIR; + previousPort = process.env.OPENCHAMBER_PORT; + dataDir = makeTempDir(); + process.env.OPENCHAMBER_DATA_DIR = dataDir; + process.env.OPENCHAMBER_PORT = '3001'; + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read,write,comments:create', + }); + setLinearSessionCommentsEnabled(true); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + clearLinearAuth(); + if (previousDataDir === undefined) { + delete process.env.OPENCHAMBER_DATA_DIR; + } else { + process.env.OPENCHAMBER_DATA_DIR = previousDataDir; + } + if (previousPort === undefined) { + delete process.env.OPENCHAMBER_PORT; + } else { + process.env.OPENCHAMBER_PORT = previousPort; + } + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('reads http(s) origins and rejects other URLs', () => { + expect(readSessionOrigin('https://app.example.com')).toBe('https://app.example.com'); + expect(readSessionOrigin('http://127.0.0.1:3001/')).toBe('http://127.0.0.1:3001'); + expect(readSessionOrigin('javascript:alert(1)')).toBe(''); + expect(readSessionOrigin('https://app.example.com/secret')).toBe(''); + expect(readSessionOrigin('openchamber:')).toBe(''); + expect(buildLinearSessionOpenUrl('ses_1', 'https://app.example.com')) + .toBe('https://app.example.com/?session=ses_1'); + expect(buildLinearSessionOpenUrl('ses_1', '')).toBe(''); + }); + + it('treats only externally reachable origins as public', () => { + expect(isPublicSessionOrigin('https://chamber.example.com')).toBe(true); + expect(isPublicSessionOrigin('http://chamber.example.com:8080')).toBe(true); + expect(isPublicSessionOrigin('https://203.0.113.10')).toBe(true); + + expect(isPublicSessionOrigin('http://localhost:3001')).toBe(false); + expect(isPublicSessionOrigin('http://127.0.0.1:3001')).toBe(false); + expect(isPublicSessionOrigin('http://[::1]:3001')).toBe(false); + expect(isPublicSessionOrigin('http://192.168.1.20:3001')).toBe(false); + expect(isPublicSessionOrigin('http://10.0.0.5:3001')).toBe(false); + expect(isPublicSessionOrigin('http://172.20.1.4:3001')).toBe(false); + expect(isPublicSessionOrigin('http://169.254.10.1:3001')).toBe(false); + expect(isPublicSessionOrigin('http://100.101.102.103:3001')).toBe(false); + expect(isPublicSessionOrigin('http://macbook.local:3001')).toBe(false); + expect(isPublicSessionOrigin('http://macbook:3001')).toBe(false); + expect(isPublicSessionOrigin('http://[fd00::1]:3001')).toBe(false); + expect(isPublicSessionOrigin('openchamber:')).toBe(false); + expect(isPublicSessionOrigin('')).toBe(false); + }); + + it('posts nothing while session comments are turned off', async () => { + setLinearSessionCommentsEnabled(false); + const graphql = vi.fn(); + vi.stubGlobal('fetch', graphql); + await expect(postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + })).resolves.toEqual({ connected: true, posted: false, skipped: 'disabled' }); + expect(graphql).not.toHaveBeenCalled(); + }); + + it('posts nothing when the session origin only the author can reach', async () => { + const graphql = vi.fn(); + vi.stubGlobal('fetch', graphql); + await expect(postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'http://127.0.0.1:3001', + })).resolves.toEqual({ connected: true, posted: false, skipped: 'origin-not-public' }); + await expect(postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_2', + issueIdentifier: 'ENG-12', + })).resolves.toEqual({ connected: true, posted: false, skipped: 'origin-not-public' }); + expect(graphql).not.toHaveBeenCalled(); + }); + + it('keeps the newest dedupe records and drops the oldest', () => { + const records = {}; + for (let index = 0; index < 5; index += 1) { + records[`ses_${index}`] = { issueIdentifier: 'ENG-12', started: true }; + } + expect(Object.keys(pruneSessionStatusRecords(records, 3))).toEqual(['ses_2', 'ses_3', 'ses_4']); + expect(Object.keys(pruneSessionStatusRecords(records, 10))).toHaveLength(5); + }); + + it('makes the whole status line one link and carries no title', () => { + expect(buildLinearSessionStatusComment({ + kind: 'started', + sessionUrl: 'https://app.example.com/?session=ses_1', + })).toBe('[OpenChamber session started](https://app.example.com/?session=ses_1)'); + expect(buildLinearSessionStatusComment({ + kind: 'completed', + sessionUrl: 'https://app.example.com/?session=ses_1', + })).toBe('[OpenChamber session completed](https://app.example.com/?session=ses_1)'); + expect(buildLinearSessionStatusComment({ + kind: 'failure', + sessionUrl: 'https://app.example.com/?session=ses_1', + })).toBe('[OpenChamber session failed](https://app.example.com/?session=ses_1)'); + }); + + it('cannot be broken by brackets in the issue title', async () => { + const graphql = stubLinearGraphql(); + vi.stubGlobal('fetch', graphql); + await postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + }); + const commentCalls = graphql.mock.calls.filter(([, options]) => { + return JSON.parse(options.body).query.includes('mutation CommentCreate'); + }); + const body = JSON.parse(commentCalls[0][1].body).variables.input.body; + // One balanced pair of brackets, so a title like "[Bug] …" can never leak in + // and split the link across the renderer. + expect(body.match(/\[/g)).toHaveLength(1); + expect(body.match(/\]/g)).toHaveLength(1); + }); + + it('returns disconnected without calling Linear when there is no auth', async () => { + clearLinearAuth(); + const graphql = vi.fn(); + vi.stubGlobal('fetch', graphql); + await expect(postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + })).resolves.toEqual({ connected: false }); + expect(graphql).not.toHaveBeenCalled(); + }); + + it('posts a started comment once and skips repeats', async () => { + const graphql = stubLinearGraphql(); + vi.stubGlobal('fetch', graphql); + + const first = await postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + }); + expect(first).toEqual({ connected: true, posted: true, commentId: 'comment-1' }); + + const second = await postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + }); + expect(second).toEqual({ connected: true, posted: false, skipped: 'already-posted' }); + + const commentCalls = graphql.mock.calls.filter(([, options]) => { + return JSON.parse(options.body).query.includes('mutation CommentCreate'); + }); + expect(commentCalls).toHaveLength(1); + const body = JSON.parse(commentCalls[0][1].body).variables.input.body; + expect(body).toBe('[OpenChamber session started](https://app.example.com/?session=ses_1)'); + expect(JSON.stringify(first)).not.toContain('access-1'); + }); + + it('skips completed until started has been posted', async () => { + const graphql = stubLinearGraphql(); + vi.stubGlobal('fetch', graphql); + await expect(postLinearSessionStatus({ + kind: 'completed', + sessionId: 'ses_1', + })).resolves.toEqual({ connected: true, posted: false, skipped: 'not-started' }); + expect(graphql).not.toHaveBeenCalled(); + }); + + it('posts completed once after started, reusing the stored open URL', async () => { + vi.stubGlobal('fetch', stubLinearGraphql({ commentId: 'comment-started' })); + await postLinearSessionStatus({ + kind: 'started', + sessionId: 'ses_1', + issueIdentifier: 'ENG-12', + sessionOrigin: 'https://app.example.com', + }); + + const graphql = stubLinearGraphql({ commentId: 'comment-done' }); + vi.stubGlobal('fetch', graphql); + const first = await postLinearSessionStatus({ kind: 'completed', sessionId: 'ses_1' }); + expect(first).toEqual({ connected: true, posted: true, commentId: 'comment-done' }); + const second = await postLinearSessionStatus({ kind: 'completed', sessionId: 'ses_1' }); + expect(second).toEqual({ connected: true, posted: false, skipped: 'already-posted' }); + + const commentCalls = graphql.mock.calls.filter(([, options]) => { + return JSON.parse(options.body).query.includes('mutation CommentCreate'); + }); + expect(commentCalls).toHaveLength(1); + const body = JSON.parse(commentCalls[0][1].body).variables.input.body; + expect(body).toBe('[OpenChamber session completed](https://app.example.com/?session=ses_1)'); + }); +}); diff --git a/packages/web/server/lib/linear/teams.js b/packages/web/server/lib/linear/teams.js new file mode 100644 index 00000000..2cdcf93a --- /dev/null +++ b/packages/web/server/lib/linear/teams.js @@ -0,0 +1,72 @@ +import { clearLinearAuth, getLinearAuth } from './auth.js'; +import { fetchLinearGraphql, getValidLinearAccessToken } from './client.js'; +import { isPlainObject, readTrimmedString } from './parse.js'; + +const TEAMS_QUERY = ` + query ListLinearTeams($first: Int!, $after: String) { + teams(first: $first, after: $after) { + nodes { id key name } + pageInfo { hasNextPage endCursor } + } + } +`; +const PAGE_SIZE = 50; +const MAX_PAGES = 20; + +function readTeam(node) { + if (!isPlainObject(node)) { + return null; + } + const id = readTrimmedString(node.id); + const key = readTrimmedString(node.key); + const name = readTrimmedString(node.name); + if (!id || !key || !name) { + return null; + } + return { id, key, name }; +} + +export async function listLinearTeams() { + try { + const token = await getValidLinearAccessToken(); + if (!token) { + return { connected: false }; + } + + const teams = []; + let after = null; + for (let page = 0; page < MAX_PAGES; page += 1) { + const variables = { first: PAGE_SIZE }; + if (after) { + variables.after = after; + } + const data = await fetchLinearGraphql(token, TEAMS_QUERY, variables); + const connection = isPlainObject(data.teams) ? data.teams : null; + const nodes = isPlainObject(connection) && Array.isArray(connection.nodes) + ? connection.nodes + : []; + for (const node of nodes) { + const team = readTeam(node); + if (team) { + teams.push(team); + } + } + const pageInfo = isPlainObject(connection) ? connection.pageInfo : null; + if (!isPlainObject(pageInfo) || pageInfo.hasNextPage !== true) { + break; + } + after = readTrimmedString(pageInfo.endCursor); + if (!after) { + break; + } + } + + return { connected: true, teams }; + } catch (error) { + if (error?.status === 401) { + clearLinearAuth(getLinearAuth()?.workspaceId); + return { connected: false }; + } + throw error; + } +} diff --git a/packages/web/server/lib/linear/teams.test.js b/packages/web/server/lib/linear/teams.test.js new file mode 100644 index 00000000..806b259e --- /dev/null +++ b/packages/web/server/lib/linear/teams.test.js @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { clearLinearAuth, setLinearAuth } from './auth.js'; +import { listLinearTeams } from './teams.js'; + +const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-teams-')); + +const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, +}); + +describe('Linear teams list', () => { + let dataDir; + let previousDataDir; + + beforeEach(() => { + previousDataDir = process.env.OPENCHAMBER_DATA_DIR; + dataDir = makeTempDir(); + process.env.OPENCHAMBER_DATA_DIR = dataDir; + setLinearAuth({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + tokenType: 'Bearer', + expiresAt: Date.now() + 86_400_000, + scope: 'read,write,comments:create', + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + clearLinearAuth(); + if (previousDataDir === undefined) { + delete process.env.OPENCHAMBER_DATA_DIR; + } else { + process.env.OPENCHAMBER_DATA_DIR = previousDataDir; + } + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('returns disconnected without calling Linear when there is no auth', async () => { + clearLinearAuth(); + const graphql = vi.fn(); + vi.stubGlobal('fetch', graphql); + await expect(listLinearTeams()).resolves.toEqual({ connected: false }); + expect(graphql).not.toHaveBeenCalled(); + }); + + it('lists teams across pages and never returns the token', async () => { + const graphql = vi.fn(async (_url, options) => { + const body = JSON.parse(options.body); + expect(body.query).toContain('query ListLinearTeams'); + expect(options.headers.Authorization).toBe('Bearer access-1'); + if (!body.variables.after) { + return jsonResponse({ + data: { + teams: { + nodes: [{ id: 'team-eng', key: 'ENG', name: 'Engineering' }], + pageInfo: { hasNextPage: true, endCursor: 'cursor-2' }, + }, + }, + }); + } + expect(body.variables.after).toBe('cursor-2'); + return jsonResponse({ + data: { + teams: { + nodes: [{ id: 'team-des', key: 'DES', name: 'Design' }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }); + }); + vi.stubGlobal('fetch', graphql); + + const result = await listLinearTeams(); + expect(result).toEqual({ + connected: true, + teams: [ + { id: 'team-eng', key: 'ENG', name: 'Engineering' }, + { id: 'team-des', key: 'DES', name: 'Design' }, + ], + }); + expect(JSON.stringify(result)).not.toContain('access-1'); + expect(graphql).toHaveBeenCalledTimes(2); + }); + + it('clears auth and reports disconnected after a GraphQL 401', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ errors: [{ message: 'Unauthorized' }] }, 401))); + await expect(listLinearTeams()).resolves.toEqual({ connected: false }); + }); +}); diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index f36f0c45..4a39043f 100644 --- a/packages/web/server/lib/opencode/feature-routes-runtime.js +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -4,6 +4,7 @@ import { registerSmallModelRoutes } from '../small-model/routes.js'; import { registerWalkthroughRoutes } from '../walkthrough/routes.js'; import { registerSessionGoalRoutes } from '../session-goal/routes.js'; import { registerGitHubRoutes } from '../github/routes.js'; +import { registerLinearRoutes } from '../linear/routes.js'; import { registerGitRoutes } from '../git/routes.js'; import { registerDevServerRoutes } from '../dev-servers/routes.js'; import { registerMagicPromptRoutes } from '../magic-prompts/routes.js'; @@ -300,6 +301,7 @@ export const createFeatureRoutesRuntime = (dependencies) => { registerWalkthroughRoutes(app, { getWalkthroughService }); registerSessionGoalRoutes(app); registerGitHubRoutes(app); + registerLinearRoutes(app); registerGitRoutes(app); registerDevServerRoutes(app, { scanner: devServerScanner, getOwnPorts }); registerMagicPromptRoutes(app, { diff --git a/packages/web/server/lib/opencode/static-routes-runtime.js b/packages/web/server/lib/opencode/static-routes-runtime.js index de935be5..2feaa4ee 100644 --- a/packages/web/server/lib/opencode/static-routes-runtime.js +++ b/packages/web/server/lib/opencode/static-routes-runtime.js @@ -47,20 +47,20 @@ export const createStaticRoutesRuntime = (dependencies) => { normalizePwaOrientation, }); - app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => { + app.get(/^(?!\/api|\/linear|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => { res.sendFile(path.join(distPath, 'index.html')); }); return; } console.warn(`Warning: ${distPath} not found, static files will not be served`); - app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => { + app.get(/^(?!\/api|\/linear|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => { res.status(404).send('Static files not found. Please build the application first.'); }); }; const registerApiOnlyFallbackRoutes = (app) => { - app.get(/^(?!\/api|\/auth|\/health|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => { + app.get(/^(?!\/api|\/auth|\/health|\/linear|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => { const command = 'openchamber connect-url --help'; res.status(200).format({ html: () => { diff --git a/packages/web/src/api/index.ts b/packages/web/src/api/index.ts index 12831517..b286108c 100644 --- a/packages/web/src/api/index.ts +++ b/packages/web/src/api/index.ts @@ -15,6 +15,7 @@ import { createWebNotificationsAPI } from './notifications'; import { createWebToolsAPI } from './tools'; import { createWebPushAPI } from './push'; import { createWebGitHubAPI } from './github'; +import { createWebLinearAPI } from './linear'; import { createWebClientAuthAPI } from './clientAuth'; export interface WebAPIsOptions { @@ -45,6 +46,7 @@ export const createWebAPIs = (options: WebAPIsOptions = {}): RuntimeAPIs => { permissions: createWebPermissionsAPI(), notifications: createWebNotificationsAPI(), github: createWebGitHubAPI({ urls: activeUrls }), + linear: createWebLinearAPI(), push: createWebPushAPI(), clientAuth: createWebClientAuthAPI(), tools: createWebToolsAPI(), diff --git a/packages/web/src/api/linear.ts b/packages/web/src/api/linear.ts new file mode 100644 index 00000000..216c72f8 --- /dev/null +++ b/packages/web/src/api/linear.ts @@ -0,0 +1,609 @@ +import type { + LinearAPI, + LinearAuthOrigin, + LinearAuthStart, + LinearAuthStatus, + LinearIssue, + LinearIssueAssignee, + LinearIssueComment, + LinearIssueLabel, + LinearIssuePriority, + LinearIssueGetResult, + LinearIssueState, + LinearIssueStatesResult, + LinearIssueUpdateInput, + LinearIssueUpdateResult, + LinearIssueSummary, + LinearIssueTeam, + LinearIssuesListOptions, + LinearIssuesListResult, + LinearMappingResult, + LinearMappingWrite, + LinearOrganizationSummary, + LinearPreferences, + LinearSessionStatusPostInput, + LinearSessionStatusPostResult, + LinearTeamMapping, + LinearWorkflowState, + LinearUserSummary, + LinearWorkspaceSummary, +} from '@openchamber/ui/lib/api/types'; +import { runtimeFetch } from '@openchamber/ui/lib/runtime-fetch'; + +type LinearJson = { + connected?: boolean; + user?: LinearUserSummary | null; + organization?: LinearOrganizationSummary | null; + scope?: string; + workspaces?: LinearWorkspaceSummary[]; + authorizationUrl?: string; + expiresIn?: number; + removed?: boolean; + error?: string; + issues?: LinearIssueSummary[]; + cursor?: string | null; + hasMore?: boolean; + issue?: LinearIssue | null; + states?: LinearWorkflowState[]; + defaultProjectPath?: string | null; + teams?: LinearTeamMapping[]; + posted?: boolean; + skipped?: string; + commentId?: string | null; + sessionComments?: boolean; +}; + +async function readLinearJson(response: Response): Promise { + try { + return await response.json(); + } catch { + return null; + } +} + +function readErrorMessage(payload: LinearJson | null, fallback: string): string { + const error = payload?.error?.trim(); + return error || fallback; +} + +function readFiniteNumber(value: number | null | undefined): number | null { + return Number.isFinite(value) ? (value ?? null) : null; +} + +function readRawString(value: string | null | undefined): string | null { + return Object.prototype.toString.call(value) === '[object String]' ? `${value}` : null; +} + +function parseUser(payload: LinearUserSummary | null | undefined): LinearUserSummary | null { + const id = payload?.id?.trim(); + if (!id) return null; + return { + id, + name: payload?.name?.trim() || null, + displayName: payload?.displayName?.trim() || null, + email: payload?.email?.trim() || null, + avatarUrl: payload?.avatarUrl?.trim() || null, + }; +} + +function parseOrganization(payload: LinearOrganizationSummary | null | undefined): LinearOrganizationSummary | null { + const id = payload?.id?.trim(); + const name = payload?.name?.trim(); + if (!id || !name) return null; + return { + id, + name, + urlKey: payload?.urlKey?.trim() || null, + }; +} + +function parseWorkspace(payload: LinearWorkspaceSummary | null | undefined): LinearWorkspaceSummary | null { + const id = payload?.id?.trim(); + if (!id) return null; + const authorizedAt = payload?.authorizedAt; + return { + id, + name: payload?.name?.trim() || null, + urlKey: payload?.urlKey?.trim() || null, + current: payload?.current === true, + user: parseUser(payload?.user), + authorizedAt: readFiniteNumber(authorizedAt), + }; +} + +function toAuthStatus(payload: LinearJson | null): LinearAuthStatus | null { + if (payload?.connected !== true && payload?.connected !== false) { + return null; + } + const workspaces = Array.isArray(payload.workspaces) + ? payload.workspaces.map(parseWorkspace).filter((entry): entry is LinearWorkspaceSummary => entry != null) + : []; + return { + connected: payload.connected, + user: parseUser(payload.user), + organization: parseOrganization(payload.organization), + scope: payload.scope?.trim() || undefined, + workspaces: payload.connected ? workspaces : undefined, + }; +} + +function toAuthStart(payload: LinearJson | null): LinearAuthStart | null { + const authorizationUrl = payload?.authorizationUrl?.trim(); + const expiresIn = payload?.expiresIn; + const scope = payload?.scope?.trim(); + if (!authorizationUrl || !Number.isFinite(expiresIn) || expiresIn == null || !scope) { + return null; + } + return { authorizationUrl, expiresIn, scope }; +} + +function parseState(payload: LinearIssueState | null | undefined): LinearIssueState | null { + const id = payload?.id?.trim() || null; + const name = payload?.name?.trim() || null; + const type = payload?.type?.trim() || null; + if (!id && !name && !type) return null; + return { id, name, type }; +} + +function parseWorkflowState(payload: LinearWorkflowState | null | undefined): LinearWorkflowState | null { + const id = payload?.id?.trim(); + const name = payload?.name?.trim(); + if (!id || !name) return null; + const position = payload?.position; + return { + id, + name, + type: payload?.type?.trim() || null, + position: readFiniteNumber(position) ?? 0, + }; +} + +function parseAssignee(payload: LinearIssueAssignee | null | undefined): LinearIssueAssignee | null { + const name = payload?.name?.trim() || null; + const displayName = payload?.displayName?.trim() || null; + const avatarUrl = payload?.avatarUrl?.trim() || null; + if (!name && !displayName && !avatarUrl) return null; + return { name, displayName, avatarUrl }; +} + +function parseTeam(payload: LinearIssueTeam | null | undefined): LinearIssueTeam | null { + const id = payload?.id?.trim(); + const key = payload?.key?.trim(); + const name = payload?.name?.trim(); + if (!id || !key || !name) return null; + return { id, key, name }; +} + +function parsePriority(value: LinearIssueSummary['priority']): LinearIssuePriority | null { + if (value !== 0 && value !== 1 && value !== 2 && value !== 3 && value !== 4) { + return null; + } + return value; +} + +function parseLabelColor(value: string | null | undefined): string | null { + const raw = value?.trim(); + if (!raw) return null; + const hex = raw.startsWith('#') ? raw.slice(1) : raw; + if (!/^[0-9A-Fa-f]{6}$/.test(hex)) return null; + return `#${hex.toLowerCase()}`; +} + +function parseLabel(payload: LinearIssueLabel | null | undefined): LinearIssueLabel | null { + if (!payload) return null; + const id = payload?.id?.trim(); + const name = payload?.name?.trim(); + if (!id || !name) return null; + return { + id, + name, + color: parseLabelColor(payload.color), + }; +} + +function parseLabels(payload: LinearIssueSummary['labels']): LinearIssueLabel[] { + if (!Array.isArray(payload)) return []; + return payload.map(parseLabel).filter((label): label is LinearIssueLabel => label != null); +} + +function parseIssueSummary(payload: LinearIssueSummary | null | undefined): LinearIssueSummary | null { + if (!payload) return null; + const id = payload?.id?.trim(); + const identifier = payload?.identifier?.trim(); + const title = payload?.title?.trim(); + const url = payload?.url?.trim(); + if (!id || !identifier || !title || !url) return null; + return { + id, + identifier, + title, + url, + state: parseState(payload.state), + assignee: parseAssignee(payload.assignee), + team: parseTeam(payload.team), + priority: parsePriority(payload.priority), + labels: parseLabels(payload.labels), + }; +} + +function parseComment(payload: LinearIssueComment | null | undefined): LinearIssueComment | null { + const id = payload?.id?.trim(); + if (!id) return null; + const body = payload?.body; + return { + id, + body: readRawString(body) ?? '', + createdAt: payload?.createdAt?.trim() || null, + user: payload?.user + ? { + name: payload.user.name?.trim() || null, + displayName: payload.user.displayName?.trim() || null, + avatarUrl: payload.user.avatarUrl?.trim() || null, + } + : null, + }; +} + +function parseIssue(payload: LinearIssue | null | undefined): LinearIssue | null { + const summary = parseIssueSummary(payload); + if (!summary) return null; + const comments = Array.isArray(payload?.comments) + ? payload.comments.map(parseComment).filter((comment): comment is LinearIssueComment => comment != null) + : []; + const description = payload?.description; + return { + ...summary, + description: readRawString(description), + comments, + }; +} + +function toIssuesList(payload: LinearJson | null): LinearIssuesListResult | null { + if (payload?.connected !== true && payload?.connected !== false) { + return null; + } + if (payload.connected === false) { + return { connected: false }; + } + const issues = Array.isArray(payload.issues) + ? payload.issues.map(parseIssueSummary).filter((issue): issue is LinearIssueSummary => issue != null) + : []; + return { + connected: true, + issues, + cursor: payload.cursor?.trim() || null, + hasMore: payload.hasMore === true, + }; +} + +function toIssueGet(payload: LinearJson | null): LinearIssueGetResult | null { + if (payload?.connected !== true && payload?.connected !== false) { + return null; + } + if (payload.connected === false) { + return { connected: false }; + } + return { + connected: true, + issue: parseIssue(payload.issue), + }; +} + +function toIssueStates(payload: LinearJson | null): LinearIssueStatesResult | null { + if (payload?.connected !== true && payload?.connected !== false) { + return null; + } + if (payload.connected === false) { + return { connected: false }; + } + const states = Array.isArray(payload.states) + ? payload.states.map(parseWorkflowState).filter((state): state is LinearWorkflowState => state != null) + : []; + return { connected: true, states }; +} + +function toIssueUpdate(payload: LinearJson | null): LinearIssueUpdateResult | null { + if (payload?.connected !== true && payload?.connected !== false) { + return null; + } + if (payload.connected === false) { + return { connected: false }; + } + return { + connected: true, + issue: parseIssue(payload.issue), + }; +} + +function parseTeamMapping(payload: LinearTeamMapping | null | undefined): LinearTeamMapping | null { + const id = payload?.id?.trim(); + const key = payload?.key?.trim(); + const name = payload?.name?.trim(); + if (!id || !key || !name) return null; + const projectPath = payload?.projectPath?.trim() || null; + return { id, key, name, projectPath }; +} + +function toMapping(payload: LinearJson | null): LinearMappingResult | null { + if (payload?.connected !== true && payload?.connected !== false) { + return null; + } + if (payload.connected === false) { + return { connected: false }; + } + const teams = Array.isArray(payload.teams) + ? payload.teams.map(parseTeamMapping).filter((team): team is LinearTeamMapping => team != null) + : []; + return { + connected: true, + defaultProjectPath: payload.defaultProjectPath?.trim() || null, + teams, + }; +} + +type LinearSessionStatusSkipped = Extract< + LinearSessionStatusPostResult, + { posted: false } +>['skipped']; + +const SESSION_STATUS_SKIPPED: readonly LinearSessionStatusSkipped[] = [ + 'already-posted', + 'issue-not-found', + 'not-started', + 'disabled', + 'origin-not-public', +]; + +function parseSkipped(value: string | undefined): LinearSessionStatusSkipped | null { + return SESSION_STATUS_SKIPPED.find((entry) => entry === value) ?? null; +} + +function toPreferences(payload: LinearJson | null): LinearPreferences | null { + if (payload?.sessionComments !== true && payload?.sessionComments !== false) { + return null; + } + return { sessionComments: payload.sessionComments }; +} + +function toSessionStatusPost(payload: LinearJson | null): LinearSessionStatusPostResult | null { + if (payload?.connected !== true && payload?.connected !== false) { + return null; + } + if (payload.connected === false) { + return { connected: false }; + } + if (payload.posted === true) { + return { + connected: true, + posted: true, + commentId: payload.commentId?.trim() || null, + }; + } + const skipped = parseSkipped(payload.skipped); + if (payload.posted === false && skipped) { + return { connected: true, posted: false, skipped }; + } + return null; +} + +export const createWebLinearAPI = (): LinearAPI => ({ + async authStatus(): Promise { + const response = await runtimeFetch('/api/linear/auth/status', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = await readLinearJson(response); + const status = toAuthStatus(payload); + if (!response.ok || !status) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear status')); + } + return status; + }, + + async authStart(origin?: LinearAuthOrigin): Promise { + const response = await runtimeFetch('/api/linear/auth/start', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(origin ? { origin } : {}), + }); + const payload = await readLinearJson(response); + const started = toAuthStart(payload); + if (!response.ok || !started) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to start Linear auth')); + } + return started; + }, + + async authDisconnect(): Promise<{ removed: boolean }> { + const response = await runtimeFetch('/api/linear/auth', { + method: 'DELETE', + headers: { Accept: 'application/json' }, + }); + const payload = await readLinearJson(response); + if (!response.ok) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to disconnect Linear')); + } + return { removed: payload?.removed === true }; + }, + + async authActivate(organizationId: string): Promise { + const response = await runtimeFetch('/api/linear/auth/activate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ organizationId }), + }); + const payload = await readLinearJson(response); + const status = toAuthStatus(payload); + if (!response.ok || !status) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to switch Linear workspace')); + } + return status; + }, + + async issuesList(options?: LinearIssuesListOptions): Promise { + const params = new URLSearchParams(); + const query = options?.query?.trim(); + const cursor = options?.cursor?.trim(); + const status = options?.status?.trim(); + const assignee = options?.assignee?.trim(); + const teamId = options?.teamId?.trim(); + const priority = options?.priority?.trim(); + if (query) params.set('query', query); + if (cursor) params.set('cursor', cursor); + if (status) params.set('status', status); + if (assignee) params.set('assignee', assignee); + if (teamId) params.set('teamId', teamId); + if (priority) params.set('priority', priority); + const queryString = params.toString(); + const suffix = queryString ? `?${queryString}` : ''; + const response = await runtimeFetch(`/api/linear/issues/list${suffix}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = await readLinearJson(response); + const result = toIssuesList(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear issues')); + } + return result; + }, + + async issueGet(id: string): Promise { + const params = new URLSearchParams({ id }); + const response = await runtimeFetch(`/api/linear/issues/get?${params.toString()}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = await readLinearJson(response); + const result = toIssueGet(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear issue')); + } + return result; + }, + + async issueStates(teamId: string): Promise { + const params = new URLSearchParams({ teamId }); + const response = await runtimeFetch(`/api/linear/issues/states?${params.toString()}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = await readLinearJson(response); + const result = toIssueStates(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear workflow states')); + } + return result; + }, + + async issueUpdate(input: LinearIssueUpdateInput): Promise { + const response = await runtimeFetch('/api/linear/issues/update', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + id: input.id, + stateId: input.stateId, + }), + }); + const payload = await readLinearJson(response); + const result = toIssueUpdate(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to update Linear issue')); + } + return result; + }, + + async mappingGet(): Promise { + const response = await runtimeFetch('/api/linear/mapping', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = await readLinearJson(response); + const result = toMapping(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear mapping')); + } + return result; + }, + + async mappingSet(mapping: LinearMappingWrite): Promise { + const response = await runtimeFetch('/api/linear/mapping', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + defaultProjectPath: mapping.defaultProjectPath, + teamProjectPaths: mapping.teamProjectPaths, + }), + }); + const payload = await readLinearJson(response); + const result = toMapping(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to save Linear mapping')); + } + return result; + }, + + async sessionStatusPost(input: LinearSessionStatusPostInput): Promise { + const response = await runtimeFetch('/api/linear/session-status', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + kind: input.kind, + sessionId: input.sessionId, + issueIdentifier: input.issueIdentifier, + sessionOrigin: input.sessionOrigin, + }), + }); + const payload = await readLinearJson(response); + const result = toSessionStatusPost(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to post Linear session status')); + } + return result; + }, + + async preferencesGet(): Promise { + const response = await runtimeFetch('/api/linear/preferences', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const payload = await readLinearJson(response); + const result = toPreferences(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to load Linear preferences')); + } + return result; + }, + + async preferencesSet(preferences: LinearPreferences): Promise { + const response = await runtimeFetch('/api/linear/preferences', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ sessionComments: preferences.sessionComments }), + }); + const payload = await readLinearJson(response); + const result = toPreferences(payload); + if (!response.ok || !result) { + throw new Error(readErrorMessage(payload, response.statusText || 'Failed to save Linear preferences')); + } + return result; + }, +}); diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 60877c12..5fbae619 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -115,6 +115,10 @@ export default defineConfig({ target: `http://127.0.0.1:${process.env.OPENCHAMBER_PORT || 3001}`, changeOrigin: true, }, + '/linear': { + target: `http://127.0.0.1:${process.env.OPENCHAMBER_PORT || 3001}`, + changeOrigin: true, + }, '/api': { target: `http://127.0.0.1:${process.env.OPENCHAMBER_PORT || 3001}`, changeOrigin: true, From 391f9383345d065717228966fba0e7d9b50cd735 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 30 Aug 2026 02:24:11 +0300 Subject: [PATCH 281/282] feat(voice): match local and macOS voices to the language of the text Text-to-speech picked one voice regardless of what language a reply was in. A dependency-free language detector (script, marker letters, function words) now decides the language of the whole message once; with the new "Match the voice to the language of the text" setting the local provider switches to a catalog model for that language (Kokoro zh/en and Piper models for 12 languages, downloaded on first use like the existing model) and macOS say switches to an installed voice whose locale matches. The local voice picker lists voices of every installed model, and the settings show which language models are on disk. The Ukrainian Piper medium build is a character-level model that sherpa-onnx turns into noise, so the espeak-based Lada build is used instead. Claude-Session: https://claude.ai/code/session_017TK5JAYDfT3Fotc23UEg98 --- .../sections/openchamber/VoiceSettings.tsx | 221 ++++++++++++------ packages/ui/src/hooks/useLocalTTS.ts | 16 +- packages/ui/src/hooks/useMessageTTS.ts | 7 + packages/ui/src/hooks/useSayTTS.ts | 3 + .../ui/src/lib/i18n/messages/de.settings.ts | 5 +- .../ui/src/lib/i18n/messages/en.settings.ts | 5 +- .../ui/src/lib/i18n/messages/es.settings.ts | 5 +- .../ui/src/lib/i18n/messages/fr.settings.ts | 5 +- .../ui/src/lib/i18n/messages/ja.settings.ts | 5 +- .../ui/src/lib/i18n/messages/ko.settings.ts | 5 +- .../ui/src/lib/i18n/messages/pl.settings.ts | 5 +- .../src/lib/i18n/messages/pt-BR.settings.ts | 5 +- .../ui/src/lib/i18n/messages/tr.settings.ts | 5 +- .../ui/src/lib/i18n/messages/uk.settings.ts | 5 +- .../src/lib/i18n/messages/zh-CN.settings.ts | 5 +- .../src/lib/i18n/messages/zh-TW.settings.ts | 5 +- packages/ui/src/stores/useConfigStore.ts | 35 +++ .../web/server/lib/dictation/DOCUMENTATION.md | 25 +- .../lib/dictation/local/model-catalog.js | 220 +++++++++++++++++ .../lib/dictation/local/model-catalog.test.js | 42 ++++ .../server/lib/dictation/local/sherpa-tts.js | 61 +++-- .../lib/dictation/local/worker-process.js | 2 + packages/web/server/lib/dictation/runtime.js | 4 + packages/web/server/lib/dictation/service.js | 31 ++- packages/web/server/lib/tts/DOCUMENTATION.md | 1 + .../web/server/lib/tts/language-detect.js | 210 +++++++++++++++++ .../server/lib/tts/language-detect.test.js | 76 ++++++ packages/web/server/lib/tts/routes.js | 24 +- packages/web/server/lib/tts/routes.test.js | 26 +++ 29 files changed, 949 insertions(+), 115 deletions(-) create mode 100644 packages/web/server/lib/dictation/local/model-catalog.test.js create mode 100644 packages/web/server/lib/tts/language-detect.js create mode 100644 packages/web/server/lib/tts/language-detect.test.js diff --git a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx index 8d3a1ac3..9abfeffe 100644 --- a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx @@ -71,6 +71,7 @@ const LOCAL_STT_MODELS = [ interface DictationModelState { id: string; + description?: string; installed: boolean; downloading: boolean; downloadProgress: number | null; @@ -288,10 +289,32 @@ const KOKORO_VOICE_OPTIONS = [ const LOCAL_TTS_MODEL_ID = 'kokoro-en-v0_19'; -const LocalTtsModelStatus = () => { - const { t } = useI18n(); - const [model, setModel] = useState(null); - const [requesting, setRequesting] = useState(false); +const KOKORO_MULTI_LANG_MODEL_ID = 'kokoro-multi-lang-v1_1'; +// A few named speakers out of the 103 in the Chinese/English Kokoro build. +const KOKORO_MULTI_LANG_VOICE_OPTIONS = [ + { id: 0, label: 'Maple (af)' }, + { id: 1, label: 'Sol (af)' }, + { id: 2, label: 'Vale (bf)' }, + { id: 3, label: 'Xiaoxiao (zf)' }, + { id: 58, label: 'Yunxi (zm)' }, +]; + +interface LocalTtsVoiceOption { + modelId: string; + speakerId: number; + label: string; +} + +const localTtsVoiceKey = (modelId: string, speakerId: number): string => `${modelId}:${speakerId}`; + +/** + * Local TTS models as the server reports them, plus the actions Settings + * offers on them. Shared by the model list and the voice picker so both see + * the same install state. + */ +const useLocalTtsModels = () => { + const [models, setModels] = useState([]); + const [requestingId, setRequestingId] = useState(null); const refresh = useCallback(async () => { try { @@ -300,11 +323,8 @@ const LocalTtsModelStatus = () => { return; } const data = await response.json(); - const entry = Array.isArray(data?.ttsModels) - ? data.ttsModels.find((m: DictationModelState) => m.id === LOCAL_TTS_MODEL_ID) - : null; - if (entry) { - setModel(entry); + if (Array.isArray(data?.ttsModels)) { + setModels(data.ttsModels); } } catch { // Display-only status; keep the previous state on fetch failure. @@ -315,81 +335,118 @@ const LocalTtsModelStatus = () => { void refresh(); }, [refresh]); + const anyDownloading = models.some((model) => model.downloading); useEffect(() => { - if (!model?.downloading) { + if (!anyDownloading) { return; } const interval = setInterval(() => { void refresh(); }, 2000); return () => clearInterval(interval); - }, [model?.downloading, refresh]); + }, [anyDownloading, refresh]); - const request = async (method: 'POST' | 'DELETE') => { - setRequesting(true); + const request = useCallback(async (modelId: string, method: 'POST' | 'DELETE') => { + setRequestingId(modelId); try { const path = method === 'POST' - ? `/api/dictation/models/${LOCAL_TTS_MODEL_ID}/download` - : `/api/dictation/models/${LOCAL_TTS_MODEL_ID}`; + ? `/api/dictation/models/${modelId}/download` + : `/api/dictation/models/${modelId}`; await runtimeFetch(path, { method }); await refresh(); } catch { // Status refresh reports errors. } finally { - setRequesting(false); + setRequestingId(null); } - }; + }, [refresh]); - if (!model) { + return { models, requestingId, request, refresh }; +}; + +// Voices the picker offers: Kokoro speakers for the Kokoro models, one voice +// per installed Piper model. Only installed models (plus the default) appear, +// so a language model the server fetched on its own becomes selectable once +// it is on disk. +const buildLocalTtsVoiceOptions = (models: DictationModelState[]): LocalTtsVoiceOption[] => { + const options: LocalTtsVoiceOption[] = KOKORO_VOICE_OPTIONS.map((voice) => ({ + modelId: LOCAL_TTS_MODEL_ID, + speakerId: voice.id, + label: voice.label, + })); + for (const model of models) { + if (model.id === LOCAL_TTS_MODEL_ID || !model.installed) continue; + if (model.id === KOKORO_MULTI_LANG_MODEL_ID) { + for (const voice of KOKORO_MULTI_LANG_VOICE_OPTIONS) { + options.push({ modelId: model.id, speakerId: voice.id, label: `${voice.label} · Kokoro zh/en` }); + } + continue; + } + options.push({ modelId: model.id, speakerId: 0, label: model.description ?? model.id }); + } + return options; +}; + +const LocalTtsModelStatus = ({ models, requestingId, request }: ReturnType) => { + const { t } = useI18n(); + + // The default English model is always listed; language models the server + // fetched on its own appear once they are installed or downloading, so + // the list shows what is on disk rather than the whole catalog. + const visible = models.filter((model) => model.id === LOCAL_TTS_MODEL_ID || model.installed || model.downloading); + if (visible.length === 0) { return null; } return ( -
- Kokoro - 305 MB - {model.installed ? ( - <> - - - - ) : model.downloading ? ( - - - - {typeof model.downloadProgress === 'number' ? `${model.downloadProgress}%` : ''} - - - ) : ( - - )} - {model.downloadError ? ( - {model.downloadError} - ) : null} +
+ {visible.map((model) => ( +
+ {model.description ?? model.id} + {model.installed ? ( + <> + + + + ) : model.downloading ? ( + + + + {typeof model.downloadProgress === 'number' ? `${model.downloadProgress}%` : ''} + + + ) : ( + + )} + {model.downloadError ? ( + {model.downloadError} + ) : null} +
+ ))}
); }; @@ -424,6 +481,12 @@ export const VoiceSettings: React.FC = () => { const sayVoice = useConfigStore((state) => state.sayVoice); const setSayVoice = useConfigStore((state) => state.setSayVoice); const localTtsVoiceId = useConfigStore((state) => state.localTtsVoiceId); + const localTtsModelId = useConfigStore((state) => state.localTtsModelId); + const setLocalTtsModelId = useConfigStore((state) => state.setLocalTtsModelId); + const localTtsModels = useLocalTtsModels(); + const localTtsVoiceOptions = useMemo(() => buildLocalTtsVoiceOptions(localTtsModels.models), [localTtsModels.models]); + const ttsFollowTextLanguage = useConfigStore((state) => state.ttsFollowTextLanguage); + const setTtsFollowTextLanguage = useConfigStore((state) => state.setTtsFollowTextLanguage); const setLocalTtsVoiceId = useConfigStore((state) => state.setLocalTtsVoiceId); const { speak: speakLocalTts, stop: stopLocalTts, isPlaying: isLocalTtsPlaying, error: localTtsError } = useLocalTTS(); @@ -432,13 +495,14 @@ export const VoiceSettings: React.FC = () => { stopLocalTts(); return; } - const voiceLabel = KOKORO_VOICE_OPTIONS.find((v) => v.id === localTtsVoiceId)?.label + const voiceLabel = localTtsVoiceOptions.find((v) => v.modelId === localTtsModelId && v.speakerId === localTtsVoiceId)?.label ?? String(localTtsVoiceId); void speakLocalTts(t('settings.voice.page.preview.voiceLine', { voiceName: voiceLabel }), { + model: localTtsModelId, speakerId: localTtsVoiceId, speed: useConfigStore.getState().speechRate, }); - }, [isLocalTtsPlaying, localTtsVoiceId, speakLocalTts, stopLocalTts, t]); + }, [isLocalTtsPlaying, localTtsModelId, localTtsVoiceId, localTtsVoiceOptions, speakLocalTts, stopLocalTts, t]); const browserVoice = useConfigStore((state) => state.browserVoice); const setBrowserVoice = useConfigStore((state) => state.setBrowserVoice); const openaiVoice = useConfigStore((state) => state.openaiVoice); @@ -959,24 +1023,39 @@ export const VoiceSettings: React.FC = () => { )} {/* Local (Kokoro) TTS model status */} - {voiceProvider === 'local' && } + {voiceProvider === 'local' && } + + {(voiceProvider === 'local' || voiceProvider === 'say') && ( + + )} {/* Voice Selection */} {voiceProvider === 'local' && ( <> diff --git a/packages/ui/src/hooks/useLocalTTS.ts b/packages/ui/src/hooks/useLocalTTS.ts index 307356a2..0efc1a46 100644 --- a/packages/ui/src/hooks/useLocalTTS.ts +++ b/packages/ui/src/hooks/useLocalTTS.ts @@ -14,10 +14,18 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { runtimeFetch } from '@/lib/runtime-fetch'; export interface LocalTTSSpeakOptions { - /** Kokoro speaker id (0-10) */ + /** Catalog id of the local model to use; defaults to the server's default model. */ + model?: string; + /** Speaker id within the model (Kokoro voices; Piper models have one) */ speakerId?: number; /** Playback speed multiplier (1.0 = normal) */ speed?: number; + /** + * `'auto'`: the server picks a model and voice for the text's language. + * The language is judged on the whole message, not on each chunk sent for + * synthesis, so a short chunk cannot flip the voice mid-reply. + */ + language?: 'auto'; onStart?: () => void; onEnd?: () => void; onError?: (error: string) => void; @@ -35,6 +43,8 @@ export interface UseLocalTTSReturn { /** Target chunk size: big enough to amortize requests, small enough for low latency. */ const MIN_CHUNK_CHARS = 60; const MAX_CHUNK_CHARS = 400; +// Enough of the message for language detection to see whole sentences. +const LANGUAGE_SAMPLE_CHARS = 2000; /** * Split text into sentence-aligned chunks for pipelined synthesis. @@ -170,6 +180,7 @@ export function useLocalTTS(): UseLocalTTSReturn { const session: PlaybackSession = { cancelled: false, abort: new AbortController() }; sessionRef.current = session; + const languageSample = options?.language === 'auto' ? text.slice(0, LANGUAGE_SAMPLE_CHARS) : undefined; const fetchChunk = async (chunk: string): Promise => { const response = await runtimeFetch('/api/dictation/tts/speak', { @@ -177,8 +188,11 @@ export function useLocalTTS(): UseLocalTTSReturn { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: chunk, + model: options?.model, ...(typeof options?.speakerId === 'number' ? { speakerId: options.speakerId } : {}), ...(typeof options?.speed === 'number' ? { speed: options.speed } : {}), + language: options?.language, + languageSample, }), signal: session.abort.signal, }); diff --git a/packages/ui/src/hooks/useMessageTTS.ts b/packages/ui/src/hooks/useMessageTTS.ts index 88e10615..4950e320 100644 --- a/packages/ui/src/hooks/useMessageTTS.ts +++ b/packages/ui/src/hooks/useMessageTTS.ts @@ -61,6 +61,8 @@ export function useMessageTTS(): UseMessageTTSReturn { const speechVolume = useConfigStore((state) => state.speechVolume); const sayVoice = useConfigStore((state) => state.sayVoice); const localTtsVoiceId = useConfigStore((state) => state.localTtsVoiceId); + const localTtsModelId = useConfigStore((state) => state.localTtsModelId); + const ttsFollowTextLanguage = useConfigStore((state) => state.ttsFollowTextLanguage); const browserVoice = useConfigStore((state) => state.browserVoice); const openaiVoice = useConfigStore((state) => state.openaiVoice); const openaiCompatibleVoice = useConfigStore((state) => state.openaiCompatibleVoice); @@ -135,8 +137,10 @@ export function useMessageTTS(): UseMessageTTSReturn { }); } else if (voiceProvider === 'local') { await speakLocalTTS(sanitizedText, { + model: localTtsModelId, speakerId: localTtsVoiceId, speed: speechRate, + language: ttsFollowTextLanguage ? 'auto' : undefined, onEnd: () => setIsPlaying(false), onError: () => setIsPlaying(false), }); @@ -145,6 +149,7 @@ export function useMessageTTS(): UseMessageTTSReturn { await speakSayTTS(sanitizedText, { voice: sayVoice, rate: wordsPerMinute, + language: ttsFollowTextLanguage ? 'auto' : undefined, onEnd: () => setIsPlaying(false), onError: () => setIsPlaying(false), }); @@ -187,6 +192,8 @@ export function useMessageTTS(): UseMessageTTSReturn { speakSayTTS, speakLocalTTS, localTtsVoiceId, + localTtsModelId, + ttsFollowTextLanguage, stop, ]); diff --git a/packages/ui/src/hooks/useSayTTS.ts b/packages/ui/src/hooks/useSayTTS.ts index c00353b1..f9108e6d 100644 --- a/packages/ui/src/hooks/useSayTTS.ts +++ b/packages/ui/src/hooks/useSayTTS.ts @@ -105,6 +105,8 @@ interface SpeakOptions { voice?: string; /** Speech rate in words per minute (defaults to 200) */ rate?: number; + /** `'auto'`: the server switches to a voice that speaks the text's language. */ + language?: 'auto'; /** Callback when playback starts */ onStart?: () => void; /** Callback when playback ends */ @@ -229,6 +231,7 @@ export function useSayTTS(options: UseSayTTSOptions = {}): UseSayTTSReturn { text: text.trim(), voice: options?.voice || 'Samantha', rate: options?.rate || 200, + language: options?.language, }), signal: abortControllerRef.current.signal, }); diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 75d596f9..86dc0985 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1795,7 +1795,10 @@ export const settingsDict = { 'settings.voice.page.provider.server': 'Server', 'settings.voice.page.provider.local': 'Lokal', 'settings.voice.page.tooltip.sttLocal': 'On-device Transkription auf dem OpenChamber-Server. Modelle werden automatisch heruntergeladen; kein API-Schlüssel erforderlich.', - 'settings.voice.page.tooltip.localTts': 'On-device Synthese auf dem OpenChamber-Server (Kokoro, Englisch). Das Modell wird automatisch heruntergeladen; kein API-Schlüssel erforderlich.', + 'settings.voice.page.tooltip.localTts': 'On-Device-Synthese auf dem OpenChamber-Server (Kokoro für Englisch; Modelle für andere Sprachen werden beim ersten Einsatz geladen). Kein API-Schlüssel nötig.', + 'settings.voice.page.field.followTextLanguage': 'Stimme an die Sprache des Textes anpassen', + 'settings.voice.page.field.followTextLanguageAria': 'Stimme an die Sprache des Textes anpassen', + 'settings.voice.page.field.followTextLanguageInfo': 'Ist eine Antwort in einer anderen Sprache, wird eine Stimme für diese Sprache verwendet: eine passende macOS-Stimme oder ein lokales Modell, das beim ersten Einsatz geladen wird.', 'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (Englisch)', 'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 europäische Sprachen)', 'settings.voice.page.stt.model.whisperBase': 'Whisper base (mehrsprachig)', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 95620f19..e0c6468f 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1862,7 +1862,10 @@ export const settingsDict = { 'settings.voice.page.provider.server': 'Server', 'settings.voice.page.provider.local': 'Local', 'settings.voice.page.tooltip.sttLocal': 'On-device transcription on the OpenChamber server. Models download automatically; no API key needed.', - 'settings.voice.page.tooltip.localTts': 'On-device synthesis on the OpenChamber server (Kokoro, English). The model downloads automatically; no API key needed.', + 'settings.voice.page.tooltip.localTts': 'On-device synthesis on the OpenChamber server (Kokoro for English; models for other languages download on first use). No API key needed.', + 'settings.voice.page.field.followTextLanguage': 'Match the voice to the language of the text', + 'settings.voice.page.field.followTextLanguageAria': 'Match the voice to the language of the text', + 'settings.voice.page.field.followTextLanguageInfo': 'When a reply is in another language, a voice for that language is used: a matching macOS voice, or a local model that downloads on first use.', 'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (English)', 'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 European languages)', 'settings.voice.page.stt.model.whisperBase': 'Whisper base (multilingual)', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index fa40f214..b72f9e26 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1839,7 +1839,10 @@ export const settingsDict = { "settings.voice.page.provider.server": "Servidor", "settings.voice.page.provider.local": "Local", "settings.voice.page.tooltip.sttLocal": "Transcripción local en el servidor de OpenChamber. Los modelos se descargan automáticamente; no se necesita clave de API.", - "settings.voice.page.tooltip.localTts": "Síntesis local en el servidor de OpenChamber (Kokoro, inglés). El modelo se descarga automáticamente; no se necesita clave de API.", + "settings.voice.page.tooltip.localTts": "Síntesis local en el servidor de OpenChamber (Kokoro para inglés; los modelos de otros idiomas se descargan en el primer uso). No requiere clave de API.", + "settings.voice.page.field.followTextLanguage": "Ajustar la voz al idioma del texto", + "settings.voice.page.field.followTextLanguageAria": "Ajustar la voz al idioma del texto", + "settings.voice.page.field.followTextLanguageInfo": "Si una respuesta está en otro idioma, se usa una voz para ese idioma: una voz de macOS adecuada o un modelo local que se descarga en el primer uso.", "settings.voice.page.stt.model.parakeetV2": "Parakeet v2 (inglés)", "settings.voice.page.stt.model.parakeetV3": "Parakeet v3 (25 idiomas europeos)", "settings.voice.page.stt.model.whisperBase": "Whisper base (multilingüe)", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 432d536d..ae7d5d4e 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1757,7 +1757,10 @@ export const settingsDict = { 'settings.voice.page.provider.server': 'Serveur', 'settings.voice.page.provider.local': 'Local', 'settings.voice.page.tooltip.sttLocal': 'Transcription locale sur le serveur OpenChamber. Les modèles se téléchargent automatiquement ; aucune clé d\'API requise.', - 'settings.voice.page.tooltip.localTts': 'Synthèse locale sur le serveur OpenChamber (Kokoro, anglais). Le modèle se télécharge automatiquement ; aucune clé d’API requise.', + 'settings.voice.page.tooltip.localTts': 'Synthèse locale sur le serveur OpenChamber (Kokoro pour l’anglais ; les modèles des autres langues sont téléchargés à la première utilisation). Aucune clé API requise.', + 'settings.voice.page.field.followTextLanguage': 'Adapter la voix à la langue du texte', + 'settings.voice.page.field.followTextLanguageAria': 'Adapter la voix à la langue du texte', + 'settings.voice.page.field.followTextLanguageInfo': 'Si une réponse est dans une autre langue, une voix pour cette langue est utilisée : une voix macOS adaptée ou un modèle local téléchargé à la première utilisation.', 'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (anglais)', 'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 langues européennes)', 'settings.voice.page.stt.model.whisperBase': 'Whisper base (multilingue)', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 87b35b08..a68f0fc7 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1872,7 +1872,10 @@ export const settingsDict = { 'settings.voice.page.provider.server': 'サーバー', 'settings.voice.page.provider.local': 'ローカル', 'settings.voice.page.tooltip.sttLocal': 'OpenChamber サーバー上でローカルに文字起こしします。モデルは自動でダウンロードされ、API キーは不要です。', - 'settings.voice.page.tooltip.localTts': 'OpenChamber サーバー上でローカルに音声合成します(Kokoro、英語)。モデルは自動でダウンロードされ、API キーは不要です。', + 'settings.voice.page.tooltip.localTts': 'OpenChamber サーバー上でローカルに音声合成します(英語は Kokoro、他の言語のモデルは初回使用時にダウンロード)。API キーは不要です。', + 'settings.voice.page.field.followTextLanguage': 'テキストの言語に合わせて音声を選ぶ', + 'settings.voice.page.field.followTextLanguageAria': 'テキストの言語に合わせて音声を選ぶ', + 'settings.voice.page.field.followTextLanguageInfo': '返答が別の言語の場合、その言語の音声を使います。対応する macOS の音声、または初回使用時にダウンロードされるローカルモデルです。', 'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2(英語)', 'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3(ヨーロッパ25言語)', 'settings.voice.page.stt.model.whisperBase': 'Whisper base(多言語)', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 08ac89c7..4fdb93af 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1839,7 +1839,10 @@ export const settingsDict = { 'settings.voice.page.provider.server': '서버', 'settings.voice.page.provider.local': '로컬', 'settings.voice.page.tooltip.sttLocal': 'OpenChamber 서버에서 로컬로 변환합니다. 모델은 자동으로 다운로드되며 API 키가 필요 없습니다.', - 'settings.voice.page.tooltip.localTts': 'OpenChamber 서버에서 로컬로 음성을 합성합니다(Kokoro, 영어). 모델은 자동으로 다운로드되며 API 키가 필요 없습니다.', + 'settings.voice.page.tooltip.localTts': 'OpenChamber 서버에서 로컬로 음성을 합성합니다(영어는 Kokoro, 다른 언어 모델은 처음 사용할 때 다운로드). API 키가 필요 없습니다.', + 'settings.voice.page.field.followTextLanguage': '텍스트 언어에 맞는 음성 사용', + 'settings.voice.page.field.followTextLanguageAria': '텍스트 언어에 맞는 음성 사용', + 'settings.voice.page.field.followTextLanguageInfo': '응답이 다른 언어이면 해당 언어의 음성을 사용합니다. 일치하는 macOS 음성 또는 처음 사용할 때 다운로드되는 로컬 모델입니다.', 'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (영어)', 'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (유럽 25개 언어)', 'settings.voice.page.stt.model.whisperBase': 'Whisper base (다국어)', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index ac8d5357..f8272cc1 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -2176,7 +2176,10 @@ export const settingsDict = { 'settings.voice.page.provider.server': 'Serwer', 'settings.voice.page.provider.local': 'Lokalny', 'settings.voice.page.tooltip.sttLocal': 'Transkrypcja lokalna na serwerze OpenChamber. Modele pobierają się automatycznie; klucz API nie jest potrzebny.', - 'settings.voice.page.tooltip.localTts': 'Lokalna synteza na serwerze OpenChamber (Kokoro, angielski). Model pobiera się automatycznie; klucz API nie jest potrzebny.', + 'settings.voice.page.tooltip.localTts': 'Lokalna synteza na serwerze OpenChamber (Kokoro dla angielskiego; modele innych języków pobierane przy pierwszym użyciu). Klucz API nie jest potrzebny.', + 'settings.voice.page.field.followTextLanguage': 'Dopasuj głos do języka tekstu', + 'settings.voice.page.field.followTextLanguageAria': 'Dopasuj głos do języka tekstu', + 'settings.voice.page.field.followTextLanguageInfo': 'Gdy odpowiedź jest w innym języku, używany jest głos dla tego języka: pasujący głos macOS albo lokalny model pobierany przy pierwszym użyciu.', 'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (angielski)', 'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 języków europejskich)', 'settings.voice.page.stt.model.whisperBase': 'Whisper base (wielojęzyczny)', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index fb5fa2c1..3f2827c5 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1839,7 +1839,10 @@ export const settingsDict = { "settings.voice.page.provider.server": "Servidor", "settings.voice.page.provider.local": "Local", "settings.voice.page.tooltip.sttLocal": "Transcrição local no servidor do OpenChamber. Os modelos são baixados automaticamente; não é necessária chave de API.", - "settings.voice.page.tooltip.localTts": "Síntese local no servidor do OpenChamber (Kokoro, inglês). O modelo é baixado automaticamente; não é necessária chave de API.", + "settings.voice.page.tooltip.localTts": "Síntese local no servidor do OpenChamber (Kokoro para inglês; modelos de outros idiomas são baixados no primeiro uso). Não requer chave de API.", + "settings.voice.page.field.followTextLanguage": "Ajustar a voz ao idioma do texto", + "settings.voice.page.field.followTextLanguageAria": "Ajustar a voz ao idioma do texto", + "settings.voice.page.field.followTextLanguageInfo": "Se uma resposta estiver em outro idioma, uma voz desse idioma é usada: uma voz do macOS correspondente ou um modelo local baixado no primeiro uso.", "settings.voice.page.stt.model.parakeetV2": "Parakeet v2 (inglês)", "settings.voice.page.stt.model.parakeetV3": "Parakeet v3 (25 idiomas europeus)", "settings.voice.page.stt.model.whisperBase": "Whisper base (multilíngue)", diff --git a/packages/ui/src/lib/i18n/messages/tr.settings.ts b/packages/ui/src/lib/i18n/messages/tr.settings.ts index c4ce2859..ead5621c 100644 --- a/packages/ui/src/lib/i18n/messages/tr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr.settings.ts @@ -1787,7 +1787,10 @@ export const settingsDict = { 'settings.voice.page.provider.server': 'Sunucu', 'settings.voice.page.provider.local': 'Yerel', 'settings.voice.page.tooltip.sttLocal': 'OpenChamber sunucusunda cihaz üstü transkripsiyon. Modeller otomatik indirilir; API anahtarı gerekmez.', - 'settings.voice.page.tooltip.localTts': 'OpenChamber sunucusunda cihaz üstü sentez (Kokoro, İngilizce). Model otomatik indirilir; API anahtarı gerekmez.', + 'settings.voice.page.tooltip.localTts': 'OpenChamber sunucusunda yerel sentez (İngilizce için Kokoro; diğer dillerin modelleri ilk kullanımda indirilir). API anahtarı gerekmez.', + 'settings.voice.page.field.followTextLanguage': 'Sesi metnin diline göre seç', + 'settings.voice.page.field.followTextLanguageAria': 'Sesi metnin diline göre seç', + 'settings.voice.page.field.followTextLanguageInfo': 'Yanıt başka bir dildeyse o dil için bir ses kullanılır: uygun bir macOS sesi veya ilk kullanımda indirilen yerel bir model.', 'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (İngilizce)', 'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 Avrupa dili)', 'settings.voice.page.stt.model.whisperBase': 'Whisper base (çok dilli)', diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index d65facf2..f3d88411 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1839,7 +1839,10 @@ export const settingsDict = { "settings.voice.page.provider.server": "Сервер", "settings.voice.page.provider.local": "Локальний", "settings.voice.page.tooltip.sttLocal": "Локальна розшифровка на сервері OpenChamber. Моделі завантажуються автоматично; ключ API не потрібен.", - "settings.voice.page.tooltip.localTts": "Локальний синтез на сервері OpenChamber (Kokoro, англійська). Модель завантажується автоматично; ключ API не потрібен.", + "settings.voice.page.tooltip.localTts": "Локальний синтез на сервері OpenChamber (Kokoro для англійської; моделі для інших мов завантажуються при першому використанні). Ключ API не потрібен.", + "settings.voice.page.field.followTextLanguage": "Підбирати голос під мову тексту", + "settings.voice.page.field.followTextLanguageAria": "Підбирати голос під мову тексту", + "settings.voice.page.field.followTextLanguageInfo": "Якщо відповідь іншою мовою, використовується голос цієї мови: відповідний голос macOS або локальна модель, яка завантажується при першому використанні.", "settings.voice.page.stt.model.parakeetV2": "Parakeet v2 (англійська)", "settings.voice.page.stt.model.parakeetV3": "Parakeet v3 (25 європейських мов)", "settings.voice.page.stt.model.whisperBase": "Whisper base (мультимовна)", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index c7f1f622..c0eef58a 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1839,7 +1839,10 @@ export const settingsDict = { 'settings.voice.page.provider.server': '服务器', 'settings.voice.page.provider.local': '本地', 'settings.voice.page.tooltip.sttLocal': '在 OpenChamber 服务器上本地转写。模型自动下载,无需 API 密钥。', - 'settings.voice.page.tooltip.localTts': '在 OpenChamber 服务器上本地合成语音(Kokoro,英语)。模型自动下载,无需 API 密钥。', + 'settings.voice.page.tooltip.localTts': '在 OpenChamber 服务器上本地合成语音(英语使用 Kokoro;其他语言的模型在首次使用时下载)。无需 API 密钥。', + 'settings.voice.page.field.followTextLanguage': '根据文本语言匹配语音', + 'settings.voice.page.field.followTextLanguageAria': '根据文本语言匹配语音', + 'settings.voice.page.field.followTextLanguageInfo': '当回复使用其他语言时,将使用该语言的语音:匹配的 macOS 语音,或首次使用时下载的本地模型。', 'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2(英语)', 'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3(25 种欧洲语言)', 'settings.voice.page.stt.model.whisperBase': 'Whisper base(多语言)', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index e24521ae..c0639769 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1746,7 +1746,10 @@ export const settingsDict = { 'settings.voice.page.provider.server': '伺服器', 'settings.voice.page.provider.local': '本機', 'settings.voice.page.tooltip.sttLocal': '在 OpenChamber 伺服器上本機轉寫。模型會自動下載,無需 API 金鑰。', - 'settings.voice.page.tooltip.localTts': '在 OpenChamber 伺服器上本機合成語音(Kokoro,英文)。模型會自動下載,無需 API 金鑰。', + 'settings.voice.page.tooltip.localTts': '在 OpenChamber 伺服器上本機合成語音(英文使用 Kokoro;其他語言的模型在首次使用時下載)。不需要 API 金鑰。', + 'settings.voice.page.field.followTextLanguage': '依文字語言選擇語音', + 'settings.voice.page.field.followTextLanguageAria': '依文字語言選擇語音', + 'settings.voice.page.field.followTextLanguageInfo': '當回覆使用其他語言時,會使用該語言的語音:相符的 macOS 語音,或首次使用時下載的本機模型。', 'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2(英文)', 'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3(25 種歐洲語言)', 'settings.voice.page.stt.model.whisperBase': 'Whisper base(多語言)', diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index b4a370ca..66f1487e 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -1067,6 +1067,10 @@ interface ConfigStore { sayVoice: string; browserVoice: string; localTtsVoiceId: number; + /** Local TTS model the chosen voice belongs to (catalog id). */ + localTtsModelId: string; + /** Local and macOS voices follow the language of the text being read. */ + ttsFollowTextLanguage: boolean; openaiVoice: string; openaiApiKey: string; openaiCompatibleUrl: string; @@ -1094,6 +1098,8 @@ interface ConfigStore { setSayVoice: (voice: string) => void; setBrowserVoice: (voice: string) => void; setLocalTtsVoiceId: (voiceId: number) => void; + setLocalTtsModelId: (modelId: string) => void; + setTtsFollowTextLanguage: (enabled: boolean) => void; setOpenaiVoice: (voice: string) => void; setOpenaiApiKey: (apiKey: string) => void; setOpenaiCompatibleUrl: (url: string) => void; @@ -1277,6 +1283,21 @@ export const useConfigStore = create()( } return 0; })(), + localTtsModelId: (() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('localTtsModelId'); + if (saved) return saved; + } + return 'kokoro-en-v0_19'; + })(), + + ttsFollowTextLanguage: (() => { + if (typeof window !== 'undefined') { + const saved = localStorage.getItem('ttsFollowTextLanguage'); + if (saved !== null) return saved === 'true'; + } + return true; + })(), // Browser voice - load from localStorage or default to empty (auto-select) browserVoice: (() => { if (typeof window !== 'undefined') { @@ -2962,6 +2983,20 @@ export const useConfigStore = create()( } }, + setLocalTtsModelId: (modelId: string) => { + set({ localTtsModelId: modelId }); + if (typeof window !== 'undefined') { + localStorage.setItem('localTtsModelId', modelId); + } + }, + + setTtsFollowTextLanguage: (enabled: boolean) => { + set({ ttsFollowTextLanguage: enabled }); + if (typeof window !== 'undefined') { + localStorage.setItem('ttsFollowTextLanguage', String(enabled)); + } + }, + setBrowserVoice: (voice: string) => { set({ browserVoice: voice }); if (typeof window !== 'undefined') { diff --git a/packages/web/server/lib/dictation/DOCUMENTATION.md b/packages/web/server/lib/dictation/DOCUMENTATION.md index e66af116..49e38aae 100644 --- a/packages/web/server/lib/dictation/DOCUMENTATION.md +++ b/packages/web/server/lib/dictation/DOCUMENTATION.md @@ -11,12 +11,25 @@ live transcript costs O(n^2) work for a result the final decode replaces. The composer shows no text while recording and inserts the full transcript on stop. -Local TTS (Kokoro via sherpa-onnx OfflineTts) runs in the same worker process -and is exposed as `POST /api/dictation/tts/speak` (JSON `{text, speakerId?, -speed?, model?}` → WAV bytes; 503 with `reasonCode` while the model is -downloading). TTS models live in the same catalog/downloader as STT models -(`local/model-catalog.js` `LOCAL_TTS_MODEL_CATALOG`) and are managed by the -same status/download/delete routes. +Local TTS (Kokoro and Piper/VITS via sherpa-onnx OfflineTts) runs in the same +worker process and is exposed as `POST /api/dictation/tts/speak` (JSON +`{text, speakerId?, speed?, model?, language?, languageSample?}` → WAV bytes; 503 with +`reasonCode` while the model is downloading). TTS models live in the same +catalog/downloader as STT models (`local/model-catalog.js` +`LOCAL_TTS_MODEL_CATALOG`) and are managed by the same status/download/delete +routes. + +Each TTS catalog entry declares the `languages` it speaks. With +`language: 'auto'` the service detects the language of `languageSample` — the +whole message the chunk belongs to, sent by the client with every chunk — or +of `text` when no sample is given +(`../tts/language-detect.js`, script plus function-word scoring, no +dependencies) and keeps the caller's model when it speaks that language; +otherwise it switches to the catalog model for the language, downloading it on +first use like any other model, and starts from that model's default speaker +(`defaultSpeakerByLanguage`) instead of the caller's speaker id. A language no +catalog model covers keeps the caller's model, so text is always spoken. The +response carries `X-Speech-Model` and `X-Speech-Language`. ## Ownership diff --git a/packages/web/server/lib/dictation/local/model-catalog.js b/packages/web/server/lib/dictation/local/model-catalog.js index 29ea524b..16af62c9 100644 --- a/packages/web/server/lib/dictation/local/model-catalog.js +++ b/packages/web/server/lib/dictation/local/model-catalog.js @@ -68,9 +68,19 @@ export const LOCAL_STT_MODEL_CATALOG = { * Local text-to-speech models (sherpa-onnx OfflineTts). Downloaded and * managed through the same pipeline as the STT models. */ +/** + * Local text-to-speech models (sherpa-onnx OfflineTts). Downloaded and + * managed through the same pipeline as the STT models. + * + * `languages` lists the languages a model speaks well; the speech service + * uses it to pick a model for the language a text is written in. Kokoro + * models carry speaker ids (`voices`); a Piper model is one voice for one + * language. `lexicon` entries are joined with commas for sherpa-onnx. + */ export const LOCAL_TTS_MODEL_CATALOG = { 'kokoro-en-v0_19': { type: 'kokoro', + languages: ['en'], archiveUrl: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-en-v0_19.tar.bz2', extractedDir: 'kokoro-en-v0_19', @@ -82,6 +92,188 @@ export const LOCAL_TTS_MODEL_CATALOG = { }, description: 'Kokoro TTS (English, natural voices)', }, + 'kokoro-multi-lang-v1_1': { + type: 'kokoro', + languages: ['zh', 'en'], + // sherpa-onnx wires this Kokoro build for Chinese and English only; + // speakers 0-2 are English, 3-102 Chinese. + defaultSpeakerByLanguage: { en: 0, zh: 3 }, + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-multi-lang-v1_1.tar.bz2', + extractedDir: 'kokoro-multi-lang-v1_1', + files: { + model: 'model.onnx', + voices: 'voices.bin', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + lexiconEnglish: 'lexicon-us-en.txt', + lexiconChinese: 'lexicon-zh.txt', + }, + lexicon: ['lexiconEnglish', 'lexiconChinese'], + description: 'Kokoro TTS (Chinese and English, 103 voices)', + }, + // The larger `ukrainian_tts-medium` build is a character-level model + // (`phoneme_type: text`); sherpa-onnx phonemizes every Piper model through + // espeak-ng, which turns that one into noise. `vits-coqui-uk-mai` sounds + // better but reads Cyrillic only and drops every Latin word (file names, + // product names), which is unusable in a coding chat. Lada is an espeak + // model: small, but it reads mixed text. + 'piper-uk_UA-lada-x_low': { + type: 'vits', + languages: ['uk'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-uk_UA-lada-x_low.tar.bz2', + extractedDir: 'vits-piper-uk_UA-lada-x_low', + files: { + model: 'uk_UA-lada-x_low.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (Ukrainian)', + }, + 'piper-de_DE-thorsten-medium': { + type: 'vits', + languages: ['de'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-de_DE-thorsten-medium.tar.bz2', + extractedDir: 'vits-piper-de_DE-thorsten-medium', + files: { + model: 'de_DE-thorsten-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (German)', + }, + 'piper-fr_FR-siwis-medium': { + type: 'vits', + languages: ['fr'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-fr_FR-siwis-medium.tar.bz2', + extractedDir: 'vits-piper-fr_FR-siwis-medium', + files: { + model: 'fr_FR-siwis-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (French)', + }, + 'piper-es_ES-davefx-medium': { + type: 'vits', + languages: ['es'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-es_ES-davefx-medium.tar.bz2', + extractedDir: 'vits-piper-es_ES-davefx-medium', + files: { + model: 'es_ES-davefx-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (Spanish)', + }, + 'piper-it_IT-paola-medium': { + type: 'vits', + languages: ['it'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-it_IT-paola-medium.tar.bz2', + extractedDir: 'vits-piper-it_IT-paola-medium', + files: { + model: 'it_IT-paola-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (Italian)', + }, + 'piper-pt_BR-faber-medium': { + type: 'vits', + languages: ['pt'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-pt_BR-faber-medium.tar.bz2', + extractedDir: 'vits-piper-pt_BR-faber-medium', + files: { + model: 'pt_BR-faber-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (Portuguese (Brazil))', + }, + 'piper-pl_PL-gosia-medium': { + type: 'vits', + languages: ['pl'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-pl_PL-gosia-medium.tar.bz2', + extractedDir: 'vits-piper-pl_PL-gosia-medium', + files: { + model: 'pl_PL-gosia-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (Polish)', + }, + 'piper-ru_RU-irina-medium': { + type: 'vits', + languages: ['ru'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-ru_RU-irina-medium.tar.bz2', + extractedDir: 'vits-piper-ru_RU-irina-medium', + files: { + model: 'ru_RU-irina-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (Russian)', + }, + 'piper-nl_NL-pim-medium': { + type: 'vits', + languages: ['nl'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-nl_NL-pim-medium.tar.bz2', + extractedDir: 'vits-piper-nl_NL-pim-medium', + files: { + model: 'nl_NL-pim-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (Dutch)', + }, + 'piper-cs_CZ-jirka-medium': { + type: 'vits', + languages: ['cs'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-cs_CZ-jirka-medium.tar.bz2', + extractedDir: 'vits-piper-cs_CZ-jirka-medium', + files: { + model: 'cs_CZ-jirka-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (Czech)', + }, + 'piper-tr_TR-dfki-medium': { + type: 'vits', + languages: ['tr'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-tr_TR-dfki-medium.tar.bz2', + extractedDir: 'vits-piper-tr_TR-dfki-medium', + files: { + model: 'tr_TR-dfki-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (Turkish)', + }, + 'piper-sv_SE-nst-medium': { + type: 'vits', + languages: ['sv'], + archiveUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-sv_SE-nst-medium.tar.bz2', + extractedDir: 'vits-piper-sv_SE-nst-medium', + files: { + model: 'sv_SE-nst-medium.onnx', + tokens: 'tokens.txt', + espeakData: 'espeak-ng-data', + }, + description: 'Piper TTS (Swedish)', + }, }; export const DEFAULT_LOCAL_STT_MODEL = 'parakeet-tdt-0.6b-v2-int8'; @@ -131,6 +323,34 @@ export function getLocalSttModelSpec(modelId) { }; } +/** + * The local TTS model to use for a language, preferring the model the user + * selected when it speaks that language. Returns null when no catalog model + * covers the language, in which case callers keep the selected model. + * @param {string} language BCP-47 primary subtag (`uk`, `zh`...) + * @param {string} [preferredModelId] + * @returns {string | null} + */ +export function resolveLocalTtsModelForLanguage(language, preferredModelId) { + const speaks = (modelId) => LOCAL_TTS_MODEL_CATALOG[modelId]?.languages?.includes(language) === true; + if (preferredModelId && speaks(preferredModelId)) return preferredModelId; + const candidate = LOCAL_TTS_MODEL_IDS.find(speaks); + return candidate ?? null; +} + +/** + * The speaker id a model should use for a language when the caller's + * speaker was chosen for another language. `undefined` keeps the caller's + * speaker. + * @param {string} modelId + * @param {string} language + * @returns {number | undefined} + */ +export function getLocalTtsDefaultSpeaker(modelId, language) { + const speaker = LOCAL_TTS_MODEL_CATALOG[modelId]?.defaultSpeakerByLanguage?.[language]; + return Number.isInteger(speaker) ? speaker : undefined; +} + /** * @param {string} modelsDir * @param {string} modelId diff --git a/packages/web/server/lib/dictation/local/model-catalog.test.js b/packages/web/server/lib/dictation/local/model-catalog.test.js new file mode 100644 index 00000000..e14a66aa --- /dev/null +++ b/packages/web/server/lib/dictation/local/model-catalog.test.js @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_LOCAL_TTS_MODEL, + LOCAL_TTS_MODEL_CATALOG, + getLocalSttModelSpec, + getLocalTtsDefaultSpeaker, + resolveLocalTtsModelForLanguage, +} from './model-catalog.js'; + +describe('local TTS catalog', () => { + it('keeps the selected model when it speaks the language', () => { + expect(resolveLocalTtsModelForLanguage('en', DEFAULT_LOCAL_TTS_MODEL)).toBe(DEFAULT_LOCAL_TTS_MODEL); + expect(resolveLocalTtsModelForLanguage('zh', 'kokoro-multi-lang-v1_1')).toBe('kokoro-multi-lang-v1_1'); + }); + + it('picks a catalog model for a language the selected model lacks', () => { + expect(resolveLocalTtsModelForLanguage('uk', DEFAULT_LOCAL_TTS_MODEL)).toBe('piper-uk_UA-lada-x_low'); + expect(resolveLocalTtsModelForLanguage('zh', DEFAULT_LOCAL_TTS_MODEL)).toBe('kokoro-multi-lang-v1_1'); + }); + + it('returns null for a language no model covers', () => { + expect(resolveLocalTtsModelForLanguage('xx', DEFAULT_LOCAL_TTS_MODEL)).toBeNull(); + }); + + it('gives Chinese a Chinese speaker on the multi-language Kokoro', () => { + expect(getLocalTtsDefaultSpeaker('kokoro-multi-lang-v1_1', 'zh')).toBe(3); + expect(getLocalTtsDefaultSpeaker('kokoro-multi-lang-v1_1', 'en')).toBe(0); + expect(getLocalTtsDefaultSpeaker('piper-uk_UA-lada-x_low', 'uk')).toBeUndefined(); + }); + + it('every TTS entry declares its languages and installable files', () => { + for (const [id, spec] of Object.entries(LOCAL_TTS_MODEL_CATALOG)) { + expect(spec.languages.length, id).toBeGreaterThan(0); + expect(spec.archiveUrl, id).toMatch(/^https:\/\/github\.com\/k2-fsa\/sherpa-onnx\/releases\/download\/tts-models\//); + const resolved = getLocalSttModelSpec(id); + expect(resolved.requiredFiles, id).toContain(spec.files.model); + for (const key of spec.lexicon ?? []) { + expect(spec.files[key], `${id} lexicon ${key}`).toBeTruthy(); + } + } + }); +}); diff --git a/packages/web/server/lib/dictation/local/sherpa-tts.js b/packages/web/server/lib/dictation/local/sherpa-tts.js index f4fa7972..589bae69 100644 --- a/packages/web/server/lib/dictation/local/sherpa-tts.js +++ b/packages/web/server/lib/dictation/local/sherpa-tts.js @@ -1,5 +1,5 @@ /** - * Sherpa-onnx offline TTS (Kokoro). Runs inside the dictation worker process + * Sherpa-onnx offline TTS (Kokoro and Piper/VITS). Runs inside the dictation worker process * only — never load the native addon in the main server process. */ @@ -23,20 +23,49 @@ function float32ToPcm16le(samples) { return Buffer.from(out.buffer, out.byteOffset, out.byteLength); } +/** + * sherpa-onnx model config for one catalog entry. Kokoro carries a voices + * bank (speaker ids) and optional lexicons; a Piper/VITS model is a single + * voice with espeak-ng phonemization. + * @param {{ modelDir: string, type?: string, files: Record, lexicon?: string[] }} config + */ +function buildModelConfig(config) { + const file = (key, label) => { + const filePath = path.join(config.modelDir, config.files[key]); + assertFileExists(filePath, label); + return filePath; + }; + const modelPath = file('model', 'TTS model'); + const tokensPath = file('tokens', 'TTS tokens'); + + if (config.type === 'vits') { + // Piper models phonemize through espeak-ng (`espeakData`); character + // models (Coqui) read the text directly and carry no espeak data. + const dataDir = config.files.espeakData ? file('espeakData', 'TTS espeak-ng dataDir') : ''; + return { vits: { model: modelPath, tokens: tokensPath, ...(dataDir ? { dataDir } : {}), lengthScale: 1.0 } }; + } + + const dataDir = file('espeakData', 'TTS espeak-ng dataDir'); + const voicesPath = file('voices', 'TTS voices'); + const lexicon = (config.lexicon ?? []).map((key) => file(key, 'TTS lexicon')).join(','); + return { + kokoro: { + model: modelPath, + voices: voicesPath, + tokens: tokensPath, + dataDir, + lengthScale: 1.0, + ...(lexicon ? { lexicon } : {}), + }, + }; +} + export class SherpaTtsEngine { /** - * @param {{ modelDir: string, files: { model: string, voices: string, tokens: string, espeakData: string }, numThreads?: number }} config + * @param {{ modelDir: string, type?: string, files: Record, lexicon?: string[], numThreads?: number }} config */ constructor(config) { - const modelPath = path.join(config.modelDir, config.files.model); - const voicesPath = path.join(config.modelDir, config.files.voices); - const tokensPath = path.join(config.modelDir, config.files.tokens); - const dataDir = path.join(config.modelDir, config.files.espeakData); - - assertFileExists(modelPath, 'TTS model'); - assertFileExists(voicesPath, 'TTS voices'); - assertFileExists(tokensPath, 'TTS tokens'); - assertFileExists(dataDir, 'TTS espeak-ng dataDir'); + const model = buildModelConfig(config); const sherpa = loadSherpaOnnxNode(); if (typeof sherpa.OfflineTts !== 'function') { @@ -44,15 +73,7 @@ export class SherpaTtsEngine { } this.tts = new sherpa.OfflineTts({ - model: { - kokoro: { - model: modelPath, - voices: voicesPath, - tokens: tokensPath, - dataDir, - lengthScale: 1.0, - }, - }, + model, numThreads: config.numThreads ?? 2, provider: 'cpu', maxNumSentences: 1, diff --git a/packages/web/server/lib/dictation/local/worker-process.js b/packages/web/server/lib/dictation/local/worker-process.js index 3a5c34b8..06a7ac8b 100644 --- a/packages/web/server/lib/dictation/local/worker-process.js +++ b/packages/web/server/lib/dictation/local/worker-process.js @@ -102,7 +102,9 @@ function getTtsEngine(modelsDir, modelId) { const spec = getLocalSttModelSpec(modelId); const created = new SherpaTtsEngine({ modelDir: getLocalSttModelDir(modelsDir, modelId), + type: spec.type, files: spec.files, + lexicon: spec.lexicon, numThreads: 2, }); ttsEngines.set(key, created); diff --git a/packages/web/server/lib/dictation/runtime.js b/packages/web/server/lib/dictation/runtime.js index 8fae1fcf..59437a12 100644 --- a/packages/web/server/lib/dictation/runtime.js +++ b/packages/web/server/lib/dictation/runtime.js @@ -63,6 +63,8 @@ export function createDictationRuntime({ model: typeof req.body?.model === 'string' ? req.body.model : undefined, speakerId: Number.isInteger(req.body?.speakerId) ? req.body.speakerId : undefined, speed: typeof req.body?.speed === 'number' ? req.body.speed : undefined, + language: req.body?.language === 'auto' ? 'auto' : undefined, + languageSample: typeof req.body?.languageSample === 'string' ? req.body.languageSample.slice(0, 4000) : undefined, }); if (result.error) { res.status(503).json({ @@ -73,6 +75,8 @@ export function createDictationRuntime({ return; } res.setHeader('Content-Type', result.format || 'audio/wav'); + res.setHeader('X-Speech-Model', result.modelId); + if (result.language) res.setHeader('X-Speech-Language', result.language); res.send(result.audio); } catch (error) { res.status(500).json({ error: error?.message || 'Failed to synthesize speech' }); diff --git a/packages/web/server/lib/dictation/service.js b/packages/web/server/lib/dictation/service.js index 7dc555d2..e40bc253 100644 --- a/packages/web/server/lib/dictation/service.js +++ b/packages/web/server/lib/dictation/service.js @@ -1,3 +1,4 @@ +import { detectTextLanguage } from '../tts/language-detect.js'; /** * Dictation service: resolves STT providers, tracks local model download * state, and exposes a readiness snapshot for the status route. @@ -16,6 +17,8 @@ import { OpenAICompatibleTranscriptionSession } from './openai-compatible-sessio import { DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_TTS_MODEL, + getLocalTtsDefaultSpeaker, + resolveLocalTtsModelForLanguage, LOCAL_STT_MODEL_CATALOG, LOCAL_STT_MODEL_IDS, LOCAL_TTS_MODEL_CATALOG, @@ -220,10 +223,30 @@ export function createDictationService({ modelsDir }) { /** * Synthesize speech with the local TTS model. Returns WAV bytes, or a * readiness error while the model is missing/downloading. - * @param {{ text: string, model?: string, speakerId?: number, speed?: number }} options + * + * With `language: 'auto'` the text's language decides the model: the + * caller's model when it speaks that language, otherwise the catalog + * model for it (downloaded on first use, reported as in-progress until it + * lands). The caller's speaker id is kept only on the caller's model; a + * substitute model starts from its own default speaker for the language. + * A language no catalog model covers keeps the caller's model, so text is + * never silently dropped. + * `languageSample` is the whole message the chunk belongs to (or a prefix + * of it): the language is judged on that, never on a short chunk alone. + * @param {{ text: string, model?: string, speakerId?: number, speed?: number, language?: string, languageSample?: string }} options */ - const synthesizeSpeech = async ({ text, model, speakerId, speed }) => { - const modelId = isLocalTtsModelId(model) ? model : DEFAULT_LOCAL_TTS_MODEL; + const synthesizeSpeech = async ({ text, model, speakerId, speed, language, languageSample }) => { + const requestedModelId = isLocalTtsModelId(model) ? model : DEFAULT_LOCAL_TTS_MODEL; + let modelId = requestedModelId; + let resolvedLanguage = null; + if (language === 'auto') { + resolvedLanguage = detectTextLanguage(languageSample || text).language; + const forLanguage = resolveLocalTtsModelForLanguage(resolvedLanguage, requestedModelId); + if (forLanguage && forLanguage !== requestedModelId) { + modelId = forLanguage; + speakerId = getLocalTtsDefaultSpeaker(modelId, resolvedLanguage); + } + } const installed = await isLocalSttModelInstalled(modelsDir, modelId); if (!installed) { const state = downloadStates.get(modelId); @@ -251,7 +274,7 @@ export function createDictationService({ modelsDir }) { speakerId, speed, }); - return { audio: result.audio, format: result.format }; + return { audio: result.audio, format: result.format, modelId, language: resolvedLanguage }; }; /** diff --git a/packages/web/server/lib/tts/DOCUMENTATION.md b/packages/web/server/lib/tts/DOCUMENTATION.md index 81a46760..2e0c4352 100644 --- a/packages/web/server/lib/tts/DOCUMENTATION.md +++ b/packages/web/server/lib/tts/DOCUMENTATION.md @@ -11,6 +11,7 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API. - `packages/web/server/lib/text/summarization.js`: Shared text summarization stub and sanitization utilities. It performs no external Zen calls. - `packages/web/server/lib/tts/stt.js`: STT proxy for OpenAI-compatible transcription endpoints. - `packages/web/server/lib/tts/base-url.js`: shared base URL validation and normalization for custom OpenAI-compatible endpoints. +- `packages/web/server/lib/tts/language-detect.js`: dependency-free language detection for voice selection (`detectTextLanguage`, `pickVoiceForLanguage`, `languageOfLocale`). Used by the macOS `say` route (`language: 'auto'` switches to an installed voice whose locale matches the text; the response carries `X-Speech-Voice` and `X-Speech-Language`) and by the dictation module's local TTS model choice. ## Public exports diff --git a/packages/web/server/lib/tts/language-detect.js b/packages/web/server/lib/tts/language-detect.js new file mode 100644 index 00000000..d87968e6 --- /dev/null +++ b/packages/web/server/lib/tts/language-detect.js @@ -0,0 +1,210 @@ +/** + * Language detection for text-to-speech voice selection. + * + * Picks the language a piece of chat text is written in so a TTS provider + * can choose a matching voice or model. Deliberately small and dependency + * free: the writing system decides most cases outright, and Latin-script + * languages are told apart by function words and characteristic letters. + * The answer is a best effort for voice selection, not a linguistic claim — + * an unknown language falls back to English rather than failing. + */ + +const SCRIPT_RANGES = [ + ['hangul', /[가-힯ᄀ-ᇿ㄰-㆏]/g], + ['kana', /[぀-ヿ]/g], + ['han', /[一-鿿㐀-䶿]/g], + ['cyrillic', /[Ѐ-ӿ]/g], + ['greek', /[Ͱ-Ͽ]/g], + ['arabic', /[؀-ۿ]/g], + ['hebrew', /[֐-׿]/g], + ['thai', /[฀-๿]/g], + ['devanagari', /[ऀ-ॿ]/g], + ['latin', /[A-Za-zÀ-ɏ]/g], +]; + +const SCRIPT_LANGUAGE = { + hangul: 'ko', + greek: 'el', + arabic: 'ar', + hebrew: 'he', + thai: 'th', + devanagari: 'hi', +}; + +// Letters that only (or overwhelmingly) occur in one language of a script. +const LATIN_MARKERS = { + pl: /[łęąńśźż]/i, + cs: /[řěůťďň]/i, + tr: /[ğışİ]/, + pt: /[ãõ]/i, + es: /[ñ¿¡]/, + de: /[ß]/, + fr: /[œ]/i, + sv: /[å]/i, +}; + +// Frequent function words per language. Scored by whole-word hits; every +// list has the same length so scores stay comparable. +const STOPWORDS = { + en: ['the', 'and', 'is', 'to', 'of', 'that', 'you', 'with', 'for', 'this', 'are', 'it', 'not', 'have', 'can', 'will', 'your', 'from', 'which', 'when'], + de: ['und', 'der', 'die', 'das', 'ist', 'nicht', 'mit', 'ein', 'eine', 'auch', 'sich', 'auf', 'für', 'wird', 'werden', 'oder', 'aber', 'wenn', 'sind', 'kann'], + fr: ['le', 'la', 'les', 'et', 'est', 'une', 'des', 'pour', 'que', 'qui', 'dans', 'pas', 'vous', 'sur', 'avec', 'sont', 'nous', 'cette', 'mais', 'plus'], + es: ['el', 'la', 'los', 'las', 'que', 'es', 'una', 'por', 'para', 'con', 'del', 'como', 'pero', 'más', 'este', 'esta', 'son', 'tiene', 'puede', 'también'], + it: ['il', 'la', 'che', 'di', 'è', 'una', 'per', 'non', 'con', 'del', 'della', 'come', 'sono', 'anche', 'questo', 'questa', 'gli', 'nel', 'più', 'essere'], + pt: ['o', 'a', 'os', 'as', 'que', 'é', 'uma', 'para', 'com', 'não', 'do', 'da', 'como', 'mas', 'também', 'este', 'esta', 'são', 'você', 'pode'], + pl: ['i', 'nie', 'jest', 'się', 'na', 'to', 'że', 'jak', 'ale', 'dla', 'oraz', 'przez', 'czy', 'tym', 'jego', 'można', 'jeśli', 'tego', 'które', 'także'], + nl: ['de', 'het', 'een', 'en', 'van', 'is', 'niet', 'dat', 'met', 'voor', 'ook', 'zijn', 'maar', 'als', 'wordt', 'deze', 'kan', 'naar', 'bij', 'dan'], + cs: ['a', 'je', 'se', 'na', 'to', 'že', 'jak', 'ale', 'pro', 'nebo', 'jsou', 'může', 'také', 'tento', 'když', 'jeho', 'které', 'být', 'aby', 'ještě'], + tr: ['ve', 'bir', 'bu', 'için', 'ile', 'de', 'da', 'ama', 'gibi', 'daha', 'var', 'olarak', 'çok', 'ne', 'her', 'kadar', 'sonra', 'değil', 'olan', 'ise'], + sv: ['och', 'att', 'det', 'är', 'en', 'som', 'för', 'inte', 'med', 'till', 'den', 'kan', 'har', 'ett', 'men', 'också', 'eller', 'från', 'när', 'vara'], + uk: ['і', 'та', 'що', 'це', 'не', 'як', 'для', 'він', 'вона', 'але', 'або', 'також', 'тільки', 'вже', 'якщо', 'його', 'цей', 'ця', 'бути', 'коли'], + ru: ['и', 'что', 'это', 'не', 'как', 'для', 'он', 'она', 'но', 'или', 'также', 'только', 'уже', 'если', 'его', 'этот', 'эта', 'быть', 'когда', 'чтобы'], +}; + +const LATIN_LANGUAGES = ['en', 'de', 'fr', 'es', 'it', 'pt', 'pl', 'nl', 'cs', 'tr', 'sv']; +const CYRILLIC_LANGUAGES = ['uk', 'ru']; + +const countMatches = (text, pattern) => { + const matches = text.match(pattern); + return matches ? matches.length : 0; +}; + +const scoreStopwords = (words, languages) => { + const scores = {}; + for (const language of languages) { + const list = new Set(STOPWORDS[language]); + let hits = 0; + for (const word of words) { + if (list.has(word)) hits += 1; + } + scores[language] = hits; + } + return scores; +}; + +const bestOf = (scores, fallback) => { + let best = fallback; + let bestScore = 0; + for (const [language, score] of Object.entries(scores)) { + if (score > bestScore) { + best = language; + bestScore = score; + } + } + return best; +}; + +const pickByMarkers = (text, markers) => { + for (const [language, pattern] of Object.entries(markers)) { + if (pattern.test(text)) return language; + } + return null; +}; + +/** + * @param {string} text + * @returns {{ language: string, script: string }} BCP-47 primary language subtag and the dominant script. + */ +export function detectTextLanguage(text) { + const source = typeof text === 'string' ? text : ''; + const counts = SCRIPT_RANGES.map(([script, pattern]) => [script, countMatches(source, pattern)]); + const letters = counts.reduce((sum, [, count]) => sum + count, 0); + if (letters === 0) return { language: 'en', script: 'latin' }; + + // Kana settles Japanese even when Han dominates the character count. + const kana = counts.find(([script]) => script === 'kana')?.[1] ?? 0; + const han = counts.find(([script]) => script === 'han')?.[1] ?? 0; + if (kana > 0 && kana + han >= letters * 0.3) return { language: 'ja', script: 'kana' }; + if (han > 0 && han >= letters * 0.3) return { language: 'zh', script: 'han' }; + + const [script] = counts.reduce((best, entry) => (entry[1] > best[1] ? entry : best)); + + if (script in SCRIPT_LANGUAGE) return { language: SCRIPT_LANGUAGE[script], script }; + + const words = source.toLowerCase().split(/[^\p{L}\p{M}']+/u).filter(Boolean); + + if (script === 'cyrillic') { + const scores = scoreStopwords(words, CYRILLIC_LANGUAGES); + const ukMarkers = countMatches(source, /[іїєґ]/gi); + const ruMarkers = countMatches(source, /[ыэъё]/gi); + // Letters decide: the two alphabets differ in letters that occur in + // nearly every sentence. Function words only settle a text that shows + // neither set, and a text with no Russian-only letters is far more + // likely Ukrainian than the reverse, so that tie goes to Ukrainian. + if (ukMarkers !== ruMarkers) return { language: ukMarkers > ruMarkers ? 'uk' : 'ru', script }; + if (scores.uk !== scores.ru) return { language: scores.uk > scores.ru ? 'uk' : 'ru', script }; + return { language: ruMarkers > 0 ? 'ru' : 'uk', script }; + } + + const scores = scoreStopwords(words, LATIN_LANGUAGES); + const marked = pickByMarkers(source, LATIN_MARKERS); + // A characteristic letter outranks stopword counts unless another language + // clearly dominates the function words (a German text quoting "façade"). + if (marked && scores[marked] * 2 >= scores[bestOf(scores, marked)]) { + return { language: marked, script }; + } + return { language: bestOf(scores, 'en'), script }; +} + +/** + * Map a detected language onto the locales a voice list uses (`uk_UA`, + * `en_US`...). Returns the preferred locale prefixes in order. + * @param {string} language + * @returns {string[]} + */ +function localePrefixesForLanguage(language) { + const table = { + en: ['en_US', 'en_GB', 'en'], + uk: ['uk_UA', 'uk'], + ru: ['ru_RU', 'ru'], + de: ['de_DE', 'de'], + fr: ['fr_FR', 'fr_CA', 'fr'], + es: ['es_ES', 'es_MX', 'es'], + it: ['it_IT', 'it'], + pt: ['pt_BR', 'pt_PT', 'pt'], + pl: ['pl_PL', 'pl'], + nl: ['nl_NL', 'nl_BE', 'nl'], + cs: ['cs_CZ', 'cs'], + tr: ['tr_TR', 'tr'], + sv: ['sv_SE', 'sv'], + zh: ['zh_CN', 'zh_TW', 'zh_HK', 'zh'], + ja: ['ja_JP', 'ja'], + ko: ['ko_KR', 'ko'], + el: ['el_GR', 'el'], + ar: ['ar_001', 'ar_SA', 'ar'], + he: ['he_IL', 'he'], + th: ['th_TH', 'th'], + hi: ['hi_IN', 'hi'], + }; + return table[language] ?? [language]; +} + +/** + * Choose a voice for a language from a `say`-style voice list. + * Prefers an enhanced/premium variant of a matching voice, then any voice of + * the exact locale, then any voice of the language. Returns null when the + * list has no voice for that language. + * @param {string} language + * @param {ReadonlyArray<{ name: string, locale: string }>} voices + * @returns {string | null} + */ +export function pickVoiceForLanguage(language, voices) { + const prefixes = localePrefixesForLanguage(language); + for (const prefix of prefixes) { + const matching = voices.filter((voice) => voice.locale === prefix || voice.locale.startsWith(`${prefix}_`) || (prefix === language && voice.locale.startsWith(`${language}_`))); + if (matching.length === 0) continue; + const enhanced = matching.find((voice) => /\((Enhanced|Premium)\)/i.test(voice.name)); + return (enhanced ?? matching[0]).name; + } + return null; +} + +/** + * Language of a voice, from its locale (`uk_UA` → `uk`). + * @param {string | null | undefined} locale + * @returns {string | null} + */ +export function languageOfLocale(locale) { + if (typeof locale !== 'string' || !locale) return null; + return locale.split(/[_-]/)[0].toLowerCase(); +} diff --git a/packages/web/server/lib/tts/language-detect.test.js b/packages/web/server/lib/tts/language-detect.test.js new file mode 100644 index 00000000..a8c0f2c3 --- /dev/null +++ b/packages/web/server/lib/tts/language-detect.test.js @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { detectTextLanguage, languageOfLocale, pickVoiceForLanguage } from './language-detect.js'; + +describe('detectTextLanguage', () => { + it.each([ + ['en', 'The build is green and the tests pass, so you can merge this now.'], + ['uk', 'Привіт! Це тестове повідомлення, і воно написане українською мовою.'], + ['ru', 'Привет! Это тестовое сообщение, и оно написано на русском языке.'], + ['de', 'Die Änderung ist fertig und die Tests laufen ohne Fehler durch.'], + ['fr', 'La modification est prête et les tests passent sans erreur.'], + ['es', 'El cambio está listo y las pruebas pasan sin errores.'], + ['it', 'La modifica è pronta e i test passano senza errori.'], + ['pt', 'A alteração está pronta e os testes passam sem erros, você pode continuar.'], + ['pl', 'Zmiana jest gotowa i testy przechodzą bez błędów.'], + ['nl', 'De wijziging is klaar en de tests slagen zonder fouten.'], + ['cs', 'Změna je hotová a testy procházejí bez chyb.'], + ['tr', 'Değişiklik hazır ve testler hatasız geçiyor.'], + ['sv', 'Ändringen är klar och testerna går igenom utan fel.'], + ['zh', '修改已经完成,所有测试都通过了。'], + ['ja', '変更が完了し、すべてのテストに合格しました。'], + ['ko', '변경이 완료되었고 모든 테스트를 통과했습니다.'], + ])('detects %s', (language, text) => { + expect(detectTextLanguage(text).language).toBe(language); + }); + + it.each([ + ['uk', 'Готово. Запушено.'], + ['uk', 'Все ок'], + ['uk', 'Добре, давай так зробимо'], + ['ru', 'Хорошо, давай так и сделаем'], + ['ru', 'Готово, всё запушено.'], + ])('tells short %s phrases apart by letters', (language, text) => { + expect(detectTextLanguage(text).language).toBe(language); + }); + + it('falls back to English for text without letters', () => { + expect(detectTextLanguage('1234 ... !!!').language).toBe('en'); + expect(detectTextLanguage('').language).toBe('en'); + }); + + it('does not let a single quoted foreign word flip an English paragraph', () => { + const text = 'The façade of the building is the part that you see from the street, and it is not the same as the interior.'; + expect(detectTextLanguage(text).language).toBe('en'); + }); +}); + +describe('pickVoiceForLanguage', () => { + const voices = [ + { name: 'Samantha', locale: 'en_US' }, + { name: 'Daniel', locale: 'en_GB' }, + { name: 'Lesya', locale: 'uk_UA' }, + { name: 'Lesya (Enhanced)', locale: 'uk_UA' }, + { name: 'Milena', locale: 'ru_RU' }, + { name: 'Anna', locale: 'de_DE' }, + ]; + + it('prefers the enhanced variant of a matching voice', () => { + expect(pickVoiceForLanguage('uk', voices)).toBe('Lesya (Enhanced)'); + }); + + it('prefers the primary locale of a language', () => { + expect(pickVoiceForLanguage('en', voices)).toBe('Samantha'); + }); + + it('returns null when no voice speaks the language', () => { + expect(pickVoiceForLanguage('ja', voices)).toBeNull(); + }); +}); + +describe('languageOfLocale', () => { + it('reads the language subtag', () => { + expect(languageOfLocale('uk_UA')).toBe('uk'); + expect(languageOfLocale('en-GB')).toBe('en'); + expect(languageOfLocale(null)).toBeNull(); + }); +}); diff --git a/packages/web/server/lib/tts/routes.js b/packages/web/server/lib/tts/routes.js index 5d2c4618..2f2074ff 100644 --- a/packages/web/server/lib/tts/routes.js +++ b/packages/web/server/lib/tts/routes.js @@ -2,6 +2,8 @@ import express from 'express'; import { normalizeCustomOpenAIBaseURL } from './base-url.js'; import { summarizeText, sanitizeForTTS, sanitizeForNote } from '../text/summarization.js'; +import { detectTextLanguage, languageOfLocale, pickVoiceForLanguage } from './language-detect.js'; + export function registerTtsRoutes(app, { sayTTSCapability }) { let ttsModulePromise = null; const getTtsModule = async () => { @@ -154,7 +156,8 @@ export function registerTtsRoutes(app, { sayTTSCapability }) { // macOS 'say' command TTS speak endpoint app.post('/api/tts/say/speak', async (req, res) => { try { - const { text, voice = 'Samantha', rate = 200 } = req.body || {}; + const { text, rate = 200, language, languageSample } = req.body || {}; + let voice = typeof req.body?.voice === 'string' && req.body.voice.trim() ? req.body.voice.trim() : 'Samantha'; if (!text || typeof text !== 'string' || !text.trim()) { return res.status(400).json({ error: 'Text is required' }); @@ -164,6 +167,23 @@ export function registerTtsRoutes(app, { sayTTSCapability }) { if (process.platform !== 'darwin') { return res.status(503).json({ error: 'macOS say command not available on this platform' }); } + + // `language: 'auto'`: keep the chosen voice while it speaks the text's + // language, otherwise switch to an installed voice that does. A + // language with no installed voice keeps the chosen voice — say still + // reads the text, just with an accent — rather than failing. + let resolvedLanguage = null; + if (language === 'auto') { + const capability = await sayTTSCapability; + const voices = Array.isArray(capability?.voices) ? capability.voices : []; + const sample = typeof languageSample === 'string' && languageSample.trim() ? languageSample.slice(0, 4000) : text; + resolvedLanguage = detectTextLanguage(sample).language; + const chosen = voices.find((entry) => entry.name === voice); + if (languageOfLocale(chosen?.locale) !== resolvedLanguage) { + const match = pickVoiceForLanguage(resolvedLanguage, voices); + if (match) voice = match; + } + } const { exec } = await import('child_process'); const { promisify } = await import('util'); @@ -195,6 +215,8 @@ export function registerTtsRoutes(app, { sayTTSCapability }) { // Send audio response res.setHeader('Content-Type', 'audio/mp4'); + res.setHeader('X-Speech-Voice', voice); + if (resolvedLanguage) res.setHeader('X-Speech-Language', resolvedLanguage); res.setHeader('Content-Length', audioBuffer.length); res.send(audioBuffer); diff --git a/packages/web/server/lib/tts/routes.test.js b/packages/web/server/lib/tts/routes.test.js index f4940265..fce960a4 100644 --- a/packages/web/server/lib/tts/routes.test.js +++ b/packages/web/server/lib/tts/routes.test.js @@ -33,6 +33,32 @@ describe('tts routes', () => { }); }); + it('switches the say voice to the language of the text when asked to', async () => { + const capability = Promise.resolve({ + available: true, + voices: [ + { name: 'Samantha', locale: 'en_US' }, + { name: 'Lesya', locale: 'uk_UA' }, + { name: 'Lesya (Enhanced)', locale: 'uk_UA' }, + ], + }); + const app = createApp(capability); + const response = await request(app) + .post('/api/tts/say/speak') + .send({ text: 'Привіт! Це відповідь українською мовою, і вона досить довга.', voice: 'Samantha', language: 'auto' }); + + // On macOS the route synthesizes; elsewhere it refuses before running say. + // Either way the chosen voice must be the Ukrainian one when the platform + // allows the request to proceed. + if (process.platform === 'darwin') { + expect(response.status).toBe(200); + expect(response.headers['x-speech-voice']).toBe('Lesya (Enhanced)'); + expect(response.headers['x-speech-language']).toBe('uk'); + } else { + expect(response.status).toBe(503); + } + }); + it('returns local note fallback while model summarization is retired', async () => { const response = await request(createApp()) .post('/api/text/summarize') From 7fb246d5302e432d8a3649be9699ae4d187c509c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 30 Aug 2026 02:25:17 +0300 Subject: [PATCH 282/282] chore: changelog for Linear, language-matched voices, failed-turn diagnostics, and session landing Claude-Session: https://claude.ai/code/session_017TK5JAYDfT3Fotc23UEg98 --- CHANGELOG.md | 8 ++++++++ packages/vscode/CHANGELOG.md | 2 ++ 2 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5e64970..be0d64f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,15 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Linear integration:** connect a Linear workspace in Settings → Integrations, browse its issues in the context rail with status, priority, assignee, and team filters, and start a session or worktree straight from an issue. Sessions started that way post started, completed, and failed comments on the issue, each linking back to the session; chat can also attach an issue to the next send (thanks to @AlexKutas). +- **Voice: the voice follows the language of the text.** With "Match the voice to the language of the text" (Settings → Voice, on by default) the local provider switches to a model for the reply's language — Kokoro for Chinese/English and Piper models for Ukrainian, German, French, Spanish, Italian, Portuguese, Polish, Russian, Dutch, Czech, Turkish, and Swedish, downloaded on first use — and macOS say switches to an installed voice of that language. The local voice picker lists every installed model's voices. - **Chat:** switching sessions is now near-instant. The clicked session highlights at once, and its conversation appears as one finished view — text, tool cards, and the recap together — instead of arriving in pieces with a moment of unstyled code blocks and links. Header session tabs switch without a crossfade, and the tab title no longer jumps when a tab becomes active. - Chat: command and skill autocomplete in a Chat (a session that belongs to no project) lists that chat's own commands and skills instead of the project last selected in the sidebar, and file mentions in a new chat draft no longer search the previous project. - Files: Ctrl/Cmd+F opens the find bar in the Markdown preview even when nothing inside the preview has focus. +- Chat: a turn that OpenCode stopped no longer ends with nothing on screen — what OpenCode reported shows under the last message, and a message an idle session has left unanswered is named as such. The status report (Ctrl/Cmd+Shift+L) now lists the last session errors, rejected sends, the managed OpenCode process's last error, and where the log files are. +- Chat: a session opened from the sidebar lands at its end and stays there, instead of landing above the bottom or snapping up a moment later while the recap and subagent cards finish measuring. +- Git: the commit graph no longer leaves a gap in a lane when the same branch is merged twice (thanks to @Naputt1). +- Desktop: on Windows and Linux the close button sits flush against the window edge, so the exact top-right corner closes the window, and its hover color follows the theme (thanks to @kydorn). ## [1.21.1] - 2026-08-29 @@ -35,6 +41,8 @@ All notable changes to this project will be documented in this file. - Small model: requests send the provider's configured headers, such as an API-gateway subscription key (thanks to @dmitrii-galantsev); a configured Anthropic endpoint is used without a doubled `/v1`, and Google models without reasoning no longer receive a thinking option (thanks to @mpeter and @IngTian). - Projects: the folder picker can select several directories at once and add them together (thanks to @herjarsa). - Files: files reached through a symlink inside the workspace, or under a project root that is itself a symlink, open again instead of failing with an access error (thanks to @herjarsa). +- Sidebar: searching sessions now also finds Chats — sessions that belong to no project — which used to vanish from the list as soon as anything was typed (thanks to @yulia-ivashko). +- Chat: a message made only of quoted context fragments now appears in the prompt navigator; opening or closing the context panel no longer leaves a blank tail under the last message. - Settings/Providers: after saving an API key or signing in, the provider no longer shows "Credentials missing" with its models hidden until you switch away and back (thanks to @herjarsa). - Projects: the folder picker can enter a directory that is already a project to browse from there (thanks to @weixiang1862), and sending, forking, and image attachments work in projects whose path has non-ASCII characters, such as `Masaüstü` (thanks to @fitzgpt). - Git: the status panel refreshes from real repository state after checkout, branch, stash, merge, rebase, or reset, and remote branches that were never fetched appear in branch lists (thanks to @makeittech); the Branch diff scope no longer compares against the wrong base for branches created from the current branch (thanks to @gaojunran); picking `origin/main` in the branch selector checks out the local branch instead of a detached `HEAD` (thanks to @yulia-ivashko); branch search hides non-matching branches (thanks to @bashrusakh). diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 2bacf37a..85dc52a5 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,6 +1,8 @@ ## [Unreleased] - Switching sessions is faster: the clicked session highlights at once, and its conversation appears as one finished view — text, tool cards, and the recap together — instead of arriving in pieces with a moment of unstyled code blocks. +- Chat: a turn that OpenCode stopped no longer ends with nothing on screen — what OpenCode reported shows under the last message, and a message an idle session has left unanswered is named as such. The status report (Ctrl/Cmd+Shift+L) now lists the last session errors and rejected sends. +- Chat: a session opened from the sidebar lands at its end and stays there, instead of landing above the bottom or snapping up a moment later. ## [1.21.1] - 2026-08-29