Skip to content

fix: resilience batch — bundled hotkey helper, visible git absence, shell probe, spawn hardening (#495) - #508

Merged
Juliusolsson05 merged 6 commits into
mainfrom
fix/silent-failure-resilience
Jul 9, 2026
Merged

Juliusolsson05 merged 6 commits into
mainfrom
fix/silent-failure-resilience

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

What

Part 3 of the #495 Tier-A portability plan — "degrade loudly, not silently", one commit per finding:

  • A4 — the dictation hotkey helper is now compiled at build time (scripts/build-hotkey-helper.mjs, wired into build:app) into out/main/runtime/hotkey-helper/ (existing asarUnpack glob covers it; electron-builder signs unpacked Mach-Os). Packaged apps no longer run xcrun swiftc on 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 with file) because out/main is 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 via xcode-select -p) through the existing dictation:hotkey-configure result + AppRunJournal instead of a swallowed console.warn.
  • A5 — a missing git binary latches a gitMissing flag (ENOENT + an existsSync(cwd) guard, since ENOENT from execFile is ambiguous with deleted-worktree cwds — routine here). git:status carries 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. AiWorkspaceRegistry now resolves git via getToolPath like everything else.
  • A8 — new src/shared/runtime/pickShell.ts implements 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: TerminalSession and the tmux command: in sessionManager (which was a second, also-unprobed copy of the expression).
  • A9 — provider sessions resolve the CLI to an absolute path or fail at create time (with one late re-resolve as a launch-from-Finder rescue, persisted through the setup-state pair), instead of optimistically spawning a bare 'claude'/'codex' that dies as exit-127 inside the PTY. ClaudeSession gets the rollbackStart() it was missing (ported from codex, proxy teardown extracted into one shared teardownProxy()), 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

Refs #495 (A4, A5, A8, A9). Plan: docs/superpowers/plans/2026-07-09-portability-tier-a-any-mac-hardening.md.

🤖 Generated with Claude Code

Juliusolsson05 and others added 4 commits July 9, 2026 10:41
… 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>
Juliusolsson05 and others added 2 commits July 9, 2026 14:30
…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>
@Juliusolsson05
Juliusolsson05 merged commit 55f0960 into main Jul 9, 2026
@Juliusolsson05
Juliusolsson05 deleted the fix/silent-failure-resilience branch July 9, 2026 12:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant