From e93da266381950c1087dba1c85086c7ea7ae068d Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 1/9] refactor: move unified launchers to launcher/, drop single-fix launchers Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 6 +- claude-context | 171 --------- claude-context.win.js | 332 ------------------ claude-think | 186 ---------- claude-think.win.js | 284 --------------- claudemax => launcher/claudemax | 0 claudemax.win.js => launcher/claudemax.win.js | 0 tests/test_regressions.py | 32 +- 8 files changed, 19 insertions(+), 992 deletions(-) delete mode 100755 claude-context delete mode 100644 claude-context.win.js delete mode 100755 claude-think delete mode 100644 claude-think.win.js rename claudemax => launcher/claudemax (100%) rename claudemax.win.js => launcher/claudemax.win.js (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0605b24..9ca70e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,17 +22,17 @@ jobs: node-version: "20" - name: Bash syntax check (bash -n) - run: bash -n claude-think claude-context claudemax patch-extension.sh test-thinking-display.sh + run: bash -n launcher/claudemax patch-extension.sh test-thinking-display.sh - name: ShellCheck # ubuntu-latest ships shellcheck. Gate at warning severity: the only # findings are info-level SC2015 (A && B || C) notes in the launchers, # where the C branch firing is the intended best-effort behavior. - run: shellcheck --severity=warning claude-think claude-context claudemax patch-extension.sh test-thinking-display.sh + run: shellcheck --severity=warning launcher/claudemax patch-extension.sh test-thinking-display.sh - name: Node syntax check (node --check) run: | - for f in claude-think.win.js claude-context.win.js claudemax.win.js proxy.js; do + for f in launcher/claudemax.win.js proxy.js; do node --check "$f" done diff --git a/claude-context b/claude-context deleted file mode 100755 index c41b769..0000000 --- a/claude-context +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env bash -# claude-context - Claude Code launcher that restores the always-visible -# context-usage icon in the VS Code chat input. -# -# Recent extension builds (2.1.165+) hide that icon until you have used >50% of -# the context window; with the 1M context window that is ~500k tokens, so it is -# effectively never shown. There is no env/CLI lever for this, so this wrapper -# idempotently patches the extension's webview bundle on each launch, flipping -# the threshold so the icon shows at any usage level. Because it re-applies every -# launch, it survives extension updates. -# -# Context-icon-only variant. For the thinking-summaries fix too, use `claudemax` -# (both fixes combined); for thinking alone, use `claude-think`. All three are -# drop-in process wrappers and differ only in what they inject/patch. -# -# NOTE: this DOES edit the extension's bundled webview/index.js. That edit is -# idempotent, backed up once to index.js.bak-context-icon, written atomically (a -# failed write leaves the original untouched), best-effort (it never blocks the -# launch), and toggle-able with CC_PATCH_CONTEXT_ICON=0. -# -# Use it: -# - VS Code (official "Claude Code" extension): set "claudeCode.claudeProcessWrapper" -# to the FULL path of this file, then reload the window. In a multi-root -# .code-workspace this setting is window-scoped, so put it in the workspace -# file's "settings" block (or User settings), not a folder .vscode/settings.json. -# - VS Code (third-party "Claude Code Chat"): set "claudeCodeChat.executable.path". -# - Terminal: run `claude-context` in place of `claude`. -# -# First-run note: the wrapper patches index.js on disk when the CLI is spawned, -# which can be AFTER the webview already loaded the old bundle. So the first time -# you enable this you may need two reloads: reload (the spawn patches the file), -# then reload again (the webview loads the patched bundle). Subsequent windows -# and post-update launches are already patched on disk. -# -# Toggle off: -# export CC_PATCH_CONTEXT_ICON=0 # leave the extension webview untouched -# -# Default: -# CC_PATCH_CONTEXT_ICON=1 -# -# The real `claude` must be installed. This wrapper finds it automatically; if it -# cannot, set CLAUDE_REAL_BIN to the full path of your real claude binary. - -set -euo pipefail - -# --- Locate the real claude binary ----------------------------------------- - -self="$(readlink -f "$0" 2>/dev/null || echo "$0")" - -# Process-wrapper convention: the official VS Code extension invokes the wrapper -# as , passing the real CLI ahead of the -# args. is either a single native-binary path (".../claude") or -# a node interpreter followed by the bundled cli.js (".../node .../cli.js"). -# Peel that off so it is not forwarded as a stray positional argument, and -# prefer it as the real claude. (Plain "claude-context " use is unaffected: -# never begins with an existing claude/node binary path.) -wrapper_bin="" -if [ "$#" -gt 0 ] \ - && printf '%s' "$1" | grep -Eqi '/claude(\.exe|\.cmd|\.bat)?$' \ - && [ -e "$1" ]; then - wrapper_bin="$1" - shift -elif [ "$#" -ge 2 ] \ - && printf '%s' "$1" | grep -Eqi '/node(\.exe)?$' && [ -e "$1" ] \ - && printf '%s' "$2" | grep -Eqi '\.(c?js|mjs)$' && [ -e "$2" ]; then - # node + cli.js: exec node directly and keep cli.js as the first forwarded arg. - wrapper_bin="$1" - shift -fi - -REAL_CLAUDE="${CLAUDE_REAL_BIN:-}" -if [ -z "$REAL_CLAUDE" ] && [ -n "$wrapper_bin" ]; then - REAL_CLAUDE="$wrapper_bin" -fi - -if [ -z "$REAL_CLAUDE" ]; then - for c in \ - "$HOME/.local/bin/claude" \ - /usr/local/bin/claude \ - /usr/bin/claude \ - /opt/homebrew/bin/claude \ - "$(command -v claude 2>/dev/null || true)"; do - - [ -n "$c" ] && [ -x "$c" ] || continue - [ "$(readlink -f "$c" 2>/dev/null || echo "$c")" = "$self" ] && continue - - REAL_CLAUDE="$c" - break - done -fi - -[ -n "$REAL_CLAUDE" ] || { - echo "claude-context: could not find the real 'claude' binary; set CLAUDE_REAL_BIN" >&2 - exit 1 -} - -# --- Restore the always-visible context-usage icon (patches the webview) ---- -# -# Best-effort and must never block the launch: every step is guarded, writes go -# through a temp file with an atomic rename (a failed write leaves the original -# untouched), and the whole routine runs under `|| true`. -# -# What it changes - component `FJe` in webview/index.js: -# if(c>=50)return null -> if(c>=101)return null -# `c` is "% of context remaining"; it maxes at 100, so >=101 never fires and the -# icon renders whenever a context window is known (the t===0 "no session yet" -# guard is left intact). Idempotent, and re-applied each launch, so an extension -# update that reinstalls a fresh bundle is re-patched on the next launch. -# -# Maintenance note: the patch keys off the stable string `>=50)return null}`, not -# the minified component name. If a future extension build changes that exact -# substring, the routine safely no-ops (the icon goes missing again) until the -# anchor here is updated. - -CC_PATCH_CONTEXT_ICON="${CC_PATCH_CONTEXT_ICON:-1}" - -_cc_patch_index_js() { - local f="$1" tmp tmp2 old_count - [ -f "$f" ] && [ -w "$f" ] || return 0 - if grep -q '>=101)return null}' "$f" 2>/dev/null; then return 0; fi # already patched - old_count="$( (grep -o '>=50)return null}' "$f" 2>/dev/null || true) | wc -l | tr -d ' ')" - if [ "$old_count" != "1" ]; then return 0; fi # absent or ambiguous (version changed) - if [ ! -e "$f.bak-context-icon" ]; then cp -p "$f" "$f.bak-context-icon" 2>/dev/null || true; fi - tmp="${f}.ccpatch.$$" - tmp2="${f}.ccpatch2.$$" - # `cp -p` preserves mode/owner portably (the GNU-only `--reference` is avoided - # so this also works on macOS/BSD): copy the original to a temp, sed into a - # second temp, copy that back over the metadata-preserving temp, then atomically - # replace the original. A failed/partial step leaves the original untouched. - cp -p "$f" "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null || true; return 0; } - if sed 's/>=50)return null}/>=101)return null}/' "$f" > "$tmp2" 2>/dev/null \ - && [ -s "$tmp2" ] \ - && grep -q '>=101)return null}' "$tmp2" 2>/dev/null; then - cat "$tmp2" > "$tmp" && mv -f "$tmp" "$f" || true - fi - rm -f "$tmp" "$tmp2" 2>/dev/null || true -} - -_cc_restore_context_icon() { - local d extdir f - # Most precise target: walk up from the real claude path the extension handed - # us (its bundled resources/native-binary/claude) to the extension root. - d="$(dirname "$REAL_CLAUDE" 2>/dev/null || echo "")" - extdir="" - while [ -n "$d" ] && [ "$d" != "/" ] && [ "$d" != "." ]; do - case "${d##*/}" in - anthropic.claude-code-*) extdir="$d"; break ;; - esac - d="$(dirname "$d" 2>/dev/null || echo "")" - done - if [ -n "$extdir" ]; then _cc_patch_index_js "$extdir/webview/index.js"; fi - - # Also cover any installed extension under this user's VS Code dirs (terminal - # launches, or when the real binary is the standalone CLI). Unmatched globs - # fall through harmlessly - _cc_patch_index_js skips non-files. - for f in \ - "$HOME"/.vscode/extensions/anthropic.claude-code-*/webview/index.js \ - "$HOME"/.vscode-insiders/extensions/anthropic.claude-code-*/webview/index.js \ - "$HOME"/.vscode-server/extensions/anthropic.claude-code-*/webview/index.js \ - "$HOME"/.vscode-server-insiders/extensions/anthropic.claude-code-*/webview/index.js; do - _cc_patch_index_js "$f" - done -} - -if [ "$CC_PATCH_CONTEXT_ICON" != "0" ]; then - _cc_restore_context_icon || true -fi - -# The `${@+...}` form guards the empty-args case under `set -u`, including older -# Bash versions such as the default Bash on older macOS systems. -exec "$REAL_CLAUDE" ${@+"$@"} diff --git a/claude-context.win.js b/claude-context.win.js deleted file mode 100644 index e7bfb4b..0000000 --- a/claude-context.win.js +++ /dev/null @@ -1,332 +0,0 @@ -// claude-context.win.js - Windows launcher for Claude Code that restores the -// always-visible context-usage icon in the VS Code chat input. -// -// Recent extension builds (2.1.165+) hide that icon until you have used >50% of -// the context window; with the 1M context window that is ~500k tokens, so it is -// effectively never shown. There is no env/CLI lever, so this wrapper -// idempotently patches the extension's webview bundle on each launch, flipping -// the threshold so the icon shows at any usage level. Because it re-applies every -// launch, it survives extension updates. -// -// Context-icon-only variant. For the thinking-summaries fix too, use -// claudemax.exe (both fixes combined); for thinking alone, use claude-think.exe. -// All three are drop-in process wrappers and differ only in what they -// inject/patch. -// -// NOTE: this DOES edit the extension's bundled webview/index.js. The edit is -// idempotent, backed up once to index.js.bak-context-icon, written via a temp -// file + rename, best-effort (it never blocks the launch), and toggle-able with -// CC_PATCH_CONTEXT_ICON=0. -// -// First-run note: the wrapper patches index.js when the CLI is spawned, which can -// be AFTER the webview already loaded the old bundle. So the first time you -// enable this you may need two reloads: reload (the spawn patches the file), then -// reload again (the webview loads the patched bundle). Later windows and -// post-update launches are already patched on disk. -// -// Use it: set the official "Claude Code" extension's "claudeCode.claudeProcessWrapper" -// setting (or the third-party "Claude Code Chat" extension's -// "claudeCodeChat.executable.path") to claude-context.exe and reload the window, -// or run claude-context.exe in place of claude in a terminal. In a multi-root -// .code-workspace, claudeProcessWrapper is window-scoped: put it in the workspace -// file's "settings" block (or User settings), not a folder's .vscode/settings.json. -// -// Toggle off: set CC_PATCH_CONTEXT_ICON=0 (default is 1). -// -// The real `claude` must be installed. This wrapper finds it automatically -// (native install `claude.exe` or npm `claude.cmd`); if it cannot, set the -// CLAUDE_REAL_BIN environment variable to the full path of your real claude. -// -// Build to a standalone .exe with vercel/pkg: -// npm i -g pkg -// pkg claude-context.win.js --targets node18-win-x64 --output claude-context.exe - -const { execFileSync, spawnSync } = require("child_process"); -const fs = require("fs"); -const path = require("path"); - -// --- Locate the real claude (native claude.exe or npm claude.cmd) ---------- -function findClaude() { - if (process.env.CLAUDE_REAL_BIN && fs.existsSync(process.env.CLAUDE_REAL_BIN)) { - return process.env.CLAUDE_REAL_BIN; - } - const home = process.env.USERPROFILE || process.env.HOME || ""; - const appdata = process.env.APPDATA || ""; - const candidates = [ - path.join(home, ".local", "bin", "claude.exe"), - path.join(home, ".local", "bin", "claude.cmd"), - appdata && path.join(appdata, "npm", "claude.cmd"), - appdata && path.join(appdata, "npm", "claude.exe"), - ].filter(Boolean); - for (const c of candidates) { - if (fs.existsSync(c)) return c; - } - // Fall back to a PATH lookup via `where`, skipping our own executable. - try { - const out = execFileSync("where", ["claude"], { encoding: "utf8" }); - const self = path.resolve(process.execPath); - const hit = out - .split(/\r?\n/) - .map((s) => s.trim()) - .find( - (s) => - s && - /\.(exe|cmd|bat)$/i.test(s) && - fs.existsSync(s) && - path.resolve(s) !== self - ); - if (hit) return hit; - } catch (_) { - /* claude not on PATH */ - } - return null; -} - -function findExecutableOnPath(name) { - const lookup = process.platform === "win32" ? "where" : "which"; - try { - const out = execFileSync(lookup, [name], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }); - const hit = out - .split(/\r?\n/) - .map((s) => s.trim()) - .find((s) => s && fs.existsSync(s)); - if (hit) return hit; - } catch (_) { - /* not on PATH */ - } - return null; -} - -function expandShimPath(raw, shimDir) { - let s = raw.trim().replace(/^["']|["']$/g, ""); - s = s.replace(/%~?dp0%?/gi, shimDir + path.sep); - s = s.replace(/%([^%]+)%/g, (m, name) => process.env[name] || m); - return path.isAbsolute(s) ? s : path.resolve(shimDir, s); -} - -function resolveShimEntrypoint(shim) { - const shimDir = path.dirname(path.resolve(shim)); - const candidates = [ - path.join(shimDir, "node_modules", "@anthropic-ai", "claude-code", "cli.js"), - path.resolve(shimDir, "..", "@anthropic-ai", "claude-code", "cli.js"), - path.resolve( - shimDir, - "..", - "node_modules", - "@anthropic-ai", - "claude-code", - "cli.js" - ), - ]; - for (const c of candidates) { - if (fs.existsSync(c)) return c; - } - try { - const data = fs.readFileSync(shim, "utf8"); - const matches = data.matchAll( - /(?:"([^"]+?\.js)"|'([^']+?\.js)'|([^\s"']+?\.js))/gi - ); - for (const m of matches) { - const hit = expandShimPath(m[1] || m[2] || m[3], shimDir); - if (fs.existsSync(hit)) return hit; - } - } catch (_) { - /* unreadable shim */ - } - return null; -} - -function resolveNodeForShim(shim) { - const shimDir = path.dirname(path.resolve(shim)); - const candidates = [ - process.env.CC_NODE_BIN, - path.join(shimDir, "node.exe"), - path.join(shimDir, "node"), - process.pkg ? null : process.execPath, - findExecutableOnPath("node"), - ].filter(Boolean); - for (const c of candidates) { - if (fs.existsSync(c)) return c; - } - return null; -} - -function resolveClaudeInvocation(command, args) { - if (!/\.(cmd|bat)$/i.test(command)) return { command, args }; - const cli = resolveShimEntrypoint(command); - const node = resolveNodeForShim(command); - if (cli && node) return { command: node, args: [cli, ...args] }; - console.error( - "claude-context: refusing to launch unresolved .cmd/.bat shim without a shell; set CLAUDE_REAL_BIN to claude.exe or CC_NODE_BIN to node.exe" - ); - return null; -} - -// Process-wrapper convention: the official VS Code extension invokes the wrapper -// as , passing the real CLI ahead of the -// args. is either a single native-binary path (".../claude.exe") -// or a node interpreter followed by the bundled cli.js (".../node .../cli.js"). -// Peel that off so it is not forwarded as a stray positional argument, and -// prefer it as the real claude. (The third-party claudeCodeChat "executable.path" -// mode calls with no leading binary, which falls through.) -const rawArgs = process.argv.slice(2); -let wrapperBin = null; -let argv = rawArgs; -if ( - rawArgs.length && - /[\\/]claude(\.exe|\.cmd|\.bat)?$/i.test(rawArgs[0]) && - fs.existsSync(rawArgs[0]) -) { - wrapperBin = rawArgs[0]; - argv = rawArgs.slice(1); -} else if ( - rawArgs.length >= 2 && - /[\\/]node(\.exe)?$/i.test(rawArgs[0]) && - fs.existsSync(rawArgs[0]) && - /\.(c?js|mjs)$/i.test(rawArgs[1]) && - fs.existsSync(rawArgs[1]) -) { - // node + cli.js: exec node directly, keep cli.js as the first forwarded arg. - wrapperBin = rawArgs[0]; - argv = rawArgs.slice(1); -} - -// Resolve the real claude: explicit override wins, then the extension-provided -// path, then autodetection. -const claude = - process.env.CLAUDE_REAL_BIN && fs.existsSync(process.env.CLAUDE_REAL_BIN) - ? process.env.CLAUDE_REAL_BIN - : wrapperBin || findClaude(); -if (!claude) { - console.error( - "claude-context: could not find the real 'claude' binary; set CLAUDE_REAL_BIN" - ); - process.exit(1); -} - -// --- Restore the always-visible context-usage icon (patches the webview) ---- -// -// Idempotent edit to component `FJe` in the extension's webview/index.js: -// if(c>=50)return null -> if(c>=101)return null -// `c` is "% of context remaining"; it maxes at 100, so >=101 never fires and the -// icon renders whenever a context window is known (the t===0 "no session yet" -// guard is left intact). Best-effort: every step is wrapped so it can never block -// the launch; a one-time backup is made and the write goes through a temp + rename -// so a failed write leaves the original untouched. Re-applied each launch, so an -// extension update that reinstalls a fresh bundle is re-patched next launch. -// -// Maintenance note: this keys off the stable string ">=50)return null}", not the -// minified component name. If a future build changes that exact substring, the -// routine safely no-ops until the anchor here is updated. -const ICON_OLD = ">=50)return null}"; -const ICON_NEW = ">=101)return null}"; - -function ccPatchIndexJs(file) { - try { - if (!fs.existsSync(file)) return; - let data; - try { - data = fs.readFileSync(file, "utf8"); - } catch (_) { - return; // not readable - } - if (data.indexOf(ICON_NEW) !== -1) return; // already patched - const oldMatches = data.split(ICON_OLD).length - 1; - if (oldMatches !== 1) return; // gate absent or ambiguous (version changed) - const bak = file + ".bak-context-icon"; - if (!fs.existsSync(bak)) { - try { - fs.writeFileSync(bak, data); - } catch (_) { - /* best-effort backup */ - } - } - const patched = data.replace(ICON_OLD, ICON_NEW); - if (patched.indexOf(ICON_NEW) === -1) return; // sanity: substitution took - const tmp = file + ".ccpatch." + process.pid; - try { - fs.writeFileSync(tmp, patched); - fs.renameSync(tmp, file); // atomic on the same volume - } catch (_) { - try { - fs.unlinkSync(tmp); - } catch (_) { - /* nothing to clean up */ - } - } - } catch (_) { - /* never block the launch */ - } -} - -// Most precise target: walk up from the real binary path the extension handed us -// (its bundled resources\native-binary\claude.exe) to a dir named -// anthropic.claude-code-*, then \webview\index.js. -function extensionRootFromBinary(binPath) { - try { - let d = path.dirname(path.resolve(binPath)); - let prev = null; - while (d && d !== prev) { - if (/^anthropic\.claude-code-/i.test(path.basename(d))) return d; - prev = d; - d = path.dirname(d); - } - } catch (_) { - /* ignore */ - } - return null; -} - -// Fallback: scan this user's VS Code extension dirs for any installed build. -function scanExtensionIndexes() { - const home = process.env.USERPROFILE || process.env.HOME || ""; - const bases = [ - path.join(home, ".vscode", "extensions"), - path.join(home, ".vscode-insiders", "extensions"), - path.join(home, ".vscode-server", "extensions"), - path.join(home, ".vscode-server-insiders", "extensions"), - ]; - const found = []; - for (const base of bases) { - let entries; - try { - entries = fs.readdirSync(base); - } catch (_) { - continue; // dir doesn't exist - } - for (const name of entries) { - if (/^anthropic\.claude-code-/i.test(name)) { - found.push(path.join(base, name, "webview", "index.js")); - } - } - } - return found; -} - -function restoreContextIcon(binPath) { - if (process.env.CC_PATCH_CONTEXT_ICON === "0") return; - const targets = new Set(); - if (binPath) { - const root = extensionRootFromBinary(binPath); - if (root) targets.add(path.join(root, "webview", "index.js")); - } - for (const f of scanExtensionIndexes()) targets.add(f); - for (const f of targets) ccPatchIndexJs(f); -} - -// Patch the webview before handing off (best-effort; never throws). -restoreContextIcon(wrapperBin); - -// This variant injects nothing into the args - it only patches the webview, then -// forwards every argument through to the real claude unchanged. -const invocation = resolveClaudeInvocation(claude, argv); -if (!invocation) process.exit(1); -const res = spawnSync(invocation.command, invocation.args, { - stdio: "inherit", - env: process.env, - shell: false, -}); -process.exit(res.status == null ? 1 : res.status); diff --git a/claude-think b/claude-think deleted file mode 100755 index c80ed44..0000000 --- a/claude-think +++ /dev/null @@ -1,186 +0,0 @@ -#!/usr/bin/env bash -# claude-think - Claude Code launcher that restores extended-thinking summaries -# on Opus 4.7 / 4.8, where the "Thinking" section otherwise renders empty. -# -# Thinking-only variant. To ALSO restore the always-visible context-usage icon, -# use `claudemax` (both fixes combined); for the icon fix alone, use -# `claude-context`. All three are drop-in process wrappers and differ only in -# what they inject/patch. -# -# How it works: the VS Code extension and the headless CLI build the request -# without thinking.display, so the API defaults to "omitted" and you get empty -# thinking. This wrapper injects `--thinking-display summarized` into the launch -# args (the one lever that is NOT interactivity-gated), so summaries render again -# WITHOUT editing Claude's files, so it keeps working across Claude Code -# updates. It covers the VS Code extension AND the headless CLI (`-p` / `--print` -# / SDK). The interactive terminal already honors the showThinkingSummaries -# setting and needs no injection. -# -# Use it: -# - VS Code (official "Claude Code" extension): set "claudeCode.claudeProcessWrapper" -# to the FULL path of this file, then reload the window. In a multi-root -# .code-workspace this setting is window-scoped, so put it in the workspace -# file's "settings" block (or User settings), not a folder .vscode/settings.json. -# - VS Code (third-party "Claude Code Chat"): set "claudeCodeChat.executable.path". -# - Terminal: run `claude-think` in place of `claude`. -# -# Toggle off: -# export CC_THINKING_DISPLAY=omitted -# -# Default: -# CC_THINKING_DISPLAY=summarized -# -# The real `claude` must be installed. This wrapper finds it automatically; if it -# cannot, set CLAUDE_REAL_BIN to the full path of your real claude binary. - -set -euo pipefail - -# --- Locate the real claude binary ----------------------------------------- - -self="$(readlink -f "$0" 2>/dev/null || echo "$0")" - -# Process-wrapper convention: the official VS Code extension invokes the wrapper -# as , passing the real CLI ahead of the -# args. is either a single native-binary path (".../claude") or -# a node interpreter followed by the bundled cli.js (".../node .../cli.js"). -# Peel that off so it is not forwarded as a stray positional argument, and -# prefer it as the real claude. (Plain "claude-think " use is unaffected: -# never begins with an existing claude/node binary path.) -wrapper_bin="" -if [ "$#" -gt 0 ] \ - && printf '%s' "$1" | grep -Eqi '/claude(\.exe|\.cmd|\.bat)?$' \ - && [ -e "$1" ]; then - wrapper_bin="$1" - shift -elif [ "$#" -ge 2 ] \ - && printf '%s' "$1" | grep -Eqi '/node(\.exe)?$' && [ -e "$1" ] \ - && printf '%s' "$2" | grep -Eqi '\.(c?js|mjs)$' && [ -e "$2" ]; then - # node + cli.js: exec node directly and keep cli.js as the first forwarded arg. - wrapper_bin="$1" - shift -fi - -REAL_CLAUDE="${CLAUDE_REAL_BIN:-}" -if [ -z "$REAL_CLAUDE" ] && [ -n "$wrapper_bin" ]; then - REAL_CLAUDE="$wrapper_bin" -fi - -if [ -z "$REAL_CLAUDE" ]; then - for c in \ - "$HOME/.local/bin/claude" \ - /usr/local/bin/claude \ - /usr/bin/claude \ - /opt/homebrew/bin/claude \ - "$(command -v claude 2>/dev/null || true)"; do - - [ -n "$c" ] && [ -x "$c" ] || continue - [ "$(readlink -f "$c" 2>/dev/null || echo "$c")" = "$self" ] && continue - - REAL_CLAUDE="$c" - break - done -fi - -[ -n "$REAL_CLAUDE" ] || { - echo "claude-think: could not find the real 'claude' binary; set CLAUDE_REAL_BIN" >&2 - exit 1 -} - -# --- Behavior --------------------------------------------------------------- - -# Set CC_THINKING_DISPLAY=omitted to hide thinking; default shows summaries. -DISPLAY_VALUE="${CC_THINKING_DISPLAY:-summarized}" -case "$DISPLAY_VALUE" in - summarized|omitted) ;; - *) - echo "claude-think: invalid CC_THINKING_DISPLAY=$DISPLAY_VALUE; using summarized" >&2 - DISPLAY_VALUE="summarized" - ;; -esac - -# --- Optional customizations ------------------------------------------------ -# -# Raise reasoning effort - longer, more detailed summaries. Uses more tokens: -# export CLAUDE_CODE_EFFORT_LEVEL="${CLAUDE_CODE_EFFORT_LEVEL:-xhigh}" -# -# Auto mode - let a classifier pick the effort level per task. This is an -# ALTERNATIVE to a fixed effort level above (when auto mode is on, a fixed -# CLAUDE_CODE_EFFORT_LEVEL may be ignored). Another frequently-requested feature: -# export CLAUDE_CODE_ENABLE_AUTO_MODE="${CLAUDE_CODE_ENABLE_AUTO_MODE:-1}" -# -# Longer network timeout for large requests: -# export API_TIMEOUT_MS="${API_TIMEOUT_MS:-600000}" - -# --- Inject the thinking-display fix into the launch args ------------------- -# -# Fire on a real agent invocation. Surfaces signal a real run differently: -# - the VS Code extension passes "--max-thinking-tokens N" (N > 0) plus the -# stream-json I/O flags, and does NOT pass "--thinking adaptive" or "-p"; -# - the SDK / older extensions pass "--thinking adaptive" (or "enabled"); -# - headless passes "-p" / "--print". -# -# Skip injection when: -# - thinking is explicitly disabled -# - --thinking-display is already present (no double-inject vs a patched extension) -# - CC_THINKING_DISPLAY=omitted -# - the command is a subcommand/probe such as mcp, config, or --version, -# which carries none of these markers - -args=("$@") -have_display=false -thinking_adaptive=false -thinking_disabled=false -print_mode=false -max_thinking_on=false -prev="" - -for a in "$@"; do - case "$a" in - --thinking-display|--thinking-display=*) - have_display=true - ;; - --thinking=adaptive|--thinking=enabled) - thinking_adaptive=true - ;; - --thinking=disabled) - thinking_disabled=true - ;; - --max-thinking-tokens=*) - v="${a#*=}" - if [ -n "$v" ] && [ "$v" != "0" ]; then - max_thinking_on=true - fi - ;; - -p|--print) - print_mode=true - ;; - esac - - if [ "$prev" = "--thinking" ]; then - case "$a" in - adaptive|enabled) - thinking_adaptive=true - ;; - disabled) - thinking_disabled=true - ;; - esac - fi - - if [ "$prev" = "--max-thinking-tokens" ] && [ "$a" != "0" ]; then - max_thinking_on=true - fi - - prev="$a" -done - -if [ "$have_display" = false ] \ - && [ "$thinking_disabled" = false ] \ - && [ "$DISPLAY_VALUE" != "omitted" ] \ - && { [ "$thinking_adaptive" = true ] || [ "$print_mode" = true ] || [ "$max_thinking_on" = true ]; }; then - args+=(--thinking-display "$DISPLAY_VALUE") -fi - -# The `${args[@]+...}` form guards the empty-array case under `set -u`, -# including older Bash versions such as the default Bash on older macOS systems. -exec "$REAL_CLAUDE" ${args[@]+"${args[@]}"} diff --git a/claude-think.win.js b/claude-think.win.js deleted file mode 100644 index bf99748..0000000 --- a/claude-think.win.js +++ /dev/null @@ -1,284 +0,0 @@ -// claude-think.win.js - Windows launcher for Claude Code that restores -// extended-thinking summaries on Opus 4.7 / 4.8, where the "Thinking" section -// otherwise renders empty. -// -// Thinking-only variant. To ALSO restore the always-visible context-usage icon, -// use claudemax (both fixes combined); for the icon fix alone, use -// claude-context. All three are drop-in process wrappers and differ only in what -// they inject/patch. -// -// How it works: the VS Code extension and the headless CLI build the request -// without thinking.display, so the API defaults to "omitted" and you get empty -// thinking. This wrapper injects `--thinking-display summarized` into the launch -// args (the one lever that is NOT interactivity-gated), so summaries render again -// WITHOUT editing Claude's files, so it keeps working across Claude Code updates. -// It covers the VS Code extension AND the headless CLI (`-p` / `--print` / SDK). -// The interactive terminal already honors the showThinkingSummaries setting and -// needs no injection. -// -// Use it: set the official "Claude Code" extension's "claudeCode.claudeProcessWrapper" -// setting (or the third-party "Claude Code Chat" extension's -// "claudeCodeChat.executable.path") to claude-think.exe and reload the window, or -// run claude-think.exe in place of claude in a terminal. In a multi-root -// .code-workspace, claudeProcessWrapper is window-scoped: put it in the -// workspace file's "settings" block (or User settings), not a folder's -// .vscode/settings.json. -// -// Toggle off: set CC_THINKING_DISPLAY=omitted (default is summarized). -// -// The real `claude` must be installed. This wrapper finds it automatically -// (native install `claude.exe` or npm `claude.cmd`); if it cannot, set the -// CLAUDE_REAL_BIN environment variable to the full path of your real claude. -// -// Build to a standalone .exe with vercel/pkg: -// npm i -g pkg -// pkg claude-think.win.js --targets node18-win-x64 --output claude-think.exe - -const { execFileSync, spawnSync } = require("child_process"); -const fs = require("fs"); -const path = require("path"); - -// --- Locate the real claude (native claude.exe or npm claude.cmd) ---------- -function findClaude() { - if (process.env.CLAUDE_REAL_BIN && fs.existsSync(process.env.CLAUDE_REAL_BIN)) { - return process.env.CLAUDE_REAL_BIN; - } - const home = process.env.USERPROFILE || process.env.HOME || ""; - const appdata = process.env.APPDATA || ""; - const candidates = [ - path.join(home, ".local", "bin", "claude.exe"), - path.join(home, ".local", "bin", "claude.cmd"), - appdata && path.join(appdata, "npm", "claude.cmd"), - appdata && path.join(appdata, "npm", "claude.exe"), - ].filter(Boolean); - for (const c of candidates) { - if (fs.existsSync(c)) return c; - } - // Fall back to a PATH lookup via `where`, skipping our own executable. - try { - const out = execFileSync("where", ["claude"], { encoding: "utf8" }); - const self = path.resolve(process.execPath); - const hit = out - .split(/\r?\n/) - .map((s) => s.trim()) - .find( - (s) => - s && - /\.(exe|cmd|bat)$/i.test(s) && - fs.existsSync(s) && - path.resolve(s) !== self - ); - if (hit) return hit; - } catch (_) { - /* claude not on PATH */ - } - return null; -} - -function findExecutableOnPath(name) { - const lookup = process.platform === "win32" ? "where" : "which"; - try { - const out = execFileSync(lookup, [name], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }); - const hit = out - .split(/\r?\n/) - .map((s) => s.trim()) - .find((s) => s && fs.existsSync(s)); - if (hit) return hit; - } catch (_) { - /* not on PATH */ - } - return null; -} - -function expandShimPath(raw, shimDir) { - let s = raw.trim().replace(/^["']|["']$/g, ""); - s = s.replace(/%~?dp0%?/gi, shimDir + path.sep); - s = s.replace(/%([^%]+)%/g, (m, name) => process.env[name] || m); - return path.isAbsolute(s) ? s : path.resolve(shimDir, s); -} - -function resolveShimEntrypoint(shim) { - const shimDir = path.dirname(path.resolve(shim)); - const candidates = [ - path.join(shimDir, "node_modules", "@anthropic-ai", "claude-code", "cli.js"), - path.resolve(shimDir, "..", "@anthropic-ai", "claude-code", "cli.js"), - path.resolve( - shimDir, - "..", - "node_modules", - "@anthropic-ai", - "claude-code", - "cli.js" - ), - ]; - for (const c of candidates) { - if (fs.existsSync(c)) return c; - } - try { - const data = fs.readFileSync(shim, "utf8"); - const matches = data.matchAll( - /(?:"([^"]+?\.js)"|'([^']+?\.js)'|([^\s"']+?\.js))/gi - ); - for (const m of matches) { - const hit = expandShimPath(m[1] || m[2] || m[3], shimDir); - if (fs.existsSync(hit)) return hit; - } - } catch (_) { - /* unreadable shim */ - } - return null; -} - -function resolveNodeForShim(shim) { - const shimDir = path.dirname(path.resolve(shim)); - const candidates = [ - process.env.CC_NODE_BIN, - path.join(shimDir, "node.exe"), - path.join(shimDir, "node"), - process.pkg ? null : process.execPath, - findExecutableOnPath("node"), - ].filter(Boolean); - for (const c of candidates) { - if (fs.existsSync(c)) return c; - } - return null; -} - -function resolveClaudeInvocation(command, args) { - if (!/\.(cmd|bat)$/i.test(command)) return { command, args }; - const cli = resolveShimEntrypoint(command); - const node = resolveNodeForShim(command); - if (cli && node) return { command: node, args: [cli, ...args] }; - console.error( - "claude-think: refusing to launch unresolved .cmd/.bat shim without a shell; set CLAUDE_REAL_BIN to claude.exe or CC_NODE_BIN to node.exe" - ); - return null; -} - -function normalizeDisplayValue(value) { - if (value === "summarized" || value === "omitted") return value; - console.error( - `claude-think: invalid CC_THINKING_DISPLAY=${value}; using summarized` - ); - return "summarized"; -} - -// Process-wrapper convention: the official VS Code extension invokes the wrapper -// as , passing the real CLI ahead of the -// args. is either a single native-binary path (".../claude.exe") -// or a node interpreter followed by the bundled cli.js (".../node .../cli.js"). -// Peel that off so it is not forwarded as a stray positional argument, and -// prefer it as the real claude. (The third-party claudeCodeChat "executable.path" -// mode calls with no leading binary, which falls through.) -const rawArgs = process.argv.slice(2); -let wrapperBin = null; -let argv = rawArgs; -if ( - rawArgs.length && - /[\\/]claude(\.exe|\.cmd|\.bat)?$/i.test(rawArgs[0]) && - fs.existsSync(rawArgs[0]) -) { - wrapperBin = rawArgs[0]; - argv = rawArgs.slice(1); -} else if ( - rawArgs.length >= 2 && - /[\\/]node(\.exe)?$/i.test(rawArgs[0]) && - fs.existsSync(rawArgs[0]) && - /\.(c?js|mjs)$/i.test(rawArgs[1]) && - fs.existsSync(rawArgs[1]) -) { - // node + cli.js: exec node directly, keep cli.js as the first forwarded arg. - wrapperBin = rawArgs[0]; - argv = rawArgs.slice(1); -} - -// Resolve the real claude: explicit override wins, then the extension-provided -// path, then autodetection. -const claude = - process.env.CLAUDE_REAL_BIN && fs.existsSync(process.env.CLAUDE_REAL_BIN) - ? process.env.CLAUDE_REAL_BIN - : wrapperBin || findClaude(); -if (!claude) { - console.error( - "claude-think: could not find the real 'claude' binary; set CLAUDE_REAL_BIN" - ); - process.exit(1); -} - -// --- Behavior -------------------------------------------------------------- -// Set CC_THINKING_DISPLAY=omitted to hide thinking; default shows summaries. -const displayValue = normalizeDisplayValue( - process.env.CC_THINKING_DISPLAY || "summarized" -); - -// --- Optional customizations ----------------------------------------------- -// -// Raise reasoning effort - longer, more detailed summaries. Uses more tokens: -// if (!process.env.CLAUDE_CODE_EFFORT_LEVEL) process.env.CLAUDE_CODE_EFFORT_LEVEL = "xhigh"; -// -// Auto mode - let a classifier pick the effort level per task. This is an -// ALTERNATIVE to a fixed effort level above (when auto mode is on, a fixed -// CLAUDE_CODE_EFFORT_LEVEL may be ignored). Another frequently-requested feature: -// if (!process.env.CLAUDE_CODE_ENABLE_AUTO_MODE) process.env.CLAUDE_CODE_ENABLE_AUTO_MODE = "1"; -// -// Longer network timeout for large requests: -// if (!process.env.API_TIMEOUT_MS) process.env.API_TIMEOUT_MS = "600000"; - -// --- Inject the thinking-display fix into the launch args ------------------- -// Fire on a real agent invocation. Surfaces signal a real run differently: -// - the VS Code extension passes "--max-thinking-tokens N" (N > 0) plus the -// stream-json I/O flags, and does NOT pass "--thinking adaptive" or "-p"; -// - the SDK / older extensions pass "--thinking adaptive" (or "enabled"); -// - headless passes "-p" / "--print". -// Skip when thinking is disabled, when --thinking-display is already present -// (no double-inject vs a patched extension), or for subcommands/probes -// (mcp, config, --version, ...), which carry none of these markers. -let haveDisplay = false, - thinkingAdaptive = false, - thinkingDisabled = false, - printMode = false, - maxThinkingOn = false; -for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (a === "--thinking-display" || a.startsWith("--thinking-display=")) { - haveDisplay = true; - } - if (a === "-p" || a === "--print") printMode = true; - if (a === "--thinking=adaptive" || a === "--thinking=enabled") { - thinkingAdaptive = true; - } - if (a === "--thinking=disabled") thinkingDisabled = true; - if (a.startsWith("--max-thinking-tokens=")) { - const v = a.slice("--max-thinking-tokens=".length); - if (v && v !== "0") maxThinkingOn = true; - } - if (a === "--max-thinking-tokens") { - const v = argv[i + 1]; - if (v && v !== "0") maxThinkingOn = true; - } - if (argv[i - 1] === "--thinking") { - if (a === "adaptive" || a === "enabled") thinkingAdaptive = true; - if (a === "disabled") thinkingDisabled = true; - } -} -const args = argv.slice(); -if ( - !haveDisplay && - !thinkingDisabled && - displayValue !== "omitted" && - (thinkingAdaptive || printMode || maxThinkingOn) -) { - args.push("--thinking-display", displayValue); -} - -const invocation = resolveClaudeInvocation(claude, args); -if (!invocation) process.exit(1); -const res = spawnSync(invocation.command, invocation.args, { - stdio: "inherit", - env: process.env, - shell: false, -}); -process.exit(res.status == null ? 1 : res.status); diff --git a/claudemax b/launcher/claudemax similarity index 100% rename from claudemax rename to launcher/claudemax diff --git a/claudemax.win.js b/launcher/claudemax.win.js similarity index 100% rename from claudemax.win.js rename to launcher/claudemax.win.js diff --git a/tests/test_regressions.py b/tests/test_regressions.py index f72f12a..55fe6c8 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -71,7 +71,7 @@ def captured_args(path): class LauncherRegressionTests(unittest.TestCase): @unittest.skipIf(os.name == "nt", "POSIX Bash launcher test") def test_bash_thinking_launchers_parse_equals_flags_and_validate_display(self): - for launcher in ("claude-think", "claudemax"): + for launcher in ("claudemax",): with self.subTest(launcher=launcher): with tempfile.TemporaryDirectory() as td: fake, capture = make_fake_claude(td) @@ -81,7 +81,7 @@ def test_bash_thinking_launchers_parse_equals_flags_and_validate_display(self): "CC_PATCH_CONTEXT_ICON": "0", } - res = run([str(REPO / launcher), "--thinking=adaptive"], env=env) + res = run([str(REPO / "launcher" / launcher), "--thinking=adaptive"], env=env) self.assertEqual(res.returncode, 0, res.stderr) self.assertEqual( captured_args(capture), @@ -90,7 +90,7 @@ def test_bash_thinking_launchers_parse_equals_flags_and_validate_display(self): capture.unlink() res = run( - [str(REPO / launcher), "--max-thinking-tokens=123"], + [str(REPO / "launcher" / launcher), "--max-thinking-tokens=123"], env=env, ) self.assertEqual(res.returncode, 0, res.stderr) @@ -106,7 +106,7 @@ def test_bash_thinking_launchers_parse_equals_flags_and_validate_display(self): capture.unlink() res = run( [ - str(REPO / launcher), + str(REPO / "launcher" / launcher), "--thinking", "adaptive", "--thinking-display=omitted", @@ -123,7 +123,7 @@ def test_bash_thinking_launchers_parse_equals_flags_and_validate_display(self): bad_env = dict(env) bad_env["CC_THINKING_DISPLAY"] = "bogus" res = run( - [str(REPO / launcher), "--thinking=adaptive"], + [str(REPO / "launcher" / launcher), "--thinking=adaptive"], env=bad_env, ) self.assertEqual(res.returncode, 0, res.stderr) @@ -137,7 +137,7 @@ def test_bash_thinking_launchers_parse_equals_flags_and_validate_display(self): # when a trigger like --print is present. capture.unlink() res = run( - [str(REPO / launcher), "--print", "--thinking=disabled"], + [str(REPO / "launcher" / launcher), "--print", "--thinking=disabled"], env=env, ) self.assertEqual(res.returncode, 0, res.stderr) @@ -147,7 +147,7 @@ def test_bash_thinking_launchers_parse_equals_flags_and_validate_display(self): ) def test_windows_thinking_launchers_resolve_cmd_shims_without_shell(self): - for launcher in ("claude-think.win.js", "claudemax.win.js"): + for launcher in ("claudemax.win.js",): with self.subTest(launcher=launcher): with tempfile.TemporaryDirectory() as td: cli, capture = make_fake_node_cli(td) @@ -161,7 +161,7 @@ def test_windows_thinking_launchers_resolve_cmd_shims_without_shell(self): res = run( [ "node", - str(REPO / launcher), + str(REPO / "launcher" / launcher), "--thinking=adaptive", "literal&arg", "%PATH%", @@ -186,7 +186,7 @@ def test_windows_thinking_launchers_resolve_cmd_shims_without_shell(self): @unittest.skipIf(os.name == "nt", "POSIX Bash launcher test") def test_bash_context_icon_launchers_skip_ambiguous_files(self): - launchers = ("claude-context", "claudemax") + launchers = ("claudemax",) for launcher in launchers: with self.subTest(launcher=launcher): with tempfile.TemporaryDirectory() as td: @@ -205,7 +205,7 @@ def test_bash_context_icon_launchers_skip_ambiguous_files(self): index.write_text(original, encoding="utf-8") res = run( - [str(REPO / launcher)], + [str(REPO / "launcher" / launcher)], env={ "HOME": str(temp), "CLAUDE_REAL_BIN": str(fake), @@ -217,7 +217,7 @@ def test_bash_context_icon_launchers_skip_ambiguous_files(self): @unittest.skipIf(os.name == "nt", "POSIX Bash launcher test") def test_bash_context_icon_launchers_patch_single_match(self): - for launcher in ("claude-context", "claudemax"): + for launcher in ("claudemax",): with self.subTest(launcher=launcher): with tempfile.TemporaryDirectory() as td: temp = pathlib.Path(td) @@ -235,7 +235,7 @@ def test_bash_context_icon_launchers_patch_single_match(self): index.chmod(0o640) res = run( - [str(REPO / launcher)], + [str(REPO / "launcher" / launcher)], env={ "HOME": str(temp), "CLAUDE_REAL_BIN": str(fake), @@ -251,7 +251,7 @@ def test_bash_context_icon_launchers_patch_single_match(self): self.assertEqual(stat.S_IMODE(index.stat().st_mode), 0o640) def test_windows_context_icon_launchers_skip_ambiguous_files(self): - win_launchers = ("claude-context.win.js", "claudemax.win.js") + win_launchers = ("claudemax.win.js",) for launcher in win_launchers: with self.subTest(launcher=launcher): with tempfile.TemporaryDirectory() as td: @@ -271,7 +271,7 @@ def test_windows_context_icon_launchers_skip_ambiguous_files(self): index.write_text(original, encoding="utf-8") res = run( - ["node", str(REPO / launcher)], + ["node", str(REPO / "launcher" / launcher)], env={ "HOME": str(temp), "USERPROFILE": str(temp), @@ -283,7 +283,7 @@ def test_windows_context_icon_launchers_skip_ambiguous_files(self): self.assertEqual(index.read_text(encoding="utf-8"), original) def test_windows_context_icon_launchers_patch_single_match(self): - for launcher in ("claude-context.win.js", "claudemax.win.js"): + for launcher in ("claudemax.win.js",): with self.subTest(launcher=launcher): with tempfile.TemporaryDirectory() as td: temp = pathlib.Path(td) @@ -301,7 +301,7 @@ def test_windows_context_icon_launchers_patch_single_match(self): index.write_text(f"before {OLD_ICON} after", encoding="utf-8") res = run( - ["node", str(REPO / launcher)], + ["node", str(REPO / "launcher" / launcher)], env={ "HOME": str(temp), "USERPROFILE": str(temp), From c1baf85d41ae27ad8aa510e503628f661934ada9 Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 2/9] refactor: move standalone fix tools into fixes// folders Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 8 ++++---- .../context-icon/fix-context-icon.py | 0 .../thinking-summaries/patch-extension.sh | 0 proxy.js => fixes/thinking-summaries/proxy.js | 0 .../thinking-summaries/test-thinking-display.sh | 0 tests/test_regressions.py | 10 +++++----- 6 files changed, 9 insertions(+), 9 deletions(-) rename fix-context-icon.py => fixes/context-icon/fix-context-icon.py (100%) rename patch-extension.sh => fixes/thinking-summaries/patch-extension.sh (100%) rename proxy.js => fixes/thinking-summaries/proxy.js (100%) rename test-thinking-display.sh => fixes/thinking-summaries/test-thinking-display.sh (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ca70e6..5298ec4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,22 +22,22 @@ jobs: node-version: "20" - name: Bash syntax check (bash -n) - run: bash -n launcher/claudemax patch-extension.sh test-thinking-display.sh + run: bash -n launcher/claudemax fixes/thinking-summaries/patch-extension.sh fixes/thinking-summaries/test-thinking-display.sh - name: ShellCheck # ubuntu-latest ships shellcheck. Gate at warning severity: the only # findings are info-level SC2015 (A && B || C) notes in the launchers, # where the C branch firing is the intended best-effort behavior. - run: shellcheck --severity=warning launcher/claudemax patch-extension.sh test-thinking-display.sh + run: shellcheck --severity=warning launcher/claudemax fixes/thinking-summaries/patch-extension.sh fixes/thinking-summaries/test-thinking-display.sh - name: Node syntax check (node --check) run: | - for f in launcher/claudemax.win.js proxy.js; do + for f in launcher/claudemax.win.js fixes/thinking-summaries/proxy.js; do node --check "$f" done - name: Python compile - run: python3 -m py_compile fix-context-icon.py tests/test_regressions.py + run: python3 -m py_compile fixes/context-icon/fix-context-icon.py tests/test_regressions.py - name: Regression tests run: python3 -m unittest discover -s tests -v diff --git a/fix-context-icon.py b/fixes/context-icon/fix-context-icon.py similarity index 100% rename from fix-context-icon.py rename to fixes/context-icon/fix-context-icon.py diff --git a/patch-extension.sh b/fixes/thinking-summaries/patch-extension.sh similarity index 100% rename from patch-extension.sh rename to fixes/thinking-summaries/patch-extension.sh diff --git a/proxy.js b/fixes/thinking-summaries/proxy.js similarity index 100% rename from proxy.js rename to fixes/thinking-summaries/proxy.js diff --git a/test-thinking-display.sh b/fixes/thinking-summaries/test-thinking-display.sh similarity index 100% rename from test-thinking-display.sh rename to fixes/thinking-summaries/test-thinking-display.sh diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 55fe6c8..9c48f6e 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -322,7 +322,7 @@ def test_proxy_exports_header_filters_that_strip_hop_by_hop_headers(self): script = textwrap.dedent( """ const assert = require('assert'); - const { headersForUpstream, headersForClient } = require('./proxy.js'); + const { headersForUpstream, headersForClient } = require('./fixes/thinking-summaries/proxy.js'); const inbound = { host: '127.0.0.1:8788', connection: 'keep-alive, x-remove-me', @@ -355,13 +355,13 @@ def test_proxy_exports_header_filters_that_strip_hop_by_hop_headers(self): class PatcherRegressionTests(unittest.TestCase): def test_fix_context_icon_atomic_replace_preserves_metadata_and_docs_limitation(self): - source = (REPO / "fix-context-icon.py").read_text(encoding="utf-8") + source = (REPO / "fixes" / "context-icon" / "fix-context-icon.py").read_text(encoding="utf-8") self.assertIn("os.replace", source) self.assertIn("copystat", source) self.assertIn("transient 0%", source) spec = importlib.util.spec_from_file_location( - "fix_context_icon", REPO / "fix-context-icon.py" + "fix_context_icon", REPO / "fixes" / "context-icon" / "fix-context-icon.py" ) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) @@ -392,12 +392,12 @@ def test_fix_context_icon_atomic_replace_preserves_metadata_and_docs_limitation( self.assertTrue((pathlib.Path(str(target) + mod.BACKUP_SUFFIX)).exists()) def test_patch_extension_avoids_bash4_mapfile(self): - source = (REPO / "patch-extension.sh").read_text(encoding="utf-8") + source = (REPO / "fixes" / "thinking-summaries" / "patch-extension.sh").read_text(encoding="utf-8") self.assertNotIn("mapfile", source) self.assertIn("while IFS= read -r", source) def test_live_ab_script_uses_temp_files_and_optional_timeout(self): - source = (REPO / "test-thinking-display.sh").read_text(encoding="utf-8") + source = (REPO / "fixes" / "thinking-summaries" / "test-thinking-display.sh").read_text(encoding="utf-8") self.assertIn("mktemp", source) self.assertIn("trap", source) self.assertNotIn("/tmp/cc_t_a.jsonl", source) From 85181669f541e4fadfd801ff8d9cdd5b91bd3df9 Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 3/9] feat(launcher): bash feature registry + per-file reconcile with ownership marker Replaces the single hard-coded context-icon sed with a generic registry whose bundle-patch features register idempotent, ownership-marked apply/undo pairs. Per-file reconcile undoes all known features (reverse) then re-applies enabled ones (forward), writing only on change. Adds CC_WORKAROUNDS master switch and CC_RECONCILE emergency bypass. New tests/test_reconcile.py covers the matrix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 2 +- launcher/claudemax | 176 ++++++++++++++++++++++++++----------- tests/test_reconcile.py | 177 ++++++++++++++++++++++++++++++++++++++ tests/test_regressions.py | 66 -------------- 4 files changed, 305 insertions(+), 116 deletions(-) create mode 100644 tests/test_reconcile.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5298ec4..f9e882d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: done - name: Python compile - run: python3 -m py_compile fixes/context-icon/fix-context-icon.py tests/test_regressions.py + run: python3 -m py_compile fixes/context-icon/fix-context-icon.py tests/test_regressions.py tests/test_reconcile.py - name: Regression tests run: python3 -m unittest discover -s tests -v diff --git a/launcher/claudemax b/launcher/claudemax index be87ce0..648e010 100755 --- a/launcher/claudemax +++ b/launcher/claudemax @@ -106,6 +106,21 @@ case "$DISPLAY_VALUE" in ;; esac +# ===== FEATURE DEFAULTS (edit to taste; environment variables override) ===== +# Master switch: 0 disables every workaround (argument injection AND bundle +# patches) and reconcile reverts the webview to clean on this launch. When 1, +# the per-feature toggles below govern. +CC_WORKAROUNDS="${CC_WORKAROUNDS:-1}" +# Emergency bundle bypass: 0 means do NOT read or write the webview bundle at all +# this launch (argument injection is unaffected). Leaves any existing patches in +# place without uninstalling. +CC_RECONCILE="${CC_RECONCILE:-1}" +# context-icon bundle patch: 0 leaves the webview's context-usage icon unpatched. +CC_PATCH_CONTEXT_ICON="${CC_PATCH_CONTEXT_ICON:-1}" +# (CC_THINKING_DISPLAY is handled above as DISPLAY_VALUE: summarized | omitted.) +# (CC_PATCH_MD_COPY is added by the markdown-copy-export fix in a later PR.) +# ============================================================================ + # --- Optional customizations ------------------------------------------------ # # Raise reasoning effort - longer, more detailed summaries. Uses more tokens: @@ -182,86 +197,149 @@ for a in "$@"; do prev="$a" done -if [ "$have_display" = false ] \ +if [ "$CC_WORKAROUNDS" != "0" ] \ + && [ "$have_display" = false ] \ && [ "$thinking_disabled" = false ] \ && [ "$DISPLAY_VALUE" != "omitted" ] \ && { [ "$thinking_adaptive" = true ] || [ "$print_mode" = true ] || [ "$max_thinking_on" = true ]; }; then args+=(--thinking-display "$DISPLAY_VALUE") fi -# --- Restore the always-visible context-usage icon (patches the webview) ---- +# --- Reconcile the webview bundle: apply enabled bundle-patch features, undo +# disabled ones, PER FILE, every launch --------------------------------- +# +# Generic engine (replaces the single hard-coded context-icon sed). Each +# bundle-patch feature registers, per target file, an idempotent + reversible +# (apply, undo) pair. Every applied edit carries an ownership MARKER, and undo +# keys off the MARKED form only, so the launcher reverses ONLY its own edits and +# never touches upstream code that merely resembles a patched value. # -# Unlike the thinking-display fix above, this one DOES edit the extension's -# bundled webview - there is no env/CLI lever for it. It is best-effort and must -# never block the launch: every step is guarded, writes go through a temp file -# with an atomic rename (a failed write leaves the original untouched), and the -# whole thing runs under `|| true`. +# Per-file reconcile (see TECHNICAL.md "patch composition"): +# C = current bytes with every KNOWN feature's undo applied in REVERSE order +# (the pristine bundle, regardless of which of our patches were present) +# D = C with every ENABLED feature's apply applied in FORWARD order +# write D only when it differs from the current bytes (idempotent) # -# What it changes - component `FJe` in webview/index.js: -# if(c>=50)return null -> if(c>=101)return null -# `c` is "% of context remaining"; it maxes at 100, so >=101 never fires and the -# icon renders whenever a context window is known (the t===0 "no session yet" -# guard is left intact). Idempotent, and re-applied each launch, so an extension -# update that reinstalls a fresh bundle is re-patched on the next launch. +# Best-effort: every step is guarded and the whole pass runs under `|| true`, so +# it can never block the launch. Writes go through a metadata-preserving temp +# (`cp -p`, portable - the GNU-only `--reference` is avoided so this also works +# on macOS/BSD) and an atomic `mv -f`; a failed step leaves the original +# untouched. # -# Maintenance note: the patch keys off the stable string `>=50)return null}`, not -# the minified component name. If a future extension build changes that exact -# substring, the routine safely no-ops (the icon goes missing again) until the -# anchor here is updated. +# context-icon feature - component `FJe` in webview/index.js: +# if(c>=50)return null -> if(c>=101)return null}/*ccwa-context-icon*/ +# `c` is "% of context remaining" (maxes at 100), so >=101 never fires and the +# icon renders whenever a context window is known; the t===0 "no session yet" +# guard is left intact. The trailing /*ccwa-context-icon*/ is our ownership +# marker. Maintenance: this keys off the stable string ">=50)return null}", not +# the minified component name; if a future build changes that substring, apply +# no-ops loudly (a one-line warning) until the anchor here is updated. -CC_PATCH_CONTEXT_ICON="${CC_PATCH_CONTEXT_ICON:-1}" +# A bundle feature is enabled when the master switch is on AND its own toggle is +# on. CC_WORKAROUNDS=0 forces every feature off, so reconcile reverts to clean. +_cc_feature_enabled() { + [ "$CC_WORKAROUNDS" != "0" ] || return 1 + case "$1" in + context-icon) [ "$CC_PATCH_CONTEXT_ICON" != "0" ] ;; + *) return 1 ;; + esac +} + +# apply/undo operate on a path in place. Each is a no-op when its target state is +# already present/absent, so chaining them is safe and idempotent. +_cc_apply_context_icon() { + local f="$1" tmp count + if grep -q '/\*ccwa-context-icon\*/' "$f" 2>/dev/null; then return 0; fi # already marked + count="$( (grep -o '>=50)return null}' "$f" 2>/dev/null || true) | wc -l | tr -d ' ')" + if [ "$count" = "0" ]; then + echo "claudemax: context-icon anchor not found in $f (extension changed?); skipping" >&2 + return 0 + fi + if [ "$count" != "1" ]; then return 0; fi # ambiguous (version changed) - skip + tmp="${f}.ccapply.$$" + if sed 's#>=50)return null}#>=101)return null}/*ccwa-context-icon*/#' "$f" > "$tmp" 2>/dev/null \ + && [ -s "$tmp" ] && grep -q '/\*ccwa-context-icon\*/' "$tmp" 2>/dev/null; then + cat "$tmp" > "$f" 2>/dev/null || true + fi + rm -f "$tmp" 2>/dev/null || true +} + +_cc_undo_context_icon() { + local f="$1" tmp + if ! grep -q '/\*ccwa-context-icon\*/' "$f" 2>/dev/null; then return 0; fi # nothing of ours + tmp="${f}.ccundo.$$" + if sed 's#>=101)return null}/\*ccwa-context-icon\*/#>=50)return null}#' "$f" > "$tmp" 2>/dev/null \ + && [ -s "$tmp" ]; then + cat "$tmp" > "$f" 2>/dev/null || true + fi + rm -f "$tmp" 2>/dev/null || true +} + +# Reconcile one target file. Registry (forward apply order): context-icon. +# md-copy appends its undo/apply calls here in its own PR. +_cc_reconcile_index_js() { + local f="$1" base patched tmpmeta + [ -f "$f" ] && [ -r "$f" ] || return 0 + + base="${f}.ccbase.$$" + patched="${f}.ccnew.$$" + + # Clean base C = current with every KNOWN feature undone, REVERSE order. + cp "$f" "$base" 2>/dev/null || { rm -f "$base" 2>/dev/null || true; return 0; } + _cc_undo_context_icon "$base" + + # Desired D = C with every ENABLED feature applied, FORWARD order. + cp "$base" "$patched" 2>/dev/null || { rm -f "$base" "$patched" 2>/dev/null || true; return 0; } + if _cc_feature_enabled context-icon; then _cc_apply_context_icon "$patched"; fi -_cc_patch_index_js() { - local f="$1" tmp tmp2 old_count - [ -f "$f" ] && [ -w "$f" ] || return 0 - if grep -q '>=101)return null}' "$f" 2>/dev/null; then return 0; fi # already patched - old_count="$( (grep -o '>=50)return null}' "$f" 2>/dev/null || true) | wc -l | tr -d ' ')" - if [ "$old_count" != "1" ]; then return 0; fi # absent or ambiguous (version changed) - if [ ! -e "$f.bak-context-icon" ]; then cp -p "$f" "$f.bak-context-icon" 2>/dev/null || true; fi - tmp="${f}.ccpatch.$$" - tmp2="${f}.ccpatch2.$$" - # `cp -p` preserves mode/owner portably (the GNU-only `--reference` is avoided - # so this also works on macOS/BSD): copy the original to a temp, sed into a - # second temp, copy that back over the metadata-preserving temp, then atomically - # replace the original. A failed/partial step leaves the original untouched. - cp -p "$f" "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null || true; return 0; } - if sed 's/>=50)return null}/>=101)return null}/' "$f" > "$tmp2" 2>/dev/null \ - && [ -s "$tmp2" ] \ - && grep -q '>=101)return null}' "$tmp2" 2>/dev/null; then - cat "$tmp2" > "$tmp" && mv -f "$tmp" "$f" || true + # No change -> done (the common idempotent case). + if cmp -s "$patched" "$f"; then rm -f "$base" "$patched" 2>/dev/null || true; return 0; fi + + # One-time pristine snapshot (C) for EMERGENCY manual restore only; routine + # reconcile never reads it. Carry the live file's mode/owner onto it. + if [ ! -e "${f}.bak-cc-workarounds" ]; then + if cp -p "$f" "${f}.bak-cc-workarounds" 2>/dev/null; then + cat "$base" > "${f}.bak-cc-workarounds" 2>/dev/null || true + fi + fi + + # Atomic write of D, preserving mode/owner via a cp -p temp + mv -f. + tmpmeta="${f}.ccwrite.$$" + if cp -p "$f" "$tmpmeta" 2>/dev/null && cat "$patched" > "$tmpmeta" 2>/dev/null; then + mv -f "$tmpmeta" "$f" 2>/dev/null || rm -f "$tmpmeta" 2>/dev/null || true + else + rm -f "$tmpmeta" 2>/dev/null || true fi - rm -f "$tmp" "$tmp2" 2>/dev/null || true + rm -f "$base" "$patched" 2>/dev/null || true } -_cc_restore_context_icon() { +_cc_reconcile() { + [ "$CC_RECONCILE" != "0" ] || return 0 # emergency bypass: touch nothing local d extdir f - # Most precise target: walk up from the real claude path the extension handed - # us (its bundled resources/native-binary/claude) to the extension root. + # Most precise target: walk up from REAL_CLAUDE to the extension root. d="$(dirname "$REAL_CLAUDE" 2>/dev/null || echo "")" extdir="" while [ -n "$d" ] && [ "$d" != "/" ] && [ "$d" != "." ]; do - case "${d##*/}" in - anthropic.claude-code-*) extdir="$d"; break ;; - esac + case "${d##*/}" in anthropic.claude-code-*) extdir="$d"; break ;; esac d="$(dirname "$d" 2>/dev/null || echo "")" done - if [ -n "$extdir" ]; then _cc_patch_index_js "$extdir/webview/index.js"; fi + if [ -n "$extdir" ]; then _cc_reconcile_index_js "$extdir/webview/index.js"; fi # Also cover any installed extension under this user's VS Code dirs (terminal # launches, or when the real binary is the standalone CLI). Unmatched globs - # fall through harmlessly - _cc_patch_index_js skips non-files. + # fall through harmlessly - _cc_reconcile_index_js skips non-files. for f in \ "$HOME"/.vscode/extensions/anthropic.claude-code-*/webview/index.js \ "$HOME"/.vscode-insiders/extensions/anthropic.claude-code-*/webview/index.js \ "$HOME"/.vscode-server/extensions/anthropic.claude-code-*/webview/index.js \ "$HOME"/.vscode-server-insiders/extensions/anthropic.claude-code-*/webview/index.js; do - _cc_patch_index_js "$f" + _cc_reconcile_index_js "$f" done } -if [ "$CC_PATCH_CONTEXT_ICON" != "0" ]; then - _cc_restore_context_icon || true -fi +# Best-effort: invoking under `|| true` suspends `set -e` for the whole pass, so +# nothing here can block the launch (matches the previous safety model). +_cc_reconcile || true # The `${args[@]+...}` form guards the empty-array case under `set -u`, # including older Bash versions such as the default Bash on older macOS systems. diff --git a/tests/test_reconcile.py b/tests/test_reconcile.py new file mode 100644 index 0000000..63836bb --- /dev/null +++ b/tests/test_reconcile.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Reconcile-engine regression tests for the unified launcher (bash + node). + +These cover the feature registry, per-file reconcile (undo-all-then-apply- +enabled), ownership marking, the master switch, the emergency bypass, and +bash/node parity. Helpers are reused from test_regressions (same tests/ dir, on +sys.path under `unittest discover`). +""" +import os +import pathlib +import stat +import tempfile +import unittest + +from test_regressions import ( + run, + make_fake_claude, + make_fake_node_cli, + make_fake_cmd_shim, + captured_args, +) + +REPO = pathlib.Path(__file__).resolve().parents[1] +LAUNCHER_BASH = REPO / "launcher" / "claudemax" +LAUNCHER_WIN = REPO / "launcher" / "claudemax.win.js" + +OLD = ">=50)return null}" +MARKER = "/*ccwa-context-icon*/" +MARKED = ">=101)return null}" + MARKER +BARE101 = ">=101)return null}" +BAK = ".bak-cc-workarounds" +STRAY_FRAGMENTS = (".ccbase.", ".ccnew.", ".ccwrite.", ".ccapply.", ".ccundo.", ".ccpatch.") + + +def make_extension(home, content): + """Create a fake installed extension webview/index.js under HOME and return it.""" + idx = ( + pathlib.Path(home) + / ".vscode" + / "extensions" + / "anthropic.claude-code-test" + / "webview" + / "index.js" + ) + idx.parent.mkdir(parents=True) + idx.write_text(content, encoding="utf-8") + return idx + + +class ReconcileMixin: + """Platform-agnostic reconcile assertions. Subclasses implement `_run`/`_captured`.""" + + def _run(self, td, home, args=None, env_extra=None): + raise NotImplementedError + + def _captured(self): + return captured_args(self._capture_path) + + def test_apply_writes_marked_form_and_pristine_backup(self): + with tempfile.TemporaryDirectory() as td: + home = pathlib.Path(td) + idx = make_extension(home, f"before {OLD} after") + res = self._run(td, home) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual(idx.read_text(encoding="utf-8"), f"before {MARKED} after") + bak = idx.with_name(idx.name + BAK) + self.assertTrue(bak.exists()) + self.assertEqual(bak.read_text(encoding="utf-8"), f"before {OLD} after") + + def test_reconcile_is_idempotent_and_leaves_no_temp_files(self): + with tempfile.TemporaryDirectory() as td: + home = pathlib.Path(td) + idx = make_extension(home, f"x {OLD} y") + self.assertEqual(self._run(td, home).returncode, 0) + first = idx.read_text(encoding="utf-8") + self.assertEqual(first, f"x {MARKED} y") + self.assertEqual(self._run(td, home).returncode, 0) + self.assertEqual(idx.read_text(encoding="utf-8"), first) + strays = [ + p.name + for p in idx.parent.iterdir() + if any(s in p.name for s in STRAY_FRAGMENTS) + ] + self.assertEqual(strays, []) + + def test_disabling_feature_reverts_only_that_feature(self): + with tempfile.TemporaryDirectory() as td: + home = pathlib.Path(td) + idx = make_extension(home, f"before {MARKED} after") + res = self._run(td, home, env_extra={"CC_PATCH_CONTEXT_ICON": "0"}) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual(idx.read_text(encoding="utf-8"), f"before {OLD} after") + + def test_master_switch_reverts_all_and_injects_nothing(self): + with tempfile.TemporaryDirectory() as td: + home = pathlib.Path(td) + idx = make_extension(home, f"before {MARKED} after") + res = self._run( + td, home, args=["--thinking=adaptive"], env_extra={"CC_WORKAROUNDS": "0"} + ) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual(idx.read_text(encoding="utf-8"), f"before {OLD} after") + self.assertEqual(self._captured(), ["--thinking=adaptive"]) + + def test_unmarked_upstream_value_is_left_untouched(self): + for env_extra in ({}, {"CC_PATCH_CONTEXT_ICON": "0"}): + with self.subTest(env=env_extra): + with tempfile.TemporaryDirectory() as td: + home = pathlib.Path(td) + original = f"keep {BARE101} this" + idx = make_extension(home, original) + res = self._run(td, home, env_extra=env_extra) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual(idx.read_text(encoding="utf-8"), original) + + def test_reconcile_bypass_leaves_bundle_untouched_but_still_injects(self): + with tempfile.TemporaryDirectory() as td: + home = pathlib.Path(td) + original = f"before {OLD} after" + idx = make_extension(home, original) + res = self._run( + td, home, args=["--thinking=adaptive"], env_extra={"CC_RECONCILE": "0"} + ) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual(idx.read_text(encoding="utf-8"), original) + self.assertEqual( + self._captured(), + ["--thinking=adaptive", "--thinking-display", "summarized"], + ) + + def test_stale_backup_is_ignored_by_routine_reconcile(self): + with tempfile.TemporaryDirectory() as td: + home = pathlib.Path(td) + idx = make_extension(home, f"before {OLD} after") + bak = idx.with_name(idx.name + BAK) + bak.write_text("GARBAGE-DO-NOT-USE", encoding="utf-8") + res = self._run(td, home) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual(idx.read_text(encoding="utf-8"), f"before {MARKED} after") + self.assertEqual(bak.read_text(encoding="utf-8"), "GARBAGE-DO-NOT-USE") + + def test_ambiguous_match_is_skipped(self): + with tempfile.TemporaryDirectory() as td: + home = pathlib.Path(td) + original = f"first {OLD} second {OLD}" + idx = make_extension(home, original) + res = self._run(td, home) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual(idx.read_text(encoding="utf-8"), original) + + +@unittest.skipIf(os.name == "nt", "POSIX bash launcher test") +class BashReconcileTests(ReconcileMixin, unittest.TestCase): + def _run(self, td, home, args=None, env_extra=None): + fake, capture = make_fake_claude(td) + self._capture_path = capture + env = { + "HOME": str(home), + "CLAUDE_REAL_BIN": str(fake), + "CAPTURE_ARGS": str(capture), + } + if env_extra: + env.update(env_extra) + return run([str(LAUNCHER_BASH), *(args or [])], env=env) + + def test_apply_preserves_file_mode(self): + with tempfile.TemporaryDirectory() as td: + home = pathlib.Path(td) + idx = make_extension(home, f"before {OLD} after") + idx.chmod(0o640) + res = self._run(td, home) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual(stat.S_IMODE(idx.stat().st_mode), 0o640) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 9c48f6e..c178a91 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -184,72 +184,6 @@ def test_windows_thinking_launchers_resolve_cmd_shims_without_shell(self): ], ) - @unittest.skipIf(os.name == "nt", "POSIX Bash launcher test") - def test_bash_context_icon_launchers_skip_ambiguous_files(self): - launchers = ("claudemax",) - for launcher in launchers: - with self.subTest(launcher=launcher): - with tempfile.TemporaryDirectory() as td: - temp = pathlib.Path(td) - fake, capture = make_fake_claude(td) - index = ( - temp - / ".vscode" - / "extensions" - / "anthropic.claude-code-test" - / "webview" - / "index.js" - ) - index.parent.mkdir(parents=True) - original = f"first {OLD_ICON} second {OLD_ICON}" - index.write_text(original, encoding="utf-8") - - res = run( - [str(REPO / "launcher" / launcher)], - env={ - "HOME": str(temp), - "CLAUDE_REAL_BIN": str(fake), - "CAPTURE_ARGS": str(capture), - }, - ) - self.assertEqual(res.returncode, 0, res.stderr) - self.assertEqual(index.read_text(encoding="utf-8"), original) - - @unittest.skipIf(os.name == "nt", "POSIX Bash launcher test") - def test_bash_context_icon_launchers_patch_single_match(self): - for launcher in ("claudemax",): - with self.subTest(launcher=launcher): - with tempfile.TemporaryDirectory() as td: - temp = pathlib.Path(td) - fake, capture = make_fake_claude(td) - index = ( - temp - / ".vscode" - / "extensions" - / "anthropic.claude-code-test" - / "webview" - / "index.js" - ) - index.parent.mkdir(parents=True) - index.write_text(f"before {OLD_ICON} after", encoding="utf-8") - index.chmod(0o640) - - res = run( - [str(REPO / "launcher" / launcher)], - env={ - "HOME": str(temp), - "CLAUDE_REAL_BIN": str(fake), - "CAPTURE_ARGS": str(capture), - }, - ) - self.assertEqual(res.returncode, 0, res.stderr) - self.assertEqual( - index.read_text(encoding="utf-8"), f"before {NEW_ICON} after" - ) - backup = index.with_name(index.name + ".bak-context-icon") - self.assertTrue(backup.exists()) - self.assertEqual(stat.S_IMODE(index.stat().st_mode), 0o640) - def test_windows_context_icon_launchers_skip_ambiguous_files(self): win_launchers = ("claudemax.win.js",) for launcher in win_launchers: From 6c90856d2200f6dc705c2c1c6205b895664abbe9 Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 4/9] feat(launcher): node feature registry + per-file reconcile (bash parity) Ports the bash reconcile engine into claudemax.win.js with the same ownership marker, master switch (CC_WORKAROUNDS), and emergency bypass (CC_RECONCILE), preserving the Windows .cmd/.bat shim resolution and discovery hardening. Co-Authored-By: Claude Opus 4.8 (1M context) --- launcher/claudemax.win.js | 78 ++++++++++++++++++++++++++++++--------- tests/test_reconcile.py | 16 ++++++++ tests/test_regressions.py | 66 --------------------------------- 3 files changed, 77 insertions(+), 83 deletions(-) diff --git a/launcher/claudemax.win.js b/launcher/claudemax.win.js index ded0b44..effb7be 100644 --- a/launcher/claudemax.win.js +++ b/launcher/claudemax.win.js @@ -232,33 +232,76 @@ if (!claude) { // minified component name. If a future build changes that exact substring, the // routine safely no-ops until the anchor here is updated. const ICON_OLD = ">=50)return null}"; -const ICON_NEW = ">=101)return null}"; +const ICON_MARKER = "/*ccwa-context-icon*/"; +const ICON_NEW = ">=101)return null}" + ICON_MARKER; -function ccPatchIndexJs(file) { +// Bundle-patch feature registry. Each feature is idempotent (apply/undo are +// no-ops when their target state already holds) and reversible; undo keys off +// the ownership MARKER only, so it reverses ONLY our own edits. Order matters: +// apply runs forward, undo runs in reverse. +function applyContextIcon(data) { + if (data.indexOf(ICON_MARKER) !== -1) return data; // already applied + const n = data.split(ICON_OLD).length - 1; + if (n === 0) { + console.error( + "claudemax: context-icon anchor not found (extension changed?); skipping" + ); + return data; + } + if (n !== 1) return data; // ambiguous (version changed) - skip + return data.replace(ICON_OLD, ICON_NEW); +} + +function undoContextIcon(data) { + if (data.indexOf(ICON_MARKER) === -1) return data; // nothing of ours + return data.split(ICON_NEW).join(ICON_OLD); +} + +function contextIconEnabled() { + if (process.env.CC_WORKAROUNDS === "0") return false; + return process.env.CC_PATCH_CONTEXT_ICON !== "0"; +} + +const BUNDLE_FEATURES = [ + { + id: "context-icon", + enabled: contextIconEnabled, + apply: applyContextIcon, + undo: undoContextIcon, + }, +]; + +// Reconcile one file: undo every known feature (reverse), re-apply enabled ones +// (forward), write only when the bytes change. Best-effort; never throws. +function reconcileIndexJs(file) { try { if (!fs.existsSync(file)) return; - let data; + let current; try { - data = fs.readFileSync(file, "utf8"); + current = fs.readFileSync(file, "utf8"); } catch (_) { return; // not readable } - if (data.indexOf(ICON_NEW) !== -1) return; // already patched - const oldMatches = data.split(ICON_OLD).length - 1; - if (oldMatches !== 1) return; // gate absent or ambiguous (version changed) - const bak = file + ".bak-context-icon"; + let base = current; + for (let i = BUNDLE_FEATURES.length - 1; i >= 0; i--) { + base = BUNDLE_FEATURES[i].undo(base); + } + let desired = base; + for (const feat of BUNDLE_FEATURES) { + if (feat.enabled()) desired = feat.apply(desired); + } + if (desired === current) return; // idempotent: nothing to write + const bak = file + ".bak-cc-workarounds"; if (!fs.existsSync(bak)) { try { - fs.writeFileSync(bak, data); + fs.writeFileSync(bak, base); // pristine snapshot, emergency-only } catch (_) { /* best-effort backup */ } } - const patched = data.replace(ICON_OLD, ICON_NEW); - if (patched.indexOf(ICON_NEW) === -1) return; // sanity: substitution took const tmp = file + ".ccpatch." + process.pid; try { - fs.writeFileSync(tmp, patched); + fs.writeFileSync(tmp, desired); fs.renameSync(tmp, file); // atomic on the same volume } catch (_) { try { @@ -316,15 +359,15 @@ function scanExtensionIndexes() { return found; } -function restoreContextIcon(binPath) { - if (process.env.CC_PATCH_CONTEXT_ICON === "0") return; +function reconcile(binPath) { + if (process.env.CC_RECONCILE === "0") return; // emergency bypass: touch nothing const targets = new Set(); if (binPath) { const root = extensionRootFromBinary(binPath); if (root) targets.add(path.join(root, "webview", "index.js")); } for (const f of scanExtensionIndexes()) targets.add(f); - for (const f of targets) ccPatchIndexJs(f); + for (const f of targets) reconcileIndexJs(f); } // --- Behavior -------------------------------------------------------------- @@ -385,6 +428,7 @@ for (let i = 0; i < argv.length; i++) { } const args = argv.slice(); if ( + process.env.CC_WORKAROUNDS !== "0" && !haveDisplay && !thinkingDisabled && displayValue !== "omitted" && @@ -393,8 +437,8 @@ if ( args.push("--thinking-display", displayValue); } -// Patch the webview before handing off (best-effort; never throws). -restoreContextIcon(wrapperBin); +// Reconcile the webview before handing off (best-effort; never throws). +reconcile(wrapperBin); const invocation = resolveClaudeInvocation(claude, args); if (!invocation) process.exit(1); diff --git a/tests/test_reconcile.py b/tests/test_reconcile.py index 63836bb..9355f60 100644 --- a/tests/test_reconcile.py +++ b/tests/test_reconcile.py @@ -173,5 +173,21 @@ def test_apply_preserves_file_mode(self): self.assertEqual(stat.S_IMODE(idx.stat().st_mode), 0o640) +class WinReconcileTests(ReconcileMixin, unittest.TestCase): + def _run(self, td, home, args=None, env_extra=None): + cli, capture = make_fake_node_cli(td) + shim = make_fake_cmd_shim(td, cli) + self._capture_path = capture + env = { + "HOME": str(home), + "USERPROFILE": str(home), + "CLAUDE_REAL_BIN": str(shim), + "CAPTURE_ARGS": str(capture), + } + if env_extra: + env.update(env_extra) + return run(["node", str(LAUNCHER_WIN), *(args or [])], env=env) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_regressions.py b/tests/test_regressions.py index c178a91..451b330 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -184,72 +184,6 @@ def test_windows_thinking_launchers_resolve_cmd_shims_without_shell(self): ], ) - def test_windows_context_icon_launchers_skip_ambiguous_files(self): - win_launchers = ("claudemax.win.js",) - for launcher in win_launchers: - with self.subTest(launcher=launcher): - with tempfile.TemporaryDirectory() as td: - temp = pathlib.Path(td) - cli, capture = make_fake_node_cli(td) - shim = make_fake_cmd_shim(td, cli) - index = ( - temp - / ".vscode" - / "extensions" - / "anthropic.claude-code-test" - / "webview" - / "index.js" - ) - index.parent.mkdir(parents=True) - original = f"first {OLD_ICON} second {OLD_ICON}" - index.write_text(original, encoding="utf-8") - - res = run( - ["node", str(REPO / "launcher" / launcher)], - env={ - "HOME": str(temp), - "USERPROFILE": str(temp), - "CLAUDE_REAL_BIN": str(shim), - "CAPTURE_ARGS": str(capture), - }, - ) - self.assertEqual(res.returncode, 0, res.stderr) - self.assertEqual(index.read_text(encoding="utf-8"), original) - - def test_windows_context_icon_launchers_patch_single_match(self): - for launcher in ("claudemax.win.js",): - with self.subTest(launcher=launcher): - with tempfile.TemporaryDirectory() as td: - temp = pathlib.Path(td) - cli, capture = make_fake_node_cli(td) - shim = make_fake_cmd_shim(td, cli) - index = ( - temp - / ".vscode" - / "extensions" - / "anthropic.claude-code-test" - / "webview" - / "index.js" - ) - index.parent.mkdir(parents=True) - index.write_text(f"before {OLD_ICON} after", encoding="utf-8") - - res = run( - ["node", str(REPO / "launcher" / launcher)], - env={ - "HOME": str(temp), - "USERPROFILE": str(temp), - "CLAUDE_REAL_BIN": str(shim), - "CAPTURE_ARGS": str(capture), - }, - ) - self.assertEqual(res.returncode, 0, res.stderr) - self.assertEqual( - index.read_text(encoding="utf-8"), f"before {NEW_ICON} after" - ) - backup = index.with_name(index.name + ".bak-context-icon") - self.assertTrue(backup.exists()) - class ProxyRegressionTests(unittest.TestCase): def test_proxy_exports_header_filters_that_strip_hop_by_hop_headers(self): From 4c1b67fb47935fd6c31d2fde82ee5b50b9cdfff2 Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 5/9] test: assert bash/node launcher parity on bundle bytes and injected args Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_reconcile.py | 42 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/test_reconcile.py b/tests/test_reconcile.py index 9355f60..8a97544 100644 --- a/tests/test_reconcile.py +++ b/tests/test_reconcile.py @@ -189,5 +189,47 @@ def _run(self, td, home, args=None, env_extra=None): return run(["node", str(LAUNCHER_WIN), *(args or [])], env=env) +@unittest.skipIf(os.name == "nt", "needs both bash and node on PATH") +class ParityTests(unittest.TestCase): + def test_bash_and_node_produce_identical_bundle_and_args(self): + results = {} + for kind in ("bash", "node"): + with tempfile.TemporaryDirectory() as td: + home = pathlib.Path(td) + idx = make_extension(home, f"before {OLD} after") + if kind == "bash": + fake, capture = make_fake_claude(td) + env = { + "HOME": str(home), + "CLAUDE_REAL_BIN": str(fake), + "CAPTURE_ARGS": str(capture), + } + res = run([str(LAUNCHER_BASH), "--max-thinking-tokens=200"], env=env) + else: + cli, capture = make_fake_node_cli(td) + shim = make_fake_cmd_shim(td, cli) + env = { + "HOME": str(home), + "USERPROFILE": str(home), + "CLAUDE_REAL_BIN": str(shim), + "CAPTURE_ARGS": str(capture), + } + res = run( + ["node", str(LAUNCHER_WIN), "--max-thinking-tokens=200"], env=env + ) + self.assertEqual(res.returncode, 0, res.stderr) + results[kind] = ( + idx.read_text(encoding="utf-8"), + captured_args(capture), + ) + self.assertEqual(results["bash"][0], results["node"][0]) + self.assertEqual(results["bash"][1], results["node"][1]) + self.assertEqual(results["bash"][0], f"before {MARKED} after") + self.assertEqual( + results["bash"][1], + ["--max-thinking-tokens=200", "--thinking-display", "summarized"], + ) + + if __name__ == "__main__": unittest.main() From c90b94ce27edbf217aba6546cb29ba9d2f99be96 Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 6/9] refactor(context-icon): standalone patcher emits the ownership-marked form Keeps fix-context-icon.py byte-consistent with the launcher, so the launcher's reconcile recognizes a standalone-patched bundle (no spurious anchor warning). --revert stays backup-based in this PR. Co-Authored-By: Claude Opus 4.8 (1M context) --- fixes/context-icon/fix-context-icon.py | 4 ++-- tests/test_regressions.py | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/fixes/context-icon/fix-context-icon.py b/fixes/context-icon/fix-context-icon.py index 8539e65..6191517 100755 --- a/fixes/context-icon/fix-context-icon.py +++ b/fixes/context-icon/fix-context-icon.py @@ -20,7 +20,7 @@ for virtually an entire normal session. This script flips the threshold so the icon is visible whenever a context window is known (t>0), at any usage level. - if(c>=50)return null -> if(c>=101)return null (c maxes at 100, so never hides) + if(c>=50)return null -> if(c>=101)return null}/*ccwa-context-icon*/ (marked; c maxes at 100) The (t===0) guard is left intact. In a resumed window, the webview can still show a transient 0% before the first fresh response updates context metadata. After @@ -53,7 +53,7 @@ import tempfile OLD = ">=50)return null}" -NEW = ">=101)return null}" +NEW = ">=101)return null}/*ccwa-context-icon*/" BACKUP_SUFFIX = ".bak-context-icon" DISCOVERY_GLOBS = [ diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 451b330..33560d8 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -254,10 +254,12 @@ def test_fix_context_icon_atomic_replace_preserves_metadata_and_docs_limitation( # path is verified by inspection only. self.assertEqual(after.st_uid, before.st_uid) self.assertEqual(after.st_gid, before.st_gid) - self.assertEqual( - target.read_text(encoding="utf-8"), f"before {NEW_ICON} after" - ) + patched_text = target.read_text(encoding="utf-8") + self.assertIn("/*ccwa-context-icon*/", patched_text) + self.assertEqual(patched_text, f"before {mod.NEW} after") self.assertTrue((pathlib.Path(str(target) + mod.BACKUP_SUFFIX)).exists()) + # Idempotent: a second patch is a no-op. + self.assertEqual(mod.patch_file(str(target)), "already-patched") def test_patch_extension_avoids_bash4_mapfile(self): source = (REPO / "fixes" / "thinking-summaries" / "patch-extension.sh").read_text(encoding="utf-8") From c7698f0a868c3312a7f404dad8848b035487102c Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 7/9] docs: launcher README, per-fix READMEs with Maintenance Contracts, fix template Co-Authored-By: Claude Opus 4.8 (1M context) --- fixes/_template/README.md | 23 +++++++++++ fixes/context-icon/README.md | 41 ++++++++++++++++++++ fixes/thinking-summaries/README.md | 52 +++++++++++++++++++++++++ launcher/README.md | 62 ++++++++++++++++++++++++++++++ 4 files changed, 178 insertions(+) create mode 100644 fixes/_template/README.md create mode 100644 fixes/context-icon/README.md create mode 100644 fixes/thinking-summaries/README.md create mode 100644 launcher/README.md diff --git a/fixes/_template/README.md b/fixes/_template/README.md new file mode 100644 index 0000000..210ba72 --- /dev/null +++ b/fixes/_template/README.md @@ -0,0 +1,23 @@ +# + +> Copy this folder to `fixes//` to start a new fix. + +## What it fixes + + + +## Standalone usage + + + +## Launcher toggle + + env var, default, and what 0 does> + +## Maintenance Contract + +- Anchors / selectors: +- Ownership marker: v1 */`> +- Failure mode if an anchor moves: +- Launcher registry entry: +- Test fixture: diff --git a/fixes/context-icon/README.md b/fixes/context-icon/README.md new file mode 100644 index 0000000..ad27254 --- /dev/null +++ b/fixes/context-icon/README.md @@ -0,0 +1,41 @@ +# context-icon + +## What it fixes + +Restores the always-visible context-usage icon in the VS Code chat input. +Extension builds 2.1.165+ hide that icon until you have used more than 50% of the +context window. With the 1M context window that is ~500k tokens, so it is +effectively never shown. This fix flips the threshold so the icon renders +whenever a context window is known, at any usage level. + +## Standalone usage + +``` +python3 fixes/context-icon/fix-context-icon.py # auto-discover & patch all installs +python3 fixes/context-icon/fix-context-icon.py --revert # restore from .bak-context-icon +python3 fixes/context-icon/fix-context-icon.py /path/to/webview/index.js # explicit target(s) +``` + +The patch is idempotent and atomic (same-directory temp + replace, owner/group/ +mode preserved). VS Code auto-updates the extension and an update reinstalls a +fresh bundle, so re-run after updates (or use the launcher, which re-applies on +every launch). After patching, reload the webview: Command Palette -> +"Developer: Reload Window". + +## Launcher toggle + +`CC_PATCH_CONTEXT_ICON` (default `1`). `0` leaves the icon unpatched, and the +launcher reverts our edit on the next launch. + +## Maintenance Contract + +- Anchors / selectors: `>=50)return null}` (component `FJe` in `webview/index.js`). +- Ownership marker: `/*ccwa-context-icon*/`. Apply rewrites `>=50)return null}` -> + `>=101)return null}/*ccwa-context-icon*/`; undo reverses only that marked form. +- Failure mode if an anchor moves: if the anchor string changes, apply no-ops with + a one-line warning (the icon goes missing again) until the anchor is updated. A + bare upstream `>=101)return null}` with no marker is never touched. +- Launcher registry entry: feature id `context-icon`, file `webview/index.js`, + apply = marked swap, undo = reverse of the marked form only. +- Test fixture: `tests/test_reconcile.py` (launcher engine, both platforms) and + `tests/test_regressions.py::PatcherRegressionTests` (standalone patcher). diff --git a/fixes/thinking-summaries/README.md b/fixes/thinking-summaries/README.md new file mode 100644 index 0000000..50203e4 --- /dev/null +++ b/fixes/thinking-summaries/README.md @@ -0,0 +1,52 @@ +# thinking-summaries + +## What it fixes + +Restores extended-thinking summaries on Opus 4.7 / 4.8, where the "Thinking" +section otherwise renders empty in the VS Code extension and in headless `-p` / +SDK runs. The fix injects `--thinking-display summarized` into the launch args - +the one lever that is not interactivity-gated. It edits no files. + +## Standalone usage + +The launcher (option 1) is what most people want. These standalone tools cover +the other delivery paths: + +``` +./patch-extension.sh # idempotent edit to the extension's extension.js +./patch-extension.sh --revert # restore the most recent .bak +./patch-extension.sh --dry-run # show what would change, touch nothing + +node proxy.js # advanced: localhost proxy that injects thinking.display + # into every request (sits in the path of your live token) + +./test-thinking-display.sh # live A/B tester (sends 2 small requests, uses tokens) +``` + +`proxy.js` sees your live auth token; read the security notes in `proxy.js` and +`TECHNICAL.md` before relying on it. After running `patch-extension.sh`, reload +the VS Code window. + +## Launcher toggle + +`CC_THINKING_DISPLAY` (default `summarized`; `omitted` disables injection). This +is an argument-injection feature: no file is patched, so there is nothing to +reconcile. + +## Maintenance Contract + +- Anchors / selectors: the launcher detects a real agent run via + `--thinking adaptive|enabled`, `-p` / `--print`, and + `--max-thinking-tokens N` / `=N`. `patch-extension.sh` keys off + `if(l.type!=="disabled"&&l.display).push("--thinking-display",l.display)`, + with the array variable captured by a regex group so a minifier rename does not + break it. +- Ownership marker: none in the bundle (this is argument injection). + `patch-extension.sh` keeps timestamped `.bak.` backups. +- Failure mode if an anchor moves: injection is skipped when `--thinking-display` + is already present, when thinking is disabled, or for subcommands/probes; the + standalone patch no-ops if its target string is absent. +- Launcher registry entry: argument-injection feature, gated by + `CC_THINKING_DISPLAY` and `CC_WORKAROUNDS`. +- Test fixture: `tests/test_regressions.py` (thinking-arg parsing, proxy header + filtering, patch-extension, and the A/B-script cases). diff --git a/launcher/README.md b/launcher/README.md new file mode 100644 index 0000000..64aecf5 --- /dev/null +++ b/launcher/README.md @@ -0,0 +1,62 @@ +# The unified launcher + +One bash launcher (`claudemax`) and one Windows launcher (`claudemax.win.js`) +carry every fix in this repo. Each fix is on by default and independently +switchable at runtime with an environment variable, so the same artifact serves +"I want everything" and "I want only X" without editing code and without +recompiling. The Windows launcher compiles to a single `claudemax.exe` with +`pkg`. + +Both launchers are drop-in process wrappers: they find the real `claude`, peel +the process-wrapper convention args, inject the thinking-display flag when a real +agent run is detected, reconcile the webview bundle, then exec the real CLI. + +## Wiring (process wrapper) + +VS Code, official "Claude Code" extension: + +- Set `claudeCode.claudeProcessWrapper` to the full path of `launcher/claudemax` + (or `claudemax.exe` on Windows), then reload the window. +- In a multi-root `.code-workspace`, this setting is window-scoped: put it in the + workspace file's `settings` block (or in User settings), not in a folder + `.vscode/settings.json`. + +VS Code, third-party "Claude Code Chat" extension: + +- Set `claudeCodeChat.executable.path` to the launcher. + +Terminal: + +- Run `claudemax` in place of `claude`. + +## Toggles + +| Env var | Default | Effect | +| --- | --- | --- | +| `CC_WORKAROUNDS` | `1` | Master switch. `0` disables every fix (argument injection and bundle patches) and reverts the webview to a clean bundle on launch. | +| `CC_RECONCILE` | `1` | `0` = do not read or write the webview bundle this launch (emergency bypass). Argument injection still runs. | +| `CC_THINKING_DISPLAY` | `summarized` | `summarized` shows extended-thinking summaries; `omitted` hides them (no injection). | +| `CC_PATCH_CONTEXT_ICON` | `1` | `0` leaves the context-usage icon unpatched (and reverts ours on the next launch). | + +Setting toggles without touching the script: + +- Use the extension's existing `claudeCode.environmentVariables` setting to set + any `CC_*` toggle from the settings UI on any OS, including against the compiled + `claudemax.exe`. +- Source-script users can instead edit the `FEATURE DEFAULTS` block near the top + of `claudemax` / `claudemax.win.js`. + +## Overriding the real binary + +- `CLAUDE_REAL_BIN` - full path to the real `claude`, if autodetection fails. +- `CC_NODE_BIN` (Windows) - path to `node.exe`, used when resolving a `.cmd` / + `.bat` shim without going through a shell. + +## Building the exe + +``` +npm i -g pkg +pkg launcher/claudemax.win.js --targets node18-win-x64 --output claudemax.exe +``` + +The exe is distributed through GitHub Releases, not committed to the repo. From 4d1a1ac40c6472cb8167efa0d228e9042efab6e2 Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 8/9] docs: rewrite README index + TECHNICAL reconcile model for the unified launcher Retargets all launcher names/paths/toggles to launcher/claudemax and the fixes// layout, adds the toggle table and clean-break migration note, documents the per-file reconcile model, ownership marker, and the .bak-cc- workarounds emergency snapshot. Updates the launcher header comments to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 156 +++++++++++++++++++------------------- TECHNICAL.md | 34 ++++++--- launcher/claudemax | 27 +++---- launcher/claudemax.win.js | 17 +++-- 4 files changed, 129 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index 4b9e8a3..00aac21 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,55 @@ # claude-code-workarounds -Unofficial community workarounds for Claude Code. Each entry below is an independent fix with its own scripts and detailed section. +Unofficial community workarounds for Claude Code. Each entry below is an independent fix, delivered through one env-toggled launcher per platform plus standalone per-fix tools. Not affiliated with or endorsed by Anthropic. A future Claude Code update could make any of the included workarounds obsolete. Use them at your own discretion. ## Workarounds 1. **Empty thinking summaries (Opus 4.7 / 4.8)** [updated 2026-06-08]. - Thinking summaries render empty in the VS Code extension and headless `-p`/SDK paths, even with `showThinkingSummaries` enabled. Fix via a launcher (recommended), a one-line extension patch, or a local proxy. + Thinking summaries render empty in the VS Code extension and headless `-p`/SDK paths, even with `showThinkingSummaries` enabled. Fix via the launcher (recommended), a one-line extension patch, or a local proxy. -> [details](#workaround-1-thinking-summaries) 2. **Missing context-usage icon (1M context window)** [updated 2026-06-08]. - The context-usage pie in the chat input is hidden until you have used more than 50% of the context window. With the 1M window that is about 500,000 tokens, so it is effectively never shown. Fix via a launcher that re-patches the webview on each launch, or a standalone patcher script. + The context-usage pie in the chat input is hidden until you have used more than 50% of the context window. With the 1M window that is about 500,000 tokens, so it is effectively never shown. Fix via the launcher (re-patches the webview on each launch), or a standalone patcher script. -> [details](#workaround-2-context-usage-icon) -## Launchers at a glance +## The launcher -The recommended fix for each workaround is a small launcher that wraps the real `claude` binary. They are drop-in process wrappers that differ only in what they inject or patch. Pick the one matching the fixes you want: +The recommended fix for everything is one small launcher that wraps the real `claude` binary. It is a drop-in process wrapper carrying every fix in this repo; each fix is on by default and independently switchable with an environment variable, so the same artifact serves "I want everything" and "I want only X" without editing code and without recompiling. -| Launcher | Thinking fix | Context-icon fix | Edits the extension? | -|---|:--:|:--:|:--:| -| `claudemax` | yes | yes | yes (webview bundle only) | -| `claude-think` | yes | - | no | -| `claude-context` | - | yes | yes (webview bundle only) | +* **Linux / macOS:** the bash script [`launcher/claudemax`](launcher/claudemax). +* **Windows:** the compiled `claudemax.exe` on the [Releases](../../releases) page, built from [`launcher/claudemax.win.js`](launcher/claudemax.win.js). -* **Linux / macOS:** the bash scripts [`claudemax`](claudemax), [`claude-think`](claude-think), [`claude-context`](claude-context). -* **Windows:** the matching compiled `.exe` builds (`claudemax.exe`, `claude-think.exe`, `claude-context.exe`) on the [Releases](../../releases) page, built from the `*.win.js` sources. +Toggles (set in the environment where Claude Code launches, then reload): + +| Env var | Default | Effect | +| --- | --- | --- | +| `CC_WORKAROUNDS` | `1` | Master switch. `0` disables every fix (argument injection and bundle patches) and reverts the webview to a clean bundle on launch. | +| `CC_RECONCILE` | `1` | `0` = do not read or write the webview bundle this launch (emergency bypass). Argument injection still runs. | +| `CC_THINKING_DISPLAY` | `summarized` | `summarized` shows extended-thinking summaries; `omitted` hides them (no injection). | +| `CC_PATCH_CONTEXT_ICON` | `1` | `0` leaves the context-usage icon unpatched (and reverts ours on the next launch). | + +See [`launcher/README.md`](launcher/README.md) for wiring details, the VS Code env-setting how-to, and the build command. > Note: The interactive terminal (`claude` in a shell) already shows thinking summaries through the `showThinkingSummaries` setting and always shows the context icon. Both issues affect the VS Code extension and the headless `-p` / SDK paths. > Requirement: The real Claude Code CLI must already be installed and working. If `claude --version` prints a version, this requirement is met. -> The thinking fix (`claude-think`) edits nothing. The context-icon fix (`claude-context`, `claudemax`) does edit the extension's webview bundle on disk - idempotently, with a one-time backup, an atomic write, and a toggle. See [Workaround 2](#workaround-2-context-usage-icon). +> The thinking fix edits nothing (it injects a launch flag). The context-icon fix does edit the extension's webview bundle on disk - idempotently, with an ownership marker, an atomic write, a one-time pristine snapshot, and a toggle. See [Workaround 2](#workaround-2-context-usage-icon). + +## Migration from the old launchers (clean break) + +The three bash launchers and three Windows launchers are gone. There is now one launcher per platform: [`launcher/claudemax`](launcher/claudemax) and [`launcher/claudemax.win.js`](launcher/claudemax.win.js) (`claudemax.exe`). Update your wrapper path and, if you want a subset of fixes, set the matching `CC_*` environment variable. + +| Old launcher | New equivalent | +| --- | --- | +| `claudemax` (both fixes) | `launcher/claudemax` - all fixes on (same behavior) | +| `claude-think` (thinking only) | `launcher/claudemax` with `CC_PATCH_CONTEXT_ICON=0` | +| `claude-context` (context icon only) | `launcher/claudemax` with `CC_THINKING_DISPLAY=omitted` | +| any `.exe` | the single `claudemax.exe`; scope features via `CC_*` (VS Code `claudeCode.environmentVariables`) | + +Old release assets remain available for anyone pinned to a previous version. --- @@ -52,18 +70,16 @@ There are three workarounds: The launcher starts the real `claude` binary and appends the missing `--thinking-display summarized` flag. It does not modify Claude Code files, so it continues working after updates. The same wrapper fixes both the VS Code extension and headless CLI. -Use [`claude-think`](claude-think) for the thinking fix alone, or [`claudemax`](claudemax) to also restore the context-usage icon ([Workaround 2](#workaround-2-context-usage-icon)). Setup is identical; substitute the launcher name. - ### Linux / macOS (tested on Ubuntu 24.04) ```sh # 1. Install the launcher mkdir -p ~/.local/bin -cp claude-think ~/.local/bin/claude-think # or claudemax for both fixes -chmod +x ~/.local/bin/claude-think +cp launcher/claudemax ~/.local/bin/claudemax +chmod +x ~/.local/bin/claudemax # 2. Sanity check. This should print normal Claude help. -~/.local/bin/claude-think --help +~/.local/bin/claudemax --help ``` ### Use it in VS Code @@ -75,7 +91,7 @@ No PATH changes are required. 3. Add this line, replacing `YOUR_USERNAME`. This is the official "Claude Code" extension's setting (shown in the UI as "Claude Code: Claude Process Wrapper"): ```jsonc - "claudeCode.claudeProcessWrapper": "/home/YOUR_USERNAME/.local/bin/claude-think" + "claudeCode.claudeProcessWrapper": "/home/YOUR_USERNAME/.local/bin/claudemax" ``` If you use the third-party "Claude Code Chat" extension instead, set `"claudeCodeChat.executable.path"` to the same path. @@ -87,33 +103,31 @@ No PATH changes are required. ### Use it in a terminal -Run `claude-think` (or `claudemax`) in place of `claude`. +Run `claudemax` in place of `claude`. ### Windows 11 The same result is achieved with the compiled `.exe`. -1. Download `claude-think.exe` (or `claudemax.exe` for both fixes) from this repository's [Releases](../../releases), or build it yourself - see [Building the .exe files](#building-the-exe-files). -2. Put it somewhere stable, such as `C:\Users\YOU\.local\bin\claude-think.exe`. +1. Download `claudemax.exe` from this repository's [Releases](../../releases), or build it yourself - see [Building the .exe](#building-the-exe). +2. Put it somewhere stable, such as `C:\Users\YOU\.local\bin\claudemax.exe`. 3. Open the Command Palette and select "Preferences: Open User Settings (JSON)". 4. Add the following setting (the official "Claude Code" extension setting). Use double backslashes in the path. ```jsonc - "claudeCode.claudeProcessWrapper": "C:\\Users\\YOU\\.local\\bin\\claude-think.exe" + "claudeCode.claudeProcessWrapper": "C:\\Users\\YOU\\.local\\bin\\claudemax.exe" ``` If you use the third-party "Claude Code Chat" extension instead, set `"claudeCodeChat.executable.path"` to the same path. In a multi-root `.code-workspace`, put `claudeCode.claudeProcessWrapper` in the workspace file's `"settings"` block or in User settings, not a folder's `.vscode/settings.json`. 5. Reload the VS Code window by opening the Command Palette and selecting "Developer: Reload Window". -6. To use it in a terminal, run `claude-think.exe` in place of `claude`. +6. To use it in a terminal, run `claudemax.exe` in place of `claude`. > The wrapper finds the real Claude binary automatically, including native installs with `claude.exe` and npm installs with `claude.cmd`. If it cannot find the binary, set `CLAUDE_REAL_BIN` to the full path of your `claude` binary. -The Windows sources are [`claude-think.win.js`](claude-think.win.js) and [`claudemax.win.js`](claudemax.win.js). - -### Turn the thinking fix on or off +### Want only the thinking fix? -The launcher reads one environment variable, `CC_THINKING_DISPLAY`: +Set `CC_PATCH_CONTEXT_ICON=0` to leave the webview untouched and inject only the thinking-display flag. The launcher reads `CC_THINKING_DISPLAY`: * unset or `summarized`: show thinking summaries, which is the default * `omitted`: hide thinking summaries @@ -128,12 +142,12 @@ The launcher inspects the arguments and, when it detects a real run via any of t ### Why Option 1 is recommended -1. It survives updates because it does not edit Claude Code files. +1. It survives updates because it does not edit Claude Code files (for the thinking fix; the context-icon fix re-applies on each launch). 2. It fixes both the VS Code extension and headless `claude -p` or SDK runs. 3. It leaves the interactive TUI unchanged because that path already works. 4. It only injects the flag on real agent runs, not on subcommands or probes such as `mcp`, `config`, or `--version`. 5. It does not add the flag twice, so it can coexist with a patched or updated extension. -6. It can be toggled with one environment variable. +6. Every fix can be toggled with one environment variable. 7. It provides one place to configure effort level, auto mode, timeouts, or model routing. See the commented customization section in the script and the [Side note](#side-note-launching-claude-code-with-third-party-models). ## Option 2: One-line `extension.js` patch (VS Code only) @@ -147,16 +161,16 @@ if(l.type!=="disabled"&&l.display)B.push("--thinking-display",l.display) if(l.type!=="disabled")B.push("--thinking-display",l.display||"summarized") ``` -> The extension is minified, so the array variable name varies by build (`B` in 2.0.x, `q` in 2.1.16x, and so on). Match the surrounding text and keep whatever variable name your build uses; [`patch-extension.sh`](patch-extension.sh) does this automatically. This version fragility is one reason Option 1 is preferred. +> The extension is minified, so the array variable name varies by build (`B` in 2.0.x, `q` in 2.1.16x, and so on). Match the surrounding text and keep whatever variable name your build uses; [`fixes/thinking-summaries/patch-extension.sh`](fixes/thinking-summaries/patch-extension.sh) does this automatically. This version fragility is one reason Option 1 is preferred. ### Automatic patching on Linux, macOS, WSL, or Git Bash -[`patch-extension.sh`](patch-extension.sh) finds every installed Claude Code extension, backs each one up, and applies the patch: +[`fixes/thinking-summaries/patch-extension.sh`](fixes/thinking-summaries/patch-extension.sh) finds every installed Claude Code extension, backs each one up, and applies the patch: ```sh -./patch-extension.sh # patch and create .bak backups -./patch-extension.sh --dry-run # preview only, change nothing -./patch-extension.sh --revert # restore backups +./fixes/thinking-summaries/patch-extension.sh # patch and create .bak backups +./fixes/thinking-summaries/patch-extension.sh --dry-run # preview only, change nothing +./fixes/thinking-summaries/patch-extension.sh --revert # restore backups ``` Reload the VS Code window after patching. Re-run the patch after every extension update because updates replace the extension folder and remove the change. @@ -180,12 +194,12 @@ A localhost proxy can add the missing field to every request, fixing VS Code, CL This is a working starting point, not a turnkey fix. It also sits in the path of your live auth token, so review the security notes before relying on it. ```sh -node proxy.js # listens on http://127.0.0.1:8788 +node fixes/thinking-summaries/proxy.js # listens on http://127.0.0.1:8788 export ANTHROPIC_BASE_URL=http://127.0.0.1:8788 # set this where Claude launches claude ... # for VS Code, set it for the extension host, then reload ``` -Security: The proxy sees your live auth token. It binds to `127.0.0.1` only, never `0.0.0.0`, and does not log headers or bodies. Unset `ANTHROPIC_BASE_URL` to return directly to Anthropic. See [`proxy.js`](proxy.js) and [TECHNICAL.md](TECHNICAL.md#option-3-local-proxy-design) for details and caveats. +Security: The proxy sees your live auth token. It binds to `127.0.0.1` only, never `0.0.0.0`, and does not log headers or bodies. Unset `ANTHROPIC_BASE_URL` to return directly to Anthropic. See [`fixes/thinking-summaries/proxy.js`](fixes/thinking-summaries/proxy.js) and [TECHNICAL.md](TECHNICAL.md#option-3-local-proxy-design) for details and caveats. --- @@ -204,47 +218,48 @@ There is no environment variable or CLI flag for this threshold, so the fix is a ## Option 1: Launcher (recommended) -Use [`claude-context`](claude-context) for the context-icon fix alone, or [`claudemax`](claudemax) to also restore thinking summaries ([Workaround 1](#workaround-1-thinking-summaries)). Install and wire it up exactly like the Workaround 1 launcher (copy to `~/.local/bin`, point `claudeCode.claudeProcessWrapper` at it, reload), substituting the launcher name. On Windows, download `claude-context.exe` or `claudemax.exe` from [Releases](../../releases). +The launcher carries this fix on by default. Install and wire it up exactly like Workaround 1 (copy [`launcher/claudemax`](launcher/claudemax) to `~/.local/bin`, point `claudeCode.claudeProcessWrapper` at it, reload). On Windows, download `claudemax.exe` from [Releases](../../releases). To get only the context-icon fix and skip thinking injection, set `CC_THINKING_DISPLAY=omitted`. -On each launch the wrapper idempotently patches the extension's `webview/index.js`, flipping the hidden threshold so the icon shows at any usage level. Because it re-applies every launch, an extension auto-update that reinstalls a fresh bundle is re-patched on the next launch. +On each launch the wrapper reconciles the extension's `webview/index.js`, flipping the hidden threshold so the icon shows at any usage level. Because it re-applies every launch, an extension auto-update that reinstalls a fresh bundle is re-patched on the next launch. > First-run note: the wrapper patches `index.js` on disk when the CLI is spawned, which can be **after** the webview already loaded the old bundle. The first time you enable it you may need **two reloads**: reload once (the spawn patches the file), then reload again (the webview loads the patched bundle). Later windows and post-update launches are already patched on disk. ### What it changes -In the indicator component, the render gate is `if (c >= 50) return null`, where `c` is the percent of context **remaining**. So the icon renders only when less than 50% remains (more than 50% used). The fix flips the threshold: +In the indicator component, the render gate is `if (c >= 50) return null`, where `c` is the percent of context **remaining**. So the icon renders only when less than 50% remains (more than 50% used). The fix flips the threshold and tags the edit with an ownership marker: ```text -if(c>=50)return null -> if(c>=101)return null +if(c>=50)return null -> if(c>=101)return null}/*ccwa-context-icon*/ ``` -`c` maxes at 100, so `c >= 101` is never true and the gate never hides the icon. The separate `if (t === 0) return null` guard (no context window known yet) is left intact, so nothing renders before a session exists. The edit is anchored on the stable string `>=50)return null}`, not on the minified component name, which changes between builds. +`c` maxes at 100, so `c >= 101` is never true and the gate never hides the icon. The separate `if (t === 0) return null` guard (no context window known yet) is left intact, so nothing renders before a session exists. The edit is anchored on the stable string `>=50)return null}`, not on the minified component name, which changes between builds. The trailing `/*ccwa-context-icon*/` marks the edit as ours, so the launcher only ever reverses its own change. ### Turn the context-icon fix on or off The launcher reads `CC_PATCH_CONTEXT_ICON`: * unset or `1`: patch the webview so the icon is visible, which is the default -* `0`: leave the extension webview untouched +* `0`: leave the extension webview untouched (and revert ours on the next launch) ### This edits the extension (unlike the thinking fix) Unlike Workaround 1, this fix edits the extension's bundled `webview/index.js`. The edit is made safe: -* **Idempotent** - it skips a file that is already patched, and skips (rather than guesses) if the `>=50)return null}` anchor is absent because the extension changed. -* **Backed up once** - `index.js.bak-context-icon` is created before the first edit. +* **Idempotent** - it skips a file that is already in the desired state, and skips (rather than guesses) if the `>=50)return null}` anchor is absent because the extension changed. +* **Ownership-marked** - the edit carries a `/*ccwa-context-icon*/` marker; the launcher reverses only its own marked edit and never touches upstream code that merely resembles a patched value. +* **Snapshotted once** - a whole-file pristine snapshot `index.js.bak-cc-workarounds` is written the first time the file is rewritten, for emergency manual restore only; routine reconcile never reads it. * **Atomic** - the change is written to a temp file and moved into place only after it is verified, so a failed or partial write leaves the original untouched. * **Best-effort** - every step is guarded; a read-only file, a renamed bundle, or a missing tool simply no-ops and never blocks the launch. -* **Reversible** - delete the patched bundle's `.bak-context-icon` after restoring it, set `CC_PATCH_CONTEXT_ICON=0`, or just let an extension update replace the file. +* **Reversible** - set `CC_PATCH_CONTEXT_ICON=0` (the launcher reverts on the next launch), set `CC_WORKAROUNDS=0` to revert every fix, or just let an extension update replace the file. ## Option 2: Standalone patcher script -[`fix-context-icon.py`](fix-context-icon.py) applies the same one-character-class change directly, without a launcher. It auto-discovers installed extensions, backs each up, and is idempotent. +[`fixes/context-icon/fix-context-icon.py`](fixes/context-icon/fix-context-icon.py) applies the same change directly, without a launcher. It auto-discovers installed extensions, backs each up to `.bak-context-icon`, and is idempotent. ```sh -python3 fix-context-icon.py # auto-discover and patch all installs -python3 fix-context-icon.py --revert # restore from backups -python3 fix-context-icon.py /path/to/webview/index.js # explicit target(s) +python3 fixes/context-icon/fix-context-icon.py # auto-discover and patch all installs +python3 fixes/context-icon/fix-context-icon.py --revert # restore from backups +python3 fixes/context-icon/fix-context-icon.py /path/to/webview/index.js # explicit target(s) ``` After patching, reload the webview (Command Palette -> "Developer: Reload Window"). Because an extension update reinstalls a fresh bundle and reverts the patch, re-run the script after updates (or use the launcher, which re-applies automatically). @@ -277,47 +292,36 @@ Setup is otherwise identical to Option 1. This is unrelated to the fixes above. ## Troubleshooting * Thinking still empty after setup: Reload the VS Code window after changing the setting. Confirm the setting points to the launcher's full absolute path. On Windows, confirm the path uses double backslashes. -* Context icon still missing after setup: It may take two reloads the first time (see the first-run note above). Confirm you are using `claude-context` or `claudemax`, and that `CC_PATCH_CONTEXT_ICON` is not set to `0`. +* Context icon still missing after setup: It may take two reloads the first time (see the first-run note above). Confirm `CC_PATCH_CONTEXT_ICON` is not set to `0` and `CC_WORKAROUNDS` is not set to `0`. * `could not find the real 'claude' binary`: Set `CLAUDE_REAL_BIN` to its full path. Use `which claude` on Linux/macOS or `where claude` on Windows. * Nothing changes in a plain terminal chat: This is expected. The interactive TUI already shows summaries and the context icon, and does not need these fixes. * Summaries are short: Summary length tracks the reasoning effort level. Try a higher `CLAUDE_CODE_EFFORT_LEVEL`, such as `xhigh`, or enable auto mode with `CLAUDE_CODE_ENABLE_AUTO_MODE=1`. Higher effort uses more tokens. -* To verify the thinking root cause: Run [`test-thinking-display.sh`](test-thinking-display.sh). It performs a live A/B test and uses a small number of tokens. +* To verify the thinking root cause: Run [`fixes/thinking-summaries/test-thinking-display.sh`](fixes/thinking-summaries/test-thinking-display.sh). It performs a live A/B test and uses a small number of tokens. ## Files | File | Workaround | Description | -|---|---|---| -| [`claudemax`](claudemax) | both | Launcher (Linux/macOS) with both fixes. | -| [`claude-think`](claude-think) | thinking | Launcher (Linux/macOS), thinking fix only. | -| [`claude-context`](claude-context) | context icon | Launcher (Linux/macOS), context-icon fix only. | -| [`claudemax.win.js`](claudemax.win.js) | both | Windows source for `claudemax.exe`. | -| [`claude-think.win.js`](claude-think.win.js) | thinking | Windows source for `claude-think.exe`. | -| [`claude-context.win.js`](claude-context.win.js) | context icon | Windows source for `claude-context.exe`. | -| [`patch-extension.sh`](patch-extension.sh) | thinking | Option 2 idempotent `extension.js` patch with `--revert` and `--dry-run`. | -| [`fix-context-icon.py`](fix-context-icon.py) | context icon | Option 2 standalone webview patcher with `--revert`. | -| [`proxy.js`](proxy.js) | thinking | Option 3 localhost proxy. Advanced and untested. | -| [`test-thinking-display.sh`](test-thinking-display.sh) | thinking | Live A/B test showing that the flag is the relevant lever. | -| [`TECHNICAL.md`](TECHNICAL.md) | both | Full root-cause analysis and design notes. | +| --- | --- | --- | +| [`launcher/claudemax`](launcher/claudemax) | both | Unified launcher (Linux/macOS), env-toggled. | +| [`launcher/claudemax.win.js`](launcher/claudemax.win.js) | both | Windows source for `claudemax.exe`. | +| [`launcher/README.md`](launcher/README.md) | both | Wiring, the toggle table, the VS Code env-setting how-to, the build command. | +| [`fixes/thinking-summaries/patch-extension.sh`](fixes/thinking-summaries/patch-extension.sh) | thinking | Option 2 idempotent `extension.js` patch with `--revert` and `--dry-run`. | +| [`fixes/thinking-summaries/proxy.js`](fixes/thinking-summaries/proxy.js) | thinking | Option 3 localhost proxy. Advanced and untested. | +| [`fixes/thinking-summaries/test-thinking-display.sh`](fixes/thinking-summaries/test-thinking-display.sh) | thinking | Live A/B test showing that the flag is the relevant lever. | +| [`fixes/context-icon/fix-context-icon.py`](fixes/context-icon/fix-context-icon.py) | context icon | Option 2 standalone webview patcher with `--revert`. | +| [`TECHNICAL.md`](TECHNICAL.md) | both | Full root-cause analysis, the reconcile model, and design notes. | ## Releases -Prebuilt Windows `.exe` launchers are published on the [Releases](../../releases) page rather than committed to the repo, since they are large and reproducible from the `*.win.js` sources. Each release attaches: - -* `claudemax.exe` - both fixes -* `claude-think.exe` - thinking fix only -* `claude-context.exe` - context-icon fix only - -Linux and macOS users run the bash scripts from the repo and do not need a download. +The prebuilt Windows `claudemax.exe` launcher is published on the [Releases](../../releases) page rather than committed to the repo, since it is large and reproducible from the `claudemax.win.js` source. Linux and macOS users run the bash script from the repo and do not need a download. -## Building the .exe files +## Building the .exe -The Windows launchers are built from their `*.win.js` sources into standalone `.exe`s with [vercel/pkg](https://github.com/vercel/pkg). Node.js is required. +The Windows launcher is built from its `*.win.js` source into a standalone `.exe` with [vercel/pkg](https://github.com/vercel/pkg). Node.js is required. ```sh npm i -g pkg -pkg claudemax.win.js --targets node18-win-x64 --output claudemax.exe -pkg claude-think.win.js --targets node18-win-x64 --output claude-think.exe -pkg claude-context.win.js --targets node18-win-x64 --output claude-context.exe +pkg launcher/claudemax.win.js --targets node18-win-x64 --output claudemax.exe ``` ## Compatibility diff --git a/TECHNICAL.md b/TECHNICAL.md index 24bcc38..34bd2fd 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -5,6 +5,8 @@ Full root-cause analysis and design notes behind the workarounds in the [README] * [Workaround 1: empty thinking summaries](#workaround-1-empty-thinking-summaries) * [Workaround 2: missing context-usage icon](#workaround-2-missing-context-usage-icon) +Both fixes live in one launcher per platform (`launcher/claudemax` and `launcher/claudemax.win.js`), each fix independently switchable by an environment variable (see [`launcher/README.md`](launcher/README.md) for the toggle table). The node launcher compiles to a single `claudemax.exe` with `pkg` - one binary per platform carrying every fix, down from one exe per fix. The standalone per-fix tools under `fixes/` cover the non-launcher delivery paths. + --- # Workaround 1: empty thinking summaries @@ -75,7 +77,7 @@ Same prompt, run twice through a logged-in account on Opus 4.8 with `claude -p . | `--thinking-display summarized` | populated (hundreds of chars) | | no flag, `showThinkingSummaries: true` | empty (0 chars) | -This proves three things: (1) `display: "summarized"` works end-to-end on 4.8, the server honors it; (2) the setting is ignored in non-interactive mode; (3) the flag is the lever. Reproduce it yourself with [`test-thinking-display.sh`](test-thinking-display.sh) (sends two small live requests; uses a few tokens). +This proves three things: (1) `display: "summarized"` works end-to-end on 4.8, the server honors it; (2) the setting is ignored in non-interactive mode; (3) the flag is the lever. Reproduce it yourself with [`test-thinking-display.sh`](fixes/thinking-summaries/test-thinking-display.sh) (sends two small live requests; uses a few tokens). VS Code UI was confirmed separately: after applying the Option 2 patch and reloading the window, thinking summaries render in the conversation on Opus 4.8. The fix has also been observed to survive an extension auto-update where `patch-extension.sh` had patched multiple installed versions. @@ -105,7 +107,7 @@ if(l.type!=="disabled")B.push("--thinking-display",l.display||"summarized") The array variable (`B` here) is minified and renames between builds (`q` in 2.1.16x), so `patch-extension.sh` matches it with a capture group rather than a fixed literal and preserves whatever name the build uses. A hand edit should keep the surrounding build's variable name. -[`patch-extension.sh`](patch-extension.sh) applies this idempotently across all installed Claude Code extensions (backing up each first), with `--revert` and `--dry-run`. It must be re-applied after every extension update (the install folder is replaced on upgrade), and it only fixes VS Code; headless/SDK still need Option 1. +[`patch-extension.sh`](fixes/thinking-summaries/patch-extension.sh) applies this idempotently across all installed Claude Code extensions (backing up each first), with `--revert` and `--dry-run`. It must be re-applied after every extension update (the install folder is replaced on upgrade), and it only fixes VS Code; headless/SDK still need Option 1. Toggle idea (untested): changing the line to `l.display || (process.env.CC_THINKING_DISPLAY || "summarized")` would let a `CC_THINKING_DISPLAY=omitted` env var (e.g. in VS Code `settings.json`) hide thinking while unset/`summarized` shows it, a way to honor an on/off switch without a code change each time. Not tested. @@ -119,7 +121,7 @@ The most thorough fix: a small localhost forward proxy that injects the field at Point `ANTHROPIC_BASE_URL` at the proxy and every request flows through it. -What the proxy does ([`proxy.js`](proxy.js)): +What the proxy does ([`proxy.js`](fixes/thinking-summaries/proxy.js)): 1. Accept requests on `http://127.0.0.1:`. 2. For `POST /v1/messages`, parse the JSON body; if `body.thinking.type` is `"adaptive"` or `"enabled"` and `body.thinking.display` is unset, set it to the configured value (default `"summarized"`, or `"omitted"` to hide). @@ -143,7 +145,7 @@ Status: provided as a working starting point but not extensively tested, so vali ## Compatibility -Confirmed on Opus 4.7 / 4.8 with VS Code extension `2.1.169` (native-binary CLI), via the `claudeCode.claudeProcessWrapper` setting, on Windows 11 and Ubuntu 24.04; earlier confirmations were on `2.1.165` / `2.1.167` (which signaled thinking with `--thinking adaptive`). The CLI flag and the request field are stable levers, but the exact minified strings used by [`patch-extension.sh`](patch-extension.sh) (Option 2) can change between extension releases (e.g. the array variable `B` -> `q`); the script matches the variable generically and, if the surrounding pattern isn't found, skips and tells you to inspect manually. Options 1 and 3 don't depend on internal strings. +Confirmed on Opus 4.7 / 4.8 with VS Code extension `2.1.169` (native-binary CLI), via the `claudeCode.claudeProcessWrapper` setting, on Windows 11 and Ubuntu 24.04; earlier confirmations were on `2.1.165` / `2.1.167` (which signaled thinking with `--thinking adaptive`). The CLI flag and the request field are stable levers, but the exact minified strings used by [`patch-extension.sh`](fixes/thinking-summaries/patch-extension.sh) (Option 2) can change between extension releases (e.g. the array variable `B` -> `q`); the script matches the variable generically and, if the surrounding pattern isn't found, skips and tells you to inspect manually. Options 1 and 3 don't depend on internal strings. --- @@ -177,10 +179,10 @@ The `CLAUDE_CODE_DISABLE_1M_CONTEXT=1` env var that circulates in the issue thre ## The fix ```text -if(c>=50)return null -> if(c>=101)return null +if(c>=50)return null -> if(c>=101)return null}/*ccwa-context-icon*/ ``` -`c` is in `[0, 100]`, so `c >= 101` is never true and the gate never hides the icon. The separate `if (t === 0) return null` guard is left intact, so nothing renders before a context window is known. Using `>=101` (rather than deleting the line) is the smallest, most legible, greppable, reversible change, and it preserves the surrounding structure for a clean string substitution. The patch is anchored on the literal `>=50)return null}`, which is stable across builds even though the minified names around it are not, and which occurs exactly once in `2.1.169`. +`c` is in `[0, 100]`, so `c >= 101` is never true and the gate never hides the icon. The separate `if (t === 0) return null` guard is left intact, so nothing renders before a context window is known. Using `>=101` (rather than deleting the line) is the smallest, most legible, greppable, reversible change, and it preserves the surrounding structure for a clean string substitution. The patch is anchored on the literal `>=50)return null}`, which is stable across builds even though the minified names around it are not, and which occurs exactly once in `2.1.169`. The trailing `/*ccwa-context-icon*/` is an ownership marker: the launcher's reconcile reverses only the marked form, so it can never corrupt upstream code that merely matches a patched value, and a pre-existing unmarked patch from an older launcher is left as-is and re-marked on the next fresh bundle. There is no integrity or subresource check on the webview bundle (the only `sha256` references in `extension.js` belong to a bundled crypto library), so an edited `index.js` loads normally. @@ -195,12 +197,24 @@ The wrapper discovers `index.js` two ways: The edit is made safe: -* Idempotent: skips an already-patched file, and skips (rather than guesses) if the `>=50)return null}` anchor is absent because the extension changed. -* Backed up once to `index.js.bak-context-icon` before the first edit. +* Idempotent: writes only when the recomputed bytes differ, and skips (rather than guesses) if the `>=50)return null}` anchor is absent because the extension changed. +* Ownership-marked: the edit carries a `/*ccwa-context-icon*/` marker; reconcile reverses only its own marked edit and never touches upstream code that merely resembles a patched value. * Atomic: written to a temp file and moved into place only after it is verified non-empty and actually patched, so a failed or partial write cannot corrupt the bundle. -* Metadata-preserving via `cp -p` (portable; the GNU-only `chmod`/`chown --reference` is avoided so it also works on macOS/BSD). The Windows launcher writes with `fs.writeFileSync` + `fs.renameSync`, inheriting the parent directory's ACLs. +* Metadata-preserving via `cp -p` (portable; the GNU-only `chmod`/`chown --reference` is avoided so it also works on macOS/BSD). The Windows launcher writes with `fs.writeFileSync` + `fs.renameSync`, inheriting the parent directory's ACLs (it does not preserve the file mode - the one intentional bash/node asymmetry). * Fully guarded so it never blocks the launch (a read-only file, a renamed bundle, or a missing tool simply no-ops). +### Patch composition (per-file reconcile) + +Every launch, for each webview file a bundle-patch feature targets, the launcher recomputes the file from scratch rather than restoring a backup: + +1. **Clean base C** - apply every KNOWN feature's `undo` in REVERSE registration order. Each `undo` reverses only its own ownership-marked edit and is a no-op when that marker is absent, so C is the pristine bundle regardless of which of our patches were present, and `undo` never touches code we did not write. +2. **Desired D** - apply each ENABLED feature's `apply` to C in FORWARD order. A feature whose anchor is absent (the extension changed the string) no-ops with a one-line warning; it does not abort the file. +3. Write D only if it differs from the current bytes (idempotent). Multiple features on one file compose without cross-clobber; toggling one off removes exactly that feature on the next launch; an extension auto-update that reinstalls a fresh bundle is simply re-applied. + +`CC_WORKAROUNDS=0` runs reconcile with every feature disabled, so D == C and the bundle returns to clean. `CC_RECONCILE=0` skips the whole pass (no reads/writes), leaving the bundle exactly as-is. + +**Emergency snapshot.** The first time a file is rewritten, a one-time whole-file pristine snapshot `index.js.bak-cc-workarounds` (= C) is written for manual restore only; routine reconcile never reads it. Because it is a whole-file image, manually restoring it is NOT composition-safe (it can clobber another feature's patch); only routine reconcile / `undo` (marker-scoped reverse transforms) composes. The standalone `fix-context-icon.py` keeps its own separate `.bak-context-icon` backup. + Timing note: the wrapper patches `index.js` on disk when the CLI is spawned, which can be *after* the webview already loaded the old bundle. So the first time you enable it you may need two reloads (the spawn patches the file, then the webview loads the patched bundle). Later windows and post-update launches are already patched on disk. ## How the icon works (context for future changes) @@ -236,4 +250,4 @@ The pie button's `onClick` is `onCompact`: clicking the icon triggers compaction ## Compatibility -Confirmed on VS Code extension `2.1.169` (native-binary CLI) on Windows 11 and Ubuntu 24.04. The `>50% used` gate appeared around `2.1.165` (absent in `2.1.131` / `2.1.128`). The patch keys off the stable substring `>=50)return null}`, not the minified component name; if a future build changes that exact substring, the launcher safely no-ops (the icon goes missing again) until the anchor is updated. The standalone [`fix-context-icon.py`](fix-context-icon.py) applies the same change directly and supports `--revert`. +Confirmed on VS Code extension `2.1.169` (native-binary CLI) on Windows 11 and Ubuntu 24.04. The `>50% used` gate appeared around `2.1.165` (absent in `2.1.131` / `2.1.128`). The patch keys off the stable substring `>=50)return null}`, not the minified component name; if a future build changes that exact substring, the launcher safely no-ops (the icon goes missing again) until the anchor is updated. The standalone [`fix-context-icon.py`](fixes/context-icon/fix-context-icon.py) applies the same change directly and supports `--revert`. diff --git a/launcher/claudemax b/launcher/claudemax index 648e010..b76e9e2 100755 --- a/launcher/claudemax +++ b/launcher/claudemax @@ -13,14 +13,17 @@ # each launch, flipping the threshold so the icon shows at any usage level. # Because it re-applies every launch, it survives extension updates. # -# This is the "both fixes" variant. For thinking-only use `claude-think`; for the -# context-icon fix alone use `claude-context`. All three are drop-in process -# wrappers and differ only in what they inject/patch. +# This single launcher carries every fix, each independently switchable by an +# environment variable (all on by default). For thinking only, set +# CC_PATCH_CONTEXT_ICON=0; for the context-icon fix only, set +# CC_THINKING_DISPLAY=omitted. # # NOTE: unlike fix #1, fix #2 DOES edit the extension's bundled webview/index.js. -# That edit is idempotent, backed up once to index.js.bak-context-icon, written -# atomically (a failed write leaves the original untouched), best-effort (it never -# blocks the launch), and toggle-able with CC_PATCH_CONTEXT_ICON=0. +# That edit is idempotent and ownership-marked, snapshotted once to +# index.js.bak-cc-workarounds (emergency restore only), written atomically (a +# failed write leaves the original untouched), best-effort (it never blocks the +# launch), reconciled per file every launch, and toggle-able with +# CC_PATCH_CONTEXT_ICON=0 (or CC_WORKAROUNDS=0 / CC_RECONCILE=0). # # Use it: # - VS Code (official "Claude Code" extension): set "claudeCode.claudeProcessWrapper" @@ -30,13 +33,11 @@ # - VS Code (third-party "Claude Code Chat"): set "claudeCodeChat.executable.path". # - Terminal: run `claudemax` in place of `claude`. # -# Toggle off: -# export CC_THINKING_DISPLAY=omitted # hide thinking summaries -# export CC_PATCH_CONTEXT_ICON=0 # leave the extension webview untouched -# -# Default: -# CC_THINKING_DISPLAY=summarized -# CC_PATCH_CONTEXT_ICON=1 +# Toggle off (defaults in parentheses): +# export CC_THINKING_DISPLAY=omitted # hide thinking summaries (summarized) +# export CC_PATCH_CONTEXT_ICON=0 # leave the extension webview alone (1) +# export CC_WORKAROUNDS=0 # master: disable every fix (1) +# export CC_RECONCILE=0 # do not touch the webview bundle (1) # # The real `claude` must be installed. This wrapper finds it automatically; if it # cannot, set CLAUDE_REAL_BIN to the full path of your real claude binary. diff --git a/launcher/claudemax.win.js b/launcher/claudemax.win.js index effb7be..7cb0d69 100644 --- a/launcher/claudemax.win.js +++ b/launcher/claudemax.win.js @@ -14,14 +14,17 @@ // launch, flipping the threshold so the icon shows at any usage level. // Because it re-applies every launch, it survives extension updates. // -// This is the "both fixes" variant. For thinking-only use claude-think.exe; for -// the context-icon fix alone use claude-context.exe. All three are drop-in -// process wrappers and differ only in what they inject/patch. +// This single launcher carries every fix, each independently switchable by an +// environment variable (all on by default). For thinking only, set +// CC_PATCH_CONTEXT_ICON=0; for the context-icon fix only, set +// CC_THINKING_DISPLAY=omitted. // // NOTE: unlike fix #1, fix #2 DOES edit the extension's bundled webview/index.js. -// That edit is idempotent, backed up once to index.js.bak-context-icon, written -// via a temp file + rename, best-effort (it never blocks the launch), and -// toggle-able with CC_PATCH_CONTEXT_ICON=0. +// That edit is idempotent and ownership-marked, snapshotted once to +// index.js.bak-cc-workarounds (emergency restore only), written via a temp file + +// rename, best-effort (it never blocks the launch), reconciled per file every +// launch, and toggle-able with CC_PATCH_CONTEXT_ICON=0 (or CC_WORKAROUNDS=0 / +// CC_RECONCILE=0). // // Use it: set the official "Claude Code" extension's "claudeCode.claudeProcessWrapper" // setting (or the third-party "Claude Code Chat" extension's @@ -34,6 +37,8 @@ // Toggle off: // set CC_THINKING_DISPLAY=omitted hide thinking summaries (default: summarized) // set CC_PATCH_CONTEXT_ICON=0 leave the extension webview untouched (default: 1) +// set CC_WORKAROUNDS=0 master: disable every fix (default: 1) +// set CC_RECONCILE=0 do not touch the webview bundle (default: 1) // // The real `claude` must be installed. This wrapper finds it automatically // (native install `claude.exe` or npm `claude.cmd`); if it cannot, set the From 06eea06dc0f3ed87e1cb6e12849680372db221a2 Mon Sep 17 00:00:00 2001 From: phase3dev <77866949+phase3dev@users.noreply.github.com> Date: Wed, 10 Jun 2026 01:55:22 -1000 Subject: [PATCH 9/9] fix(launcher): adopt legacy unmarked context-icon patch; Windows precise walk-up Codex audit follow-ups, folded into the reconcile mechanism so md-copy builds on a clean base. Finding 1 (legacy bare-101): the old launcher/standalone wrote a bare, unmarked `>=101)return null}`. The marker-only undo treated that as foreign, so every default launch warned "context-icon anchor not found (extension changed?)" and `CC_PATCH_CONTEXT_ICON=0` could not revert it (breaking the migration table). undo now recognizes the bare form as a second ownership fingerprint (`>=101` is dead upstream code, so it only ever appears as our own output). A legacy bundle now self-heals: a default launch reverts bare -> `>=50` then re-applies the marked form, capturing the correct pristine snapshot, with no warning; a disabled launch reverts cleanly. The genuine "extension changed" warning still fires for an unrecognized bundle. Finding 2 (Windows precise discovery): reconcile was called with only wrapperBin, ignoring the resolved `claude` (CLAUDE_REAL_BIN / autodetected). An extension whose root sits outside the HOME fallback scan was left unpatched on a terminal launch, contradicting the documented discovery model and bash's walk from REAL_CLAUDE. Now reconcile(wrapperBin || claude), at parity with bash. Tests: replace the obsolete "bare-101 is upstream" assertion with legacy adoption + stderr-quiet coverage (upgrade-when-enabled, revert-when-disabled, revert-by-master-switch, unrecognized-still-warns) across bash and node, plus a precise-walk-up test with an extension root outside the fallback scan on both platforms. 24 -> 32 tests, all green; bash -n / shellcheck --severity=warning / node --check / py_compile clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- launcher/claudemax | 17 +++-- launcher/claudemax.win.js | 25 +++++--- tests/test_reconcile.py | 126 +++++++++++++++++++++++++++++++++++--- 3 files changed, 147 insertions(+), 21 deletions(-) diff --git a/launcher/claudemax b/launcher/claudemax index b76e9e2..9526333 100755 --- a/launcher/claudemax +++ b/launcher/claudemax @@ -212,8 +212,9 @@ fi # Generic engine (replaces the single hard-coded context-icon sed). Each # bundle-patch feature registers, per target file, an idempotent + reversible # (apply, undo) pair. Every applied edit carries an ownership MARKER, and undo -# keys off the MARKED form only, so the launcher reverses ONLY its own edits and -# never touches upstream code that merely resembles a patched value. +# keys off our own fingerprints (the MARKER, plus any legacy unmarked form an +# older version of this tool wrote), so the launcher reverses ONLY its own edits +# and never touches upstream code that merely resembles a patched value. # # Per-file reconcile (see TECHNICAL.md "patch composition"): # C = current bytes with every KNOWN feature's undo applied in REVERSE order @@ -266,10 +267,18 @@ _cc_apply_context_icon() { } _cc_undo_context_icon() { + # Revert our edit to the pristine upstream form. Two ownership fingerprints are + # recognized: the current MARKED form, and the legacy BARE form that older + # launcher/standalone versions wrote before the marker existed. `>=101)return + # null}` is dead upstream code (c maxes at 100), so it appears only as our own + # output; adopting it lets a legacy install revert and upgrade cleanly instead + # of warning on every launch. The MARKED substitution runs first because the + # bare string is a prefix of the marked one. local f="$1" tmp - if ! grep -q '/\*ccwa-context-icon\*/' "$f" 2>/dev/null; then return 0; fi # nothing of ours + grep -qF '>=101)return null}' "$f" 2>/dev/null || return 0 # nothing of ours tmp="${f}.ccundo.$$" - if sed 's#>=101)return null}/\*ccwa-context-icon\*/#>=50)return null}#' "$f" > "$tmp" 2>/dev/null \ + if sed -e 's#>=101)return null}/\*ccwa-context-icon\*/#>=50)return null}#g' \ + -e 's#>=101)return null}#>=50)return null}#g' "$f" > "$tmp" 2>/dev/null \ && [ -s "$tmp" ]; then cat "$tmp" > "$f" 2>/dev/null || true fi diff --git a/launcher/claudemax.win.js b/launcher/claudemax.win.js index 7cb0d69..284a590 100644 --- a/launcher/claudemax.win.js +++ b/launcher/claudemax.win.js @@ -238,12 +238,14 @@ if (!claude) { // routine safely no-ops until the anchor here is updated. const ICON_OLD = ">=50)return null}"; const ICON_MARKER = "/*ccwa-context-icon*/"; -const ICON_NEW = ">=101)return null}" + ICON_MARKER; +const ICON_BARE = ">=101)return null}"; // legacy unmarked form (older launcher/standalone) +const ICON_NEW = ICON_BARE + ICON_MARKER; // Bundle-patch feature registry. Each feature is idempotent (apply/undo are // no-ops when their target state already holds) and reversible; undo keys off -// the ownership MARKER only, so it reverses ONLY our own edits. Order matters: -// apply runs forward, undo runs in reverse. +// our own fingerprints (the ownership MARKER, plus any legacy unmarked form an +// older version wrote), so it reverses ONLY our own edits. Order matters: apply +// runs forward, undo runs in reverse. function applyContextIcon(data) { if (data.indexOf(ICON_MARKER) !== -1) return data; // already applied const n = data.split(ICON_OLD).length - 1; @@ -258,8 +260,13 @@ function applyContextIcon(data) { } function undoContextIcon(data) { - if (data.indexOf(ICON_MARKER) === -1) return data; // nothing of ours - return data.split(ICON_NEW).join(ICON_OLD); + // Revert our edit to the pristine upstream form. Two ownership fingerprints + // are recognized: the current MARKED form, and the legacy BARE form older + // versions wrote before the marker existed. ICON_BARE is dead upstream code + // (c maxes at 100), so it appears only as our own output; adopting it lets a + // legacy install revert/upgrade cleanly. Marked must go first: ICON_BARE is a + // prefix of ICON_NEW. + return data.split(ICON_NEW).join(ICON_OLD).split(ICON_BARE).join(ICON_OLD); } function contextIconEnabled() { @@ -442,8 +449,12 @@ if ( args.push("--thinking-display", displayValue); } -// Reconcile the webview before handing off (best-effort; never throws). -reconcile(wrapperBin); +// Reconcile the webview before handing off (best-effort; never throws). Walk up +// from the resolved binary - wrapperBin when the extension handed us one, else +// the CLAUDE_REAL_BIN/autodetected `claude` - so an extension whose root sits +// outside the HOME fallback scan is still reached (parity with the bash walk +// from REAL_CLAUDE). +reconcile(wrapperBin || claude); const invocation = resolveClaudeInvocation(claude, args); if (!invocation) process.exit(1); diff --git a/tests/test_reconcile.py b/tests/test_reconcile.py index 8a97544..180b6a9 100644 --- a/tests/test_reconcile.py +++ b/tests/test_reconcile.py @@ -47,6 +47,23 @@ def make_extension(home, content): return idx +def make_extension_outside_scan(td, content): + """Create an extension whose root is NOT under any HOME .vscode scan dir. + + Returns (index_js, extroot, bindir). The extension's bundled binary lives at + /resources/native-binary/, mirroring the real layout the official + process-wrapper hands the launcher. Reaching this bundle requires the precise + walk-up from the resolved binary path; the HOME fallback scan never sees it. + """ + extroot = pathlib.Path(td) / "custom-ext-dir" / "anthropic.claude-code-9.9.9" + idx = extroot / "webview" / "index.js" + idx.parent.mkdir(parents=True) + idx.write_text(content, encoding="utf-8") + bindir = extroot / "resources" / "native-binary" + bindir.mkdir(parents=True) + return idx, extroot, bindir + + class ReconcileMixin: """Platform-agnostic reconcile assertions. Subclasses implement `_run`/`_captured`.""" @@ -102,16 +119,55 @@ def test_master_switch_reverts_all_and_injects_nothing(self): self.assertEqual(idx.read_text(encoding="utf-8"), f"before {OLD} after") self.assertEqual(self._captured(), ["--thinking=adaptive"]) - def test_unmarked_upstream_value_is_left_untouched(self): - for env_extra in ({}, {"CC_PATCH_CONTEXT_ICON": "0"}): - with self.subTest(env=env_extra): - with tempfile.TemporaryDirectory() as td: - home = pathlib.Path(td) - original = f"keep {BARE101} this" - idx = make_extension(home, original) - res = self._run(td, home, env_extra=env_extra) - self.assertEqual(res.returncode, 0, res.stderr) - self.assertEqual(idx.read_text(encoding="utf-8"), original) + def test_legacy_bare_patch_is_upgraded_to_marked_when_enabled(self): + # A bundle left by the OLD launcher/standalone carries the bare, + # unmarked >=101 form. `c` maxes at 100, so >=101 is dead upstream code + # that only ever appears as our own output; reconcile adopts it as a + # legacy fingerprint, upgrading it to the marked form and capturing the + # correct pristine (>=50) snapshot - without the spurious "anchor not + # found" warning the marker-only path used to emit on every launch. + with tempfile.TemporaryDirectory() as td: + home = pathlib.Path(td) + idx = make_extension(home, f"before {BARE101} after") + res = self._run(td, home) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual(idx.read_text(encoding="utf-8"), f"before {MARKED} after") + self.assertNotIn("anchor not found", res.stderr) + bak = idx.with_name(idx.name + BAK) + self.assertTrue(bak.exists()) + self.assertEqual(bak.read_text(encoding="utf-8"), f"before {OLD} after") + + def test_legacy_bare_patch_is_reverted_when_feature_disabled(self): + # Migration-table promise: disabling the fix reverts our edit. A legacy + # bare patch must revert to pristine just like a marked one does. + with tempfile.TemporaryDirectory() as td: + home = pathlib.Path(td) + idx = make_extension(home, f"before {BARE101} after") + res = self._run(td, home, env_extra={"CC_PATCH_CONTEXT_ICON": "0"}) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual(idx.read_text(encoding="utf-8"), f"before {OLD} after") + self.assertNotIn("anchor not found", res.stderr) + + def test_legacy_bare_patch_is_reverted_by_master_switch(self): + with tempfile.TemporaryDirectory() as td: + home = pathlib.Path(td) + idx = make_extension(home, f"before {BARE101} after") + res = self._run(td, home, env_extra={"CC_WORKAROUNDS": "0"}) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual(idx.read_text(encoding="utf-8"), f"before {OLD} after") + + def test_unrecognized_bundle_warns_and_is_left_untouched(self): + # The genuine "extension changed" signal must survive: a bundle with + # neither our anchor nor either of our fingerprints is left untouched and + # still warns (so a real upstream change is not silently ignored). + with tempfile.TemporaryDirectory() as td: + home = pathlib.Path(td) + original = "totally unrelated minified code; return null}" + idx = make_extension(home, original) + res = self._run(td, home) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual(idx.read_text(encoding="utf-8"), original) + self.assertIn("anchor not found", res.stderr) def test_reconcile_bypass_leaves_bundle_untouched_but_still_injects(self): with tempfile.TemporaryDirectory() as td: @@ -172,6 +228,33 @@ def test_apply_preserves_file_mode(self): self.assertEqual(res.returncode, 0, res.stderr) self.assertEqual(stat.S_IMODE(idx.stat().st_mode), 0o640) + def test_precise_walkup_patches_extension_outside_scan_dirs(self): + # bash walks up from REAL_CLAUDE, so this already held; it guards against + # regressing the precise-target path the Windows fix brings to parity. + with tempfile.TemporaryDirectory() as td: + home = pathlib.Path(td) / "empty-home" + home.mkdir() + idx, _extroot, bindir = make_extension_outside_scan(td, f"before {OLD} after") + fake = bindir / "claude" + fake.write_text( + "#!/usr/bin/env bash\n" + "python3 - \"$@\" <<'PY'\n" + "import json, os, sys\n" + "open(os.environ['CAPTURE_ARGS'], 'w').write(json.dumps(sys.argv[1:]))\n" + "PY\n", + encoding="utf-8", + ) + fake.chmod(fake.stat().st_mode | stat.S_IXUSR) + capture = pathlib.Path(td) / "args.json" + env = { + "HOME": str(home), + "CLAUDE_REAL_BIN": str(fake), + "CAPTURE_ARGS": str(capture), + } + res = run([str(LAUNCHER_BASH), "--thinking=adaptive"], env=env) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual(idx.read_text(encoding="utf-8"), f"before {MARKED} after") + class WinReconcileTests(ReconcileMixin, unittest.TestCase): def _run(self, td, home, args=None, env_extra=None): @@ -188,6 +271,29 @@ def _run(self, td, home, args=None, env_extra=None): env.update(env_extra) return run(["node", str(LAUNCHER_WIN), *(args or [])], env=env) + def test_precise_walkup_patches_extension_outside_scan_dirs(self): + # Finding 2: when CLAUDE_REAL_BIN resolves to the extension's bundled + # binary in a location the HOME fallback scan never sees, reconcile must + # still patch that extension via the precise walk-up from the resolved + # binary. The old code passed only wrapperBin (null on a terminal launch), + # so the bundle was left unpatched; reconcile(wrapperBin || claude) fixes it. + with tempfile.TemporaryDirectory() as td: + home = pathlib.Path(td) / "empty-home" + home.mkdir() + idx, _extroot, bindir = make_extension_outside_scan(td, f"before {OLD} after") + cli, capture = make_fake_node_cli(td) + shim = bindir / "claude.cmd" # the extension's bundled npm-shim binary + shim.write_text(f'@ECHO off\nnode "{cli}" %*\n', encoding="utf-8") + env = { + "HOME": str(home), + "USERPROFILE": str(home), + "CLAUDE_REAL_BIN": str(shim), + "CAPTURE_ARGS": str(capture), + } + res = run(["node", str(LAUNCHER_WIN), "--thinking=adaptive"], env=env) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual(idx.read_text(encoding="utf-8"), f"before {MARKED} after") + @unittest.skipIf(os.name == "nt", "needs both bash and node on PATH") class ParityTests(unittest.TestCase):