Repository navigation
fix: resilience batch — bundled hotkey helper, visible git absence, shell probe, spawn hardening (#495) - #508
Merged
Conversation
… degrade reason (#495 A4) Packaged Agent Code used to compile native/macos-hotkey-helper's main.swift ON THE END-USER'S MACHINE via `xcrun swiftc` the first time the dictation hotkey was configured, and mapped every failure to a bare `return false` plus a console.warn nobody sees. Net effect: a Mac without Xcode Command Line Tools silently lost the dictation hotkey — the works-on-my-machine assumption catalogued as finding A4 in #495. Three changes, one per layer of the fix: 1. Build-time compile (scripts/build-hotkey-helper.mjs, wired into build:app after copy-packaged-resources.mjs). Output lands under out/main/runtime/hotkey-helper/ deliberately: that subtree is already covered by electron-builder's `out/main/runtime/**/*` asarUnpack glob, already staged by the packaging flow, and unpacked Mach-O binaries get signed with the app's hardenedRuntime entitlements — zero new packaging plumbing. The script hard-fails when swiftc fails: build hosts must have CLT precisely so end users don't need it. DEVIATION FROM PLAN (documented, intent-preserving): the plan said "run the exact same xcrun swiftc invocation compileSwift uses". That would produce a HOST-ARCH-ONLY Mach-O — but out/main is staged once and feeds BOTH per-arch dmgs (Part 2's arm64+x64 matrix shares the staged tree, same as the all-arch bundled mitmproxy/tmux archives), so an arm64-only helper would ship broken inside the x64 dmg. The script therefore builds a universal binary (two thin swiftc slices, arm64-apple-macos11.0 + x86_64-apple-macos10.15 matching Electron 31's Intel floor, lipo'd together). Verified: `file` reports a 2-arch universal Mach-O. 2. Runtime resolution order (macHotkeyHelper.ts ensureHelperBinary): packaged builds resolve the bundled binary through the same .asar -> .asar.unpacked swap the bundled mitmproxy uses — unpackAsarPath is now EXPORTED from runtimeTools.ts instead of mirrored, so the swap has one implementation. No compile fallback in packaged builds on purpose: a missing bundled binary is a packaging bug we want surfaced, not papered over on the subset of user Macs that happen to have CLT. Dev keeps the hash-cached runtime compile (dev machines have CLT by definition). 3. Surfaced failure reason. startMacDictationHotkeyHelper now returns { ok, message } — mirroring CaffeinateController's { supported, message } graceful-degrade shape — where message is built by probing `xcode-select -p` (dev compile failures name the actual fix: "Xcode Command Line Tools not installed") or flagging the packaging bug (packaged, bundled binary missing), else the swiftc stderr tail. The reason rides the EXISTING dictation:hotkey-configure result (its ok:false arm already had an optional `message`; no new IPC channel) and is journaled durably via AppRunJournal in ipc/dictation.ts — threaded through registerAllIpc deps because the per-recording dictation debug journals don't exist yet at hotkey-registration time. Verified: npx tsc -b tsconfig.node.json PASS; npx tsc --noEmit -p tsconfig.web.json PASS (no non-TS6305 output); build script produces the universal binary under out/main/runtime/hotkey-helper/. Part of #495 Part 3 (resilience batch). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tate (#495 A5) ipc/git.ts's central runner deliberately swallows every failure into '' so callers can treat "no output" as a valid signal (clean worktree, missing HEAD, missing submodule checkout) — that contract stays, callers depend on it. But it also collapsed ENOENT ON THE GIT BINARY into the same '', so a Mac without git rendered GitBar as "not a git repository" (or a fake clean state) with no hint that the actual problem — and the actual fix — was the missing binary. Changes: * runGitCommand latches a module-level `gitMissing` flag on ENOENT and clears it on ANY later success, so installing git mid-session recovers on the next 10s GitBar poll without an app restart. One flag for all cwds because binary presence is machine-global. Guard worth knowing about: execFile reports ENOENT both for a missing binary AND a missing cwd, and deleted-worktree cwds are routine here (WorktreesBar polls paths that `git worktree remove` can race). The latch therefore only fires when existsSync(cwd) — when ENOENT can only mean the binary. * GitBarStatusResult's ok:false arm gains a REQUIRED `gitMissing: boolean` (shared contract in @shared/types/gitStatus.ts, so main must set it and the renderer must branch — optional would have let one side silently ignore it). * GitBar renders a muted persistent "git not found — Git features disabled" state for gitMissing instead of the misleading "not a git repository". State, not toast: the 10s poll would re-fire a toast forever. * Opportunistic in-blast-radius consistency fix (repo convention): AiWorkspaceRegistry.gitField now resolves git via getToolPath('git','git') like every other git caller instead of a bare PATH-dependent 'git' — a Finder-launched app inherits launchd's minimal PATH, where the bare name can miss a git the user already validated through Setup. All non-ENOENT failures keep the exact ''-as-signal behavior. Verified: npx tsc -b tsconfig.node.json PASS; npx tsc --noEmit -p tsconfig.web.json PASS (no non-TS6305 output). Grep confirms GitBar.tsx is the only git:status consumer. Part of #495 Part 3 (resilience batch). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TerminalSession's shell selection comment always CLAIMED a fallback chain ($SHELL → /bin/zsh → /bin/bash → /bin/sh) but the expression was just `options.shell ?? process.env.SHELL ?? '/bin/zsh'` with no existence check — and even admitted it in the comment. A stale $SHELL (uninstalled homebrew fish, chsh to a removed binary, dotfiles restored from another machine) made every terminal pane die on spawn in a way that read like an app crash. sessionManager's tmux path carried a SECOND copy of the same unchecked expression, so tmux-backed terminals had the same failure mode via `tmux new-session` running an unspawnable command. New src/shared/runtime/pickShell.ts implements the chain the comment promised, with an accessSync(X_OK) probe per candidate. Deliberate choices, each commented in the module: * sync — one probe per PTY spawn, nanoseconds against a fork+exec; * module-level memo — the answer can't meaningfully change within a process lifetime; * explicit `preferred` arg bypasses the probe — a caller that names a concrete shell owns the consequences, silently substituting another shell would be worse than failing visibly; * hail-mary /bin/sh return (never throws) — a spawn error surfaces through the PTY's normal exit path with better context than a throw from a path picker; * one console.debug on first resolution so support bundles show which shell the run actually used. Both former call sites now use it (TerminalSession constructor + sessionManager tmux `command:`), collapsing the duplicated expression to one probed implementation. Verified: npx tsc -b tsconfig.node.json PASS; npx tsc --noEmit -p tsconfig.web.json PASS (no non-TS6305 output). Runtime check with a throwaway tsx script (deleted): SHELL=/nonexistent/fish resolves to /bin/zsh; explicit preferred arg is returned untouched. Part of #495 Part 3 (resilience batch). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Start to ClaudeSession (#495 A9) Two halves of the same silent-failure mode: an optimistic bare-name provider spawn, and a spawn-failure path that leaked the Claude proxy. 1. sessionManager.spawn (agent branch): `binary: getToolPath(kind, kind)` fell back to the bare literal ('claude'/'codex') whenever setup.json had no cached path. node-pty PATH-resolves that at exec time — and on a Finder-launched app with launchd's minimal PATH it dies as exit-127 INSIDE the PTY, where it reads like a provider crash rather than a lookup failure (and, for Claude, the already-started mitmproxy leaked; see half 2). Now: resolve-or-fail BEFORE any session-scoped state exists (placed ahead of the MCP registerSession call so a throw can't leak an MCP registration either) — getToolPath(kind,'') → one late resolveToolPath(kind) re-probe (launch-from-Finder rescue for a CLI installed after the setup gate ran; a hit is persisted via the same updateToolPaths → refreshToolchainFromState pair the gate uses) → throw "<kind> CLI not found — open Setup to locate it". The throw rejects the session:spawn invoke, which the renderer already surfaces through sessionSpawnErrorMessage (verified: raw message passes through to the toast path). 2. claudeSession: codexSession has had a guarded start (rollbackStart) since its birth; ClaudeSession never got the port. Its start() called proxy.start() and then ran ~250 lines — two unguarded ptySpawn calls plus headless.start() — before any stop() plumbing existed, so ANY throw in that window leaked a live mitmdump child with its port bound; nothing would ever call stop() on it. Now both ptySpawn calls AND headless.start() are wrapped in try → rollbackStart() → rethrow (same shape as codexSession :293-305/:399-405; headless.start() is included because codex guards its equivalent and the leak shape is identical — proxy + PTY alive, wrapper half-built). The proxy teardown (detach proxyEventHandler → await stop() with the leak-breadcrumb warn → null) is EXTRACTED from stop() into a shared private teardownProxy() rather than duplicated — two hand-maintained copies of that sequence is exactly how the rollback path was missed originally. rollbackStart additionally clears the liveBridgeTimer (armed just before headless.start()) so a stray timer can't fire on a rolled-back session, and deliberately skips headless.stop() — if headless.start() threw, its internal state is undefined and stop() risks a second throw masking the original (same reasoning as codex's rollbackStart comment). Verified: npx tsc -b tsconfig.node.json PASS; npx tsc --noEmit -p tsconfig.web.json PASS (no non-TS6305 output). Part of #495 Part 3 (resilience batch). Note per plan: PR opening is handled by the orchestrator, not this commit series. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…detection, worktree-surface git signal - Hotkey helper start now waits for the helper's own verdict (ready line or early exit 64/65/66) instead of declaring ok right after spawn; exit 66 maps to the Accessibility-permission fix message. Renderer surfaces configure failures as a long-duration GlobalToast (#495 A4's whole point: packaged users never see console.warn). - git-unavailable classification extended beyond ENOENT to the macOS no-CLT case (xcrun/xcode-select stderr with git present on disk), and threaded through git:worktrees / git:worktree-status so the worktree surfaces show "git not found" instead of silently blanking. Finished from the review-fix agent's in-progress worktree after it was terminated by the spend limit; gates re-run green (tsc node+web clean, vitest 474/474, hotkeyBinding suite failure pre-existing). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
App.tsx conflict: this branch predates #505's thin-composition-root refactor — took main's App.tsx wholesale and re-applied the #508 toast surfacing into features/voice-dictation/useDictationHotkeySync.ts, where the dictation-configure effect lives post-#505. Same behavior, new home. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Part 3 of the #495 Tier-A portability plan — "degrade loudly, not silently", one commit per finding:
scripts/build-hotkey-helper.mjs, wired intobuild:app) intoout/main/runtime/hotkey-helper/(existing asarUnpack glob covers it; electron-builder signs unpacked Mach-Os). Packaged apps no longer runxcrun swiftcon the end-user's machine, so Xcode CLT stops being a hidden runtime dependency. Plan deviation (deliberate): the helper is built as a universal binary (arm64+x86_64, lipo'd, verified withfile) becauseout/mainis staged once and feeds both per-arch dmgs from PR feat(build): cross-Mac packaged build — multi-arch dmg, pty unpack, unsigned-dev fallback, .icns (#495) #506. Dev builds keep the hash-cached runtime compile; failures now surface as{ ok, message }(CLT detection viaxcode-select -p) through the existingdictation:hotkey-configureresult + AppRunJournal instead of a swallowedconsole.warn.gitMissingflag (ENOENT + anexistsSync(cwd)guard, since ENOENT from execFile is ambiguous with deleted-worktree cwds — routine here).git:statuscarries it and the Git Bar renders a muted "git not found — Git features disabled" instead of a plausible-but-false clean/0-ahead state. The deliberate''-as-signal contract for all other failures is untouched.AiWorkspaceRegistrynow resolves git viagetToolPathlike everything else.src/shared/runtime/pickShell.tsimplements the fallback chain the old comment only claimed ($SHELL→/bin/zsh→/bin/bash→/bin/sh, with a real X_OK probe, memoized). Used by both spawn paths:TerminalSessionand the tmuxcommand:in sessionManager (which was a second, also-unprobed copy of the expression).'claude'/'codex'that dies as exit-127 inside the PTY.ClaudeSessiongets therollbackStart()it was missing (ported from codex, proxy teardown extracted into one sharedteardownProxy()), and — beyond the plan —headless.start()is guarded too, since it has the identical leak shape (live mitmdump + live PTY, no stop plumbing yet).Verification
npx tsc -b tsconfig.node.json+npx tsc --noEmit -p tsconfig.web.jsongreen per task and re-run independently on the final tree.pickShellruntime-verified with a throwaway script (deleted): bogus$SHELL→/bin/zsh, explicit preferred arg passes through.Refs #495 (A4, A5, A8, A9). Plan:
docs/superpowers/plans/2026-07-09-portability-tier-a-any-mac-hardening.md.🤖 Generated with Claude Code