Skip to content

v2.15.0

Latest

Choose a tag to compare

@github-actions github-actions released this 07 Sep 23:15
· 9 commits to main since this release
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 through a wrapper outlived the session. Forge's teardown killed only
    the direct child, but npm exec … / uvx … run the real server as a grandchild, so ending a
    session left it under pid 1 — nine token-counter-mcp node processes (~500 MB) from sessions that
    had ended hours earlier were found on one laptop. Stdio servers now start in their own process
    group, every teardown path (disconnect, reconnect, shutdown, manager drop) signals the whole
    group, and on Linux the child asks for SIGTERM when Forge itself dies, so even a SIGKILLed Forge
    leaves nothing behind (crates/forge-mcp/src/transport.rs).
  • A removed worktree left its whole code index behind. Each daemon worktree session indexes its
    own copy of the repository and nothing pruned it afterwards: 48 dead forge-wt-* copies were
    ~1 GB of a 2.5 GB store. Session start now drops the index of any root whose directory no longer
    exists, before the incremental update (Lattice::prune_stale_roots). Roots that still exist are
    never touched; the file shrinks after forge lattice prune --stale --vacuum.
  • Markdown tables in forge chat rendered as one long line of pipes. The transcript renderer
    parsed with no extensions, so a GFM table was just a paragraph whose rows were joined by soft
    breaks — every model-written comparison table came out as | Field | App | … |---|---| … on a
    single wrapped line. Tables now parse and lay out as aligned columns with a header rule; cell
    styling (inline code, bold) is kept, and an over-wide cell is clipped with an ellipsis so one
    long value cannot wrap every row. ~~strikethrough~~ no longer leaks its tildes either
    (crates/forge-tui/src/render.rs).
  • A session pinned to a free model auto-compacted on a ChatGPT-plan model. Two pins exist:
    the router's --model from session creation, which /model never updates, and the session's
    own /model pin. Compaction and refinement built their candidate chain from the ROUTER's pin —
    a stale codex-oauth::gpt-6-astra from two days earlier — ahead of the session's current free
    pin, and when the free shortlist was exhausted the summary ran on the low-allowance
    subscription. A session-level pin now drops the routed hop entirely, and a subscription model
    that is not the model the session is running on is never a candidate for compaction,
    refinement, or any other side call (recap, suggestion, memory, shell diagnosis)
    (crates/forge-core/src/compaction_policy.rs, refinement.rs, routing_policy.rs).
  • The mobile app flipped a whole conversation to plain grey text and stayed there. Whenever
    REST history was late, failed, or came back empty — a daemon restart, a server switch, the
    first paint — the screen fell back to the socket snapshot's transcript painted as bare lines,
    and nothing asked for history again until a turn completed. The fallback now renders the v9
    transcript_rows through the same message and tool rows history uses (the two states are
    indistinguishable), history is refetched on every socket reconnect, and an empty history page
    that contradicts a non-empty snapshot is re-asked for every 5 s until it agrees
    (mobile/src/lib/transcriptFiller.ts, mobile/src/app/session/[id]/index.tsx).
  • Mobile reconnect could stall until the app was reopened. A WebSocket constructor that threw
    fired no onclose, so no retry was ever scheduled; and returning to the foreground reused the
    backoff accrued while backgrounded, so the first visible attempt could wait 15 s. Construction
    failures now take the normal backoff path, and foregrounding resets the attempt counter so the
    first reconnect is immediate (mobile/src/lib/ws.ts).
  • The lattice file watcher pinned a CPU core per Forge process, indefinitely. Two Forge MCP
    agents on one laptop each burned ~90% of a core for their whole lifetime with nothing changing on
    disk — package temperature 97–100 °C and 46,000 thermal-throttle events in 32 minutes. The
    watcher's inotify backend subscribes to IN_OPEN, so every file the reindexer read to hash it
    came back as an event for that same file, which queued another reindex, which read it again:
    a loop that fed itself forever once the initial index walk seeded it (measured: every source
    file re-opened 73 times in 6 seconds, 97,000 events). Access-only events are now dropped before
    they reach the worker; a real write still reindexes. A regression test reproduces the loop
    shape — a reindex action that reads its file — and proves the count stops moving once the tree
    is quiet (crates/forge-index/src/watch.rs).
  • The watcher registered ~55,000 inotify watches on the Forge repository. One recursive watch
    on the project root pulled in target/, node_modules/, .git/ and every worktree, so each
    cargo build or checkout anywhere under the tree woke the watcher thread for nothing. It now
    registers one watch per directory the indexer actually walks (~300 here), sharing the indexer's
    walker so the watched set and the indexed set cannot drift, and follows the tree as directories
    appear. The debouncer crate and its extra thread are gone; the existing coalescing worker does
    that job.
  • forge mcp agent launched a second full agent beside itself. The self-MCP guard compared
    executable basenames, but the repo's MCP launcher runs a snapshot copy named
    forge-<hash>, so a target/debug/forge mcp agent entry in .forge/mcp.toml was not
    recognised as self and was spawned — a second session, index and file watcher for every real
    one. Any forge-named binary invoked as mcp agent is now treated as a nested Forge agent
    (crates/forge-cli/src/cli/commands/run/session.rs).
  • A forge mcp agent could outlive the process that spawned it. Stdin EOF ends the server
    loop when the parent exits cleanly, but a parent killed outright left the agent — session, index,
    watcher — running until someone found it in top. On Linux the agent now asks the kernel for
    SIGTERM when its parent dies (PR_SET_PDEATHSIG), so a dead orchestrator can no longer leave
    a live Forge behind (crates/forge-cli/src/mcp_agent.rs).

What's Changed

  • chore(dist): update package manifests to v2.14.1 by @github-actions[bot] in #1328
  • fix(index,cli): stop the lattice watcher feeding itself and pinning a core per process by @florisvoskamp in #1329
  • fix(mcp,index): end MCP child trees with the session; prune index copies of removed roots by @florisvoskamp in #1330
  • fix(tui): render GFM tables as aligned columns instead of a line of pipes by @florisvoskamp in #1331
  • fix(core): never compact or run side calls on a subscription model the session is not pinned to by @florisvoskamp in #1333
  • fix(mobile): keep the transcript styled when history is late; self-healing reconnect by @florisvoskamp in #1332
  • fix(mobile): stop the always-on frame sampler from draining the battery by @florisvoskamp in #1334
  • perf(mobile): stop re-rendering the whole session tree on every snapshot by @florisvoskamp in #1335
  • feat(tools): let file tools read/write configured roots outside the workspace by @florisvoskamp in #1336
  • fix(tools): honor tools.extra_roots in the file tools' in-process confine net by @florisvoskamp in #1337
  • feat(mesh,tui): /subagents — release one session's children from its model pin by @florisvoskamp in #1338
  • feat(tui): expandable tool cards in the chat transcript by @florisvoskamp in #1344
  • fix(core): pick up an AGENTS.md written or edited while a session runs by @florisvoskamp in #1345
  • chore: prepare v2.15.0 release by @florisvoskamp in #1346

Full Changelog: v2.14.1...v2.15.0