W1 (P1): every shell-authored result and event schema was .strict(), and the page fails a schema mismatch as invalid_message with retryable:false. The shell (APK) and the page (served by the desktop) ship from different releases, so one additive field or one new session tab kind from a newer APK killed the subscription and its one-shot fallback on the same byte - Loading tabs forever, surviving force-quit. tolerantMobileWebShellPayload deep-rewrites a schema at the page's two shell-payload parse sites: strict objects strip unknown keys, an array of unions drops members it cannot classify, and an unknown value for an optional/nullable closed set reads as absent. Page to shell request schemas keep .strict() - the shell is the security authority there. A census ratchet walks every contract export the page parses and fails if a strict node survives the transform.
W3 (P2): a host RPC failure collapsed into host_error, which is retryable, so method_not_found and the mobile allowlist's forbidden looked like blips. Both now map to unsupported_capability (non-retryable) through mobileWebBrokerHostRpcError, applied by codemod to the 44 regular 'if (!x.ok) throw host_error' sites.
W4 (P3): BrowserScreencastResult gains the navigation member the host already emits. Decoders unchanged.
W2 (P2): the file: confinement test gains a clientKind runtime case - pairedDeviceId is minted for scope 'runtime' too, so the fence also governs the web client, a remote desktop, and remote orca CLI.
W5 (P3): inputFloor and queryReplyAuthority stay literals with a WHY comment. Traced: the host publishes neither over the terminal stream. isMobileTerminalQueryReplyAuthority is never sent, and opcode-17 WriteUnavailable reports one refused write with no regain signal, so it is not the floor state the field declares.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The account, workspace, session, sourceControl, nativeChat, and browser ledgers were the same algorithm six times: identical record shape, admission guards, subscribe-then-reattach dance, cancel/cancelByRequest/countForOperation/dispose, and delivery chain. MobileWebSubscriptionLedger<TEvent, TRecord> now owns all of it; each concrete ledger keeps only its host subscribe call, its projection, and its operation key. MobileWebCapabilitySubscriptions replaces its four hand-written six-way fan-outs with loops over a ledger list.
postClosed stays a required constructor option and every construction site, tests included, now supplies it - three test fixtures previously left it undefined, which is why no shell-side closure frame had coverage.
Adds the missing closure-path tests: every ledger's invalid-host-message and failed-page-post paths, plus dispose/closeAll behaviour. dispose() stays silent toward the page because the document is going away with the shell; the new closeAll(closure) is what the broker calls on replaceClient, so a page that outlives a client swap learns its live subscriptions are over instead of freezing on their last value.
Behaviour change: a duplicate nativeChat subscription ID now raises invalid_request like the other five ledgers instead of rate_limited. The broker's replay guard rejects duplicate subscription IDs before the ledger sees them, so the path is unreachable in production.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
- Revert CdpBridge.tabList to main's delegation: CdpBridge has no production
constructor, browser.tabList is served by AgentBrowserBridgeTabs, and the
override published canGoBack/canGoForward that BrowserTabInfo never declared.
The hybrid shell reads navigation state from the screencast 'navigation'
event, not from tabList, so nothing regresses.
- pr-code-change-scope: mobile/host-web-app and mobile/app/h import broadly
across mobile/src, so a mobile/src edit changes out/mobile-web-rnw, which
ships in every desktop installer. Add mobile/src/ to the carve-out and keep
mobile test files desktop-irrelevant so the cost stays bounded.
- Move @noble/hashes to devDependencies: its only root consumers are
src/mobile-web/src, which Metro inlines at packaging time, matching buffer.
- files.unwatch awaits teardown again on the connection-scoped path via
cleanupIfOwnedByConnectionAndWait, so a rewatch cannot hold two watchers.
- readGuestNavigationState warns once instead of silently greying out
Back/Forward when navigationHistory is missing.
- One exported isRecord in src/shared and one byte-length encoder in
src/mobile-web/src; mobile/ copies left to a later pass.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Both binary query-reply paths called isAcceptableTerminalQueryReplyFrame without connectionClientId, so the guard fell back to the client-DECLARED client.id and one paired phone could author a reply as another. Thread the RpcContext clientId through TerminalSubscriptionArgs and TerminalMultiplexConnectionBase as a distinct connectionClientId, pass it at both binary sites, and make the guard field required so a new call site cannot silently degrade. Also covers browser.tabCreate file: confinement over RPC for a paired mobile device.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The query-reply guard pushed terminal-multiplex-slot-frames.ts over max-lines; the
Input/QueryReply branch now lives in terminal-multiplex-input-frame.ts. The
subscriptionClosed test built envelopes with a widened `version: number`, which
config/tsconfig.mobile-web.json rejects.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Replacing the policy call with `true` shipped a release APK with WebView
DevTools on and passed every gate: the only assertion tying the probe to the
policy pinned the absence of an old code shape, and the JVM suite always passed
isDebugBuild/isInspectableRelease explicitly, so the BuildConfig defaults the
sole caller uses were never compiled, let alone run.
The census asserts the policy-gated call is the one and only
setWebContentsDebuggingEnabled in the shell. The JVM test drives the
one-argument overload, and testDebugUnitTest now finalizes testReleaseUnitTest
so the shipped BuildConfig values are exercised under the CI command.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
A relay/direct cutover rejects in-flight requests without changing `connState`.
The capability probe retries; the package downloader did not. Every throw
collapsed to `host_error`, and the refresh effect's deps are all unchanged by a
seamless cutover, so nothing re-ran and the user had to tap Retry — on the one
screen that has no content yet.
The contract now marks a cutover or ambiguous-delivery throw `retryable`, using
the same two predicates the native-chat send path already trusts, and both
package reads re-issue it on the replacement session with bounded backoff: the
chunk read next to its existing read-limited retry, and the manifest read, which
is the one request a cutover can kill before any chunk exists to retry.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Neutering throwIfAborted survived: the one abort test was killed by the chunk
pipeline's own check, not by the downloader. Each case here aborts where only
the downloader's check can catch it, paired with an un-aborted control that
reaches stager.commit.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The bridge had seven shell -> page frames and none of them closed a
subscription. `response` is keyed by `requestId` and only reports the subscribe
call; `event` carries values. Once the subscribe response said `success`, every
shell-side failure after that point was unrepresentable — and the shell failed
subscriptions late in six ledgers, calling `cancel()` and discarding the request
id the signature returns for exactly this purpose. The page kept a live entry
with no timeout and no heartbeat, so tabs, workspaces, source control, accounts,
native chat, and dictation could all freeze on their last value with no error.
Adds `subscriptionClosed { subscriptionId, error: { code, retryable } }`, which
is additive to bridge version 2: a page built before it fails the union parse,
and `native-shell-channel` already returns on a parse failure rather than
tearing the session down, so an older page ignores it exactly as it ignores any
unknown type.
Every ledger's `cancel()` now takes an optional reason and posts the frame, and
the page routes it to the existing `fail()`, which already deletes the entry and
calls `onError`. Normal end-of-stream retirements pass no reason, so they stay
silent. No UI or presentation change: this is protocol completeness, and the
retry policy on top of `retryable` is a separate decision.
Two files crossed max-lines from the additions, so the event-verdict logic and
the closed-frame poster moved to modules of their own.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The binding test banned Clipboard.setStringAsync, Linking.openURL and
router.push('/terminal-settings') over a 42-file list, so injecting all three
into MobileTerminalInputActions.tsx passed. The census walks the 1052 modules
the hosted session route actually reaches and catches it.
AsyncStorage is deliberately excluded: it is genuinely reachable from the hosted
bundle today through the shared storage and transport modules.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Seeding the walk with an empty pending list made the reachability oracle pass
over an empty universe. The walk moves to a shared support module so the
session-route census can reuse it instead of re-implementing the resolution order.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Nothing called receive(), request() or unsubscribe() on a disposed bridge
object. This kills the terminal scheduler's reportError guard and the
subscription client's unsubscribe identity guard; the receive() guards in
MobileWebBridgeClient and MobileWebBridgeSubscriptionClient are defence in
depth over already-cleared state, so removing either changes nothing observable.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Three survivors in filterWorktrees: dropping the unread arm, widening the live
terminal check to >= 0 (which makes "Hide sleeping" filter nothing), and
dropping the non-empty branch guard that keeps a detached main worktree visible.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS appeared in no test, so disabling the
shared 64-request cap survived, as did dropping the maxResponseBytes check.
The saturation fill uses distinct operations so the shared cap is what fires.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
mobile-tasks-legacy-foundation.tsx is a 13-line `export *` barrel over the
live Tasks domain — provider and view-state types, options, item mapping,
mutation targets, review and comment surfaces, project fields, repository
presentation, and the PR file diff. "Legacy foundation" describes neither its
contents nor its status, and AGENTS.md rules out that class of name.
Renamed to mobile-tasks-model.tsx, matching the `model` the Tasks route already
threads through its hook composition, and repointed all 58 importers.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Two limits govern the same snapshot and they disagreed. The 200-tab count limit
slices, keeps the active tab, and reports `truncated`. The 128 KiB event byte
cap cancelled the subscription with no frame the page could see, and since a
browser tab at the schema maximum serializes to roughly 5 KB, ~40 long-URL tabs
crossed the byte cap long before the count cap ever fired. The page then showed
"Loading tabs" forever, deterministically, on every re-entry into that
workspace.
The byte cap now drops tabs to fit — active tab reserved first — and reports the
same `truncated` flag, so the projection is the single place that decides how a
snapshot degrades.
The sibling native-chat cap is left alone: it bounds one incremental chat event,
which has no partial form to degrade to.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Each name was re-verified with `rg -w` to appear only on its own declaration
line: localTranscriptFileSource (superseded by createProviderTranscriptFileSource
plus a bare filePath), the two unused bridge-operation-registry lookups,
projectRowGitHubRepository, mobileSessionMarkdownTargetKey, four ReturnType
aliases on the hybrid host-screen hooks, MobileSessionPanelRouteActionsModel,
and nine zod-derived contract aliases whose schemas stay in use.
The Tasks parity ratchet counts top-level declarations, so its declaration
count and digest move with projectRowGitHubRepository; the reason is recorded
in the file's baseline note.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
`session.createBrowser` is reachable from unprivileged page script and the page
it creates is streamed back over `browser.screencast`, so a `file:` URL turned
any host file into frames the page could decode. `browser.navigate` already
refused `file:` and result URLs were redacted; only the create path was open.
Both sides of the call now fence it instead of banning the scheme, because the
native HTML-artifact file tap is the same call:
- the shell re-resolves the path through `files.resolveTerminalPath` against the
workspace the page named and forwards the host's own absolute path, so nothing
the page wrote reaches `browser.tabCreate`, and an SSH worktree (whose path is
on another machine) is refused;
- the runtime independently requires a paired caller's `file:` URL to sit inside
that worktree's root on this host, so the page is not the only fence.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Deleting the invisible-drop block, the post-await authority re-check, and the
whole body of dispose() all survived the existing suite, which only exercises
the pre-enqueue guard.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The spec runs in no CI job. It cannot: it needs an iOS simulator and a Docker
daemon on one runner, and no GitHub runner has both — the whole
hosted-mobile-webview e2e family is manual-only, not just this spec. It already
carries the fallback the finding asks for: two test.skip lines naming both
missing prerequisites, so it never reports a green skip as coverage.
What was only a YAML comment is now a checked contract: the exclusion from the
changed-spec lane, the named manual entry points for the dev and packaged runs,
and the spec's own refusal to run without either prerequisite. A rename or a
dropped skip now reddens instead of quietly changing what CI covers.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The grant map key survived being reduced to either half: no test varied the
operation dimension, so a page holding file.read was treated as holding
file.write, and two capabilities sharing an operation name shared limits.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Traced the receiving side rather than assuming: handleStreamingResponse passes
every screencast result straight to the listener, use-mobile-browser-stream
casts the payload with no schema parse, and handleBrowserScreencastEvent is an
if/else-if chain with no else and no throw. A build predating the member drops
it silently, so Rule 3 is satisfied without a capability gate.
Pin that with a test, since the reasoning only holds while the handler stays
tolerant of an unrecognized type.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
mobile-web-history-session-fragment and mobile-web-queryless-history were the
same module twice: same writer/target types, same private WeakSet, same origin
guard and try/catch, differing only in the URL mutation. Both installed at
module scope on the same history object, so every pushState was double-wrapped
and each navigation parsed the URL twice.
One installer now takes an ordered rewrite list behind a single WeakSet and a
single URL parse. The shell-session fragment gate moved from install time into
the rewrite, which is where the hash is read anyway.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Three native-path regressions plus a doc correction:
- Unpair awaited removeMobileWebHostCache before removeHost. The native store
throws on an empty identity or a failed tree delete, and that cache need not
exist at all on a native build, so a hybrid-only failure stranded a paired
host. Both cache cleanups are best-effort now.
- Activation diagnostics dropped the target and the RPC failure code, leaving
concurrent activations indistinguishable and failures unexplained. Restore
the redacting helpers from main; a new test pins that only the 8-char suffix
reaches the log.
- The Sleep action lost its `.catch`, so a rejected fire-and-forget sleep
surfaced as an unhandled rejection.
- The README claimed an unset architecture keeps native. It does for release
builds, but a development build defaults to hybrid; document the real rule
and how to opt a dev build back into native.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
gzipChunks was keyed on the client-chosen `length` and cleared only when a new
package fingerprint verified — which on a shipped desktop is never, since the
bundle is fixed for the life of the install. A client walking every offset at
all eight permitted lengths caches each source byte 36 times, so request
parameters alone could retain ~100 MB in the main process with no cap and no
TTL. acquireRead bounds in-flight bytes, not retained ones.
Keep the key (dropping `length` would mean gzipping per request and losing the
cache) and bound the map instead: an LRU over total compressed bytes with a
16 MiB cap, reset alongside the existing fingerprint clear.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The stream presentation replaced runTerminalViewportFitPass with its own
correction pass, leaving the STA-3337 budget built and cleared but never
charged. It also coerced absent host dims to 80x24, which can never equal a
phone viewport, so a host that omits cols looped scrollback -> measure ->
unsubscribe -> resubscribe with no cap, backoff, or degrade toast.
Restore readTerminalViewportDims plus the `hostCols ?? viewport ?? 80`
fallback (absent dims now yield hold), call runTerminalViewportFitPass from
the presentation, and let a `resized` frame charge convergence through
observeResize. Two other main behaviors return with it: a stream end/error
signals terminal-inventory recovery so `exit` retires the tab promptly
instead of waiting for the 60s sweep, and the display-mode Map keeps its
identity when the mode is unchanged so a stream pass stops re-rendering the
whole route.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Home Resume/Tasks/Accounts and notification taps went back to a plain
router.push into the nested host navigator. On the native build a cold push
there resolves to the host index without the dynamic id, so HostProtocolGate
mounts with hostId undefined and the host screen renders blank (the bug #12001
fixed by mounting /h/[hostId] first and replacing once its stack commits).
navigateFromMobileHome now maps a MobileWebNavigationIntentTarget onto the
matching HostStackRouteTarget and hands the deep ones (session, tasks,
accounts) to coordinateHostStackNavigation via useOpenHostStackRoute. Host-index
intents (newWorkspace, workspaceList) and the whole hybrid build keep their
plain push, and the hybrid navigation intent is still published either way.
The Resume card also regains the `name` param it lost, so the session header
has a title before the workspace loads.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
`files` is exclusion-only, so electron-builder's default `**/*` packed both
out/mobile-web-rnw and out/mobile-web-rnw-export on top of the
Resources/mobile-web copy mobileWebExtraResource makes. Only that resource copy
is ever read: resolveMobileWebPackageRoot joins process.resourcesPath when
packaged and never consults the asar. At the 10 MiB build budget that is up to
~20 MiB of dead bytes per installer, and -export is the pre-hardening Metro
artifact that still contains the eval/new Function forms
disableRuntimeCodeGeneration strips.
The new assertion drives the real FileMatcher rather than pinning the pattern
strings, so it proves the trees are excluded, and re-asserts the extraResources
copy so the exclusion cannot orphan the feature.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The account, session, source-control, workspace, and browser ledgers each
declared a byte-identical private error class, but mobileWebBridgeErrorCode
recognised only the session one. The other four collapsed to host_error, which
also flipped retryability: a malformed subscribe or a hit per-ledger cap read
as a retryable host fault.
All five now throw the shared MobileWebBrokerError, so the converter has two
branches and no longer imports a ledger.
The downgrade is latent rather than live: the four non-session grants pin
maxConcurrent to 1 and the replay guard pre-empts duplicate subscription IDs,
so the broker's own guards fire first today. The new test covers both layers —
the per-ledger converter path (red before this change for all four) and an
end-to-end proof that a duplicate surviving replay-ring eviction reaches the
page with its own non-retryable code.
Leaves the shared ledger shell unextracted: cancel is not mechanical across the
six (browser clears a pending frame, source-control latches a closing flag), so
a base class would need per-ledger hooks.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
files.watch was mobile-allowlisted but files.unwatch was not, so the teardown
RpcClient sends on cancel was rejected as forbidden and the source-control
view leaked a @parcel/watcher handle per workspace visit. The client's cancel
swallows the rejection, so it was silent.
The hand-maintained MOBILE_STREAMING_CLEANUP_RPC_METHODS list existed for
exactly this class and still missed it. The allowlist test now derives the
subscribe -> unsubscribe pairs from buildServerSubscriptionUnsubscribe's own
map and requires the unsubscribe to be allowlisted and registered whenever the
subscribe is allowlisted; the hand list keeps only the cleanups that map does
not cover.
The leak was bounded: files.watch registers its cleanup with the connection id
and runtime-rpc-lifecycle calls cleanupSubscriptionsForConnection on socket
close, so watchers were released when the socket died, not before.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Opcode 18 set inputKind:'query-reply' on the client's word alone, and that
kind forces clientId undefined in sendTerminalStreamInput, so the frame
skipped beginMobileInputFloor entirely. A phone that is not the elected
reply authority could write arbitrary bytes into a desktop-driven pane
unfloored.
Lift the terminal.send guard into terminal-query-reply-guard.ts and apply it
at all three sites. A binary frame that fails is dropped, matching the JSON
path, which throws on shape and returns accepted:false on authority — neither
demotes the bytes to ordinary input. The page-side sender now runs the same
isTerminalQueryReply grammar check the native sender does, so the phone never
emits a non-grammar query reply on either opcode.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
The hybrid WebView migration routes h/[hostId] through
useHybridHostScreenController, leaving the seven pre-hybrid hooks with zero
call sites. The four view components typed their prop against the dead
controller's ReturnType, so they now take HybridHostScreenController directly.
That retype surfaced a live mismatch: the hybrid forceReconnectHost takes no
arguments, but the header and workspace list still passed hostId through the
old loose type.
Repoints the STA-5781 cross-client source pin at
use-hybrid-host-screen-settings.ts, which carries the same patch-only builder
call; verified the assertion still fails when that call is mutated.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
Resolves#16239's shared-client terminal identity against the hybrid split: the
hosted page has no native client, so identity readiness is a flag
(hostClientIdentityReady) rather than a non-null clientId, and the bridge
terminal operations keep their workspaceId/terminalId/clientId contract.
Adds getClientId to the disabled hosted client context and keeps the
mobile-web extra resource alongside main's new emoji shortcode dataset.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* perf(ssh): coalesce concurrent pty.inspectProcess and git.listWorktrees reads
Both were the only reads in their provider class with no in-flight dedupe while
their siblings already had it. Route them through the existing
InFlightPromiseDedupe, keyed per (relay pty id, incarnation) and per repoPath,
scoped to the provider instance so two hosts never share an entry. The worktree
listing clears from invalidateGitReads(), and a signalled read keeps its own
request so one caller's abort cannot cancel its joiners' scan.
In-flight only, no TTL: the relay does answer inspectProcess from a 500ms
TTL-cached process table, but a client TTL would compound with it rather than
match it, so it is not a free win.
* perf(ssh): drop the inspectProcess half, ratchet per-read host observations
The `git.listWorktrees` dedupe ships unchanged. The `pty.inspectProcess` dedupe
is reverted: the host mints one `observationEpoch` per request and the pane
foreground reader commits it per read, so two overlapping probes sharing one
reply make the second read a stale replay and `admitRemoteForegroundEvidence`
rejects it -- a would-be `live` identity read becomes `unverifiable`. The pane
foreground tracker overlaps its own probes by design (cancel-and-reissue after
a 350 ms settle), so that path is reachable.
Adds a ratchet that fails when the dedupe returns, driving the real reader
through the real provider operations.
* fix(ssh): move the inspect ratchet to the provider it guards
The ratchet lived under src/renderer and imported
src/main/providers/ssh-pty-provider-rpc-operations, dragging the whole
main-process graph into config/tsconfig.tc.web.json (TS6307).
Split it: the request-counter ratchet moves next to the provider it
pins, and the renderer file keeps the why -- a shared host observation
degrades the second overlapping read to unverifiable -- against the real
reader with no cross-project import.
* docs(ssh): document the worktree-list coalescing contract
* fix(windows): repair the poisoned install-dir ACL before the window, not after
The install-dir LPAC ACL poison (electron/electron#51761) still costs every
affected machine at least one crash: the probe that detects it is
setImmediate-deferred and answers 0.9-3.0s in, while createMainWindow runs
synchronously in the same frame and its renderer dies at init 48-1373ms later.
- Persist the poison verdict the moment the probe reports it, and await the
repair (bounded at 20s) before any window is created on a launch that already
carries the marker.
- Do not engage the GPU safe-graphics fallback while the install-dir ACL verdict
is poisoned or still outstanding. Safe graphics does not rescue a poisoned
tree, and --in-process-gpu removes the GPU child, erasing the sibling-death
evidence that identifies the shape (4 field reports landed in 'misc' this way).
- Clear the safe-graphics marker once the repair lands, so a repaired machine
stops launching software-rendered for the rest of that build.
- Give the repair marker a bounded retry budget: it was written on failure and
matched regardless of outcome, so one transient failure pinned a machine to
'marker-hit' for the life of that version.
* test(windows): pin the install-dir ACL repair against the real icacls binary
* fix(windows): stop the install-DACL verdict from outliving the evidence
Adversarial review round 1. Five blocking findings, all addressed.
1. gpu-lifecycle guard had only a source grep (green with the polarity
inverted). The stated justification -- that gpu-lifecycle's import graph
cannot be driven in-process -- was wrong: mocking `electron` plus
`@electron-toolkit/utils` imports it fine. Replaced with
gpu-lifecycle-install-dir-acl-guard.test.ts, which drives the real
handleGpuChildCrash against a stub tracker. All four cases go red when the
guard is flipped to `if (!isInstallDirAclSuspect())`.
2. A clean probe verdict retired the on-disk marker but not the in-memory
`poison` verdict, so a machine the probe just proved healthy kept
suppressing the GPU safe-graphics fallback and kept the dialog accusing the
install folder -- permanently, since a `status:'failed'` probe deliberately
keeps the marker. A positive clean reading now latches `installDirReadClean`,
drops the verdict, and outranks a repair result that lands after it (a
'failed' from a repair with nothing left to fix must not re-accuse).
'repaired' is kept: it is not a contradiction and it is what tells the user
to reload.
3. `noteWindowsInstallDirAclProbePending()` ran on every `openMainWindow` while
the probe is once-per-process, so every tray/second-instance reopen armed a
15s window in which `recordGpuCrash` was never called at all -- on healthy
machines. `probeWindowsInstallDirAcl` now reports whether THIS call
dispatched, and only a dispatch arms the grace window.
4. The pre-window ordering guarantee was defeatable and untested.
`focusExistingMainWindow` opens a window whenever there is none and the app
is ready -- true for the whole 20s gate, which is exactly when a user
double-clicks the shortcut again. Added a `canOpenWindow` seam (same
'pending' semantics as the existing `!app.isReady()` case) wired to
`isBlockingInstallDirAclRepairInFlight()`, plus
windows-install-dir-acl-startup-wiring.test.ts pinning the await ahead of
both window-creation paths and both new call sites.
5. windows-install-dir-acl-repair.win32.test.ts was absent from the pr.yml
win32 allowlist, so it ran nowhere. Added.
Also from the non-blocking list:
- The repair no longer clears a `userConfirmed: true` safe-graphics marker;
"keep safe graphics" is a user choice, not Orca's automatic latch.
- `repairWindowsInstallDirPackageAcl` now reports its dispatch too, so a second
entry into the gate resolves immediately instead of eating the full 20s
budget waiting on an `onDone` that is never coming.
- The gate is wrapped in try/catch/finally, matching the contract the probe
documents as mandatory for anything upstream of window creation.
Rebutted, not applied:
- "Gate should be conditioned on app.isPackaged." A dev launch only carries the
poison marker if a dev launch actually probed that tree and found the
signature, in which case the dev renderer is dying the same way and the
repair is exactly what is needed. The adjacent `isPackaged` check guards a
packaged-only early-window optimisation, not a correctness boundary.
- "Fold the poison marker into the repair marker's `outcome`." They answer
different questions with different lifetimes. The repair marker is a retry
budget (`attempts >= 3` disables the repair for that version) and is never
cleared; the poison marker is cleared by a successful repair and by a clean
probe. A `'pending'` outcome written before the attempt would bump `attempts`,
so three launches killed mid-repair would permanently disable a repair that
never once ran icacls to completion.
* fix(windows): keep counting GPU crashes while the install-DACL verdict is pending
Adversarial review round 2. Both blocking findings addressed.
1. handleGpuChildCrash early-returned on isInstallDirAclSuspect() BEFORE
recordGpuCrash, so the crash left no trace in the 30s rolling window. The
suspect window is armed on every win32 non-serve launch, and the field
bundles put it at 0.8-1.7s after main_window_created on hosts whose DACL is
clean (matchesPoisonSignature=false) -- squarely inside the 2.1-6.2s
bad-driver bursts this repo already pinned in
gpu-crash-fallback-field-sessions.test.ts. A healthy machine with a failing
driver could lose an entire coalesced burst and never engage safe graphics.
The crash is now always recorded; only the engagement consults the verdict,
and it waits for the verdict rather than acting on the suspicion
(waitForInstallDirAclVerdict, resolved by the probe's onDone or by the
existing 15s grace, whichever lands first).
Deviation from the review's suggested shape, deliberately: awaiting the
verdict before persisting anything reintroduces the exact race
gpu-fallback-engagement.ts documents -- Chromium aborts the whole browser
process on the 6th GPU crash, ~1.3s after the 3rd, which is less than the
probe takes to answer. So the unconfirmed marker is written up front and
withdrawn if the verdict comes back poisoned. A machine killed mid-wait
still comes back software-rendered, and its marker is unconfirmed, which is
the state the repair's own clear already retires.
gpu-lifecycle-install-dir-acl-guard.test.ts now drives the real
GpuCrashFallbackTracker and the real engagement path (the restart prompt
firing is the signal) instead of a stub tracker, and covers the case the
previous suite could not express: a burst that lands entirely inside the
pending window still engages once the probe reports clean. Four reverts go
red -- restoring the pre-record guard (2 tests), dropping the wait, dropping
the post-wait re-check, and dropping the pre-wait marker write (2 tests).
2. The round-1 evidence block quoted commits, a test name and pass counts that
no longer exist, and its real-icacls Windows run predated the commit that
rewrote the gate. Re-run at this commit; counts and the live-Windows result
are restated in the handoff rather than carried forward.
Also from the non-blocking list:
- 'marker-hit' conflated "already repaired" with "retry budget spent", because
hasMarkerFor matches outcome === 'repaired' too. The result now carries
alreadyRepaired, and the recovery maps that to stage 'repaired' -- so a launch
killed between a successful repair and its marker clear no longer tells the
user the folder needs an administrator, no longer latches
isInstallDirAclSuspect() for the session, and does retire the poison marker.
Not applied, with reasoning:
- "clearGpuFallbackMarker narrowed to userConfirmed === false leaves the target
population software-rendered after a repair." The summary was overstated and
is corrected, but the narrowing stands: a userConfirmed marker now requires a
clean DACL verdict, because the restart prompt that writes it is exactly what
the gate above withholds while the install is a suspect. The population this
family targets can no longer reach confirmMarker while poisoned.
- "writeInstallDirAclPoisonMarker re-stamps on a budget-exhausted machine
forever." True, but on that machine the tree really is still poisoned and the
gate resolves immediately ('skipped', no icacls spawn, no 20s wait), so the
marker is telling the truth. Retiring it would be wrong; only a clean probe
reading should.
* fix(windows): register the real-icacls spec and stop its teardown racing icacls
Two ratchets were red:
- windows-lane-tree-removal-boundary: the win32 spec's afterAll used raw
rmSync on a tree two icacls.exe children had just rewritten DACLs on, which
is the EPERM race removeTreeSync exists for.
- win32-test-lane-registration: the spec was in the pr.yml argv but not in
WINDOWS_PACKAGE_TESTS, so a future diff touching only test files would not
select package_windows and the spec would self-skip on ubuntu and report
success.
* fix(windows): re-arm the GPU fallback latch when the install-DACL verdict withholds it
recordGpuCrash reports the threshold crossing exactly once and latches `engaged`.
handleGpuChildCrash consumes that report before consulting the DACL verdict, and
installDirAclClearsGpuFallback then discards it — so nothing could ever engage
safe graphics again in that process. A machine whose tree the repair fixes and
whose driver is genuinely broken stayed hardware-accelerated through an unbounded
crash loop, with no prompt and no marker.
disengage() releases only the one-shot latch; the crash window is untouched, so a
real driver burst is still never erased. Test is RED without the re-arm.
* fix(windows): keep the safe-graphics marker while an install-DACL repair is in flight
The gate dispatches a repair without arming the probe clock, so
waitForInstallDirAclVerdict() returns immediately and the withdrawal deleted the
marker inside Chromium's FATAL window (crash 6 lands ~1.3s after crash 3, well
inside the 20s gate). The process then died mid-repair, spent no attempt, and
relaunched hardware accelerated into the same gate — spawning the same GPU
children, FATALing again, forever.
Hold the marker while poison.stage is 'pending' so that launch comes back
software rendered and the next gate runs to completion. Still not engaged this
launch, so --in-process-gpu does not erase the sibling-death evidence. A
terminal verdict has no next step to rescue, so it still withdraws. Both new
tests are RED without the retention.
* fix(windows): stop a repaired marker outranking a fresh poison verdict
The probe reads the install DACL and finds it poisoned; `startRepair` dispatches;
`markerHitFor` sees a repair marker recording `outcome: 'repaired'` for the same
installDir+appVersion and reports `alreadyRepaired`, which the recovery module maps
to stage 'repaired'. So the launch that just proved the tree poisoned runs no icacls,
deletes the poison marker that arms the next launch's pre-window gate, clears the
suspect flag so `--in-process-gpu` can engage on a tree safe graphics cannot rescue,
and tells the user "Orca repaired the permissions."
Reachable whenever the tree is re-poisoned after one successful repair of the same
version, and whenever a repair reports success without clearing the tree — the silent
icacls no-op this module exists to document.
A DACL reading taken this launch now outranks the marker: `probeConfirmedPoisoned`
stops `outcome: 'repaired'` short-circuiting the repair. The attempt budget still
bounds it, so an unrepairable tree does not re-spawn icacls forever. The pre-window
gate does not set the flag — it acts on a marker from an earlier launch, not on
evidence of its own, so a recorded repair still outranks it there.
Also drives the GPU-fallback re-arm test through a repair that actually completes
'repaired', rather than a later clean probe, which is the route the review exercised.
* fix(windows): make the pre-window ACL gate act on the poison evidence it fired on
The gate fired on a poison marker — an earlier launch's DACL reading that nothing has
retired — but withheld `probeConfirmedPoisoned` from the repair, so a repair marker
recording an older success still short-circuited it. On the three-launch shape the gate
exists for (repair succeeds; tree is re-poisoned; the next launch's probe records the
poison but dies before writing its repair marker) the gate ran no icacls, deleted the
poison marker that arms every later gate, un-suspected the tree so --in-process-gpu could
engage, and told the user "Orca repaired the permissions." `applyInstallDirAclProbeVerdict`
then swallowed that launch's own reading behind `if (poison) return`.
Both callers of `startRepair` hold outstanding poison evidence, so the flag is now
unconditional (renamed `poisonEvidenceOutstanding`) and `marker-hit` means only that the
attempt budget is spent. The probe guard is narrowed to an in-flight gate repair: a reading
taken after the gate finished re-arms the poison marker and downgrades a claimed repair.
Also: withholding safe graphics now ends with the repair budget. A machine whose attempts
are spent while the signature persists was denied safe graphics on every launch for the
life of that appVersion — and had its marker deleted each time — including the healthy
installs the probe's flag-blind ACE match over-matches, where the driver really is broken.
Non-blocking, same lane: re-read `isQuitting` after the up-to-15s verdict wait, and skip
the recovered-launch prompt when the ACL gate retired the marker read before whenReady.
* fix(windows): stop a timed-out gate repair outranking a later poison reading
The gate's 20s budget expires while icacls runs on under its own 120s cap, so
the probe can read the tree poisoned while that repair is still in flight. Its
success claim then deleted the poison marker, un-suspected the tree and told the
user their permissions were fixed. The reading is now latched and outranks it.
* fix(windows): stop a gate repair claim pre-empting this launch's probe reading
Round-7 adversarial findings, both driven against the real modules:
- isInstallDirAclSuspect returned false the moment the pre-window gate set
stage 'repaired', short-circuiting ahead of the probe-pending grace check.
The GPU children die 48-1373ms after window creation while the probe
answers 0.9-3.0s in, so an icacls that silently no-opped (exit 0, tree
untouched) opened exactly that interval to --in-process-gpu on a
still-poisoned tree - and a 'keep safe graphics' answer then pinned a
userConfirmed marker no later repair may clear, with the poison marker
already deleted so no later launch gates. The claim now stays provisional
until this launch's probe corroborates it or the grace window lapses.
- A probe reading that disproves a 'repaired' claim re-armed the poison
marker but never restored the unconfirmed safe-graphics marker the claim
had cleared, so the next launch relaunched hardware-accelerated into the
re-armed gate. The clear is now captured and handed back on disproof.
* test(windows): pin the nested and update-inherited grants against real icacls
The live spec asserted the grant landed on the root-level module file only.
It now also pins that the flagless /T pass reaches a nested file carrying
its own protected DACL (the shape app.asar.unpacked and node_modules have),
and that a file written after the repair inherits the (OI)(CI) root grant -
the stated reason that grant form exists.
* fix(windows): keep the recovered-launch prompt silent while the tree is the suspect
Round-8 fresh-eyes finding, driven against the real modules: the prompt
re-read the marker the pre-window gate may have retired, but never consulted
isInstallDirAclSuspect() - so after a FAILED gate (tree still a live suspect,
window blank behind the 10s reveal fallback, Keep as both defaultId and
cancelId) a 'keep it' answer pinned a userConfirmed marker no later repair
may clear, on the exact victim class the repair cannot help. The guard now
covers both gate outcomes; staying silent leaves the marker unconfirmed,
which a successful repair still retires.
---------
Co-authored-by: Orca Worker <orca-worker@localhost>
Co-authored-by: OrcaWin <alpha-eng@stably.ai>
* perf(remote): read the repo catalog once per publish, not once per worktree
`remoteWorkspace:setForConnectedTargets` costs 13 ms of main-thread time per
call at 0.48 calls/sec — 0.63% of wall on a real session, the second most
expensive IPC handler in the main process.
Almost all of it is one line. `exportRemoteWorkspaceSession` asks
`isTargetWorktree(worktreeId)` once per worktree in the session, and that
callback called `targetForWorktree(store, ...)`, which called
`store.getRepos()` — and `getRepos()` maps `hydrateRepo` over every repo row.
So publishing to one SSH target re-hydrated the whole repo catalog once per
worktree, then threw a fresh `createRepoRowExecutionHostLookup` (which itself
`filter`s the catalog per lookup) away each time.
The lookup is now built once per handler invocation and shared across targets:
the rows cannot change inside one synchronous projection, and they are the same
for every target.
On the session that surfaced this — 413 worktrees, 13 repos, 1 connected target
— that is 413 catalog hydrations (5369 `hydrateRepo` calls) per publish reduced
to 1 (13 calls). The repo normaliser reached through `hydrateRepo` was the #2
self-time function in a 30 s main-process CPU profile at 0.25%.
No user-facing trade-off: identical ownership resolution, identical exported
session, identical stale-revision handling.
* perf(remote): resolve each worktree's owning target once per publish
Follow-up on the same handler: hoisting `store.getRepos()` removed the repeat
hydration, but the ownership resolution itself was still repeated once per
connected target.
`targetForWorktree` computes a connection id from the repo catalog alone — only
the final `=== targetId` differs — so exporting to N targets ran the identical
resolution N times over every worktree key, and the projection asks the question
once per key of `tabsByWorktree`, `activeTabIdByWorktree`,
`lastVisitedAtByWorktreeId` and `defaultTerminalTabsAppliedByWorktreeId`.
Resolutions are now memoised for the life of one publish, keyed on
`(worktreeId, executionHostId)` because both participate in resolution.
Test asserts 6 worktree keys resolve 6 times across 2 targets instead of 12.
* perf(remote): skip the session and repo reads when no hydrated target is connected
Hoisting the catalog read made a zero-connected-target publish pay for a full
repo hydration it never did before. Return early instead.
* perf(persistence): stop writing every worktree metadata row twice
`setWorktreeMetaForHost` assigns one object to both `worktreeMeta` and
`worktreeMetaByIdentity`, so the profile serialized every metadata row twice.
On a measured 3.64 MB install, 1,347 of 1,349 locator rows were byte-identical
to their identity twin.
The serializer now omits a `worktreeMeta` row the identity map can rebuild, and
the load path rebuilds it — reinstating the shared object reference `JSON.parse`
splits in two. A row is only omitted when exactly one alias claims the locator
and that alias names exactly one identity key, so the rebuild is a pure function
of the file with no winner selection to disagree about.
Omission rather than an in-value sentinel: a downgraded build reads a non-object
`worktreeMeta` value as corruption and deletes that locator's lineage companions
with it. An absent key is a shape every build already tolerates, and it falls
back to the untouched identity map.
* fix(persistence): keep the lineage maps when a profile file has no worktreeMeta key
The rebuild returned `parsed.worktreeMeta` untouched when it was not a plain
record, so an absent key became an explicit `worktreeMeta: undefined` that
outranked the defaults spread. `normalizeWorktreeLinkedItemMetadata` reads a
non-object `worktreeMeta` as corruption and wipes that file's
`worktreeLineageById` and `workspaceLineageByChildKey` with it, then marks the
state dirty so the wipe is persisted. Before this branch the spread supplied
`{}` and the lineage survived.
Also pins the two raw-file readers the projection made load-bearing: the
history GC recovering projected ids from the alias keys (a miss deletes shell
history a live workspace is using), and the profile-transfer read rebuilding
the omitted locator rows (a miss transfers workspaces with no metadata).
* perf(persistence): drop the identity twin, not the locator row
Reverses the projection direction: `worktreeMeta` stays complete on disk and
`worktreeMetaByIdentity[K]` is omitted instead, only when the locator row
regenerates K by construction (`wt2:<hostId>:<instanceId>`) and the two rows are
equal. That removes the format marker, the deliberate-absence list, both raw-file
reader patches and every downgrade hazard, because "alias present, identity row
absent, locator derives it" is a shape every shipped build already heals to
exactly this state.
Keeps 88% of the byte win (541 KB vs 613 KB) and the whole heap-sharing win.
* chore(persistence): keep the derivation predicate module-private
* test(persistence): pin that a file with no identity map never gains one
Answers the review ask that the projection's absent-key contract be asserted on the
bytes, not inferred: a profile whose file carries no `worktreeMetaByIdentity` must
still not have one after a load+flush.
* perf(runtime): stop the expired-SSH-lease sweep from rescanning every tab layout
The `runtime:syncWindowGraph` IPC handler is the most expensive thing the main
process does: measured on a real session it costs 20.7 ms per call at 0.71
calls/sec, which is 1.47% of wall and ~17% of all main-thread JS. 76% of that
sits in one subtree: `getHydrationTargets` -> `hasRuntimeOwnedPtyCandidate` ->
`getRecentExpiredSshLease` -> `findTerminalTabIdForLeaf`.
Three pieces of pure waste, none of which change an answer:
1. `getRecentExpiredSshLease` evaluated its cheapest and most selective filter
LAST. `SSH_PANE_RECOVERY_GRACE_MS` is 30 s, so nearly every stored expired
lease fails it — but only after the predicate had already resolved the
lease's leaf to its current tab, which is the expensive part. The freshness
and reattach-eligibility gates now run first; the predicate is otherwise
identical and side-effect free, so the selected lease is unchanged.
2. The sweep ran once per tab. `workspaceSessionWorktreeHasRuntimeOwnedPtyCandidate`
asked "does a recent expired lease name THIS tab" for every tab in a
worktree, and each ask re-read and re-filtered the whole lease list. It now
resolves the worktree's recoverable tab ids once, lazily, so a worktree whose
first tab already owns a serve/SSH pty still never sweeps.
3. `findTerminalTabIdForLeaf` allocated a `Set` and walked a whole pane tree per
tab to answer one leaf lookup. It now reads a leafId -> tabId index built
once per layouts record and reused until a layout object is replaced, which
keeps first-tab-wins ordering identical.
Measured by replaying a real 414-worktree / 801-tab / 137-lease session:
2.51 ms -> 0.27 ms per publish for this subtree, a 9.3x cut.
No user-facing trade-off: same leases selected, same tabs reported recoverable,
same SSH pane recovery affordance.
* fix(runtime): revalidate the leaf membership index on root identity
persistPtyBinding grafts a leaf by assigning `layout.root` on the SAME
layout object inside the SAME layouts record, so the layout-identity
revalidation kept serving an index blind to the grafted leaf and
findTerminalTabIdForLeaf answered `undefined` where the pre-index linear
scan answered the tab. That fed the SSH reattach fence
(restoreReattachedPtyRuntime) and the expired-lease pane recovery
resolver, both of which then fall back to the frozen lease tabId.
Membership is a pure function of the root tree and no writer mutates a
node in place, so root identity is the exact revalidation key — same
O(tabs) pointer compare, no new cap, cadence or staleness window.
* perf(runtime): resolve a leaf's tab by scan instead of a cached membership index
Fix#3 of this PR cached a leafId -> tabId map per layouts record and revalidated
it by comparing every root reference on every read. It was the only mutable
cross-call state in the change, the only piece carrying a staleness invariant,
and it had already needed one follow-up fix (1c23c544) after a layout-identity
key turned out to be blind to `persistPtyBinding`'s in-place `layout.root` graft.
The index was never what produced the measured win. After fix#1 moves the
freshness gate first, the reporter's replay never calls `findTerminalTabIdForLeaf`
at all — every stored expired lease is older than the 30 s recovery grace, so the
entire 24.9 ms -> 0.9 ms comes from fixes#1 and #2, both of which are unchanged.
`findTerminalTabIdForLeaf` is now an allocation-free scan over the existing
`layoutContainsLeafId`, which short-circuits on the first matching leaf instead of
materialising a Set per tab. Same answers, same first-tab-in-record-order
semantics, no revalidation key, nothing for a writer to invalidate.
Re-measured on the same 414-worktree / 801-tab / 137-lease replay
(process.cpuUsage deltas, median of 3; wall clock is useless on this box):
scenario main index scan
all leases stale (replay) 24.86 0.87 0.88 ms/publish
one lease inside the grace 24.31 1.08 1.04 ms/publish
all 137 inside the grace 18.04 3.71 4.31 ms/publish
The measured win is unchanged. Only the synthetic worst case — every one of 137
leases expiring inside the same 30 s window — pays for the cache's absence, and
even there the two ranges overlap because the index's own revalidation is O(tabs)
per lookup.
Removes 208 net lines. `terminal-leaf-tab-resolution.test.ts` keeps the parity
cases and adds the guard the cache needed: a subtree replaced in place after an
earlier read must be visible to the next one. That test fails against the index.
* docs(runtime): say why the leaf scan keeps Object.keys
'Allocation-free' overstated it — Object.keys does allocate one key array.
A guarded for...in trades that for a hasOwn call per tab and measures slower,
so record the reason the next reader does not re-litigate it.
* fix(cloud): recalibrate the relay monitor's exhausted-retry freeze to a measured bar
The pre-drain dry-run froze at minute one on relayPostgresRetryExhausted: 0
in every run since #18521 reached the director, blocking the cell roll that
carries the same fix. #18521 made contended request-path waiters fail fast
(500 ms) instead of succeeding slowly, so exhaustion is now a steady
contention rate: 236/236 five-minute windows non-zero over 23 h; post-#18521
p50 42 / p90 147 / max 220 fleet-wide; the 2026-08-23 incident peaked at 467.
300 clears every measured healthy window and stays under the incident shape.
/v1/assign 503 share was unchanged by #18521 (13.9% vs 12.3%).
* test(cloud): pin the exhausted-retry freeze boundary at exactly 300
* docs(cloud): reword relay comments that still described the zero exhausted-retry bar
* perf(startup): stop queueing window creation behind the proxy apply and i18n
Three independent, measured startup wins, all free:
1. Park the initial Chromium proxy apply on `mainProcessState` instead of
awaiting it mid-`initializeReadyFoundation`. `setProxy` still starts at the
identical moment; the default-session request guard (which holds, not
cancels) is what actually fences fetchers on it, so only window creation
stops waiting. Runtime launch still awaits it before the desktop relay and
before every headless-serve fetcher.
2. Run `initializeMainProcessI18nAndMenu` concurrently with
`initializeMainProcessRuntimeLaunch`. Nothing in window creation reads a
translated string or the native menu.
3. Load `emojibase-data` in main through `createRequire` on first use instead
of a static import, keeping 166 KB of JSON off `out/main/index.js` and its
~2 ms parse off every launch. The renderer keeps its eager copy unchanged.
out/main/index.js 7,210,071 -> 7,040,147 bytes. No renderer behaviour changes.
* fix(packaging): ship the emoji shortcode dataset main lazily requires
app.asar carries no node_modules, so main's bare requires resolve only out of
Resources/node_modules. emojibase-data is a devDependency and is not in the
packaged runtime allowlist, so the new createRequire in
deferred-emoji-shortcode-dataset.ts threw MODULE_NOT_FOUND in every packaged
build — breaking sanitizeWorktreeName, and with it workspace creation.
Copy the single 166 KB dataset (not the 49 MB package root) into
Resources/node_modules, and gate every createRequire'd bare specifier in
src/main against the packaged resource plan. verifyPackagedMainRuntimeDeps
cannot catch these: the bundler renames the require binding.
* test(proxy): fail CI when a main-process fetcher escapes the default-session guard
The hoist relies on installElectronProxyRequestGuard(session.defaultSession) holding every app-owned request until the persisted proxy lands. Nothing enforced that every fetcher actually lands on defaultSession. Two source-anchored rules do now: no net.fetch/net.request may name a session/partition, and every non-net .fetch( call site is counted against an allowlist.
* test(proxy): close the shorthand and chained-receiver holes in the fetch call-site audit
The audit caught `net.request({ session: x })` and `ident.fetch(`, but not the two
shapes a real regression is just as likely to take: the `{ url, session }` shorthand
that both `net.request` overloads accept, and a receiver with no bare identifier
(`session.fromPartition(...).fetch(`, `ctx.session.fetch(`). Rule 1 now also matches
the shorthand key; rule 2 scans every `.fetch(` and excludes only a literal
`net`/`globalThis`/`global` receiver. Audited counts are unchanged (2/2/1).
* fix(startup): scope the deferred emoji loader to the projects that own it
TS6307: the composite web project lists src/main/ipc/worktree-logic.ts, which
now imports the deferred dataset loader, and the shared lazy test reached into
src/main from a project that has no src/main files. Add the loader to
tsconfig.tc.web.json and move the cross-project case into a src/main test.
Also close the last two review gaps: gate the runtime-RPC startup failure
dialog (the only launch-phase translateMain reader) on a published i18n
barrier so a concurrent i18n phase cannot leave a non-English user with the
English fallback, and let the fetch call-site audit match `net.fetch (url)`.