Skip to content

Releases: adulari/forge

v2.15.0

Choose a tag to compare

@github-actions github-actions released this 07 Sep 23:15
4177622

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) or brew 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 132ms line — 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 (or Ctrl+T for the most recent,
    rebindable as toggle_tool_card) expands it in place into the decoded arguments and the output,
    and clicking again collapses it. PresenterEvent::ToolResult and LiveEvent::ToolResult gained
    a bounded detail field (200 lines / 8000 chars) to carry that output, so cards work in
    forge attach and 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 editing config.toml and restarting, and it then applied to every session.
    The command sets a per-session override that Session::subagents_free resolves against the
    config default and hands to children as AgentCtx::inherit_pinroute_child now 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_args hard-rejected any path/cwd/paths resolving
    outside workspace.root() for read_file/write_file/edit_file/multi_edit/apply_patch/
    append_file/notebook_edit/delete_file/list_dir/search/glob — but the shell tool reaches any
    path via its command string, so an agent doing analysis work against scratch/capture files in
    /tmp could write and read them with shell but not with the structured tools, forcing a
    cp into workspace; read; rm dance 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 from crates/forge-core/src/tool_dispatch.rs
    via a new Session::extra_tool_roots, populated in crates/forge-core/src/session_lifecycle.rs
    from config.tools.extra_roots) and crates/forge-tools/src/workspace.rs
    (WorkspaceTool/ToolRegistry::bind_extra_roots, wired at both real binding sites:
    crates/forge-cli/src/cli/commands/run/session.rs via Session::build and the mcp-serve
    CLI-bridge path in crates/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_roots is separate from shell.sandbox_writable (the
    Landlock write-sandbox) and does not change shell, which was already unconfined.

Changed

  • The repository's .mcp.json no 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 want forge_chat from Claude Code.

Fixed

  • A session never picked up an AGENTS.md that 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 (one stat; 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
    written AGENTS.md reaches the next turn, and an unchanged one is never restated
    (crates/forge-core/src/session_controls.rs refresh_project_instructions).
  • tools.extra_roots was still refused by the file tools' in-process safety net. The
    allowlist reached the two argument validators but not confine() in
    crates/forge-tools/src/core_tools.rs, whose workspace_roots() only trusted the workspace
    (and, for standalone runs, the system temp dir) — so a daemon-hosted read_file/write_file
    on an allowlisted path still failed with "resolves outside the workspace (workspace-confinement
    safety net)". The extra roots now ride a SESSION_EXTRA_ROOTS task-local scoped alongside
    SESSION_WORKSPACE by the tool wrapper, and confine() 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 —
    including MessageRow (already React.memo'd) and the 1000+ line Composer — re-rendered
    per frame regardless of whether it read snapshot at all; Composer only needed
    snapshot.model/snapshot.effort, and the session shell's SessionHeader/StatusStrip
    received ~25 fresh inline-arrow-function props on every frame too. sessionContext.tsx now
    splits into a live context (snapshot, snapshotTimedOut, connectionState, changing per
    frame) and a stable context (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 new useSessionStable()/useSessionLive() hooks let a
    component opt into just the slice it needs. MessageRow and Composer now read
    useSessionStable() only; Composer takes model/effort as props from its caller instead
    of reading them off snapshot itself, and is wrapped in React.memo. SessionHeader and
    StatusStrip are also React.memo'd, with their handlers hoisted into useCallbacks and the
    weekly/transport object 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
    requestAnimationFrame loop 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, with frameIntervals/composerSamples/composerImeSamples/
    composerInputEvents/composerImeEvents all growing forever. Separately, the iOS Home Screen
    widget was resynced (an app-group write plus a WidgetCenter reload) 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 memoizes buildTranscript on historyRows/transcriptRows instead of
    rebuilding it on every snapshot, and derives the live tool-activity ledger through a
    content-keyed memo so FlatList's renderItem identity — 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...
Read more

v2.14.1

Choose a tag to compare

@github-actions github-actions released this 07 Sep 03:51
763a0ed

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) or brew 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 timestamped snapshot.debian.org view, 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

  • shell can 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, and nohup cmd & does not escape that (nohup detaches from the terminal, not
    the process group), nor does systemd-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 new shell_job tool 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 at background: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.sh to stand it
    up in one command and FORGE_SEARXNG_URL to point at another instance
    (crates/forge-tools/src/web/search.rs, scripts/searxng-setup.sh).

Fixed

  • web_search was 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 for tokio select macro it returned ten
    well-formed results for plumbers near 1 Microsoft Way — so it is last and labelled, never trusted
    silently. Bing's /ck/a tracking 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_start picked
    -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 with git stash create under refs/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-json answered 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

Full Changelog: v2.14.0...v2.14.1

v2.13.9

Choose a tag to compare

@github-actions github-actions released this 03 Sep 05:25
0954646

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) or brew 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-in codex CLI 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), and codex-cli:: joins claude-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 with forge setup / forge auth whenever 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 a forge serve restart
    (crates/forge-cli/src/anywhere/state.rs).

Changed

  • The use_skill listing 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

Choose a tag to compare

