Commit graph

920 commits

Author SHA1 Message Date
brooklyn!
eb52760564
Merge pull request #71901 from NousResearch/bb/session-click-active
fix(desktop): clicking the active session from a full page returns to the chat
2026-07-26 05:32:43 -05:00
hermes-seaeye[bot]
529ae164ae
fmt(js): npm run fix on merge (#71897)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-26 10:27:29 +00:00
Brooklyn Nicholson
ecf8ef970e fix(desktop): clicking the active session from a full page returns to the chat
While the workspace pane shows a full page (Artifacts, Skills, Messaging, a
plugin route), a sidebar click on the ACTIVE session did nothing: onResumeSession
took focusOpenSession's `true` for the main-session branch as "already on
screen" and skipped the navigate, but fronting the workspace tab doesn't put the
chat back — the page is still routed. The user had to click some other session
and then the active one to get back.

focusOpenSession now reports WHICH surface it fronted ('main' | 'tile' | null),
and focusedSessionNeedsRoute decides: a tile never needs a route (its pane
renders the chat regardless), a main hit does while a page covers the workspace.
2026-07-26 05:26:26 -05:00
brooklyn!
3b9bd0de6d
Merge pull request #71848 from NousResearch/bb/sidebar-plus-tab
Open a tab from the sidebar "+" when a chat is already loaded
2026-07-26 05:19:51 -05:00
brooklyn!
080ee077a8
Merge pull request #71891 from NousResearch/bb/slash-tab-target
fix(desktop): send a skill's kickoff into the tab that invoked it
2026-07-26 05:15:16 -05:00
Brooklyn Nicholson
2a6368f041 fix(desktop): send a skill's kickoff into the tab that invoked it
`/work` typed into a fresh Cmd+T tab loaded the skill in that tab and
printed " loading skill: work" there, then fired the skill's kickoff
prompt as a user message into whatever conversation was on screen.

The dispatcher resolves its target once, through resolveTargetSessionId,
and every other consumer of that answer already honors it: the output
writer binds to the target's stored id, and the busy gate reads the
target's own state. The send did not — `submitPromptText(message)` passed
no target at all, so submit fell back to `activeSessionIdRef`, which
names the foreground chat. #71805 fixed the two sibling leaks in this
same function; this is the third and the one that actually moved the
user's prompt.

Forward the resolved pair instead. Every target the dispatcher serves —
a tile, a background queue drain, a session this very call created —
was hitting the same fallback, so the fix covers the class rather than
the tab case that surfaced it.
2026-07-26 05:08:37 -05:00
brooklyn!
02721cc1c0
Merge pull request #71872 from NousResearch/bb/desktop-ui-polish
Desktop UI polish: link chips, sidebar arc, tab strip rule
2026-07-26 05:08:08 -05:00
Brooklyn Nicholson
76e9ac3915 fix(desktop): preserve correction order in session tabs 2026-07-26 05:00:18 -05:00
Brooklyn Nicholson
6b816ad8c3 fix(desktop): make the running-session arc legible in the sidebar
The arc reduced to a few faint dots on session rows. Two causes: the ring
was outset by 2px into a scroller that clips horizontally, losing its
left and right runs; and its tail color defaults to the chrome
background, which is invisible against the sidebar, leaving only the
bright stop of each gradient pass.

An `arc-row` variant sits flush and ties the tail back to the ring
color. The ring's radius is now derived from its standoff (r_host + gap)
rather than inherited, which keeps any outset host concentric instead of
pinched.
2026-07-26 04:25:25 -05:00
hermes-seaeye[bot]
953707103f
fmt(js): npm run fix on merge (#71863)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-26 09:04:27 +00:00
Brooklyn Nicholson
fc39c7ac31 test(desktop): scope e2e transcript helpers to the active chat surface
The sidebar "+" now stacks a tab instead of replacing the surface, so the
prior session stays mounted and several chat surfaces can be on the page at
once. Helpers that waited for the old transcript to disappear from the page
timed out, and `.first()` locators / bare `document.querySelector` calls
started resolving against the wrong session (CI's "resolved to 2 elements"
strict-mode violation).

Target the most recently mounted `[data-composer-target]` surface instead,
and assert the NEW surface is empty rather than waiting for the old text to
vanish.
2026-07-26 03:57:49 -05:00
brooklyn!
4dae897265
Merge pull request #71835 from NousResearch/bb/stream-history-cost
perf(desktop): make streaming cost independent of transcript length
2026-07-26 03:56:05 -05:00
brooklyn!
de9196ed0c
Merge pull request #71836 from NousResearch/bb/stream-foreground-leak
Render the workspace pane from its own session slice
2026-07-26 03:55:52 -05:00
Brooklyn Nicholson
bd86ce9938 feat(desktop): open a tab from the sidebar "+" when a chat is loaded
The sidebar "+" ran startFreshSessionDraft, so it replaced whatever was on
screen — ⌘N behavior. With a conversation already open (possibly mid-turn)
that discards it to make room for a blank draft, which is not what a create
affordance should do. The tab-strip "+" and ⌘T already stack a new tab.

Route the sidebar button through the same path once a session is loaded,
falling back to the fresh-draft path when the surface is empty and there is
no tab worth preserving. openNewSessionTile now takes an optional cwd so the
new tab stays anchored to the clicked project/worktree lane.
2026-07-26 03:22:09 -05:00
Brooklyn Nicholson
e7f4b95b47 refactor(desktop): tighten the surface-var helper
Fold the null fallback into chatSurfaceRoot so both callers share one
target resolution, hoist the var names next to it, and drop the
duplicated rationale from the status-stack comment.
2026-07-26 03:20:12 -05:00
Brooklyn Nicholson
ad09bf3872 fix(desktop): flush stream deltas from a timer, not an animation frame
Once the coalescing floor had already elapsed, the next delta was scheduled
with requestAnimationFrame. Chromium pauses rAF for a renderer it considers
hidden, and that is not something this code can verify: backgroundThrottling:
false and the process-level switches in electron/main.ts cover the blurred and
occluded cases, but not a minimized window, a fully off-screen one, or a
renderer the compositor has otherwise parked. In those states the callback is
accepted and never runs, so a finished answer sits in the queue until some
later focus or input event happens to wake a frame — the reply looks stalled,
then lands all at once on refocus.

Always use a timer. The coalescing cadence is unchanged (the floor above is
what enforces it), timers are clamped rather than suspended in background
renderers, and disable-background-timer-throttling already opts out of that
clamp. The teardown path loses its cancelAnimationFrame branch with it.

The regression test parks rAF the way an occluded renderer does and asserts
the delta still arrives. It has to send a delta, let it flush, then idle past
the floor before the delta under test, because the frame-gated branch was only
reachable on that second scheduling — a single-delta version of this test
passes on main and proves nothing.

Co-authored-by: NetRunner2037 <rerdi92@users.noreply.github.com>
2026-07-26 03:15:44 -05:00
Brooklyn Nicholson
c52bc5ec95 fix(desktop): scope measured-height vars to each chat surface
The composer and the out-of-flow status stack published their measured
heights onto document.documentElement, but both components mount once per
chat surface. Session tiles render a full ChatView beside the workspace
pane, so N surfaces raced for one value: a background tab with a tall
status stack inflated the foreground thread's bottom clearance and pushed
its jump-to-bottom button into mid-screen. Whichever surface unmounted
last also cleared the var for everyone still showing.

Publish onto the surface's own root instead, and re-declare the clearance
calc there — `:root` substitutes the root measurements once, so scoping
only the inputs would leave every thread reading the same value.
2026-07-26 03:05:30 -05:00
Brooklyn Nicholson
c489480cbb fix(desktop): render the workspace pane from its own session slice
The workspace pane read the global $messages/$busy atoms — a mirror of
whichever session was active — while every ⌘T tile rendered from its own
$sessionStates slice. With two turns in flight, navigating away from a
still-streaming session left it painting into the surface now showing a
different conversation: the wrong transcript under the right route.

Point the primary view at the active session's own slice, keeping the
global atoms as the draft surface for a chat that has no runtime id yet.
2026-07-26 02:58:52 -05:00
Brooklyn Nicholson
beb3f566ba perf(desktop): stop re-normalizing the transcript on every stream delta
useRuntimeMessageRepository keeps a WeakMap of converted messages so a
settled turn converts once. Building the export with
ExportedMessageRepository.fromBranchableArray threw that away again: it maps
the whole array through fromThreadMessageLike on every call, so each streamed
delta re-normalized the entire settled history and handed the runtime a fresh
object for every message.

Normalize on the cache miss instead, using the same fallback status
fromBranchableArray applies, and build the export literal directly. Settled
turns now keep reference identity across deltas, which is what lets the
reconcile below tell that only the tail moved.

Co-authored-by: Jakub Wolniewicz <frizikk@users.noreply.github.com>
2026-07-26 02:56:04 -05:00
hermes-seaeye[bot]
a97b6ff8f6
fmt(js): npm run fix on merge (#71818)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-26 07:20:08 +00:00
brooklyn!
6deb92df52
Merge pull request #71800 from NousResearch/bb/project-sort
fix(desktop): sort projects by real activity and record terminal session cwd
2026-07-26 02:06:00 -05:00
brooklyn!
d471fc9560
Merge pull request #71799 from NousResearch/bb/model-picker-perf
perf(desktop): faster model picker open + idle CPU on stacked tabs
2026-07-26 01:51:20 -05:00
Brooklyn Nicholson
428c909d28 fix(desktop): bind a tab's slash command to its own session
A slash command in a ⌘T tab or split pane routes through the primary
chat's dispatcher, which read the FOREGROUND view's identity for two
decisions it had no business asking the foreground about.

Busy: the gate read `busyRef`, a mirror of whatever chat is on screen.
A brand-new tab with zero turns was told "session busy — message queued"
because an unrelated chat was mid-stream, and the converse let a
background send fire into a live turn.

Identity: the output writer bound to the foreground's stored session, so
a tab's transcript writes re-keyed its cache entry onto the primary's
stored id and its queued payload landed on the primary's queue — the
kickoff would then drain into the wrong conversation. `submitText` also
dropped an explicit target when it routed to a slash command, running a
queue drain's command against whatever was in front.

Read the target session's own published state for both. One shared
resolver so submit and slash cannot drift apart again.
2026-07-26 01:44:03 -05:00
Brooklyn Nicholson
7ef3f1407b fix(desktop): gate the slash/submit busy queue on the target session
A slash command runs against the session `resolveTargetSessionId` picks,
which is routinely not the session on screen — a tile, a route rebind, or
a session created by the call itself. Both prompt pipelines gated on
`busyRef`, the FOREGROUND view's busy flag, so one session's send was
gated on another session's turn: a stale foreground `true` (a warm resume
of a still-running chat leaves one behind) parked an idle session's
command on the composer queue and reported "session busy" about a session
doing nothing. The converse also leaked — a background send could fire
mid-turn while the foreground happened to be idle.

Read the published per-session state instead, falling back to the
foreground flag only when the target has no state yet (a just-minted
session whose first publish hasn't landed). One shared resolver so submit
and slash cannot drift apart again.
2026-07-26 01:39:56 -05:00
Brooklyn Nicholson
19025eb7ce fix(desktop): rank projects by real activity, not disk-scan time
The sidebar's project overview put git checkouts with zero Hermes sessions
above the repos the user actually works in, and dragging a project into place
did not stick.

Two causes. The repo discovery payload folded `discovered_repos.last_seen`
into `last_active`, but `last_seen` is when the disk scan last saw the
directory, so every scanned checkout was stamped "just now" and outranked
real work. Activity is now session-derived only; a repo with no sessions
reports no activity.

The overview also applied the manual drag-order through `orderByIds`, which
floats every id missing from the saved order to the top. That is right for
sessions, where a new chat should not sink, but the overview keeps receiving
newly-scanned repos — so once the user dragged anything, each new discovery
jumped above their hand-picked list. Projects the user has not ordered now
keep their deterministic position: ones with real activity still surface on
top, zero-session discoveries sort below the ordered list.
2026-07-26 01:20:23 -05:00
Brooklyn Nicholson
667758ac24 perf(desktop): defer model-row submenu bodies until hover
ModelMenuPanel mounts a ModelEditSubmenu per model row, and each body ran
its hooks and built its JSX eagerly on menu open — ~90 rows of switches,
radio groups, and preset lookups nobody hovered yet, the largest app-code
slice in the open-latency profile. Wrap the body in a child component under
SubContent so Radix's presence gate leaves it unrendered until the submenu
actually opens; open latency drops roughly in half on a 91-model catalog.
2026-07-26 01:10:22 -05:00
Brooklyn Nicholson
47f0cdf3a2 perf(desktop): freeze hidden tab transcripts during streaming
Keep-alive keeps every ever-active tab mounted, but each hidden tab's
ChatRuntimeBoundary still subscribed to its view's $messages — so every
streaming delta flush (~30x/s) re-rendered every busy tab's whole thread,
and five concurrent sessions dropped the app to a crawl.

Flow the pane layer's visibility down as PaneVisibleContext and gate the
$messages subscription on it: a hidden tab freezes its transcript (status
dots stay live through the separate status atoms) and catches up in one
commit on reveal, since subscribe fires immediately with the current value.
2026-07-26 00:20:40 -05:00
hermes-seaeye[bot]
6c9718151c
fmt(js): npm run fix on merge (#71706)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-26 02:46:01 +00:00
brooklyn!
6ab5d2df2a
Merge pull request #71664 from NousResearch/bb/composer-slash-trigger
fix(desktop): make skills referenceable anywhere in the composer
2026-07-25 21:26:14 -05:00
brooklyn!
3a06908433
Merge pull request #71688 from NousResearch/bb/profile-gateway-inherit-label
fix(desktop): clarify inherited profile gateway
2026-07-25 21:22:41 -05:00
brooklyn!
a135b278dd
Merge pull request #71601 from helix4u/fix/desktop-model-picker-authority
fix(desktop): keep model picker aligned with session state
2026-07-25 21:20:54 -05:00
KaliShodan
4656a1a009 fix(desktop): clarify inherited profile gateway 2026-07-25 21:06:40 -05:00
brooklyn!
a606d24cf2
Merge pull request #71679 from NousResearch/bb/default-effort
fix(desktop): honor the configured reasoning effort instead of assuming medium
2026-07-25 20:58:00 -05:00
Brooklyn Nicholson
43d1088ba2 refactor(desktop): give reasoning effort one owner
The seven effort levels were enumerated four times in four shapes: a
labels map, a radio-option list, a settings value array, and an enum
list for the config field. Each carried its own hardcoded medium
fallback, which is how the picker drifted from the configured default in
the first place.

Collapse them into lib/reasoning-effort, which owns the scale, the
labels, and the resolve/fallback rules. Net -80/+26 in source. Dropping
the effortLabelKey cast also means the i18n keys are now type-checked
against the canonical list instead of asserted into place.
2026-07-25 20:51:18 -05:00
brooklyn!
10160a1808
Merge pull request #71672 from NousResearch/bb/tab-session-titles
fix(desktop): name a Cmd+T session from its first message
2026-07-25 20:51:01 -05:00
Brooklyn Nicholson
412a535433 fix(desktop): scope mid-message references to skills and keep them as chips
Two corrections to the inline slash reference.

Only skills are offered mid-message now. A built-in like `/model` or `/new`
acts on the app, so it reads as nothing useful in the middle of a sentence —
whereas a skill is exactly the thing you want to point at while describing
work. A leading `/` is unchanged and still offers the full command set.

A picked skill also stays a pill in the sent message. The composer already
inserts one, but the message renderer knew nothing about slash references and
flattened it back to raw text on send. It now parses a mid-prose `/skill` into
a chip segment and renders it with the same styling the composer uses. The
submitted text is untouched — the chip still round-trips to the literal
`/clean` the backend expects — so this is presentation only. Path-like tokens
(`/usr/local/bin`) are excluded: unlike the caret-anchored composer trigger,
this scans finished text, so it also has to reject a token that runs on into
a path.
2026-07-25 20:45:11 -05:00
Brooklyn Nicholson
fab0e60570 fix(desktop): name a Cmd+T session from its first message
A Cmd+T tab's session is created unlisted (openNewSessionTile passes
`listed: false`), so it has no $sessions row until its first turn
persists and a refresh surfaces it. For that whole first exchange the
tab strip and the sidebar read "New session".

Cmd+N has no such gap: its session is created per-send, and
createBackendSessionForSend seeds the optimistic row with the user's
text as the preview. A tile never reaches that path — its runtime id is
already bound, so its createBackendSessionForSend seam is a no-op that
returns the existing id.

Seed the row the same way on a tile's first send. The tab strip already
re-syncs on $sessions (watchSessionTiles lists it in `also`), so the tab
and the sidebar both name the session within the first message; the
server's auto-title supersedes the preview when the turn completes.

Guarded so it only ever fires once per session: no-op on empty text and
on an already-listed row, matching across compression by lineage root so
a re-send can't clobber a real title with a raw message preview.
2026-07-25 20:44:19 -05:00
Brooklyn Nicholson
be1edef5ce fix(desktop): show the real effort on the composer pill
The pill hardcoded 'Med' whenever the surface had no explicit effort, so
a profile configured for high advertised a level the agent would not
use. Take the profile default as a fallback and drop the now-unused
shell.modelMenu.medium key across the locales.
2026-07-25 20:37:13 -05:00
Brooklyn Nicholson
e2122f22b8 fix(desktop): stop the model picker overriding the configured effort
Selecting a model applied 'preset.effort ?? medium', so a user running
agent.reasoning_effort: high was silently downgraded to medium on every
model switch and every new chat that inherited the pick. The Thinking
toggle and the effort radio group defaulted to the same literal.

Resolve all of them from the profile default instead, falling back to
DEFAULT_REASONING_EFFORT only when config has not loaded.
2026-07-25 20:37:09 -05:00
Brooklyn Nicholson
58f5a05655 fix(desktop): publish the profile default reasoning effort to a store
The composer only seeded effort from agent.reasoning_effort when it was
about to reseed the whole selection, which a sticky manual model pick
skips. The configured value was read and discarded, leaving every other
surface with no way to resolve the profile default.

Mirror it into $defaultReasoningEffort on every config load, independent
of the composer reseed, so the manual pick stays sticky without hiding
the default from the surfaces that need it.
2026-07-25 20:37:06 -05:00
Brooklyn Nicholson
bfc8e3b00d fix(desktop): give ⌘T session tabs a live gateway so slash completions load
`useGatewayRequest` only exposed the gateway through a ref that its own
subscription effect fills in, so a component reading it during render saw
null on mount. Session tiles — what ⌘T and the tab-strip "+" open — did
exactly that and passed `gateway={null}` down to ChatBar. The slash
adapter is disabled without a gateway, so a new tab had no completions at
all while ⌘N, which resolves its gateway through a state-driven memo,
worked fine. Nothing re-rendered the tile afterwards unless the connection
state happened to flip, so it never recovered.

Return the `$gateway` atom as a reactive value alongside the ref and use
it wherever the gateway is needed as a render-time value. The ref stays
for `requestGateway`, which wants a stable identity. The model picker,
model visibility, and settings overlays read the same ref during render,
so they're moved over too.
2026-07-25 20:30:55 -05:00
Brooklyn Nicholson
58d805302a fix(desktop): let a slash command be typed anywhere in the prompt
The `/` trigger was anchored strictly at position 0, so the completion
popover only opened when the slash was the first character. Typing
`please run /clean` mid-message produced nothing to pick, which put every
skill out of reach unless the prompt started with it.

Position 0 and mid-message are genuinely two different things, so detect
them separately. At position 0 a slash is a command invocation and keeps
its arg completion (`/personality alic`). After whitespace it's an inline
reference inside prose, so it completes as a single token and never
expands into an arg step. Both stay trailing-anchored, which keeps file
paths (`src/foo/bar`, `look at /usr/local/bin`) from matching.
2026-07-25 20:30:55 -05:00
hermes-seaeye[bot]
ba159d6fa9
fmt(js): npm run fix on merge (#71667)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-26 01:21:38 +00:00
teknium1
751c15f9eb lint(desktop): exempt benign ref writes flagged by the new atom-mirror guard
The guard matches any `.current` write inside a `useEffect`, so it also
flags refs that are not mirroring a reactive value. Seven such sites
landed on main after the rule was written:

  - wiring.tsx: one-shot request-seen sentinel
  - billing/plans-view.tsx: previous-confirming tracker for focus return
  - config-settings.tsx: autosave bookkeeping
  - gateway-settings.tsx: monotonic request-sequence counters
  - toolset-config-panel.tsx: mount flag + one-shot provider-choice claim
  - voice-provider-fields.tsx: one-shot config seed flag

None mirrors an atom/prop/state value, so none can produce the
one-render-lag stale read the rule exists to prevent. Each gets an
eslint-disable with the specific reason rather than widening the rule,
which would reopen the hole for real mirrors.

Verified the guard still catches the real antipattern: a probe file
mirroring `useStore($activeSessionId)` into a ref via useEffect is
reported. eslint over apps/desktop/src is 0 errors, and the 24 remaining
warnings match the pre-change baseline exactly.
2026-07-25 18:12:48 -07:00
ethernet
e026fd61d8 lint(desktop): ban atom-mirrored refs via no-restricted-syntax eslint rule
A ref synced from a nanostores atom via useEffect lags the atom by one
render. Callbacks that read the ref instead of the atom get stale values —
cancelRun sent session.interrupt to the wrong session (#66485), and
steerPrompt/restoreToMessage/editMessage had closure-priority stale reads.

The rule catches the three shapes of this antipattern:
  - useEffect(() => { ref.current = value }, [value])
  - useEffect(() => { ref.current = value; ... }, [value])
  - useEffect(() => { setMutableRef(ref, value) }, [value])

All 79 existing hits get eslint-disable-next-line with a comment. The
legitimate ref writes (DOM instances, mount flags, request tokens,
prev-value tracking, prop mirrors) stay suppressed; the 11 real bug
sites will be fixed in the next commit so their suppressions can be
removed.

Rule is scoped to apps/desktop only — ui-tui and tests-js don't use
nanostores and don't have this pattern.
2026-07-25 18:12:48 -07:00
hermes-seaeye[bot]
44480617ff
fmt(js): npm run fix on merge (#71638)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-25 23:26:29 +00:00
hermes-seaeye[bot]
2ba1e028e4
fmt(js): npm run fix on merge (#71634)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-25 23:18:02 +00:00
SHL0MS
6b26b409cf fix(desktop): /goal arg stays editable, kickoff queues when busy, slash header stops echoing long args
Four symptoms from the same /goal flow on desktop:

- Typing '/goal <text>' sealed the command into a directive chip on
  Space because /goal was registered without args:true, so the goal
  prose rendered awkwardly after a pill. The registry row now matches
  /personality and /tools: the arg stays editable text.

- The slash status header echoed the ENTIRE invocation ('slash:/goal
  <whole goal prose>') in mono, immediately above the backend notice
  that repeats the goal text again, and the kickoff user bubble that
  repeats it a third time. The header now carries just the command
  token (slash:/goal).

- When the session was busy, handleDispatch rendered 'session busy'
  and dropped the dispatch message. For /goal that message is the
  kickoff prompt, and the backend has ALREADY set the goal by then —
  the goal existed but the agent never heard about it, and later turns
  looked goal-unaware (#63352). The busy path now queues the kickoff
  on the composer queue: it sends on settle and is visible/editable in
  the queue panel meanwhile. Falls back to the old message if the
  queue rejects the entry.

- A slash command issued on a fresh draft created the backend session
  with no preview, so the sidebar row sat as 'Untitled session' —
  and when the kickoff was dropped, auto-title never fired either
  (it needs a completed user->assistant exchange). ensureSessionId now
  seeds the preview with the typed command.

Tests: registry row contract, busy-path queueing (kickoff neither
sends mid-turn nor vanishes), and the header-token assertion.
2026-07-25 16:09:38 -07:00
hermes-seaeye[bot]
ec734b6658
fmt(js): npm run fix on merge (#71626)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-25 23:00:16 +00:00
teknium1
584b274495 fix(desktop): slash commands target the user's chat, not a new session
`/goal status` reported "No active goal" for a goal that was live: the
desktop's slash pipeline resolved its target session differently than the
submit pipeline, so the command ran against a different session than the
chat on screen.

`/goal` state is persisted per-session in SessionDB (`state_meta` key
`goal:<session_id>`). slash.ts resolved with a bare
`hint || activeRef || createBackendSessionForSend()`, so whenever the
runtime binding was momentarily absent — profile swap, reconnect,
orphan-reap, request timeout — it silently MINTED A NEW SESSION and ran
there. submit.ts already handles this case by resuming the routed stored
session on its owning profile (#55578, #67603).

Extract that ladder into one shared resolver both pipelines use, per the
"one resolver owns each policy" rule in apps/desktop/AGENTS.md. This fixes
the whole class, not just `/goal`: every exec/rpc slash command
(`/usage`, `/status`, `/tools`, …) had the same hole.

A targeted durable conversation whose runtime cannot be rebound now
returns null instead of forking the chat — reporting that a command could
not run beats running it against the wrong session.
2026-07-25 15:44:39 -07:00