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 through a wrapper outlived the session. Forge's teardown killed only
the direct child, butnpm exec …/uvx …run the real server as a grandchild, so ending a
session left it under pid 1 — ninetoken-counter-mcpnode 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 forSIGTERMwhen 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 deadforge-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 afterforge lattice prune --stale --vacuum. - Markdown tables in
forge chatrendered 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--modelfrom session creation, which/modelnever updates, and the session's
own/modelpin. Compaction and refinement built their candidate chain from the ROUTER's pin —
a stalecodex-oauth::gpt-6-astrafrom 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_rowsthrough 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 noonclose, 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 toIN_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 intarget/,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 agentlaunched 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 atarget/debug/forge mcp agententry in.forge/mcp.tomlwas not
recognised as self and was spawned — a second session, index and file watcher for every real
one. Any forge-named binary invoked asmcp agentis now treated as a nested Forge agent
(crates/forge-cli/src/cli/commands/run/session.rs).- A
forge mcp agentcould 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 intop. On Linux the agent now asks the kernel for
SIGTERMwhen 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