Commit Graph

28243 Commits

Author SHA1 Message Date
Brooklyn Nicholson 21198d9401 feat(desktop): add confirm() as the imperative front door to ConfirmDialog
Handlers that need the answer inline had no way to reach the shared dialog
without hoisting state and a JSX mount into their component, so they all
reached for window.confirm instead. This mirrors notify(): a store action
carries the question, one host at the shell renders the real ConfirmDialog.
2026-08-19 23:52:23 -05:00
Brooklyn Nicholson 5df9cd27ea feat(desktop): let ConfirmDialog carry a secondary action
The worktree removal prompt offers a third way out — hide the lane but leave
the worktree on disk — which is why it was still hand-rolled. One optional
slot between Cancel and Confirm covers it, and it keeps Confirm as the
focused button so Enter still means the destructive action.
2026-08-19 23:52:23 -05:00
Brooklyn Nicholson 1e26c02de6 refactor(desktop): route cron delete and review revert through ConfirmDialog
Both were hand-rolled copies of the shared confirm — same two-button shape,
same busy/close beat — and neither answered Enter. Folding them in drops the
duplication and picks up the focus fix.
2026-08-19 23:52:23 -05:00
Brooklyn Nicholson bb0e9ee95a fix(desktop): confirm dialogs take focus so Enter confirms
The delete-session dialog opted out of Radix's autofocus, which left focus
on the sidebar row that opened it — Enter re-activated the row instead of
confirming, and ConfirmDialog's Enter handler never saw the key.

ConfirmDialog now focuses its own Confirm button on open. The existing Enter
test fired the key at the dialog node, so it passed over the bug; it now
fires at whatever actually holds focus.
2026-08-19 23:52:23 -05:00
Brooklyn Nicholson bfcfdb30d1 test(desktop): prove the status bar keeps its own right-click menu
The unit test covers the primitive contract — the marker survives Radix's
asChild Slot merge. This adds the end-to-end half: mount the real coordinator
next to the real status bar, right-click it, and assert the customize menu
opens while the app fallback stays shut. That is the assertion that fails on
a build where the two halves drift apart, and it holds regardless of how the
ownership marker is spelled.

Drops the hand-stamped DOM fixture that asserted the coordinator honors an
attribute the test itself wrote.

Co-authored-by: huklaa <huklaa@users.noreply.github.com>
2026-08-19 23:51:10 -05:00
aydnOktay 2d6d7c550f fix(desktop): keep Radix context menus when asChild overwrites data-slot
The app-wide context-menu coordinator recognizes surfaces that own a Radix
menu by `[data-slot="context-menu-trigger"]`. Radix `asChild` merges as
mergeProps(slotProps, childProps), so a child that sets its own `data-slot`
wins and the marker never reaches the DOM. The status bar footer is
`data-slot="statusbar"`, so the coordinator swallowed its right-click and
showed the window-verbs fallback instead — leaving every default-hidden
status bar item, the context meter included, unreachable from the UI.

Stamp a dedicated `data-hermes-context-menu-trigger` after `{...props}` on
ContextMenuTrigger and bail on that marker. Any asChild surface with its own
`data-slot` is covered, not just the status bar.
2026-08-19 23:51:10 -05:00
Brooklyn Nicholson b4f978d983 fix(nous): treat "takes no reasoning parameter" as a definitive no
Both the wire path and the picker only consulted the catalog's
`mandatory` flag, so a route the Portal lists as accepting no reasoning
parameter at all still got sent a disable, and still offered a Thinking
toggle in the model picker.

For a route it serves, the aggregator's own catalog outranks the
models.dev inference: `supports_reasoning: false` now suppresses the
disable on the wire and drops reasoning controls from the picker
entirely, so there is no disable left to describe.
2026-08-19 23:28:14 -05:00
Brooklyn Nicholson 9c0cd1add2 fix(nous): make "thinking off" stick on a cold start
Portal reasoning capabilities were held only in memory, so a process that
had not yet fetched them answered "unknown" — and on that answer the Nous
profile drops the disable rather than risk a 400. A short-lived process
(`hermes -p`, a cron job, a freshly booted gateway) is always in that
state, so every one of those runs silently ignored "thinking off" and
billed the user for reasoning they had turned off.

