The platform a host runs is a fact about the host, not about whether its last
probe came back. Reading `entry.status` fell through to the client's platform
the moment a probe went unverifiable, so a Windows host driven from a Mac
silently started resolving keystrokes and paths with POSIX conventions
mid-session -- and switched back on the next successful probe.
Same conversion as the four sibling reads, using the same shared reader.
Settings > Available Hosts and the repository host-setup section render the
same host from the same store entry, but this row derived its own answer from
raw `entry.status`. An unverifiable probe nulls that while the transport is
still up, so the row flipped to "error" and swapped Disconnect for Connect
while the other surface -- which already goes through
runtimeHostConnectionStateForEntry -- still showed the host as reachable.
One host, two surfaces, opposite answers. A probe that did not come back is
not a host that went away.
* fix(ssh): record the superseded-relay pass the Windows arm abandons
`sweepSupersededRelayEndpoints` returned `[]` for every Windows remote host
and for every failed listing without writing a line. Both returns are
indistinguishable from "this host had no orphans", which is the one thing
this sweep exists not to be: its own header says it makes the orphan
population "visible and deliberate rather than silent".
The Windows population is real. `relayEndpointForHost` hashes the version
directory into the pipe name, so an app update strands the incumbent exactly
as it does on POSIX, and with `--grace-time 0` that relay keeps its PTYs and
agents forever. Measured on a Windows 11 host (awin): the NPFS root lists 262
named pipes from an unprivileged shell, and the count of `orca-relay-*` names
goes 0 -> 1 the moment a relay binds, so the endpoints are enumerable; the
repo already enumerates them for GC via `relayLivenessProbeCommand`'s
`.windows-active-pipe-*` marker scan.
Reclaiming them is not this change. `probeRelayEndpointIncumbent` answers
`unverifiable` for every Windows path, so nothing here could be classified,
let alone reaped, and nothing about the kill path moves. What changes is that
an abandoned pass now leaves a trace.
* fix(ssh): keep the endpoints a half-run superseded sweep already classified
The Windows arm and the failed-listing arm now both leave a line. The loop between
them did not: socket 1 could be fully probed and classified, and an exec on socket 2
that threw took `logSupersededRelayFindings` with it — so a half-run pass and a host
with nothing to sweep produced the same silence, and socket 1's verdict was lost.
Only one failure class can leave that loop, and it is the one that matters: an exec
whose SSH channel never confirmed close, which may still be running remotely and
which `probeRelayEndpointIncumbent` rethrows by design. Every ordinary probe failure
already degrades to `unverifiable` and the pass continues — a test now pins that too,
so nobody "fixes" the loop into stopping on an absence of evidence.
Findings are logged before the rethrow, which propagates unchanged. The added line
says how far the pass got and claims nothing about the endpoints it never reached.
* fix(ssh): word the Windows sweep skip so a first install does not read as orphaned
The line fired on every Windows relay launch and asserted a population: "orphans from
earlier builds are neither listed nor reclaimed" reads as a finding on a machine that
has never had an earlier build. The skip is what is being recorded, not a census.
* fix(relay): one malformed pre-auth handshake frame closes its connection, not the daemon
parseHandshakeMessage returned whatever JSON.parse produced, and the daemon
interpolates the peer's version into a log line before any credential check.
A version that is an object with a non-callable toString throws TypeError
there, inside the frame-decoder callback. FrameDecoder.drainTurn wrapped its
synchronous dispatch in try/finally with no catch, so the throw escaped
feed(), escaped the socket data handler, and reached uncaughtException: the
relay daemon exited and every PTY and agent session it held died with it.
Two layers, because only the second closes the class:
- parseHandshakeMessage now requires the string fields each arm carries
(version; expected/got) and rejects a non-object payload. Both readers
share the parser, so neither side can interpolate a non-string again.
- FrameDecoder contains a frame owner that throws on the synchronous turn
the same way it already contained one on a continuation turn: reset the
residue and report one FrameDecoderContinuationError to onError. Every
owner's onError already closes its own connection, so any future throw
of this shape costs one connection instead of the process.
The relay CLI channel gains an explicit onError so a malformed reply still
ends that one-shot command instead of parking it.
* fix(relay): keep the diagnostic the refusal path exists to produce
Two error paths that destroy their own evidence.
`parseHandshakeMessage`'s unknown-type refusal interpolated `String(t)` on a
peer-supplied value: `{"type":{"toString":1}}` makes String() throw "Cannot
convert object to primitive value", so the refusal arrives without naming what
was refused. `describeRelayProtocolVersion` guards this exact hazard two files
away; the sibling was missed.
`runRelayOrcaCliChannel`'s new `onDecodeError` wrote to stderr and then exited
synchronously. stderr is async on a pipe transport, so the one line recording
why the command died could be dropped — the reason relay-handshake.ts already
exits inside its write callback.
* fix(relay): prove the optional handshake field too, not just the required ones
The parser refuses a non-string `version`, `expected` and `got`, then returns the
object with `endpointCredential` unproved — the most pre-auth field on the frame.
It is safe today only by accident: its one reader compares it, and a non-string
loses that comparison. Nothing holds that shape in place, and the next reader to
put it in a log line reinstates the template-literal throw this function exists
to stop.
Present-but-not-a-string is now refused at the parser. Absent stays absent: a
bridge presenting no credential is the common case, and refusing it would close
every unauthenticated-endpoint connection.
Wire-visible delta, deliberate: a peer sending a non-string credential used to get
`orca-relay-handshake-credential-mismatch` and exit 43; it now gets a bare close.
No first-party client can reach it — `runConnectHandshake` types the parameter
`string` and omits it when falsy — and a bare close is the right answer to a frame
that was malformed before any credential was checked.
* fix(relay): carry the SAFETY: rationale main's casting gate now requires
Main gained a `typescript/consistent-type-assertions` scan while this branch sat 432
commits behind, so every `as` the branch touches lands as a new finding. The parser is
the one place the handshake shape is proved, so each cast names the check that earns it,
and the hostile-frame cast in the round-trip test names the fact that it is a deliberate
lie the type system cannot describe.
* test(relay): annotate the hostile handshake frame instead of suppressing a cast
JSON.parse answers `any`, so a typed const expresses the same deliberate lie the
assertion did and the casting gate has nothing to flag. One fewer suppression.
resolveTerminalLayoutRoot discarded any known tree that did not cover the
published leaf set exactly and rebuilt the tab as a flat chain with a guessed
'horizontal' direction, restacking side-by-side panes. The guess is then
published, mirrored to every paired client, and written back over the real
tree, so the direction is gone from disk.
Prune a known tree to the leaves that survive and graft only the leaves no
tree places, which is now the sole place a direction is invented and is still
reported through onSynthesize.
* fix(mobile): mint one pairing offer per Continue on the sidebar page
Step 2 auto-minted as soon as it became visible, which is the same commit
that starts the network-interface lookup. The offer therefore advertised
whatever address was left over from the last visit (or none at all, so
main picked its own default), and when the lookup settled on a different
address the refresh handler reminted with rotate: true. Two overlapping
getPairingQR calls then raced for one pending credential: main rotates the
pending device away for the rotate mint, and orders concurrent offers by
arrival at its generation counter rather than by the order the renderer
issued them, so the request the pane is waiting on can be the one main
decided to supersede.
Defer the auto-mint until the interface lookup settles, and keep Step 2
reading as busy while it waits — the sidebar has no separate Generate step
the user is expected to reach, so it must still mint on its own, unlike
Settings which clears and waits for an explicit press.
* fix(mobile): gate the Step 2 mint on this flow visit's address lookup
The first attempt gated on a single boolean ref meaning "an address lookup
is running". That cannot describe a re-entrant operation: entering the
flow, leaving, and re-entering runs two overlapping lookups, and the first
to land clears the flag while the second is still out — so the mint went
out against the superseded lookup's address and the second lookup then
reminted with rotate: true. The same double mint the change exists to
remove, one path over.
Gate on positive evidence instead. Each flow entry bumps a visit counter;
the lookup records the visit it answered (max, so an abandoned visit
landing last cannot walk the marker backwards); the mint waits for
addressedFlowVisit === pairingFlowVisit, which is false at t=0 by
construction and makes exactly one false-to-true transition per visit. The
ref is gone and the effect's dependencies now name what it depends on.
A superseded lookup's response is also discarded outright, so it cannot
move the picker onto an address a newer lookup already replaced — that
reselection is itself a remint trigger.
The derived busy flag collapses to one clause and is renamed
awaitingPairingAddress: it was being passed down as pairLoading while
local readers used the real one. It stays separate from pairLoading
because that feeds shouldRegenerate in the invalidation hook, where
merging them would let a mode switch mint before the address settles.
* fix(mobile): put the visit-settled write behind the lookup epoch guard
setAddressedFlowVisit was the one completion side-effect outside
networkInterfacesRequestIdRef, so a superseded lookup *for the same visit*
still marked that visit addressed and released the mint while its own
replacement was still pending — the newer address then rotated the offer
away. The visit counter cannot see this case: both lookups belong to one
visit, and only the request epoch distinguishes them.
Reaching it needs a manual Refresh click to beat the commit that disables
that button, so field impact is low. The point is that the invariant is now
structural instead of resting on a button being disabled in time.
Math.max is dropped with the move. Every visit bump starts its own lookup,
so the newest request always carries the highest visit and the marker
cannot move backwards — the max could no longer be killed by any single
mutation, which made it dead code asserting a hazard the guard removes.
Also swap the test reset to _resetPairedMobileDevicesCacheForTests, matching
the sibling suites: replacePairedMobileDevices is production API that
publishes loaded:true and leaves the recovery-listener refcount untouched.
* refactor(mobile): make the unaddressed flow visit an explicit null
-1 only worked because visits start at 0 and count up; null says "no visit
has been addressed yet" without depending on that. Also record at the visit
bump why it cannot move into the stage effect: an effect runs a render after
Step 2 is visible, so the auto-mint would see the previous visit settled.
* fix(mobile): invalidate abandoned pairing mints
* fix(runtime): treat selector_not_found as definitive tab absence (#21189)
When closing a tab whose worktree selector is absent, propagate the
error through host RPC and classify it as unknown-tab on the renderer
to engage durable tombstones and prevent resurrection loops.
Pin host RPC error propagation with dedicated regression tests.
Co-authored-by: Neil Parker <neil@stably.ai>
* test(runtime): remove invalid absent-tab Docker spec
The spec dynamically imported renderer source from the browser and did not exercise a real close RPC. Keep the executable renderer and host regression coverage instead.\n\nCo-authored-by: Lesley Murfin <lesley@revivebusiness.ca>
* fix(runtime): narrow durable tab absence to tab and terminal absence (#21189)
Narrow durable close tombstones in web-runtime-session-tab-lifecycle to
tab_not_found and terminal_tab_not_found. In production, session tab close
requests pass explicit `id:` worktree selectors and take the fast path in
closeMobileSessionTab, bypassing resolveWorktreeSelector. Transient
selector_not_found errors retain normal TTL eviction.
---------
Co-authored-by: Neil Parker <neil@stably.ai>
* fix(chat): release provider children after lost resume holds
* test: load audit fixtures as modules and verify combined mobile payload
---------
Co-authored-by: m4air <m4air@Mac.localdomain>
* fix(filesystem): match allowed roots across Unicode forms
macOS returns a path in whichever Unicode form its source held: APFS gives
back what it stores (NFD), while the file picker and git
(core.precomposeunicode) give back NFC. A workspace registered in one form
never matched a file read in the other, so fs:readFile denied a path inside
the open workspace (#21172).
isDescendantOrEqual now compares byte-exactly first and retries in NFC only
when that fails and both sides carry non-ASCII, leaving ASCII containment and
the traversal guards untouched.
* fix(filesystem): prove identity before admitting a Unicode-folded root
Canonical equivalence is not identity: APFS folds both spellings onto one
directory, but a byte-exact filesystem can hold them as distinct siblings,
and admitting the unregistered one widened the allow-list.
The NFC fold now only locates the ancestor of the target that the
registered root would have to be; containment is granted only when that
ancestor and the root stat to the same dev+ino. A failed stat or an ino of
0 denies. ASCII paths and roots that do not fold onto the target never
reach the disk.
* fix(relay): probe a half-open control socket instead of waiting out the silence bound (STA-7672)
A socket CLOSE rejects a pending request as relay_control_closed_<code>, so a
relay_control_request_timeout is positive proof the socket stayed open and
simply never replied. The only thing that reaps such a socket is
RELAY_CONTROL_SILENCE_LIMIT_MS = 75_000, combed every 15s, against a 10s
request deadline. On Windows behind NAT/VPN or across sleep-resume a half-open
TCP socket accepts send() into a dead pipe and stays invisible for 75-90s, so
every pairing attempt in that window times out. The reporter burned ~7.
A request that times out with no inbound frame since its send now arms an RFC
6455 ping probe. Terminating on the timeout alone was rejected: relay control
ops run DB transactions that can outlive the deadline, and the existing comment
in handleMessage records that self-closing on a late reply was strictly worse
than ignoring it -- it orphaned the relay session and answered the phone with
HOST_OFFLINE for minutes. The probe distinguishes the two cases instead of
guessing.
The probe deadline deliberately exceeds the relay's own 15s application-level
ping cadence. Relay liveness never depended on RFC 6455 control frames
surviving end to end, so a shorter window would let a middlebox that swallows
pongs turn every request timeout into a reconnect loop. At 20s a healthy cell
clears the probe either way -- with a pong, or with the ping it was going to
send anyway -- so a probe that fires means the pipe carried neither. Detection
drops from 75-90s to ~30s.
A pong clears a probe but deliberately does not feed the silence watchdog: it
proves the pipe, not that the relay still indexes the session.
The timeout error also stops being a bare string; it now names the request
kind, the cell, the socket age, the time since the last inbound frame, and
whether a probe was armed.
The silence watchdog, the probe and the socket age now live in one
RelayControlLiveness owner rather than scattered across RelayControlClient.
* fix(relay): require a run of unanswered probes before tearing down a control
A single unanswered probe was treated as proof of a dead pipe. STA-3320 already
established that it is not: a cellular/VPN blackhole or a stalled TCP
retransmit routinely swallows one pong from a peer that is still there, which
is why RemoteRuntimeServerHeartbeat requires three consecutive misses. The
networks this detection exists for are exactly the ones that drop a lone frame,
so the first cut was more trigger-happy than the rest of the product.
Three changes, all aimed at the cost of a false positive rather than the
detection itself:
- Three consecutive unanswered probes are now required. The interval drops to
8s so the full run (24s) still outlasts the relay's 15s application-level
ping, preserving the property that a healthy cell clears the probe even where
a middlebox swallows RFC 6455 control frames. Detection lands at ~34s rather
than ~30s, against 75-90s before the fix. Any inbound frame retires the whole
run, so a later probe never inherits an earlier miss.
- The deadline carries the fleet's existing +/-10% jitter
(RELAY_RENEWAL_JITTER_RATIO). Without it every host timing out against one
slow cell would probe and terminate on the same boundary -- the synchronized
cohort burst that constant was introduced for. The pre-existing 75s watchdog
comb has the same defect; this path does not add to it.
- A liveness teardown now names its cause in the log. It reaches the origin as
an ordinary 1006 close, so without a label a probe-driven reconnect is
indistinguishable from any other drop, and a fleet-wide false positive would
be invisible in exactly the incident where it matters.
Mutation-checked: a miss limit of 1 fails four tests, 2 fails one, and removing
the jitter fails one.
* fix(relay): keep the request-timeout rejection classifiable
The diagnostics added in the previous commit were appended to the rejection's
message, which silently destroyed the signal they were meant to add.
`mobileRelayMintFailureFromUnknown` classifies a relay failure by testing
`error.message` against an anchored `/^relay_[a-z0-9_]{1,74}$/`, so
`relay_control_request_timeout reqKind=invite cell=...` stopped matching and
every pairing timeout was reported as the generic `relay_mint_failed` instead --
in exactly the flow STA-7672 is about. The pairing path logs only the resolved
code and discards the rejection's text, so nothing ever surfaced the suffix:
the change was a net loss of diagnosis.
The message is bare again and the diagnostics are logged from
RelayControlLiveness, which is the only place they survive.
Added relay-control-timeout-classification.test.ts to pin the contract end to
end through the real classifier, since the coupling is invisible at both sites:
restoring the suffix turns the assertion into relay_mint_failed.
Found in adversarial review.
* refactor(relay): collapse the half-open detection onto one object
Design review of the three commits on this branch. No behaviour change: the
184 relay tests pass unmodified, and reverting PROBE_MISS_LIMIT to 1 or 2, or
dropping the jitter, still fails them.
Dead plumbing. `probeIntervalMs` had zero callers across three layers
(client options -> conditional spread -> liveness default), and `silenceLimitMs`
the same -- the only production construction site, relay-control-origin.ts,
passes neither. Both are gone. `livenessRandom` stays; one test uses it. The
conditional-spread idiom went with them: `exactOptionalPropertyTypes` is off for
src/ (only cloud/apps/relay-ops sets it), so it bought nothing that
`?? Math.random` does not already do.
Teardown owns its own log. A two-member reason union crossed a module boundary
just to reach a console.warn, and the client re-derived `cell=` from
relayOrigin when liveness already held `cellUrl`. Liveness now tears itself
down and calls `terminate`; the client lost the import, the method, and the
exported type.
One probe object, one interval. `probeTimer` + `missedProbes` are now
`probe: { timer, misses } | null`, so "no timer implies no misses" is structural
instead of maintained by resetting in two places, and the
sendProbe/onProbeUnanswered mutual recursion is a plain setInterval. Jitter is
computed once per run rather than per tick -- one offset already desynchronizes
the cohort.
Honest probe label. If ping() throws, the old arm path returned false and the
caller logged `probe=in-flight/0` moments after terminating the socket -- a
false statement in the line that exists for incident forensics. The arm path
now returns the label it means, including `probe=send-failed`.
Absorbed RelayControlSilenceWatchdog. It had one consumer and no test file, and
this branch had to punch a `lastInboundTime` getter through it purely so
liveness could read state it holds. `lastInboundAt` now sits next to `openedAt`;
the file, the getter, the import, and the onDead('silence-limit') lambda are all
gone.
Also: dropped `RelayControlRequestTimeout.reqId` and `PendingRequest.sentAt`
(both written, never read -- the timeout closure captures the local `sentAt`);
dropped the two `'n/a'` branches, unreachable because a request timeout can only
fire after sendActive succeeded, which requires a state only handleProofMessage
reaches on the line before it calls liveness.start(); moved the classifier
invariant off a void-returning callback type and onto REQUEST_TIMEOUT_CODE,
where an edit to the string is next to the warning about editing the string;
and replaced the `live` parameter with an `isLive()` option so liveness asks
rather than being told, which also let `liveness` be constructed before
`requests` instead of a closure reading a field assigned on a later line.
* feat(mobile-web): add the Phase A bootstrap web source
A peer of src/ so the root workspace owns it and mobile's separate lockfile
stays out of packaging. Four assets across four content types, enough to
exercise multi-asset manifest handling rather than assume it.
The page reads buildId from manifest.json at runtime: buildId hashes the asset
list that index.html belongs to, so injecting it into a hashed asset would make
that asset's hash depend on itself.
Registered as a fourth typecheck project; without it the entry would be the
only TypeScript in a release path that tsc never sees.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(build): build and verify the mobile web bundle from the root workspace
Root esbuild over mobile-web/ into out/mobile-web/, content-addressed as
assets/<sha256>.<ext> with index.html the only stable name. buildId is the
sha256 of the canonical serialization of the sorted asset list, so it is a pure
function of content and usable as a cache key with no further reasoning.
The verifier builds twice into scratch dirs and compares: a timestamp, an
absolute path, or an unstable ordering fails the build when someone introduces
it, not the first time a phone gets a spurious cache miss. It also enforces the
Phase A budget of 16 assets and 256 KiB, separate from the permanent contract
ceiling.
build:release does not call build:desktop, so build:mobile-web is wired into
build:desktop, build:release, and build:release:parallel.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(packaging): fail the release when the mobile web bundle is missing or stale
electron-builder only warns about a missing input, so without a beforePack
guard a release ships an app that advertises the bundle capability and then
errors on every request. The hash check, not the existence check, is what
catches a half-written or stale out/.
The source tree is excluded from app.asar; out/mobile-web ships inside it under
the existing out rules, exactly as out/web does.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile-web): narrow the manifest with `in` instead of a cast
The changed-code casting gate rejects assertions, and `in` narrows the same
untrusted JSON without one.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile-web): move the bundle source under src/ so the root guard passes
.github/scripts/check-root-directory-entries.mjs blocks any new top-level entry
by name, so mobile-web/ could not live at the root.
The source is excluded from app.asar by the existing '!src{,/**/*}' rule; the
explicit '!src/mobile-web{,/**/*}' entry stays as a marker. out/mobile-web is
unaffected and still ships under the out rules like out/web. No tsconfig
includes src/**, so node, web, cli, and relay do not pick the tree up; it is
registered as a knip entry so audit:dead-code does not call it unused.
buildId is unchanged at 9d78435e: the builder hashes content, not paths.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(build): resolve the entry-script guard through pathToFileURL
`file://${process.argv[1]}` never equals import.meta.url on Windows, where that
url is file:///C:/... So the builder exited 0 having written nothing and the
Windows packaging job failed later, at the guard, with no clue why. Every other
script in config/scripts already uses pathToFileURL; this one now does too, via
an exported predicate a posix runner can exercise with a win32 path.
The verify script had no entry guard at all, so importing its budget constants
ran the whole verification — including its process.exit — inside the test
worker. It is now a function behind the same guard.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(ci): build the mobile web bundle in the PR package job
That job assembles packaging inputs step by step instead of calling
build:release, so the new beforePack guard hard-failed it.
The census test added here is the oracle: it walks every workflow job that
invokes electron-builder without --prepackaged (which short-circuits doPack
before beforePack) and requires a bundle-producing script in the same job. It
goes red on exactly pr.yml's package job when this step is removed. Ten jobs
covered; the other nine already ran build:release, build:release:parallel, or
build:desktop.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile-web): pin source line endings, because CRLF changes the buildId
Every text byte under src/mobile-web is hashed into an asset digest and from
there into buildId, so a CRLF checkout produces a different bundle id for the
same commit: 91af2897 instead of 9d78435e. That would make a Windows-built
desktop disagree with a mac-built one about which bundle a phone has cached.
.gitattributes pins eol=lf for the text sources and -text for the PNG, matching
the four trees already pinned for byte-hashing. The verify script asserts no
source file carries a CR, so the build fails if the pin ever stops applying
rather than silently shipping a second bundle identity.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(build): read the test's own path from import.meta.filename
oxlint unicorn/prefer-import-meta-properties.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(test): census packaging jobs over raw workflow text, not re-serialized YAML
yaml.stringify folds long lines, and in dev-channel-win-build.yml's build-win the
fold landed between `electron-builder` and `--config`, so a real packaging job was
invisible to the census: 11 jobs exist, the test saw 10. Slice each job's raw source
by its parsed boundaries instead, and pin the inventory so a new packaging workflow
has to be added here on purpose.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(build): assert the script chain the packaging census trusts
The census only checks that a packaging job invokes one of ten build scripts; that
those scripts still reach build:mobile-web was asserted nowhere, so a dropped link
would leave every job looking covered while packaging failed at beforePack. Resolve
each script for real, and pin pr.yml's hand-rolled step, since that job never calls
build:release.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(build): realpath the entry path before the direct-invocation compare
Node resolves symlinks in import.meta.url but not in argv[1], so `node /tmp/...`
against a /private/tmp realpath compared two different strings: the builder and the
verifier exited 0 having written and checked nothing. Same silent-success shape as
the Windows file:// bug, so the fix sits next to it, with both seams injectable.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* style(mobile-web): format bootstrap.css with oxfmt
It was the only tracked CSS failing oxfmt --check. The buildId is unchanged at
9d78435e8bb73c3341f833c20aaefbd7bfdfc414b68dadf87c1689d86728fe33, because esbuild's
CSS minifier normalises the whitespace this touches before the asset is hashed.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(packaging): reject bundle files the manifest does not list
The guard only walked the manifest, so a dropped assets/stale.js passed: assets are
content-addressed, nothing ever overwrites a stale copy, and it would ship inside
asar unreachable and unverified. Require every file under out/mobile-web to be the
manifest or a listed asset.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(packaging): give beforePack an explicit mobile web bundle root
The bundle guard read the repo's out/mobile-web unconditionally, so the two
arch-aware packaging tests that call the real beforePack went red in the unit-test
job, which never runs build:mobile-web. beforePack now takes the bundle root as a
second parameter defaulting to out/mobile-web, which is what electron-builder gets,
and those tests build a real bundle into a temp dir instead. The guard is neither
skipped nor made tolerant of a missing bundle.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(packaging): census sees script-wrapped packers; dev verify reuses the guard
The workflow census only matched a literal `electron-builder --config` line, so
daemon-relocation-spike's `pnpm run build:unpack` (which packs and runs beforePack) was
invisible to it. Jobs now count when any `pnpm run <script>` they invoke chains to
electron-builder without --prepackaged; the spike joins the pinned list (12 jobs).
verify-mobile-web-bundle.mjs re-implemented a weaker subset of the packaging guard
(no safe-path check, no buildId recompute). It now calls assertMobileWebBundleBuilt, so a
manifest edited after the build fails at `pnpm build:mobile-web` exactly as at beforePack.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile-web-bundle): add the manifest contract and content-addressed build id
The schema every later Phase A lane parses against: the ceilings that bound host
memory (256 assets, 32 MiB total, 10 MiB per asset), and a build id that is a
pure function of content so a client can use it as a cache key unconditionally.
The serializer sorts its input rather than trusting the caller, so a producer
that emits assets in any order still lands on the same id.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile-web-bundle): add the bundle RPC payload contract
Method names, capability name, the 48 KiB chunk size, params/result schemas for
both methods, and the six error codes as a closed union pinned by a coverage
record. Constants and data only; the host wiring and the capability push land in
later lanes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile-web-bundle): hash the build id without node:crypto
Metro ships no Node core shims, so a value import from these modules would fail
to bundle on the phone. The pure-JS sha256 keeps both contract modules
runtime-neutral, which also lets a cached manifest be re-verified on device.
Verified digest parity against node:crypto across the 55/56/64-byte padding
boundaries before the swap.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile-web-bundle): reject a manifest whose buildId is not its content hash
A stale id passed every other check and would then serve the wrong bytes under a
cache key the client already trusts. Runs last of the invariants because it is
the only one that hashes.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile-web-bundle): require a lowercase content type
The pattern carried an `i` flag over lowercase character classes, so the same
bytes described as `Text/HTML` and `text/html` produced two different build ids.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile-web-bundle): move the capability name to a zod-free module
A4 wires this constant into protocol-version.ts, which the phone reads on the
capability path. Leaving it in the schema module would have dragged zod along
with it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* refactor(mobile-web-bundle): name the chunk reply's length assetByteLength
It is the whole asset's length, not the chunk's, and sitting beside dataBase64
under the old name it read as the chunk's. Both are non-negative integers, so a
producer that emitted the wrong one would only surface at the final hash check.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile-web-bundle): reject asset paths that are not portable or that fold together
Two paths differing only in case are one file on macOS and Windows, so the host
would serve the same bytes under two entries and one of the two hashes could
never match. Windows-reserved segment names and trailing dots cannot be written
to the bundle root at all.
Both follow skill-package-manifest's checks, the folded-path Set and the
reserved-segment pattern.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile-web-bundle): accept one spelling of a parameterised content type
The optional space in `; ?charset=` let the same bytes carry two content types
and therefore two build ids. Pinned to the single-space form the bundle builder
emits.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* perf(mobile-web-bundle): stop hashing a manifest a cheaper invariant already rejected
zod runs superRefine even after the asset-array ceiling has failed, so a 257
asset manifest was still sorted and hashed. Each invariant now returns on its
own issue and the count is checked first, which is what the comment claimed.
The tests read the issue paths: an oversized or otherwise invalid manifest with
a deliberately wrong buildId reports no buildId issue, while the same wrong id
inside the ceiling does.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile-web-bundle): say that the manifest has no additive path
`.strict()` plus a literal schemaVersion closes the shape completely, so the
version bump is the only way to change it. The phone value-imports this schema,
so Phase B must read an unrecognised schemaVersion as a bundle to re-fetch
rather than as a parse crash.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(agent-launch): deliver a launch prompt from the host
`agent.launch` created the surface and then reported the caller's text as
`not-delivered`, always: delivery lived in the renderer, so mobile and any
other caller got an agent and no prompt.
The host now commits a `submit` prompt to the structured session it just
created, through the same send path `agentSession.send` runs, and reports
`journaled` with the transcript row's id. Nothing is queued — the durable
record that the text is owed is the journal's own submission row, which the
send appends before dispatching, so a host-side copy could only disagree with
it. The outbox's entry and envelope builders are reused so this send is shaped
exactly like a client's, fingerprint included.
Everything else under-claims as `not-delivered`: a terminal's paste is
observed by whoever owns the pane, a `draft` has no host-side home, and a
refused or thrown send commits nothing. There is no fourth "maybe" arm — a
caller holding one could neither resend nor drop the text — and dispatch doubt
stays on the submission row where it already lives.
* fix(agent-launch): recover committed prompt after send errors
* fix(mobile): move the last six reply-enum pins where tsc looks
mobile/tsconfig.json excludes *.test.ts, so a `Record<HostUnion, true>`
coverage record in a schema test is never typechecked: the two that existed
(SshConnectionStatus, GitHubProjectOwnerType) checked nothing, and the four
closed enums beside them had only a doc citation of the host type.
Each arm list moves into its schema module as hostUnionArms<Union>(), which
#21269 introduced for the same reason, and each test iterates the exported
list instead of holding its own copy:
- SSH_CONNECTION_STATUS to SshConnectionStatus
- PROJECT_OWNER_TYPE to GitHubProjectOwnerType
- DETAIL_FILE_STATUS to GitHubPRFile['status']
- PUSH_TEST_REFUSAL_REASONS and PUSH_REGISTER_REFUSAL_REASONS to the refusal
arms of MobilePushTestResult and MobilePushRegisterResult
- SETUP_RUN_POLICIES to SetupRunPolicy
openEnum's parameter widens from a non-empty tuple to `readonly string[]` so
a hostUnionArms list can feed it. z.enum already accepts the same, so the
tuple constraint only excluded callers zod itself takes; behaviour unchanged.
Twelve mutations prove the pins: dropping one arm and adding a bogus one
each fail mobile tsc in all six places. Zero goldens move, the schemas'
behaviour being unchanged, and the 21 recording suites pass at the existing
baseline.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): fix the type errors in eighteen test files
Found by typechecking the tests for the first time (see the config that
follows). All mechanical, none weakens a product type:
- 67 `act(() => vi.advanceTimersByTime(...))` callbacks return VitestUtils
where act wants void, so each becomes a block. The async ones await only a
genuinely promise-returning call, so no extra microtask tick is introduced.
- Four fixtures were stale against a product type that gained a required
member: MobileViewState.alwaysShowDefaultBranch, PrSidebarData.checksError,
the branch-compare summary's errorMessage, and SessionOptionDescriptor's
transport, which #20884 added precisely so a producer could not inherit the
wrong lane's rendering by omission.
- `getLastConnectedAt` on the shared relay fake was typed `() => null`, which
refused the timestamp two escalation suites assign to it.
- Two holders used before assignment take `!`, one `advance!.kind === ...`
becomes `advance?.kind`, one widened status arm takes `as const`, and the
Expo notification fixture keeps `data` required because the dismissal cases
assign through it.
631 test files pass, 6222 tests, unchanged.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): typecheck the test files, on a ratchet
mobile/tsconfig.json excludes *.test.ts so Metro never compiles tests into the
release bundle, and vitest transpiles without checking types. Nothing had ever
typechecked a mobile test, which is why a `Record<HostUnion, true>` pin written
in one proved nothing and why 144 of the 630 test files had drifted.
tsconfig.test.json is that program with the tests put back, behind
`typecheck:tests`. Four files stay out: they import the desktop main process or
src/shared/child-process, which are written against @types/node, and this
program's libs are React Native's, where setTimeout answers a number rather
than a NodeJS.Timeout. Pulling that graph in reports ~280 errors about the
desktop rather than about mobile; vitest runs those four under Node, which is
where they belong.
The CI gate is a ratchet rather than the raw typecheck, modelled on
check-ts-nocheck-ratchet.mjs: 126 files still fail, so the gate freezes that
set and fails when a file that checks today stops checking, or when a baseline
entry starts checking and was not pruned. The list may only shrink.
Why not zero: 180 of the remaining 510 errors are one seam — tests locate
mocked react-native components by string name, which `ElementType` does not
admit — and closing it means either 180 casts or a global JSX declaration for
the mocked names. That is a design decision, not a mechanical fix, so it is
left for a follow-up rather than made here. The rest are smaller clusters of
the same kind: vi.fn mocks assigned into typed slots, call-arg tuple indexing,
and createElement props fixtures.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile-recorder): correct the corpus counts and the salvage claim
The oracle section still quoted the corpus as 368 scenarios and 727 goldens;
it is 393 and 778, and the three replay suites report 781 tests. Each number
now names the command that measures it.
"No golden carries one" was the load-bearing error: 44 goldens carry a
recorded `reply-salvage` today, starting with the push-test unknown-reason
scenario #21176 added for exactly that purpose. The paragraph claimed the
observation pins an absence when on those families it pins a recorded drop.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): pin the tests-typecheck ratchet's parser
The gate reads tsc's output, and tsc indents the "Overload 1 of 2, ..." detail
under an error. Counting those as filenames would write unparseable entries
into the baseline and leave the gate unprunable, so the parser is pinned on
that shape as well as on the added/stale diff.
Written against the gate itself: it flagged this file before the directive it
carried was removed, which is the end-to-end proof the spawn half works.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): await the timer advances the act() rewrite dropped
Rewriting `await act(async () => vi.advanceTimersByTimeAsync(n))` into a
braced body left the returned promise floating at 27 sites, so the advance
was no longer ordered before the assertions that follow it.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): unshadow MobileHostCard's .tsx suite
A wildcard `include` keeps only the higher-priority extension, so
MobileHostCard.test.tsx sat outside every tsc program while
MobileHostCard.test.ts existed beside it. Its one error is the same
react-test-renderer seam its sibling is baselined for.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): census every test file into the typecheck program
The ratchet diffs only files that error, so a test excluded from
tsconfig.test.json or shadowed by a sibling extension left the gate
silently. Every *.test.ts(x) on disk must now be in the program or
named in TESTS_OUTSIDE_PROGRAM with its reason.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(shared): make the enum helpers refuse the ways they can prove nothing
openEnum takes a `const` T so a bare literal keeps its arms rather than
widening to string. hostUnionArms blocks inference of U with NoInfer and
defaults it to never, so a call that omits the host union — where the
record would only pin itself — no longer compiles.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* docs(mobile): describe the census and correct the baseline count
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): give the push fixture cast its SAFETY rationale
Widening the pre-existing cast made the changed-code gate attribute it as
a new finding.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): build the push fixtures as typed notifications
Replaces the `as unknown as` cast with Expo's own types, filling
FirebaseRemoteMessage and its notification once in two builders, and
passes the data payload in rather than mutating through an optional
member. Typing the fixture showed one assertion comparing the scheduled
content against the whole arriving content, which only held while the
cast let the fixture omit the two members the presenter drops; it now
names the four members the presenter forwards.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): keep the grouped-question advance read non-optional
`advance?.kind` let an absent advance take the null-draft branch instead
of failing.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): run the tests-typecheck ratchet on Windows
Spawns tsc's JS entry on this Node instead of the node_modules/.bin
shim, which is a POSIX shell script that Windows resolves to tsc.CMD and
then appends .exe to. Parsed paths are normalised to POSIX so a Windows
run does not read every baseline entry as both stale and added.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): close the ratchet's @ts-nocheck hole and read tsc once
tsc exits 0 on a @ts-nocheck file, so a baselined test could be "fixed"
with one line, pruned, and never checked again; the census now names any
program test file whose leading comment carries the directive.
`--noEmit --listFiles` answers both questions in one pass, so the gate
spawns tsc once rather than twice. Corrects the two stale counts, and
states hostUnionArms' real reason for living in the schema module now
that tests are typechecked.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(perf): pin the pane-title global-scan repro at live-capture scale
One setRuntimePaneTitle at Jinjing's scale (~870 workspaces, 1,408 terminal
tabs, 857 sleeping records, 20 mounted worktrees) reads 19,711 sleeping-agent
records: 23 executions of selectSleepingRecordParkExemptTabIds x 857. The two
budget cases are it.fails so the before-state lands in history.
Refs STA-7552, STA-7551
* perf(renderer): memoize the sleeping-record park exemption on slice identity
A pane title update writes runtimePaneTitlesByTabId, but zustand re-runs every
mounted subscriber's selector, so each retained worktree walked the whole
sleeping-agent inventory to conclude nothing changed for it. useShallow
suppressed the re-render, never the scan.
selectSleepingRecordParkExemptTabIds now goes through the existing
createWorktreeRecordSelector generation cache, keyed on the record-map
identity, so the walk happens once per worktree per real inventory change
instead of once per store write. The cache moves from components/sidebar to
store/ now that terminal-pane shares it, and takes an isEmpty override so a
Set-valued selector can use it.
19,711 sleeping-record reads -> 0 for one title update at capture scale.
Refs STA-7552, STA-7551
* test(perf): model the full sidebar fanout and count all four axes
The first repro mounted 20 retained workspaces (~60 subscribers) and counted
record reads only, which under-models the capture. The sidebar worktree list is
not virtualised, so all 870 rows mount and each WorktreeCardStatusSlot opens
~6 subscriptions. Mounting the real row component brings the harness to 5,500
zustand listeners, inside the capture's 5,462-7,478.
One setRuntimePaneTitle now reports listener invocations, per-module selector
executions, React commits, and records scanned. Before/after the memo, only
records scanned moves: 19,711 -> 0. Notification work stays O(mounted
workspaces) by construction; each visit is now an identity check.
Refs STA-7552, STA-7551
* test(perf): count sidebar-row commits inside the row subtree
* refactor(store): teach the selector cache Set/Map emptiness instead of an option
* test(perf): drop the duplicate mounts and unasserted counters
* refactor(terminal-pane): tighten the park-exemption selector's shape and why
* refactor(store): keep the emptiness check off the broad object type
* test(renderer): count the three instrumented selector modules in the fanout comment