@github-actions github-actions released this 03 Sep 02:39
36d21a8

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) or brew 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-* and grok-*
    answer only on /responses there, 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.3 matched "Muse Spark 1.2" on the shared 1. 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 doctor reports a provider-rejected key as invalid rather than unreachable, and
    forge models says 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 forge shows 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 run can 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-fleet hand 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 the forge 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 run no 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}/interrupt ends 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; --steer is 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-build no 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 forge shows 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 ...
Read more

v2.13.7

Choose a tag to compare

@github-actions github-actions released this 02 Sep 15:15
349d44d

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) or brew 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 /login after a
    Forge process ran inside a bridged turn.
    real_claude_config_dir honoured an inherited
    CLAUDE_CONFIG_DIR even when it was Forge's own isolated mirror, so forge mcp-serve (and any
    child session it spawned) rebuilt the mirror onto itself and every entry became a symlink to
    itself, .credentials.json included. The inherited value is now ignored when it is the mirror,
    prepare_claude_bridge_home refuses 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
    headless forge run (or --mode bypass) treats mesh.max_steps as 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 new mesh.max_turn_input_tokens ceiling (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
    rationale last resort: pacing hold overridden, and forge mesh marks 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,
    with tier inherited from previous turn in 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
    functionCall parts 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 -p printed
    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-json and a 600 s print timeout, its usage block is recorded (no more
    ↑0 ↓0), and a stall names the budget that fired in model_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 by largest quota / model quota (fallback table, since the usage
    endpoint exposes no per-model data) and forge mesh prints 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 mesh and forge doctor name the active secret-store
    backend and warn loudly when it is the test-secrets in-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

Choose a tag to compare

@github-actions github-actions released this 02 Sep 02:34
6b2cf6b

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) or brew 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, and muse-spark-1.2-contributor reject 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
    working claude-cli::opus[1m] was stored as excluded: auth failed: auth failed because 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 --now is a no-op for an already-active unit, so the rendered
    ExecStart could point at the release while the old process kept serving; in the observed
    failure this ended in a 203/EXEC service 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 doctor reports 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

Choose a tag to compare

@github-actions github-actions released this 01 Sep 21:21
54a6499

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) or brew 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 shipping expo@57.0.19 upstream was enough to fail app preflight
    on a commit that had passed CI unchanged, and because app-desktop.yml checks out
    refs/tags/<release_tag> no fix landing on main can 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
    own EXPO_DOCTOR_SKIP_DEPENDENCY_VERSION_CHECK while 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 plain npm 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 browserslist 4.28.8 for GHSA-73wf-gq98-2v4g and
    GHSA-c83g-rgw3-j3cx.
    Both advisories were published after main's last green run and cover
    browserslist <= 4.28.6, which main carried 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

Choose a tag to compare

@github-actions github-actions released this 30 Jul 17:30
f607271

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) or brew 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 checks results from the current branch, with no bypass actors
    (CONTRIBUTING.md and repository ruleset 17796318).

Fixed

  • Persistent CI and release-runner storage is bounded after every relevant job: aggregate Cargo
    targets are capped at 24 GiB, mobile node_modules at 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.sh and 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-expansion and 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, and mobile-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.1 instead of silently falling back to unpatched upstream source
    (docs/RELEASING-crates.md and scripts/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

Choose a tag to compare

@github-actions github-actions released this 30 Jul 12:00
2af33f0

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) or brew 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 in docs/ 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.svg is now the only place the mark
    is drawn, and scripts/gen-brand-assets.py renders 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 .ico and a .icns (written directly,
    since iconutil is 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.py is superseded and removed.

  • The web root now serves a real favicon.ico. Browsers request /favicon.ico unprompted 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 aggregate CI gate 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

Full Changelog: v2.11.0...v2.12.0

v2.11.0

Choose a tag to compare

@github-actions github-actions released this 27 Jul 00:43
8a2c852

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) or brew 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 successful eas-update run 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 as base_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 in updateNotice() 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 — RNSTabBarController is a
    genuine UITabBarController, 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 ScrollView with pagingEnabled, and that choice is the feature rather
    than an implementation detail. canCancelContentTouches means 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-rolled Gesture.Pan and
    could not achieve that: a horizontal drag across a full-width row stays inside that row's hit rect,
    so RN's Pressable kept 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. directionalLockEnabled settles 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 UITabBarController only 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 — and pagerGeometry always 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-expansion in the mobile lockfile (to 1.1.16 / 2.1.2 / 5.0.8) and fast-uri in 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-config and @bacons/apple-targets — so this clears noise rather
    than exposure. The mobile lockfile is regenerated with npm 10, which is what CI's npm ci reads;
    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 a UITabBarController only 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's Pressable retained 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 horizontal ScrollView with
    pagingEnabled, whose UIScrollView cancels touches in its subviews the moment it scrolls, or an
    interactive UITabBarController transition in Swift. The swipe, its thresholds and the absent
    arrival haptic all stay.

Fixed

  • Three forge-index watcher tests raced the watch they were testing. Each made its external edit
    once, immediately after spawn_watcher returned — 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, as cargo test --workspace does, 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's AbortSignal now 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. A UIScrollView clamps its contentOffset into 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 to pagerGeometry in lib/tabSwipe.ts, where the invariant t...
Read more