The parsed catalog is now mirrored to `cache/reasoning_caps.json`, keyed
by the URL it came from, and hydrated on a cold lookup without touching
the network. Every picker and pricing fetch already pulls that same
document, so they seed the mirror for free.

The catalog URL itself now resolves through the same ladder as the rest
of the Nous catalog reads (`NOUS_INFERENCE_BASE_URL` → credential base →
production) instead of being pinned to production, which had a staging
profile deciding the reasoning-mandatory question from prod's answers.
Keying the mirror by URL keeps those deployments apart.
2026-08-19 23:28:14 -05:00
Ben Barclay 2f77ca1993 fix(relay): reader without a socket settles pending waiters instead of asserting
_read_loop opened with 'assert self._ws is not None' — an exit that
escaped BEFORE the finally that fails pending futures, contradicting
the 'fails pending on ANY exit path' invariant the hardening commit
established. Production currently assigns _ws before scheduling the
reader, so this was latent, but any future lifecycle change hitting it
would strand every in-flight waiter for the full outbound timeout with
only an AssertionError in the logs.

Turn it into a guarded early-return INSIDE the try: the reader logs
the lifecycle bug and unwinds through the same finally as every other
exit, settling all waiters. Regression test drives _read_loop with
_ws=None against a registered pending future.
2026-08-20 14:13:18 +10:00
Ben Barclay ca3438d395 fix(relay): gate sends on the socket handle, not supervisor state
The mid-redial fail-fast guard used 'supervisor task not done' as the
definition of the redial window, and that signal is wrong from both
directions:

- Too narrow: the wedge it fixed also occurs on reader exits that arm
  NO supervisor (terminal 4401 revocation, reconnect=False) — those
  stayed wedged.
- Too broad: _reconnect_loop -> _dial_and_start installs the fresh
  socket and starts its reader, THEN awaits one hello send per fronted
  identity before the supervisor unwinds. Through those awaits the
  transport is fully live, yet the guard rejected every send as
  'reconnecting' — refusing real traffic on a healthy socket.

With the previous commit the reader clears _ws on unexpected exit, so
the existing 'is None' check now covers the entire outage window
honestly: _ws is the single liveness signal. Drop the supervisor-state
guard.

The redial-window test now drives the real sequence (reader exit arms
the supervisor and clears _ws) instead of hand-crafting a stale-_ws
state the transport can no longer reach, and a new test pins the
post-dial window: a send issued while the supervisor is still
unwinding past a live socket must reach that socket (fails with the
guard reinstated).
2026-08-20 14:12:33 +10:00
Teknium 6851841112 fix(bot-mode): group chat opens as one room pane, not two (#89788)
Opening a Bot Mode group chat painted the room twice — once as a main-window
workspace tab (host.openWorkspace) and once as the in-panel fallback, because
the Bots pane rendered off $groupChatWorkspace alone. Two live panes with
independent drafts drove one shared engine, and the roster disappeared behind
the duplicate.

The in-panel room is the fallback surface, not a second copy: it now renders
only while no main tab owns the group. The selection atom stays set either way
so the roster row still highlights, and desktops without the door — or whose
door throws — keep the in-pane room.

Consolidates #89881, #90274 and #90398, which fixed the same bug.

Closes #89788

Co-authored-by: helix4u <helix4u@users.noreply.github.com>
2026-08-19 23:12:25 -05:00
Ben Barclay 52f9979a6c fix(relay): reader clears the dead socket handle on unexpected exit
Two reader-exit paths arm NO reconnect supervisor: a terminal 4401
revocation (deliberately never re-dials) and reconnect=False
transports. On both, _ws kept pointing at the dead socket after the
reader unwound, so the 'is None' liveness guard reported connected and
a send registered a future nothing could ever resolve — the full
_outbound_timeout_s (~30s) wedge this PR exists to eliminate. The
revocation case is the sharpest: the fatal-error notification that
path emits is itself an outbound send, so it ate the stall.

Null the handle in the reader's finally, identity-guarded (only if _ws
still points at the socket THIS reader served) so a supervisor re-dial
that already installed a fresh socket is never clobbered, and gated on
not _closing so disconnect() keeps sole ownership of teardown.

This also makes _ws the single honest liveness signal for the redial
window itself — groundwork for retiring the supervisor-state send
guard, which misreads that window from both directions.

