Releases: adulari/forge
Release list
v2.15.0
Forge v2.15.0
CLI/TUI and desktop release v2.15.0; mobile remains on its compatible native version.
- CLI / TUI — binaries below (
*.tar.gz/*.zip) orbrew upgrade forge - Desktop (macOS · Windows · Linux) — app bundles below + in-app auto-update
- Mobile (iOS) — production OTA by default; native/TestFlight builds are manual when required
Added
- Expandable tool cards in the chat transcript. A tool call printed two separate scrollback
lines — a↳ name {raw json…}line truncated mid-JSON and, later, an unrelated-looking
✓ name: exit 0 in 132msline — and the tool's actual output never reached the screen at all,
because the presenter only ever received the result's first line. A call is now ONE row that
carries its own outcome at the right margin; clicking it (orCtrl+Tfor the most recent,
rebindable astoggle_tool_card) expands it in place into the decoded arguments and the output,
and clicking again collapses it.PresenterEvent::ToolResultandLiveEvent::ToolResultgained
a boundeddetailfield (200 lines / 8000 chars) to carry that output, so cards work in
forge attachand daemon-hosted sessions too. Inline mode (--inline) keeps the two-line
rendering: the terminal's native scrollback cannot be rewritten after the fact
(crates/forge-tui/src/app/tool_cards.rs,docs/features/tui-tool-cards.md). /subagents [free|pinned]— let one pinned session fan its children out onto other models.
Pin inheritance was config-only (mesh.subagents.inherit_pin), so releasing subagents from the
parent's pin meant editingconfig.tomland restarting, and it then applied to every session.
The command sets a per-session override thatSession::subagents_freeresolves against the
config default and hands to children asAgentCtx::inherit_pin—route_childnow reads only
that field, so the runtime command and the config default meet in one place. Bare/subagents
toggles and reports the state. The override is persisted on the session row (migration #33) and
restored on resume, so a daemon restart mid-goal no longer silently drags every child back onto
the pin. Default is unchanged: children inherit the pin. A released session stays pinned itself;
only its children route the full mesh (crates/forge-tui/src/commands.rs,
crates/forge-core/src/{session_controls,subagent,orchestration}.rs,
crates/forge-cli/src/cli/commands/run/dispatch.rs).- Structured file tools could not touch a path outside the workspace, even to clean up their
own scratch files.validate_workspace_argshard-rejected anypath/cwd/pathsresolving
outsideworkspace.root()for read_file/write_file/edit_file/multi_edit/apply_patch/
append_file/notebook_edit/delete_file/list_dir/search/glob — but theshelltool reaches any
path via its command string, so an agent doing analysis work against scratch/capture files in
/tmpcould write and read them withshellbut not with the structured tools, forcing a
cp into workspace; read; rmdance that polluted the git tree. Added an opt-in allowlist,
tools.extra_roots([tools]in config.toml), of absolute paths outside the workspace that
the structured file tools may also read and write; default empty, so existing configs see zero
behavior change. Honored by both validator copies —crates/forge-core/src/lib.rs
(validate_workspace_args, now also consulted fromcrates/forge-core/src/tool_dispatch.rs
via a newSession::extra_tool_roots, populated incrates/forge-core/src/session_lifecycle.rs
fromconfig.tools.extra_roots) andcrates/forge-tools/src/workspace.rs
(WorkspaceTool/ToolRegistry::bind_extra_roots, wired at both real binding sites:
crates/forge-cli/src/cli/commands/run/session.rsviaSession::buildand themcp-serve
CLI-bridge path incrates/forge-cli/src/mcp_serve.rs). A deliberately-kept regression test
(workspace_validation_rejects_peer_repository_paths) still asserts that an unlisted sibling
temp-dir path is rejected;tools.extra_rootsis separate fromshell.sandbox_writable(the
Landlock write-sandbox) and does not changeshell, which was already unconfined.
Changed
- The repository's
.mcp.jsonno longer registers Forge as an MCP server for Claude Code.
Every Claude Code session in this checkout spawned a full Forge agent (session, index, file
watcher) whether or not it was ever used; the two runaway processes above were exactly those.
Add the entry back locally if you wantforge_chatfrom Claude Code.
Fixed
- A session never picked up an
AGENTS.mdthat appeared or changed while it was running. The
body was read once at construction and a resume set the "already injected" flag without reading
at all, so the session that wrote the file (or ran/init) never saw it, and a long-lived
daemon session stayed on whatever existed the day it started — restart after restart, because a
restart is a resume. Each turn now re-checks the file in the post-persist window the git-branch
refresh already uses (onestat; the body is read only when the fingerprint moves) and injects
it only when the transcript does not already carry that exact text — so an edited or newly
writtenAGENTS.mdreaches the next turn, and an unchanged one is never restated
(crates/forge-core/src/session_controls.rsrefresh_project_instructions). tools.extra_rootswas still refused by the file tools' in-process safety net. The
allowlist reached the two argument validators but notconfine()in
crates/forge-tools/src/core_tools.rs, whoseworkspace_roots()only trusted the workspace
(and, for standalone runs, the system temp dir) — so a daemon-hostedread_file/write_file
on an allowlisted path still failed with "resolves outside the workspace (workspace-confinement
safety net)". The extra roots now ride aSESSION_EXTRA_ROOTStask-local scoped alongside
SESSION_WORKSPACEby the tool wrapper, andconfine()honors them, so all three confinement
layers agree (crates/forge-tools/src/lib.rs,workspace.rs,core_tools.rs).- Every visible chat message re-rendered on every ~30 ms WebSocket frame.
useSessionCtx()
exposed one context whose value object was rebuilt on every snapshot, so any consumer —
includingMessageRow(alreadyReact.memo'd) and the 1000+ lineComposer— re-rendered
per frame regardless of whether it readsnapshotat all;Composeronly needed
snapshot.model/snapshot.effort, and the session shell'sSessionHeader/StatusStrip
received ~25 fresh inline-arrow-function props on every frame too.sessionContext.tsxnow
splits into alivecontext (snapshot,snapshotTimedOut,connectionState, changing per
frame) and astablecontext (session id,send, drafts, pending answer, header height,
focus signal — changing only when one of those actually changes);useSessionCtx()still
merges both for existing callers, and newuseSessionStable()/useSessionLive()hooks let a
component opt into just the slice it needs.MessageRowandComposernow read
useSessionStable()only;Composertakesmodel/effortas props from its caller instead
of reading them offsnapshotitself, and is wrapped inReact.memo.SessionHeaderand
StatusStripare alsoReact.memo'd, with their handlers hoisted intouseCallbacks and the
weekly/transportobject props memoized in the session shell so the memo isn't defeated by
a fresh object every render (mobile/src/lib/sessionContext.tsx,
mobile/src/components/chat/MessageRow.tsx,mobile/src/components/chat/Composer.tsx,
mobile/src/components/session/SessionHeader.tsx,
mobile/src/components/session/StatusStrip.tsx,mobile/src/app/session/[id]/_layout.tsx,
mobile/src/app/session/[id]/index.tsx). - The mobile app burned battery just for being on screen. v2.13.6's desktop performance
monitor ran on every platform, not just Tauri:startDesktopPerformanceMonitor()scheduled a
requestAnimationFrameloop that pushed to an unbounded array and re-sorted the whole thing
every frame to find the median — measured at 0.9 ms/frame after 1 minute on screen, 33 ms/frame
after 30 minutes, withframeIntervals/composerSamples/composerImeSamples/
composerInputEvents/composerImeEventsall growing forever. Separately, the iOS Home Screen
widget was resynced (an app-group write plus aWidgetCenterreload) on every fleet refetch —
up to twice a second while any session streams — even when nothing the widget renders had
changed, and the session timeline rebuilt its whole transcript from history on every ~30 ms
WebSocket snapshot instead of only when history actually changed. The sampler now only starts on
Tauri (the only platform with a consumer for it); the diagnostics and perf-fixture screens start
and stop it themselves on demand elsewhere. Frame intervals live in a bounded 1024-entry ring
buffer with an O(1) running estimate for dropped-frame detection instead of a per-frame sort,
composer sample/event arrays cap at the newest 512 entries, and a new
stopDesktopPerformanceMonitor()cancels the loop and long-task observer.syncWidgetSessions
now skips the write and reload when the top-4 snapshot is byte-identical to the last one synced.
The session screen memoizesbuildTranscriptonhistoryRows/transcriptRowsinstead of
rebuilding it on every snapshot, and derives the live tool-activity ledger through a
content-keyed memo soFlatList'srenderItemidentity — and therefore every visible cell's
render — only changes when the ledger's actual content changes
(mobile/src/lib/performance.ts,mobile/src/app/_layout.tsx,mobile/src/app/diagnostics.tsx,
mobile/src/app/perf-fixture.tsx,mobile/src/lib/widgetData.ts,
mobile/src/app/session/[id]/index.tsx). - **MCP servers launched...
v2.14.1
Forge v2.14.1
CLI/TUI and desktop release v2.14.1; mobile remains on its compatible native version.
- CLI / TUI — binaries below (
*.tar.gz/*.zip) orbrew upgrade forge - Desktop (macOS · Windows · Linux) — app bundles below + in-app auto-update
- Mobile (iOS) — production OTA by default; native/TestFlight builds are manual when required
v2.14.0 was tagged but never published: its release build failed 24 seconds in, before compiling
anything, because Debian Bullseye is EOL and its security pool rotated out from under the live
mirror — the apt index advertised libc-dev-bin 2.31-13+deb11u14 while deb.debian.org had
already deleted that .deb. Both Linux targets died identically. This release is the same work
plus the build fix; per RELEASING.md a release tag is never moved, so it ships as a new version.
Fixed
- The portable Linux release build could not install its toolchain packages. The container now
pins apt to a timestampedsnapshot.debian.orgview, which keeps the security suite and cannot
rotate underneath a build — making the step reproducible the same way the image digest and Rust
toolchain already are (.github/workflows/release.yml).
Added
shellcan start something that keeps running. Every attempt before this died the moment the
call returned, and nothing said why — a session spent hours concluding "the sandbox kills
background processes" and reaching for transient systemd units. The cause was three lines: after a
command exits, the tool SIGKILLs its whole process group so a leaked descendant cannot hold the
output pipes open, andnohup cmd &does not escape that (nohup detaches from the terminal, not
the process group), nor doessystemd-run --scope.shell{background:true}now spawns into its
own session (setsid) with output on a log file, so the job outlives the call, the turn, and
Forge itself; the newshell_jobtool lists, tails, inspects and stops those jobs from state on
disk under.forge/jobs/, so a later turn — or a whole new session — can find what an earlier one
started. Jobs deliberately survive shutdown: an emulator that took two minutes to boot must not
die because a turn ended (crates/forge-tools/src/shell/background.rs,
crates/forge-tools/src/shell.rs).- A foreground call now says when it killed what the command left running, naming the processes
and pointing atbackground:true. The silent kill is what turned a three-line problem into hours
of dead ends (crates/forge-tools/src/shell/background.rs). - A local SearXNG is now the default search backend, with
scripts/searxng-setup.shto stand it
up in one command andFORGE_SEARXNG_URLto point at another instance
(crates/forge-tools/src/web/search.rs,scripts/searxng-setup.sh).
Fixed
web_searchwas effectively down without a key. The keyless DuckDuckGo default answered the
FIRST query from an IP and then returned HTTP 202 with an empty body — one query per session is
not a search tool — and the error it produced advised setting a Brave key "for reliable results",
advice that expired when Brave retired its free tier in February 2026. Search is now a chain
(local SearXNG → DuckDuckGo → keyless Bing) so one engine being throttled no longer takes it down,
and every result says which engine answered. That attribution is load-bearing: keyless Bing never
throttles but returns confident nonsense — asked fortokio select macroit returned ten
well-formed results for plumbers near 1 Microsoft Way — so it is last and labelled, never trusted
silently. Bing's/ck/atracking redirects are decoded to real URLs
(crates/forge-tools/src/web/search.rs).- The emulator booted on a renderer that segfaults it. A headless
emulator_startpicked
-gpu swiftshader_indirect; SwiftShader renders in JIT-compiled shader code, so a bad guest draw
call faults inside it — an out-of-bounds SIMD load — and the SIGSEGV takes the whole emulator
down mid-test. It killed the local AVD twice in a row. Headless now uses-gpu auto-no-window,
the emulator's own renderer selection, which also drops the CPU cost of compositing a phone-sized
screen (crates/forge-device/src/emulator.rs,crates/forge-tools/src/device.rs). - A language server that could never succeed was retried forever.
clear_failure()ran on a
successful handshake, but rust-analyzer initializes in milliseconds and dies minutes later while
indexing — so the counter reset to zero before every failure and the exponential backoff never
once doubled. One project logged 386 identical "retrying in 30s" lines over three days, ~90% of
its session log, each cycle also injecting an unactionable "diagnostics unavailable" notice into
the model's context after every write. Only delivered diagnostics clear the failure state now, a
(language, root)pair is given up on after five consecutive failures with a reason naming
lsp.memory_limit_mb, and outage notices stay in the log where they belong
(crates/forge-lsp/src/registry.rs,crates/forge-core/src/lsp_hints.rs). - A hard guard abandoned the work it was ending. The error named 60 modified files and stopped,
leaving a human to reconstruct what a 400-step turn had been in the middle of. Hard guards now
snapshot tracked edits withgit stash createunderrefs/forge/aborted-turns— nothing moves,
the files stay exactly where they are — and the guard messages print billable and cache-inclusive
input, because printing only the latter made a working token ceiling look broken
(crates/forge-core/src/turn_guards.rs,crates/forge-core/src/lib.rs). forge run --output-format stream-jsonanswered with prose on any machine running a daemon.
The fleet-publish check ran before the stream-json branch, so an explicit machine-readable format
was silently replaced by two human-readable lines and exit 0 — every parsing caller saw a
successful run and no events. It went unnoticed because CI has no daemon: the e2e test passed
everywhere except a developer's own box. An explicit machine-readable format now opts out of the
fleet (crates/forge-cli/src/cli/commands/run/one_shot.rs).- The emulator inherited Forge's process group, so any group-directed kill threw away a
two-minute boot for a reason invisible from the device side (crates/forge-device/src/emulator.rs).
What's Changed
- chore: prepare v2.14.1 release — fix the portable Linux build by @florisvoskamp in #1327
Full Changelog: v2.14.0...v2.14.1
v2.13.9
Forge v2.13.9
CLI/TUI and desktop release v2.13.9; mobile remains on its compatible native version.
- CLI / TUI — binaries below (
*.tar.gz/*.zip) orbrew upgrade forge - Desktop (macOS · Windows · Linux) — app bundles below + in-app auto-update
- Mobile (iOS) — production OTA by default; native/TestFlight builds are manual when required
Fixed
- A fresh install could fail its very first prompt with a usable model sitting right there. With
no catalog yet the router only ever tried the classified tier's built-in seeds; the trivial tier
is a local ollama plus groq, so on a machine with no API keys and ollama not running the chain
exhausted and the turn failed — while a logged-incodexCLI could have served it. The seed chain
now carries the other tiers as a deduped tail (the tier's own candidates still lead, so the
primary pick is unchanged), andcodex-cli::joinsclaude-cli::as a default seed: it was in no
tier list at all (crates/forge-mesh/src/lib.rs,crates/forge-config/src/lib.rs). - A first-run failure named an adapter instead of a next step. The verdict only offered setup
guidance when EVERY attempt reported missing credentials, and a keyless candidate fails as
"provider unavailable", so a machine with nothing configured was told "attempted providers failed
for mixed reasons". It now leads withforge setup/forge authwhenever no API key and no
logged-in CLI exist, keeping the failure mix after it. Neither "binary on PATH" nor "not known
logged out" proves a login, so the check requires positive evidence
(crates/forge-core/src/failure_verdict.rs). - A read-only turn inside a build session is no longer re-driven to produce a diff. #1266 fixed
which contract such a turn derives, but the empty-diff nudge and the code-change classification
still read the session-wide flag a worktree daemon session arms for its whole life, so the turn
was still pushed to "implement the fix now" against its own instruction
(crates/forge-core/src/lib.rs). - The CLI-bridge terms notice reads like Forge, not a raw timestamped log line wedged between
the routing line and the model's first token. It also survives the failover path, which is how a
keyless run reaches a bridge at all (crates/forge-provider/src/lib.rs). - A bare bridge id reads as what it means.
claude-cli::is a valid pin for "whatever model
that CLI is configured to use" and the first built-in complex-tier default, but printed verbatim
it looked like a truncated id on a new user's first turn. It now renders as
claude-cli (its default model)(crates/forge-tui/src/lib.rs). - A relay link that has never exchanged is no longer reported as disconnected. The two states
call for different actions: one is still coming up, the other needs aforge serverestart
(crates/forge-cli/src/anywhere/state.rs).
Changed
- The
use_skilllisting costs about 626 fewer tokens on every tool-bearing turn. It advertises
every skill in its own description — 64 skills at 100 characters was 7,438 characters riding every
request, a quarter of the whole tool payload and more than the system prompt. Each summary is
clipped to 60 characters; discovery is unchanged since every skill is still listed by name
(crates/forge-core/src/lib.rs).
What's Changed
- chore(dist): update package manifests to v2.13.8 by @github-actions[bot] in #1274
- chore(deps): bump the cargo-minor-patch group across 1 directory with 6 updates by @dependabot[bot] in #1272
- fix(anywhere): a link that has never exchanged is not a dropped link by @florisvoskamp in #1273
- fix(cli): the CLI-bridge terms notice reads like Forge, not like a log line by @florisvoskamp in #1275
- perf(core): the skill listing costs ~600 fewer tokens on every tool-bearing turn by @florisvoskamp in #1276
- fix(tui): a bare bridge id reads as the CLI's default model, not a truncated id by @florisvoskamp in #1277
- fix(core): a zero-credential install is told how to set up, whatever failed first by @florisvoskamp in #1278
- fix(mesh): a first run no longer dies with a usable bridge one tier away by @florisvoskamp in #1279
- chore: prepare v2.13.9 release by @florisvoskamp in #1280
Full Changelog: v2.13.8...v2.13.9
v2.13.8
Forge v2.13.8
CLI/TUI and desktop release v2.13.8; mobile remains on its compatible native version.
- CLI / TUI — binaries below (
*.tar.gz/*.zip) orbrew upgrade forge - Desktop (macOS · Windows · Linux) — app bundles below + in-app auto-update
- Mobile (iOS) — production OTA by default; native/TestFlight builds are manual when required
Fixed
-
A long tool loop threw away the task it was working on. When the transcript overflowed the
model's window, the fit kept every system message and a newest-first suffix of history — and the
user's own message was neither, so a tool loop that filled the budget evicted the instruction
while keeping the output it produced. The model then reported that no task had arrived and
invented work from what was left. The newest user message is now reserved before the walk, and
clipped rather than dropped when it alone exceeds the budget (crates/forge-core/src/context_pipeline.rs). -
A model behind a gateway assumed a 32k window when its real one is a million tokens. OpenCode
Zen and Go list models without a context length, as do most custom OpenAI-compatible endpoints, so
every model behind one fell to the conservative floor and trimmed long turns for no reason. A
model with no window row of its own now inherits the window published for the same model under
another namespace, lowest match winning (crates/forge-mesh/src/pricing.rs,
crates/forge-core/src/routing_policy.rs). -
An explicitly read-only turn was re-driven to produce a diff. A worktree-backed daemon session
arms a code-change expectation for its whole life, and that beat the prompt, so a turn that said
"do not edit anything" ended with no edits, tripped the empty-diff guard, and was pushed to
"implement the fix now". An imperative read-only instruction in the prompt now wins, and the
phrase list recognises what an operator actually types (crates/forge-core/src/turn_contract.rs). -
OpenCode Zen's Responses-only models could not be called at all.
muse-*,gpt-*andgrok-*
answer only on/responsesthere, while Forge always built an OpenAI-chat request and got an
instant 500. Zen now reuses the per-model wire-format matrix the Go adapter already carries
(crates/forge-provider/src/genai_provider.rs). -
A new model silently inherited an older sibling's benchmark score. The cross-version guard
compared version numbers by membership, so a shared major digit let a different minor through:
muse-spark-1.3matched "Muse Spark 1.2" on the shared1. Comparison is positional now, and a
benchmark row missing one of its two indices is kept on the index it has rather than dropped
(crates/forge-mesh/src/bench.rs,crates/forge-cli/src/benchmarks.rs). -
An Ask temper on the CLI bridge approved silently instead of refusing. A bridged turn had no
one to answer a permission prompt, so the gate resolved the wrong way
(crates/forge-core/src/permission.rs). -
Routing stalled on rediscovery when a cached catalog already existed, and the daemon's models
page served the last terminal's catalog rather than its own
(crates/forge-cli/src/cli/commands/models/discovery.rs,crates/forge-cli/src/serve/serve_models.rs). -
An over-pace subscription pool is now held entirely, and the last-resort override is
re-checked per failover hop instead of being spent once and left open
(crates/forge-mesh/src/lib.rs,crates/forge-core/src/model_request.rs). -
An unattended bridge turn that stalled with tasks still open now fails instead of reporting
success (crates/forge-core/src/turn_guards.rs). -
An explicitly configured
[mesh.models]tier is honoured over an auto-discovered one
(crates/forge-mesh/src/catalog.rs). -
forge doctorreports a provider-rejected key as invalid rather than unreachable, and
forge modelssays which listed models have no key
(crates/forge-cli/src/doctor_health.rs,crates/forge-cli/src/cli/commands/models.rs). -
A keyless first run skips network enrichment, bare
forgeshows a first-run panel instead of
the full command list, non-tty setup is actionable, and CLI bridges known to be logged out are no
longer probed (crates/forge-cli/src/cli/commands/run.rs,crates/forge-provider/src/lib.rs). -
Claude quota is read from
unifiedWindows, and model reasoning is no longer printed as answer
text on a non-tty (crates/forge-provider/src/claude_quota.rs,crates/forge-tui/src/lib.rs). -
Opt-in: a standalone
forge runcan execute in the daemon and show in the Anywhere fleet.
[remote] publish_local_runs(default off) and per-run--publish-to-fleet/
--no-publish-to-fleethand the prompt to the local daemon, which creates a session carrying the
cwd, model and a title from the prompt's first line. A one-shot run was previously invisible to
the phone however healthy the relay was. Failure is soft: no daemon means the run proceeds locally
exactly as before. Output is not streamed back to the handing terminal, which prints the session
id and theforge attach <id>command (crates/forge-cli/src/cli/commands/run/one_shot.rs). -
The empty-diff nudge and the code-change classification read the turn's contract, not the
session-wide flag a worktree daemon session arms for its whole life, so an explicitly read-only
turn is no longer re-driven with "implement the fix now" (crates/forge-core/src/lib.rs). -
forge runno longer stalls on rediscovery when a cached catalog exists, and one reader now
serves both the router and the daemon's models page
(crates/forge-cli/src/cli/commands/models/discovery.rs). -
The mesh explanation marks a rank a routing rule decided instead of restating the score as
something it is not (crates/forge-mesh/src/explain.rs).
Added
POST /api/sessions/{id}/interruptends a fleet session's current turn and leaves it live and
idle. The daemon accepted an interrupt over its WebSocket but had no HTTP route, so a script could
only stop a runaway turn by ending the session;--steeris no substitute, since it lands at the
next turn boundary and a session stuck in a tool loop never reaches one (crates/forge-cli/src/serve.rs).
Changed
release-buildno longer gates pull requests. The release compile plus its upgrade,
reconnect and rollback end-to-end is the longest job in the pipeline, and every heavy job
serializes on one runner, so running it per pull request set the merge throughput of the project.
It runs on the push to main after a merge, on the weekly schedule, and on the dispatch the release
workflow fires — still before anything ships (.github/workflows/ci.yml).
What's Changed
- chore(dist): update package manifests to v2.13.7 by @github-actions[bot] in #1245
- fix(provider): read claude quota from unifiedWindows, not a top-level field that no longer exists by @florisvoskamp in #1244
- fix(tui): don't print model reasoning as answer text on a non-tty by @florisvoskamp in #1242
- fix(cli): bare
forgeshows a first-run panel, not the 40-command help wall by @florisvoskamp in #1250 - fix(onboarding): make the keyless first run and non-tty setup actionable by @florisvoskamp in #1248
- fix(provider): stop probing CLI bridges that are already known to be logged out by @florisvoskamp in #1251
- fix(cli): forge models says which listed models have no key instead of implying a keyless install is ready by @florisvoskamp in #1247
- perf(startup): skip network enrichment on a keyless run and memoize keyring reads by @florisvoskamp in #1252
- fix(doctor): report a provider-rejected key as invalid instead of "usable" by @florisvoskamp in #1249
- fix(mesh): honour an explicitly configured [mesh.models] tier over auto-discovery by @florisvoskamp in #1253
- ci(release): automate the install/upgrade-path verification for RELEASING.md §7 by @florisvoskamp in #1255
- fix(core): fail an unattended bridge turn that stalls with tasks open by @florisvoskamp in #1256
- chore(core): keep model_request.rs under the size guard after #1248 and #1251 landed together by @florisvoskamp in #1258
- fix(mesh): hold an over-pace subscription pool entirely, cap the last resort to one hop, keep over-pace Go windows by @florisvoskamp in #1261
- ci: release-build stops gating pull requests by @florisvoskamp in #1265
- fix(bridge): an Ask temper on the CLI bridge must refuse, not silently run the tool by @florisvoskamp in #1259
- fix(core): an explicitly read-only turn is not re-driven to produce a diff by @florisvoskamp in #1266
- fix(serve): /api/models serves the daemon's live catalog, not the last terminal's by @florisvoskamp in #1254
- fix(core): a long tool loop can no longer evict the turn's task statement by @florisvoskamp in #1263
- fix(mesh): a gateway model inherits the context window published for it elsewhere by @florisvoskamp in #1264
- fix(mesh): route OpenCode Zen Responses-only models; stop 1.3 inheriting 1.2's bench score by @florisvoskamp in #1262
- feat(serve): interrupt a session's current turn over HTTP by @florisvoskamp in #1267
- fix(mesh): mark ranks a ...
v2.13.7
Forge v2.13.7
CLI/TUI and desktop release v2.13.7; mobile remains on its compatible native version.
- CLI / TUI — binaries below (
*.tar.gz/*.zip) orbrew upgrade forge - Desktop (macOS · Windows · Linux) — app bundles below + in-app auto-update
- Mobile (iOS) — production OTA by default; native/TestFlight builds are manual when required
Fixed
- Every claude bridge on the machine stopped reporting
Not logged in · Please run /loginafter a
Forge process ran inside a bridged turn.real_claude_config_dirhonoured an inherited
CLAUDE_CONFIG_DIReven when it was Forge's own isolated mirror, soforge mcp-serve(and any
child session it spawned) rebuilt the mirror onto itself and every entry became a symlink to
itself,.credentials.jsonincluded. The inherited value is now ignored when it is the mirror,
prepare_claude_bridge_homerefuses to mirror a directory onto itself, and a first auth failure
seconds after the same provider completed a turn benches one model for five minutes instead of
excluding the provider for thirty (crates/forge-provider/src/claude_bridge_home.rs,
crates/forge-core/src/compaction_policy.rs). - Unattended sessions no longer die on failover with
rate limited: HTTP error.while 150+
models are usable. The headless presenter answers the compact-on-switch prompt with
NO_ANSWER, which the consent gate read as "No", skipping every smaller-window fallback until
the chain ran dry. A non-answer now compacts and continues (crates/forge-core/src/compaction_policy.rs). - Unattended turns run past the soft step cap instead of exiting with uncommitted work. A
headlessforge run(or--mode bypass) treatsmesh.max_stepsas a checkpoint: one warning
with the step count and the turn's cumulative tokens, then it continues to
mesh.max_steps_unattended(default 400) and ends with an ERROR naming the uncommitted work.
Attended sessions still pause. A newmesh.max_turn_input_tokensceiling (default 10M) ends a
runaway turn on every surface, and both guards latch so re-drives cannot reset the counters
(crates/forge-core/src/turn_guards.rs,crates/forge-config/src/lib.rs). - Failover hops obey the subscription pacing verdict, not just the primary pick. Two builder
sessions failed over onto a held codex model and burned 5–7M input tokens each. Held models are
now parked until every non-held candidate is exhausted, reached only as a last resort with the
rationalelast resort: pacing hold overridden, andforge meshmarks the hold per model
(crates/forge-core/src/model_request.rs,crates/forge-mesh/src/lib.rs). - A resumed follow-up turn inherits the session's routing tier. "continue" on a complex
session was classified on its own text and handed to a free trivial-tier model; the turn is now
floored at the session's most recent routing tier unless a pin or explicit effort overrides it,
withtier inherited from previous turnin the rationale (crates/forge-core/src/routing_policy.rs,
crates/forge-store/src/provenance_store.rs). - Gemini 3.x no longer rejects a transcript whose tool calls came from another model. Unsigned
functionCallparts get the documented placeholder signature, captured signatures are replayed
intact, and an HTTP 400 that names a transcript-compatibility problem classifies as a per-model
capability failure so failover continues instead of ending the turn
(vendor/genai-0.6.5/src/adapter/adapters/gemini/adapter_impl.rs,
crates/forge-provider/src/genai_provider/error_policy.rs). - The Antigravity bridge stops being killed at exactly 120 s on healthy turns.
agy -pprinted
nothing until the whole answer was ready, so the idle watchdog killed every complex turn and the
mesh walked through -high/-low/-medium for six minutes per cascade. agy now runs with
--output-format stream-jsonand a 600 s print timeout, its usage block is recorded (no more
↑0 ↓0), and a stall names the budget that fired inmodel_health
(crates/forge-provider/src/cli_provider.rs,crates/forge-provider/src/cli_provider/cli_stream.rs). - OpenCode Go burn weights account for each model's own weekly quota. The dashboard's weekly
percentage is the sum of per-model percentages against $7.50 / $15 / $30 quotas, so a dollar on
Grok 4.6 or Kimi K3 drains the pool four times faster than a dollar on Muse; the price-derived
weight is now multiplied bylargest quota / model quota(fallback table, since the usage
endpoint exposes no per-model data) andforge meshprints the quota buckets
(crates/forge-mesh/src/subscription_cost.rs). - A binary compiled alongside the test suite says so instead of reporting "no keys".
forge auth --list,forge models,forge meshandforge doctorname the active secret-store
backend and warn loudly when it is thetest-secretsin-memory store
(crates/forge-config/src/secret_store.rs).
Added
- The subscription pacing verdict is visible everywhere routing acts on it.
forge mesh, the
TUI usage overlays, the daemon usage API and the mobile usage screen show used vs allowed, the
elapsed fraction and whether models are being held (crates/forge-types/src/subscription_pacing.rs,
mobile/src/app/usage.tsx).
What's Changed
- chore(dist): update package manifests to v2.13.6 by @github-actions[bot] in #1228
- fix(store): migrate model_pricing to the cache_read_per_1k column and stop swallowing price writes by @florisvoskamp in #1229
- fix(provider): read agy's slug column, clamp its argv prompt, and fail over instead of dying on a rejected model or a failed spawn by @florisvoskamp in #1230
- feat(mesh): show the subscription pacing verdict in forge mesh, the TUI overlays, and the usage API by @florisvoskamp in #1231
- fix(core): unattended sessions compact on failover instead of dying by @florisvoskamp in #1232
- fix(provider): claude bridge home mirrored onto itself logged every bridge out by @florisvoskamp in #1234
- fix: preserve Gemini thought signatures on failover by @florisvoskamp in #1236
- fix(config): report which secret store a forge binary reads by @florisvoskamp in #1233
- fix: inherit prior routing tier on resume by @florisvoskamp in #1238
- fix(mesh): weight OpenCode Go burn by each model's weekly quota, not price alone by @florisvoskamp in #1239
- fix(agy): stream agy output so idle watchdog stops killing healthy turns by @florisvoskamp in #1237
- fix(core): unattended turns run past the step checkpoint instead of dying half-done by @florisvoskamp in #1240
- fix(core): apply the subscription pacing verdict to failover hops, not just the primary pick by @florisvoskamp in #1241
- chore: prepare v2.13.7 release by @florisvoskamp in #1243
Full Changelog: v2.13.6...v2.13.7
v2.13.6
Forge v2.13.6
CLI/TUI and desktop release v2.13.6; mobile remains on its compatible native version.
- CLI / TUI — binaries below (
*.tar.gz/*.zip) orbrew upgrade forge - Desktop (macOS · Windows · Linux) — app bundles below + in-app auto-update
- Mobile (iOS) — production OTA by default; native/TestFlight builds are manual when required
Fixed
-
OpenCode Go's top-ranked models now reach the endpoint they actually implement instead of
failing or spending 6m47s in “recovering provider”. The service exposes three incompatible
wire formats without identifying them in/models:gpt-5.6-luna,grok-4.5,
grok-4.6, andmuse-spark-1.2-contributorreject Chat Completions immediately and answer
only on Responses, while the other Go models do the reverse. Forge now seeds that measured
matrix, learns an unknown model's endpoint only after its characteristic rejection and a
successful one-shot Responses retry, and omits unsupported temperature parameters by model
family. Two identical errors returned within two seconds are treated as a rejection, so a pinned
model surfaces the real error immediately instead of consuming the 600-second outage budget;
live turn-loop checks answered on Muse, Luna, Grok, and GLM in 7–12 seconds
(vendor/genai-0.6.5/src/adapter/adapters/opencode_go/adapter_impl.rs,
crates/forge-provider/src/genai_provider.rs,crates/forge-core/src/model_request.rs). -
Claude CLI tool and filesystem errors no longer disable a valid login for 30 minutes. A
workingclaude-cli::opus[1m]was stored asexcluded: auth failed: auth failedbecause the
permanent-auth phrase list accepted generic “permission denied” and “credentials” text emitted
by tool gates, OS errors, and keychain notices. Only text that identifies the login can now earn
that provider-wide verdict, and the stored health row retains up to 240 characters of the CLI's
actual evidence instead of repeating the classification. Discovery also unions Claude 2.1.257's
initialize picker with its documented aliases, so Fable is available even though initialize
advertises only Opus, Sonnet, and Haiku (crates/forge-provider/src/cli_provider.rs,
crates/forge-provider/src/cli_provider/error_policy.rs,
crates/forge-core/src/compaction_policy.rs). -
Reinstalling the daemon service now applies the new binary instead of merely rewriting the
unit.systemctl --user enable --nowis a no-op for an already-active unit, so the rendered
ExecStartcould point at the release while the old process kept serving; in the observed
failure this ended in a203/EXECservice outage. Active systemd units are explicitly restarted,
loaded launchd agents are reloaded, and active Windows scheduled tasks are ended and re-run.
Install and status inspect the live process before and after activation, report its executable
and version, and fail honestly when the replacement cannot be established
(crates/forge-cli/src/cli/commands/service.rs,
crates/forge-cli/src/cli/commands/service_report.rs). -
forge doctorreports the daemon's version, not the version of the doctor binary printing the
report. A unit stamped 2.12.2 with a daemon actually running 2.13.5 was reported as “running
2.13.2” because 2.13.2 happened to be the separately installed CLI invoking doctor. Version
evidence now comes from the live daemon's authenticated/api/identity, then the unit's
ExecStart --version, otherwise an explicit unknown; the report labels the unit stamp, daemon
binary, and current CLI separately so upgraded-on-disk-but-not-restarted processes are visible
(crates/forge-cli/src/doctor.rs,crates/forge-cli/src/doctor_daemon.rs).
Added
-
Routing prices now follow current model economics instead of stale hardcoded burn weights.
OpenRouter has no GPT-5.6 rows, leaving Codex decisions at$0, while the fallback
Sol/Terra/Luna ladder of 5/2.5/1 predated current $4/$20, $2/$12, and $0.20/$1.20 per-million-token
prices—roughly 17.5× and 10× Luna for Sol and Terra. Forge fetches models.dev beside OpenRouter,
maps its prices onto native and CLI-bridge namespaces, preserves bundled rates on fetch failure,
and resolves override → fetched/bundled price → table. A nonzero subscription floor prevents a
heavier sibling winning on a marginal score at zero pressure; that old behavior burned 64% of a
fresh $12/5h OpenCode Go pool in two hours on Kimi K3 over a 0.14-point advantage
(crates/forge-cli/src/context_windows.rs,crates/forge-mesh/src/pricing.rs,
crates/forge-mesh/src/subscription_cost.rs,docs/features/mesh-routing.md). -
Subscription routing accounts for the size of the pool and the share consumed by one request.
At OpenCode Go 28% and Codex 25%, the former's Kimi K3 scored 3.27 over Codex OAuth's Sol at
2.96 even though one Kimi request consumed about 1% of its $12/5h pool and Sol used a fraction of
a much larger plan. Providers now carry an explicit capacity class—OpenCode Go is Tiny; captured
CLI plan slugs map 20x to Large, max/pro to Medium, plus/team to Small, and an unset plan remains
Unknown—and ranking applies request share times model burn times scarcity, with scarcity capped
at 3×. Equal models therefore prefer the larger, fuller pool without guessing an unknown plan
(crates/forge-mesh/src/catalog.rs,crates/forge-mesh/src/subscription_cost.rs).
What's Changed
- chore(dist): update package manifests to v2.13.5 by @github-actions[bot] in #1220
- feat(mesh): track OpenCode Go usage windows by @florisvoskamp in #1219
- fix: report actual daemon version in doctor by @florisvoskamp in #1221
- fix: restart daemon when reinstalling service by @florisvoskamp in #1222
- feat(mesh): fetch model prices from models.dev and let prices outrank the burn-weight table by @florisvoskamp in #1223
- fix(provider): route OpenCode Go per model, learn endpoints, and stop waiting out rejections by @florisvoskamp in #1224
- fix(provider): stop benching claude-cli as 'auth failed' on non-login text, record the evidence, and list Fable by @florisvoskamp in #1225
- feat(mesh): weigh a request by its share of the subscription pool by @florisvoskamp in #1226
- chore: prepare v2.13.6 release by @florisvoskamp in #1227
Full Changelog: v2.13.5...v2.13.6
v2.13.5
Forge v2.13.5
CLI/TUI and desktop release v2.13.5; mobile remains on its compatible native version.
- CLI / TUI — binaries below (
*.tar.gz/*.zip) orbrew upgrade forge - Desktop (macOS · Windows · Linux) — app bundles below + in-app auto-update
- Mobile (iOS) — production OTA by default; native/TestFlight builds are manual when required
Fixed
-
An Expo patch publish can no longer turn a tagged release red on its own — this is what kept
v2.13.4 from ever publishing. expo-doctor's "packages match versions required by installed Expo
SDK" check resolves the SDK's expected patch versions over the network, so the answer lives
outside the repository: Expo shippingexpo@57.0.19upstream was enough to failapp preflight
on a commit that had passed CI unchanged, and becauseapp-desktop.ymlchecks out
refs/tags/<release_tag>no fix landing onmaincan rescue the already-cut tag. That is the
fourth occurrence of this exact failure mode (#993, #1129, #1160, and v2.13.4). The eleven
drifted SDK packages are realigned with a lockfile regenerated under npm 10 to match CI's Node 20
toolchain (mobile/package.json,mobile/package-lock.json), and the release path now runs
scripts/ci/mobile-release-check.sh, which suppresses only that one check through expo-doctor's
ownEXPO_DOCTOR_SKIP_DEPENDENCY_VERSION_CHECKwhile the other 18 checks, ESLint,tsc --noEmit
and Vitest stay fully enforcing (.github/workflows/app-desktop.yml,
.github/workflows/app-web.yml,scripts/ci/test-mobile-release-check.sh,
.github/workflows/ci.yml,mobile/README.md). PR CI still runs the plainnpm run check, so
version drift is still caught — just where a human can act on it instead of where it strands a
release. -
The mobile lockfile moves to
browserslist4.28.8 for GHSA-73wf-gq98-2v4g and
GHSA-c83g-rgw3-j3cx. Both advisories were published aftermain's last green run and cover
browserslist <= 4.28.6, whichmaincarried at 4.28.4; every dependent range is^4.x, so a
lockfile bump clears the audit gate with no override (mobile/package-lock.json). Same
non-hermetic class as the expo-doctor failure above, on the audit gate rather than the doctor
gate.
What's Changed
- chore(dist): update package manifests to v2.13.4 by @github-actions[bot] in #1215
- fix(ci): stop upstream Expo patch publishes from blocking tagged releases by @florisvoskamp in #1216
- chore: prepare v2.13.5 release by @florisvoskamp in #1218
Full Changelog: v2.13.4...v2.13.5
v2.12.1
Forge v2.12.1
CLI/TUI and desktop release v2.12.1; mobile remains on its compatible native version.
- CLI / TUI — binaries below (
*.tar.gz/*.zip) orbrew upgrade forge - Desktop (macOS · Windows · Linux) — app bundles below + in-app auto-update
- Mobile (iOS) — production OTA by default; native/TestFlight builds are manual when required
Changed
- Main-branch governance now strictly requires the aggregate
CI,mobile checks, and
security checksresults from the current branch, with no bypass actors
(CONTRIBUTING.mdand repository ruleset 17796318).
Fixed
- Persistent CI and release-runner storage is bounded after every relevant job: aggregate Cargo
targets are capped at 24 GiB, mobilenode_modulesat 4 GiB, and the exact allowlisted release
Docker volumes at 24 GiB, with dry-run and destructive-behavior regression coverage
(scripts/ci/trim-runner-cache.shand workflow wiring). - Forge Anywhere prunes acknowledged superseded local revisions and terminal remote staging rows
after successful sync while retaining pending uploads, newest anchors, conflicts, cursors, and
materialized data (crates/forge-store/src/sync_journal.rs). - The mobile production graph pins patched
brace-expansionand now fails the required mobile gate
on high-severity production advisories; its lockfile is compatible with CI's npm 10 resolver
(mobile/package.json,mobile/package-lock.json, andmobile-typecheck.yml). - crates.io publication now covers all 16 publishable Forge crates in dependency order, including
forge-agent-anywhere-protocol, and publishes the exact vendored provider fork as
forge-agent-genai@0.6.5-forge.1instead of silently falling back to unpatched upstream source
(docs/RELEASING-crates.mdandscripts/ci/test-crates-release-order.sh).
What's Changed
- chore(dist): update package manifests to v2.12.0 by @github-actions[bot] in #936
- fix: bound storage growth and close release gaps by @florisvoskamp in #937
- chore: prepare v2.12.1 release by @florisvoskamp in #938
Full Changelog: v2.12.0...v2.12.1
v2.12.0
Forge v2.12.0
CLI/TUI and desktop release v2.12.0; mobile remains on its compatible native version.
- CLI / TUI — binaries below (
*.tar.gz/*.zip) orbrew upgrade forge - Desktop (macOS · Windows · Linux) — app bundles below + in-app auto-update
- Mobile (iOS) — production OTA by default; native/TestFlight builds are manual when required
Added
-
Forge has a logo. Every icon the product shipped was the stock Expo placeholder — the blue "A"
on the iOS app icon, the Android adaptive icon, the desktop bundles, the PWA manifest and the
favicon, and the grid-and-circles placeholder on both splash screens, which is the "stock Expo
flash" visible on every cold start. The only real mark lived indocs/and no app target used it.
The new mark is a pair of tongs closing on a billet at welding heat; reduced to its silhouette it
also reads as< >, so it means smithing and code at once. Symmetric, flat single colour, and it
holds its shape down to 16px. -
One vector source for every icon.
scripts/brand/forge-mark.svgis now the only place the mark
is drawn, andscripts/gen-brand-assets.pyrenders all 25 shipped assets from it: iOS app icon,
Android foreground/background/monochrome, both splash marks plus the six committed native splash
images, the web manifest icons, two favicons, a multi-size.icoand a.icns(written directly,
sinceiconutilis macOS-only). There was previously no vector source anywhere and each target
carried its own hand-placed PNG, so changing the logo meant finding them all and missing some.
scripts/gen-splash-light-variant.pyis superseded and removed. -
The web root now serves a real
favicon.ico. Browsers request/favicon.icounprompted and there
was nothing there. -
Reproducible, history-safe benchmark cells for Codex, Claude, and full-mesh routing. The
harnesses now recreate exact source trees, gate model/effort/CLI identity, include child-session
usage, preserve superseded attempts, and publish official-evaluator plus quota/integrity evidence.
The matched July samples retain the important caveat: they are evidence for those tasks, models,
hosts, and dates, not population-wide performance estimates.
Changed
- Single coding tasks stay direct and recursive delegation is opt-in. Completeness, named-API,
and migration guidance is stronger without paying for redundant orchestration or repeated audits;
failed environment setup is bounded and child-session cost is included in benchmark accounting. - Claude's persistent bridge is stricter and more resilient. Authoritative model discovery,
bounded tool aliases, MCP readiness, partial-message deduplication, safe no-replay behavior, and a
bounded extra idle window for known long-running tools make subscription-backed Claude sessions
less prone to stalls, duplicate activity, or silent capability drift. - Long mesh sessions retain quality with less repeated context. Complex task-defining turns get
a usable quality anchor, continuations keep controlled diversification, verified session/model/
account boundaries can reuse provider prefixes and Codex response chains, completed tool logs are
pruned, and task-list bookkeeping no longer consumes an independent model round trip. - Runtime ownership is split behind narrower internal boundaries. Core, Mesh, Store, CLI, TUI,
Tools, Config, Provider, Anywhere, and Serve now use cohesive private modules, with no
implementation owner above 5,000 lines. This is an architecture improvement, not a claim that the
longer-term file-size distribution or numerical coverage targets have been reached. - Auto-merge reconciliation now observes completed workflows instead of depending on events GitHub
can drop, while still requiring the protected aggregateCIgate for code-bearing changes.
Fixed
- Rust Analyzer can no longer create an unbounded workstation burst. Forge permits one live
analyzer tree process-wide, uses a one-worker/one-Cargo-job lightweight profile, enforces a
configurable aggregate RSS guard (2 GiB by default), reaps idle servers after 120 seconds, keeps
healthy timed-out servers warm, and rejects diagnostics for stale document versions. A real
workspace probe reduced the observed peak from 3.7 GiB/37 processes/about 14 cores to
1675.7 MiB/four processes/about one core while still finding an injected Rust type error. - Long-running sessions now handle queued steering, interruption cleanup, stale completion markers,
context fitting, cancellation rollback, stream snapshots, and provider reconnect/recovery without
advancing the wrong turn, repeating activity, or retaining detached work. - OAuth pasted callbacks preserve CSRF-state validation; explicit model pins survive reservation
pressure; context windows no longer borrow unrelated provider metadata; and usage-store failures
no longer become plausible zero values. - Serve now aborts timed-out or dropped drivers, prunes unexpectedly completed drivers, performs
bounded shutdown joins, preserves malformed MCP catalogs during mutation, rejects project-path
ambiguity and symlink escapes, serializes configuration writes, and includes stored pricing in
model projections. - Queue repository validation, gate exits and failed-task branches, Assay semantics, MCP dynamic
registration/device-flow separation, Claude import policy and error propagation, Codex alias
freshness, Gemini classification, and TypeScript protocol parity were corrected. - Tauri desktop icons are generated as RGBA PNGs, so
tauri::generate_context!accepts the shared
brand assets instead of failing release builds on RGB-only icons.
What's Changed
- Improve Forge GPT-5.6 quality and benchmark efficiency by @florisvoskamp in #917
- Surface latest GPT-5.6 benchmark in README and docs by @florisvoskamp in #918
- feat(claude): harden bridge and publish Claude 5 benchmark by @florisvoskamp in #919
- chore(dist): update package manifests to v2.11.0 by @github-actions[bot] in #916
- ci: reconcile auto-merge on workflow completion, not on events that get dropped by @florisvoskamp in #920
- ci: approve parked runs that carry no external code, instead of weakening the policy by @florisvoskamp in #921
- Benchmark Forge with history-safe pinned and mesh runs by @florisvoskamp in #922
- feat(brand): give Forge a logo, generated from one vector source by @florisvoskamp in #925
- Harden Forge for long-running mesh sessions by @florisvoskamp in #926
- Close long-session stress honest-review gaps by @florisvoskamp in #927
- Gate native stress execution identity by @florisvoskamp in #928
- fix(stress): publish matched native Claude result by @florisvoskamp in #929
- perf(mesh): add cache-aware session affinity by @florisvoskamp in #930
- refactor(mesh): extract task classification policy by @florisvoskamp in #931
- refactor: harden architecture boundaries and runtime stability by @florisvoskamp in #932
- fix(lsp): bound rust-analyzer resource usage by @florisvoskamp in #934
- chore: prepare v2.12.0 release by @florisvoskamp in #935
Full Changelog: v2.11.0...v2.12.0
v2.11.0
Forge v2.11.0
CLI/TUI and desktop release v2.11.0; mobile remains on its compatible native version.
- CLI / TUI — binaries below (
*.tar.gz/*.zip) orbrew upgrade forge - Desktop (macOS · Windows · Linux) — app bundles below + in-app auto-update
- Mobile (iOS) — production OTA by default; native/TestFlight builds are manual when required
Added
-
CI now reconciles what devices are running against what main contains. Twice — #890 and #910 —
a merge to main created no workflow run at all, so no OTA was published and nothing said so; both
were found days later by a human noticing the fix had not arrived. A missing run cannot be caught
by anything keyed off that run, so a scheduled job now works the other end: it finds the newest
commit touching the OTA-safe paths, checks whether any successfuleas-updaterun covers it (by
ancestry, since a push's head can be a later commit than the change itself), and dispatches the
publish for exactly the uncovered range if not. It reconciles against runs rather than the Expo
update list because a missing run is precisely the defect, and it passes the range asbase_ref
so the existing OTA-safety guard still decides what may ship. -
The app now says when it has updated, and what changed. An OTA is applied silently on the
launch after it downloads and a TestFlight build arrives with nothing in-app to mark it, so "did it
actually update?" was unanswerable without reading CI. A sheet now appears once per update with the
newest changelog section in it, distinguishing a native build from an OTA — a build that also
brings an OTA is reported as one event, not two, because that is what the user experienced. A fresh
install stays silent: there is no version it came from. The decision lives inupdateNotice()as a
pure function of running-versus-last-seen, so it is testable without a device, and the seen build
is recorded when the sheet appears rather than when it is dismissed — a sheet swiped away is still
a sheet that was seen. The changelog is read from the daemon, so with no server paired it says so
rather than showing an empty panel. -
Tabs page under the finger. Dragging horizontally on Fleet / Inbox / History / Settings moves
the content with the drag and peeks the neighbouring tab in behind it; releasing either springs back
or completes, and dragging past the first or last tab resists instead of stopping dead. The bottom
bar is no longer the only way across, and it is still the real one —RNSTabBarControlleris a
genuineUITabBarController, so Liquid Glass, scroll-to-minimize and native badges are untouched
and nothing about the bar is reimplemented in JS.The pager is a horizontal
ScrollViewwithpagingEnabled, and that choice is the feature rather
than an implementation detail.canCancelContentTouchesmeans the moment the scroll view decides it
is scrolling it cancels the touches it has already delivered to its subviews, so dragging across
a row cannot press it. A first attempt drove the translation from a hand-rolledGesture.Panand
could not achieve that: a horizontal drag across a full-width row stays inside that row's hit rect,
so RN'sPressablekept the press and fired it on release — swiping History → Settings opened
History's "Resume this session?" dialog, which then floated over Settings. There is no way to take
that press back after the fact.directionalLockEnabledsettles the vertical axis with the same
owner instead of arm-wrestling the list, and paging supplies the peek, the rubber-band at the ends
and the settle from the platform's own physics rather than from numbers picked by hand.Because a
UITabBarControlleronly keeps the SELECTED child's view laid out, the pager is rendered
by each tab route rather than around the navigator, so a peeked neighbour is a second instance of
that screen. Two consequences follow and are deliberate: neighbours mount once the tab is settled
and then stay mounted — mounting them at the start of a drag was too late, since a state update plus
a lazy import cannot finish inside a quick swipe and the neighbour slid past empty, while dropping
them on blur put a whole screen mount on the exact frame a tab became visible — and they render as
peeks (useIsPeeking), showing cached data and asking the network for nothing, because a screen
sliding past under a thumb is not an arrival.Guarded by assertions that were each verified to fail when deliberately broken:
TAB_SWIPE_ORDER
matches the tab bar's declaration order in both navigators, each route passes the index its position
implies — a wrapper wired to the wrong number would page to the wrong tab while looking perfectly
correct in the bar — andpagerGeometryalways pins a content width the resting page fits inside,
which is what keeps the scroll view from clamping the offset onto the wrong tab.
Changed
- Patched the four open high-severity advisories in the build toolchain: all ten transitive copies of
brace-expansionin the mobile lockfile (to 1.1.16 / 2.1.2 / 5.0.8) andfast-uriin the promo
video pipeline (3.1.4). None of them is reachable from the app bundle or the daemon — they hang off
eslint, sucrase,@expo/prebuild-configand@bacons/apple-targets— so this clears noise rather
than exposure. The mobile lockfile is regenerated with npm 10, which is what CI'snpm cireads;
npm 12 prunes entries it needs. - Tab swiping no longer peeks; it switches immediately. The peek rendered a second live instance
of the neighbouring screen inside the current tab, because aUITabBarControlleronly keeps the
selected child's view laid out and iOS keeps its real tab bar here. That leaked in every direction,
and a screen recording caught the worst of it: a horizontal drag across a full-width row stays
inside that row's hit rect, so RN'sPressableretained the press and fired it on release —
swiping History → Settings opened History's "Resume this session?" confirm dialog, which then
floated over the Settings tab, because a peek that stays mounted keeps its state alive in a tab
it does not belong to. Duplicate fetches and loading states from screens never navigated to, and a
one-frame light flash at the handover, came from the same place. Each was fixable alone and the
next appeared; they share one cause, which is a screen rendered outside the tab that owns it. A
faithful interactive transition needs the platform to own it — a horizontalScrollViewwith
pagingEnabled, whose UIScrollView cancels touches in its subviews the moment it scrolls, or an
interactiveUITabBarControllertransition in Swift. The swipe, its thresholds and the absent
arrival haptic all stay.
Fixed
- Three
forge-indexwatcher tests raced the watch they were testing. Each made its external edit
once, immediately afterspawn_watcherreturned — but registration happens on the watcher's own
thread, so the write could land before any watch existed, produce no event, and then no amount of
polling could recover it (the polling backend has the same shape: an edit made before its first
scan is simply part of the baseline). Alone the gap is too small to notice; run beside each other
under load, ascargo test --workspacedoes, and it was wide enough to fail the release gate. The
edit now repeats until the watch picks it up, which tests what the tests meant to test without
weakening either assertion. - One stale frame from a phone took the whole Anywhere connector offline. The host's list of open
session streams is per relay connection, and that connection drops and reconnects on its own —
three times in the last two days' logs, from resets and heartbeats. The phone's socket survives
those drops, so it goes on sending frames for streams the reconnected host has no record of, and
the host treated an unknown stream id as fatal: it tore down the connector, reconnected, and died
again on the next frame. Every bridge request in those windows failed against a connector that had
just reported itself online. An unknown stream is a race, not an attack, so the host now answers it
with a close — telling the phone to stop using that socket — and keeps serving everything else. - Voice over Anywhere timed out on anything but a short clip. The relay applied a flat 30s
deadline to every bridge request, overriding whatever the caller asked for, so
transcribeAudio's 120s budget was never in effect. A bridge request is not a proxy hop: the host
transcribes the entire clip before it answers — measured at ~4.5s for a 4s recording — so a voice
memo of any real length could not come back in time. The caller'sAbortSignalnow reaches the
relay and governs, which also means cancelling a recording actually cancels the request; the relay
keeps a deadline of its own only for callers that set none. - The tab you swiped away from no longer flashes when you arrive. Four previous attempts moved a
corrective scroll earlier and earlier — onto a 150ms timer, then into a layout effect on arrival —
and each one made the flash shorter without removing it. Shortening it was the clue: the correction
was racing something rather than preventing it. AUIScrollViewclamps itscontentOffsetinto its
contentSize, and the pager's content width was left to be measured from its pages, so any layout
pass that measured it short pulled the offset to zero — and page zero is the neighbour on the LEFT,
which is the tab you just swiped away from. The clamp happens inside layout, earlier than any scroll
JS can schedule, which is why no delay could have been the answer. The content width is now pinned
from the page count, so there is no pass in which it is too narrow and no clamp to recover from. The
geometry moved topagerGeometryinlib/tabSwipe.ts, where the invariant t...