Regression tests cover both uncovered paths, asserting _ws is cleared
and a post-drop send fails fast; both wedge (fail) with the finally
reverted.
2026-08-20 14:10:54 +10:00
Brooklyn Nicholson 145cd763ca feat(desktop): drag markdown table columns to resize them
A colgroup of percentages is the only state, so widths never touch the
cells: one <col> per column, table-layout fixed, and the browser does the
rest. A drag moves one seam and the pair either side trade width, so the
table box never changes size mid-drag — no reflow of the message around
it, no scrollbar appearing under the pointer.

Handles are markup inside each <th>; the table listens once and resolves
the grabbed seam from the DOM, so there is no context, no per-column
component, and no index threading. Tables stay in auto layout until they
are resized, and double-clicking a seam hands them back to it — the same
reset gesture the pane sashes use.

On a 43-row table a 40-step drag mutates 78 col[style] attributes and
touches no cell.
2026-08-19 23:10:25 -05:00
Brooklyn Nicholson e361e70b3b feat(desktop): keep markdown table column widths across turns and sessions
A markdown table has no id — it is re-parsed from text on every render, so
any resize state hung off the transcript dies on the next turn. Key the
record by a hash of the header row instead: the same table resolves to the
same key after a re-render, a session switch, or a reload, without the
transcript carrying anything.

Widths are percentages of the table box, never pixels, so a restored table
stays fluid in a narrow pane. The namespace is deliberately disposable —
one key, 64 entries, 7-day expiry, swept on first access. Losing it costs
one drag.
2026-08-19 23:10:25 -05:00
Ben Barclay 8772702503 test(relay): dedupe regression tests drive the real wire-decode path
The dedupe tests validated hand-built events only, so a key that read a
field the production event type doesn't have still went green — and the
'all 7 new tests fail against base' mutation claim didn't hold either
(the fail-open and distinct-message tests pass against base because a
no-op dedupe trivially satisfies both).

Add a wire-level class that decodes a connector frame with
_event_from_wire and dispatches it through _on_inbound — the exact
production path — asserting: a decoded event yields a dedupe key at
all, a re-delivered frame is dropped (fresh decode each time, so
identity must come from the key, not the object), and identical
chat/message ids on two different platforms are NOT conflated
(Phase 1.5 multiplex).

Mutation-checked: with the previous event-shape key reinstated, the
wire tests fail (3 failed); with the fix, all 7 pass.
2026-08-20 14:08:36 +10:00
Ben Barclay 22dfdc1c56 fix(relay): dedupe key reads chat identity from event.source, keyed per platform
The dedupe key read event.chat_id — a field MessageEvent does not have
(chat identity lives on event.source.chat_id; see how every other read
in this adapter resolves it). getattr defaulted to None, so
_inbound_dedupe_key returned None for EVERY production event: the
fail-open branch always taken, the seen-set permanently empty, and a
replayed inbound still re-ran the whole turn. The tests passed because
their SimpleNamespace events carried a top-level chat_id no production
code path produces.

Read the chat id from event.source, and join the underlying platform
into the key: one relay adapter fronts several platforms (Phase 1.5
multiplex), and two platforms' numeric chat/message ids must not
collide into one replay identity.

The test event factory now builds real MessageEvent/SessionSource
objects in the wire decoder's shape, so the replay and bounded-set
tests fail against the broken key instead of green-lighting it.
2026-08-20 14:07:11 +10:00
hermes-seaeye[bot] a72c9ca248 fmt(js): npm run fix on merge (#90461)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-20 03:21:47 +00:00
Brooklyn Nicholson d15cd18fa1 feat(desktop): hide the Thinking toggle where a disable is rejected
The picker offered an off switch for every reasoning model, including routes
whose upstream answers a disable with HTTP 400 — so "thinking off" was a
control that could not work. Carry the catalog's mandatory verdict through
model.options as can_disable_reasoning and hide the toggle when it is false.

Effort levels are left alone. The catalog's supported_efforts under-reports
what the Portal serves (z-ai/glm-5.3 publishes max, high, low yet honors
minimal at its lowest thinking), so filtering the scale by it would hide
levels that work.
2026-08-19 22:14:56 -05:00
Brooklyn Nicholson d39a031329 fix(nous): stop dropping "thinking off" on Portal models that can honor it
reasoning: {enabled: false} is the only shape the Portal honors, and the
profile refused to send it for every model. Sending nothing means the
upstream default instead, which on a thinking-first route like
deepseek/deepseek-v4-pro (catalog: default_effort high) is thinking ON — so
turning thinking off kept billing reasoning tokens on every turn.

The blanket omission was over-broad. The Portal only rejects a disable on
reasoning-mandatory routes ("Reasoning is mandatory for this model"), which
its catalog flags per model, so that flag now gates the omission. Models the
catalog can't speak to keep the old behavior rather than risk the 400.

extra_body.thinking, DeepSeek's own disable shape, is not forwarded upstream
by the Portal and is not an option here.
2026-08-19 22:14:56 -05:00
Brooklyn Nicholson 608fa9c7af feat(models): read Nous Portal reasoning capabilities from its catalog
The Portal serves OpenRouter's catalog schema, so the existing parser and
cache-only tri-state contract carry over unchanged. Only the HTTP fetch is
generalized across the two catalogs; each keeps its own cache because they
list different models.

The Portal 403s a catalog read with no User-Agent, so the shared fetch now
sends one.
2026-08-19 22:14:56 -05:00
Teknium aebab05f9e Merge pull request #90313 from NousResearch/feat/keyless-web-search-fallback
feat: web search works keyless on fresh installs (Parallel + Exa free tiers)
2026-08-19 19:47:12 -07:00
Teknium 258410a184 fix(cli): first launch banner shows the seeded skill catalog, not "No skills installed" 2026-08-19 19:44:42 -07:00
Teknium 76bf6c7c40 fix(cli): bare /hatch no longer freezes input with an invisible raw input() prompt 2026-08-19 19:42:31 -07:00
Teknium f4a866b484 fix(cli): /config displays the live agent credential, not the env-var constructor seed 2026-08-19 19:42:07 -07:00
Teknium b035036582 fix(cli): /yolo reports locked-ON under process-frozen YOLO instead of a false OFF 2026-08-19 19:41:59 -07:00
Teknium e73b8519f6 test: pin -z/--oneshot --skills forwarding and partial-success contract 2026-08-19 19:41:52 -07:00
Teknium e7091d5fb7 chore: map contributor email for GarlicGo 2026-08-19 19:41:52 -07:00
GarlicGo 466665282b fix(cli): Fix oneshot skills preload 2026-08-19 19:41:52 -07:00
Jeff Mettel 22f66de638 fix(skills): publish and read the (map, platform) pair under one lock
Review feedback: publishing the map and its platform tag as two separate
global assignments is not atomic. A reader landing between them sees the
NEW map still carrying the OLD tag, and if that stale tag matches its own
platform it accepts the map without rescanning — serving another
platform's disabled-skill view, the leak #14536 closed.

Guard the pair with a module lock. scan_skill_commands publishes both
under it; get_skill_commands resolves its platform first, then reads the
map and tag together under the same lock to make the freshness decision.
Scanning stays outside the lock — it does file I/O and deferred imports,
and concurrent scans are already independent after the local-map change.

get_skill_commands now returns the scan's own completed map rather than
re-reading the global, so a concurrent publish cannot swap the result
between the decision and the return.

Adds a regression test that holds the publish lock and asserts a reader
cannot complete its lookup until it is released.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 19:41:42 -07:00
kronexoi 4f12cfd98d fix(cli): wire /whoami slash command in classic CLI 2026-08-19 19:41:32 -07:00
Teknium 1fa66f2577 Merge remote-tracking branch 'origin/main' into feat/keyless-web-search-fallback
# Conflicts:
#	website/docs/user-guide/configuration.md
2026-08-19 19:36:12 -07:00
Axmr1 4511ba49dd fix(image_gen/openai-codex): do not save progressive partial frames as finals
Codex Responses streams can emit partial_image_b64 previews without a final
image_generation_call.result. The provider treated any b64 as success and could
let a partial overwrite a coexisting final in the same payload, delivering
smeared intermediates as finished GPT Image 2 outputs.

Request partial_images=0, prefer final over partial in extraction, fail closed
(with one content-agnostic retry) unless source=final, and surface image_source
plus pixel_size for QA.
2026-08-19 19:35:07 -07:00
Teknium d0132b5821 fix(cli): give every wizard free-text prompt arrow-key and Ctrl+A/E editing
Widens #90327's line_input() to the whole bug class: all 46 bare input()
free-text prompt sites across the setup wizards (model_setup_flows, setup,
config, gateway, auth, auth_commands, plugins_cmd, skills_hub, bundles,
setup_whatsapp_cloud, main) now route through line_input(), and the shared
cli_output.prompt() / setup.prompt() helpers do too — so every CLI wizard
gets cursor editing, not just the custom model prompt.

Redirected stdin and missing prompt_toolkit keep builtin input() behavior.
E2E: real PTY with raw escape bytes through line_input, cli_output.prompt,
and setup.prompt (arrows + Ctrl+A/E edit correctly); redirected-stdin
fallback verified.
2026-08-19 19:34:36 -07:00
Gille fa62b22ee7 fix(cli): enable editing in custom model prompt 2026-08-19 19:34:36 -07:00
Teknium 938f41e1cd Merge remote-tracking branch 'origin/main' into feat/keyless-web-search-fallback 2026-08-19 19:32:38 -07:00
Owenz-creator 8408edcfb5 fix(bot-mode): protect ordinary sessions from hide sweep 2026-08-19 19:30:28 -07:00
Teknium d604ba6585 fix(bot-mode): running kanban/tool workers now light the Bots roster (#90268)
Worker sessions are deny-listed out of every conversation list, so a
profile grinding through a 30-minute kanban task read idle ('3 hr ago')
with no ACTIVE NOW entry the entire run.

- tui_gateway/methods_profiles.py: profiles.list rows gain worker_session
  — the newest kanban/tool row (id, source, title, last_active). Workers
  heartbeat last_activity_at every <=60s while running (#72016), so the
  field stays fresh exactly while work is happening. last_session keeps
  its deny-list contract; include_sessions:false omits the field; older
  clients ignore it.
- hermes-bots plugin: workerActiveAt() (150s window, one missed heartbeat
  of slack) feeds ACTIVE NOW, the row pulse dot ('Working on a task right
  now'), and the row age label while a worker runs. Chat semantics are
  untouched when no worker is live.
- Tests: 4 new pytest (real SessionDB on temp HERMES_HOME), 2 new node
  behavior tests; sabotage-verified.

Session-list visibility of workers (the issue's first half) is left as-is
by design — auto-resume and shared lists must keep excluding workers; the
roster signal was the actionable gap.
2026-08-19 19:30:12 -07:00
Teknium 6d1284a073 test(update): deflake the Windows progress self-test (both race directions seen in CI)
test_progress_advances_while_the_orchestrator_blocks raced its subject on
both edges within one hour of PR CI (#90358):

- Run 1: sampled right after the shim URL printed, before the orchestrator
  published its stage — caught the page boot default
  ('Hermes will open once done.' != 'Testing quiet update').
- Run 2 (rerun): with HOLD=4s on a slow runner, the second sample slid past
  the hold and caught the cleared terminal state ('' != 'Testing quiet
  update').

Fix: wait (<=10s) for the published stage to actually land before starting
the 1.5s stability window, and raise the hold to 10s so both samples land
inside it. Same assertions, same contract — just anchored to the event the
test is about instead of wall-clock luck.
2026-08-19 19:29:58 -07:00
Teknium f7d90c9410 refactor: single canonical reasoning-effort vocabulary ends the per-vendor clamp drift
The #89503/#70058/#74295/#87279 bug class kept regenerating because every
transport and provider profile hand-rolled its own effort translation map
(9 sites, 4 distinct policies). New agent/reasoning_effort.py is the single
source of truth:

- EFFORT_LADDER: canonical low->high ordering (superset check against
  VALID_REASONING_EFFORTS pinned by test)
- clamp_effort(): one policy — supported passes verbatim, otherwise nearest
  WEAKER supported level (never escalate, never invert the ladder), floor
  when nothing weaker, 'none' never a degradation target, declared
  vendor-documented overrides win, bespoke names pass through
- declared wire vocabularies as data: OpenAI-compat, Codex Responses,
  xAI (4.6/legacy), Actual relays, Kimi K3/K2, TokenHub, GLM-5.2,
  DeepSeek V4, Ollama Cloud, Meta, Solar

Converted sites (all behavior-preserving except noted):
- chat_completions chokepoint, Kimi + TokenHub paths
- codex transport (backend branches now pick a declared set)
- auxiliary_client Responses path
- hermes_cli.models clamp_reasoning_effort_to_supported -> thin wrapper
- plugins: kimi-coding, zai, opencode-zen, deepseek, ollama-cloud,
  meta-ai, upstage, custom (copilot already routes via the wrapper)

Behavior fixes the shared policy surfaces:
- ollama-cloud/opencode-go 'minimal' now degrades to 'low' instead of
  being dropped (drop left the server default = MORE thinking than asked)

New tests: ladder contract (every configurable level is clamped by every
declared wire set; monotonicity across the full ladder for every set).
2026-08-19 19:29:10 -07:00
Teknium 3d62508240 style: sort sidebar-archive import per perfectionist/sort-imports 2026-08-19 19:25:55 -07:00
Teknium 3e05033275 fix(desktop): deleting an archived session no longer leaves a ghost row that spins forever
Archived rows render from $archivedSessions (their own capped store —
they're excluded from $sessions by design), but removeSession only pruned
$sessions. Deleting from the Archived filter left the row in place; a
click on it resumed a hard-deleted id: resume 404 -> goneSessionVerdict
saw the row still listed -> 'retry' -> unrecoverable spinner.

removeSession now resolves the row from either store, evicts both
optimistically, restores the archived row on RPC failure, and forwards
the archived row's owning profile to deleteSession.
2026-08-19 19:25:55 -07:00
Teknium 26da56fd53 docs: tool provider selection follows the hermes tools pick (post #90317) 2026-08-19 19:25:50 -07:00
Teknium b2ea0f3810 fix(desktop): gateway restart no longer clickable-by-mistake next to reconnect
Community report (X @Cobalt_Peak): Reconnect and Restart gateway in the
statusbar gateway popover rendered the same RefreshCw icon side by side,
so users triggered full gateway restarts when they meant to reconnect.

- Restart now uses a Power icon with a destructive hover tint
- Moved restart to the end of the row, after the system-panel button,
  behind a visual divider separating it from the benign actions
2026-08-19 19:25:37 -07:00
fangliquanflq cfc55d3487 fix(config): remove obsolete cwd warning parameter 2026-08-19 19:24:41 -07:00
fangliquanflq 2cc83543bb test(config): cover unreadable dotenv warning path 2026-08-19 19:24:41 -07:00
fangliquanflq a93f1b2bec fix(config): warn for all deprecated dotenv cwd entries 2026-08-19 19:24:41 -07:00
fangliquanflq 31561e37ed fix(config): read deprecated cwd settings from dotenv 2026-08-19 19:24:41 -07:00
Teknium 5ead089775 fix(desktop): cron panel empty states stop suggesting a broader search when no search is active
Both cron empty states used search-flavored copy unconditionally; a fresh
panel with zero jobs and no query told users 'Try a broader search
query'. Copy now follows the query state, reusing existing i18n keys.
2026-08-19 19:24:32 -07:00
Teknium 20059cbc69 fix(desktop): sidebar search results no longer show raw >>>term<<< FTS markers
The backend's session search wraps matched terms in sqlite snippet()
delimiters '>>>'/'<<<' (hermes_state_search.py). The sidebar rendered the
snippet as plain text via searchResultToSession(), so searching 'foo'
painted rows literally titled '>>>foo<<<'. Strip the markers before the
snippet becomes the row preview.
2026-08-19 19:24:22 -07:00
Teknium 98b6f8676d fix(desktop): Models/Providers settings no longer hang 20s when gh CLI is signed out
/api/model/options probed Copilot auth via `gh auth token` four separate
times per payload build. When gh has no credential store for the backend's
HOME (fresh profile, desktop-spawned backend, CI), each probe blocks its
full 5s subprocess timeout on keyring/D-Bus, so every open of the Desktop
Models or Providers settings page took 20s — past the renderer's 15s IPC
budget, painting 'Error invoking remote method hermes:api: Timed out'.

Fix: cache the gh-CLI probe result (hit or miss) for 5 minutes with an
invalidation hook, feed gh stdin=DEVNULL, and disable gh interactive
prompts/update notifier in the probe env.

Measured on the failing profile: 20.5s -> 5.3s cold (one bounded probe),
0.03s warm.
2026-08-19 19:22:48 -07:00