Commit Graph

28271 Commits

Author SHA1 Message Date
Mengfan ba501f02ce fix(desktop): Bot Mode mention readers miss the connection-suffixed roster cache
useRoster() keys its query on [...ROSTER_KEY, connectionId] so every
connection the window has been on gets its own cache entry, but the two
imperative roster readers — the @mention completions and the mention
middleware — still called getQueryData(ROSTER_KEY). TanStack Query's
getQueryData is an exact-key match, so the bare key matched nothing and
both readers saw an empty roster: the composer offered no agent handles,
and remote @name-device mentions (e.g. @default-vera for a same-named
profile on an SSH connection) fell through to the local profiles.list
fallback, which only knows bare local names — the mention passed through
untouched to the local agent instead of being routed over Connections.

Add a cachedRoster() helper: honor the legacy bare-key write first, then
scan the key family with getQueriesData, preferring the active
connection's entry and falling back to any other entry (a stale roster
beats none). Never throws, matching the readers' existing posture.

Regression tests model the real cache shape — entries under the suffixed
key, exact-key reads missing them — so the key-shape mismatch is pinned;
the previous tests stubbed getQueryData to ignore keys, which is exactly
why this slipped through.
2026-08-20 19:42:00 -07:00
Teknium 309e8837d1 chore: map contributor email for kadiratesdev 2026-08-20 19:41:03 -07:00
Teknium 19c6a11924 feat(opencode): sync Zen/Go catalogs — Ox Alpha stealth model, Grok routing, new free tier
- Add x-preview-f-free (Ox Alpha: free, 1M context, ZDR) plus all newly
  listed Zen models (gpt-5.6 sol/terra/luna, claude-opus-5, gemini-3.7/3.6
  flash + lite, grok-4.6/4.5, muse-spark-1.2, kimi-k3, qwen3.7-max,
  hy3-free, laguna-s-2.1-free, nemotron-3.5-lightning-free,
  muse-spark-1.2-contributor-free) and Go models (gpt-5.6-luna, grok-4.5,
  glm-5.3, qwen3.8-max, hy3, hy3-preview, muse-spark-1.2-contributor).
- Drop delisted north-mini-code-free from Zen.
- Route grok-* on Zen and Go through /v1/responses per the published
  endpoint tables (grok-4.6/4.5/build-0.1 on Zen, grok-4.5 on Go).
- 1M context fallback for x-preview-f (Ox Alpha).
- Refresh hermes setup provider samples for both providers.

Catalogs verified against live GET /zen/v1/models and /zen/go/v1/models
plus https://opencode.ai/docs/zen/ and /docs/go/ endpoint tables (2026-08-20).
2026-08-20 19:41:03 -07:00
Abdulkadir Ateş b9855f5eb2 test(opencode): cover prefixed Zen Muse Spark model id
Reviewer asked for opencode-zen/muse-spark-1.2 to match the Go
prefixed-id assertion.
2026-08-20 19:41:03 -07:00
Abdulkadir Ateş 5969ea1558 fix(opencode): route Muse Spark through the Responses API
OpenCode Go and Zen serve muse-spark* only on /v1/responses.
Hermes was sending /chat/completions, which returns HTTP 503
with an empty assistant message. Match the published endpoint
table and the existing gpt-* routing.

- Route muse-spark* to codex_responses on opencode-go and opencode-zen
- Add regression assertions next to the gpt-5.6-luna cases
2026-08-20 19:41:03 -07:00
Ben Barclay 2d3f5c1554 feat(gateway): GATEWAY_RELAY_ALLOW_DIRECT_PLATFORMS opt-out for relay-exclusive mode
Deployments that intentionally mix connector-fronted and direct ingress
can set GATEWAY_RELAY_ALLOW_DIRECT_PLATFORMS=true to keep directly-
connected messaging adapters enabled beside the relay. Unset, the
GATEWAY_RELAY_URL env stamp keeps its exclusive behavior. Like the
trigger, the opt-out is a deploy-stamp env var, not config.yaml.
2026-08-21 12:26:41 +10:00
Ben Barclay 67292ec5b8 fix(gateway): GATEWAY_RELAY_URL env stamp disables direct messaging platforms
A GATEWAY_RELAY_URL set in the process environment marks a
connector-fronted deployment where the connector owns every platform
connection. A directly-connected messaging adapter in the same process
is a second, unmanaged ingress path: it causes duplicate deliveries and
split sessions, and its live socket disarms scale-to-zero.

At the end of _apply_env_overrides, after all enablement passes, the
env stamp now disables every other enabled messaging platform:

- Explicitly-enabled platforms (config.yaml enabled: true) are disabled
  with a WARNING that names the platform.
- Credential-auto-enabled platforms are disabled with an INFO line.
- Non-messaging surfaces (local, api_server, webhook) are untouched --
  the same exclusion set as the scale-to-zero arm gate.
- gateway.relay_url in config.yaml alone (no env stamp) keeps the old
  additive behavior: relay runs beside direct adapters.
2026-08-21 12:16:06 +10:00
Gille 76952ba54f fix(desktop): follow focused bot for mentions 2026-08-20 19:12:39 -07:00
ArthurFranckPat 770f9a5747 fix(desktop): read the live union roster in mention-completions + mention middleware
useRoster caches under [...ROSTER_KEY, activeConnectionId] — a 3-element
key — but the composer @ autocomplete provider and the mention-routing
middleware both read getQueryData(ROSTER_KEY) with the bare 2-element
key. An exact-match lookup against a 3-element cache key returns
undefined forever, so:

- @ autocomplete offered ZERO bot rows (local and remote alike)
- remote-bot mentions never matched the union roster and fell back to a
  local-only profiles.list, so @name-device mentions did not route

That is issue #89303: the remote-source Bot row tells the user to
`@name-device` the agent, and the completion surface never offers it.

Fix: cachedUnionRoster() — read the live connection's cache entry first
(getQueryData with the full 3-element key), fall back to a prefix scan
(getQueriesData) keeping the freshest snapshot. Never throws; callers
keep their existing cold-cache fallbacks.

The plugin test mocked getQueryData as key-agnostic, which is why this
never failed in CI: the mock now reproduces the real QueryClient v5
exact-key semantics (a bare ROSTER_KEY lookup misses the 3-element
cache), and the tests fail against the old code (verified: 2 failures
before the plugin fix, 9/9 pass after).

Fixes #89303
2026-08-20 19:12:39 -07:00
SHL0MS 8794e5a21c Merge pull request #91025 from liuhao1024/liuhao/cron-bugfix-91022
fix(goals): honor auxiliary.goal_judge.timeout instead of a hardcoded 30s cap
2026-08-20 19:49:52 -04:00
Ben Barclay c1e25cadff Merge pull request #91155 from NousResearch/chore/scale-to-zero-default-2min
chore(gateway): drop the default scale-to-zero idle timeout to 2 minutes
2026-08-21 09:32:38 +10:00
Ben Barclay 48f15c1ece chore(gateway): drop the default scale-to-zero idle timeout to 2 minutes
With the gateway owning the suspend, the idle predicate covers every
work source (agent turns, cron jobs, API-server runs, background work,
fail-awake on unreadable sources) and the relay drains + flips before
the freeze, so a long timeout no longer buys safety — real work always
blocks the suspend and resume is sub-second. 5 idle minutes just bills
idle RAM. Per-instance override stays config.yaml
gateway.scale_to_zero.idle_timeout_minutes (D2).

New behavior-contract test: invalid config values degrade to the module
default (whatever it is), never zero/negative — asserts the RELATION,
not the literal, per the no-change-detector-tests rule.
2026-08-21 09:08:02 +10:00
Ben Barclay ee000768ce Merge pull request #90761 from NousResearch/fix/scale-to-zero-cron-aware-idle
fix(gateway): count cron and API-server work in the scale-to-zero idle predicate
2026-08-21 08:19:36 +10:00
Ben Barclay 28f7c4e68a Merge pull request #90760 from NousResearch/fix/flaps-socket-group-access
fix(docker): grant the gateway group access to the Fly Machines API socket
2026-08-21 07:50:25 +10:00
Ben Barclay 0215930526 review: fail-awake work accounting + rename is_idle param to active_work_count
Address sol-reviewer findings:
- The shared shutdown-drain counters swallow exceptions to 0 — fine for
  a drain, unsafe for a suspend predicate (a transient read failure made
  live work look idle, reopening the mid-job freeze). The suspend path
  now reads both sources itself and treats an unreadable source as work
  (sentinel 1, fail-awake) with a debug log. A MISSING api_server
  adapter remains a normal not-work state.
- is_idle()'s parameter renamed running_agent_count -> active_work_count:
  it receives the broad aggregate, and the old name invited future
  callers to pass only agents again.
- New failure-path tests: unreadable cron source and unreadable API
  source each hold the machine awake (both fail against the fail-open
  shape); missing adapter stays idle-capable.
2026-08-21 07:37:11 +10:00
Ben Barclay c3533b3abd review: harden flaps socket grant (symlink guard, verified outcome, precise scope note)
Address sol-reviewer findings: route the mutation through the existing
refuse_symlinked_path helper (consistency with the script's CWE-59
protections), only print success when both chgrp and chmod actually
succeeded (warn otherwise instead of a false-positive boot log), fix the
lifecycle wording (this hook runs after the supervision tree is up,
before user services), and state the widening scope precisely: the whole
local Machines API becomes group-writable, accepted because the agent
already runs arbitrary user code as the same principal.
2026-08-21 07:33:49 +10:00
Teknium e30388e409 fix: retained group panes re-anchor to the latest message on reopen
Keep-alive workspace panes (#89788) stay mounted while hidden, so returning
to an already-open room never remounted GroupChatWorkspace and the
mount-time bottom anchor didn't rerun — reopening group A after visiting
group B left A at the old scroll position. GroupChatMainView now subscribes
to the pane's visibility via feature-detected host.paneVisibility (always-
visible atom fallback for older SDKs) and the workspace scrolls the bottom
sentinel into view on the hidden→visible edge. Repro and fix direction from
the live-audit review comment on #90526.

Also updates the composer shape test to slice only GroupMentionInput
(GroupClarifyCard, merged since the branch was cut, legitimately uses
Input) and teaches the legacy-SDK react proxy about useMemo.
2026-08-20 14:25:50 -07:00
Teknium 3c5fb284e5 fix(bot-mode): group-room UX cluster — Shift+Enter newlines, open-at-latest, late replies harvested (#89884, #89835, #89545)
Composer was a single-line Input (Enter always submitted, newlines
impossible) — now the SDK Textarea with Enter=send / Shift+Enter=newline
and popover-first key handling. Room log had no scroll anchoring (opened
at position 0) — bottom sentinel + near-bottom-guarded anchor effect.
Stranded replies were only harvested inside an active turn loop (stuck
until the user's next send) — the settle path now runs a bounded
background harvest that yields to a live loop.
2026-08-20 14:25:50 -07:00
Teknium 7ab2e417f0 chore: map contributor email for attribution gate 2026-08-20 14:14:51 -07:00
Bikash Joshi 693d6341b7 fix(desktop): expose group deletion from roster rows 2026-08-20 14:14:51 -07:00
Sara Burke ddc78217fd Merge current Bot Mode group fixes
Carry the reviewed hybrid routing branch through current main at 603d5651b,
preserving fresh room-session identity and prior signed history.
2026-08-20 16:55:00 -04:00
Teknium 603d5651b3 chore: map contributor email for attribution gate 2026-08-20 13:53:28 -07:00
Teknium d1ea11d589 fix: disband tombstones no longer hold the group name or leak into storage
Found in live GUI E2E of the roomId salvage: disbanding a room while a
drive is mid-turn leaves an epoch-bump tombstone under the room's key.
Two defects surfaced: (1) updateGroupChat persists the WHOLE atom map, so
the next unrelated room write persisted the tombstone as an empty room;
(2) create/rename collision sets counted the tombstone's key, so
recreating the group under the same display name silently became
'<name> 2'. Tombstones are now flagged, excluded from the durable map,
from create/rename collision sets (liveGroupChatNames), and from roster
rows. Regression coverage in the existing tombstone test.
2026-08-20 13:53:28 -07:00
Teknium 92b6da23bc fix: disband confirm copy no longer hardcodes the legacy session title
New rooms title member sessions 'Group: <roomId>'; the dialog copy now
describes the kept sessions generically instead of quoting a title that is
wrong for post-roomId rooms.
2026-08-20 13:53:28 -07:00
YuYigeng 3752948bb7 fix(bot-mode): recreating a same-name group mints fresh member sessions 2026-08-20 13:53:28 -07:00
Sara Burke ba42346e99 Merge latest upstream into hybrid remote Bot routing
Carry the reviewed hybrid Bot routing branch through current main at
4a5b6dd45 without rewriting prior signed commits.
2026-08-20 16:52:28 -04:00
Brooklyn Nicholson 4a5b6dd451 feat(desktop): a connector consent card that knows nothing about connectors
The visual half of asking someone to connect a thing: the mark, the card,
the settled scaffold line it collapses to, and the trust badge.

It renders a subject, a state, and an outcome, and calls back. What a
connector IS, how one connects, and where the strings come from all stay
with the caller — the prop types are structural, so a richer type satisfies
them without this layer importing it. That boundary is the point: any
surface that has to ask "connect this?" should look identical without
sharing a data layer.

MarkdownLinkText comes along because setup steps need inline links.
LinkifiedText can't serve them — it finds bare URLs and guesses a label,
and here the label carries the meaning while the URL is a console page
whose own title is useless or, behind a login wall, actively wrong.
2026-08-20 14:07:53 -05:00
Brooklyn Nicholson 73d1041000 feat(desktop): resolve a site's own favicon in the main process
A name with no bundled brand glyph had nothing to render, and the renderer
could not go find one — CORS makes another origin's markup unreadable.

So resolution moves to the main process: read the page, collect every icon
it declares in its head and web manifest, rank them, sniff the bytes to
catch a challenge page served as image/png, and cache the answer per host
on disk. Only ever the site's own marks — a public icon service would
answer for the hosts behind a bot wall, but asking one means telling a
third party which tools someone is wiring up, so an unreadable site keeps
its monogram instead.
2026-08-20 14:07:53 -05:00
Brooklyn Nicholson 9ed308f901 refactor(desktop): one identity chip behind every avatar
Messaging platforms, the MCP servers tab, and the connector surfaces each
drew their own square brand chip, and the three copies had already drifted
on radius, glyph size, and what an unknown name falls back to.

They now share one AvatarChip. Size and radius stay the caller's call;
everything else is fixed in one place, so a Slack row and a Linear card
wear the same mark. brandFor also stops caring how a name is spelled —
catalog slug, registry id, or display name all reach the same glyph.
2026-08-20 14:07:53 -05:00
liuhao1024 847a9df09c fix(goals): honor auxiliary.goal_judge.timeout instead of a hardcoded 30s
The config key is declared in DEFAULT_CONFIG and shown in the auxiliary
config UI, but the judge path never read it — a user raising the timeout
for a slow-but-healthy endpoint got the same 30s cap, and the loop
auto-paused on transport failures advising a provider/key check. Mirror
the _goal_judge_max_tokens reader and resolve the timeout at call time;
explicit timeout= arguments still win.

Fixes #91022
2026-08-21 02:48:14 +08:00
brooklyn! 9ef9b2d2d0 feat(desktop): let plugins switch the theme, and document the theme surface (#91018)
* feat(desktop): let plugins switch the theme from outside React

The SDK could contribute a theme through `THEMES_AREA` but never select one,
and `useTheme().setTheme` needs a component to hang the hook on. A plugin that
repaints on an event — a gateway coming up, a socket message — had nowhere to
call, so shipping one meant patching the app and re-patching it after every
upgrade.

`requestTheme(name)` writes to the same one-shot channel the backend `/skin`
sync already uses, so the ThemeProvider drains it through `setTheme` and an
imperative switch normalizes and persists per profile exactly like a manual
pick — one policy, one owner.

An unresolvable name is refused rather than coerced. `setTheme` falls back to
the default skin, which is right for a person picking off a list and wrong for
code reacting to an event: a gateway naming a theme the user never installed
would silently reset an appearance the caller never meant to touch. The
returned boolean doubles as the availability check.

* docs: document the desktop plugin SDK theme surface

`useTheme`, the accent override, the retint helper and the OKLCH math reached
the SDK without reaching this page, which still documented `THEMES_AREA` alone.
Registering a theme only lists it in the picker, so the natural reading was that
plugins cannot switch themes at all — and the one person who tried concluded
exactly that and patched the app instead.

Documents the selection half: the hook for components, `requestTheme` for
callbacks with no component around them, and a Theming row in the export table.
The agent-facing reference gets the same note, since it never covered themes.
2026-08-20 13:36:08 -05:00
Brooklyn Nicholson 89ecaf03d5 docs: document the desktop plugin SDK theme surface
`useTheme`, the accent override, the retint helper and the OKLCH math reached
the SDK without reaching this page, which still documented `THEMES_AREA` alone.
Registering a theme only lists it in the picker, so the natural reading was that
plugins cannot switch themes at all — and the one person who tried concluded
exactly that and patched the app instead.

Documents the selection half: the hook for components, `requestTheme` for
callbacks with no component around them, and a Theming row in the export table.
The agent-facing reference gets the same note, since it never covered themes.
2026-08-20 13:30:35 -05:00
Brooklyn Nicholson 2e1e3cc91d feat(desktop): let plugins switch the theme from outside React
The SDK could contribute a theme through `THEMES_AREA` but never select one,
and `useTheme().setTheme` needs a component to hang the hook on. A plugin that
repaints on an event — a gateway coming up, a socket message — had nowhere to
call, so shipping one meant patching the app and re-patching it after every
upgrade.

`requestTheme(name)` writes to the same one-shot channel the backend `/skin`
sync already uses, so the ThemeProvider drains it through `setTheme` and an
imperative switch normalizes and persists per profile exactly like a manual
pick — one policy, one owner.

An unresolvable name is refused rather than coerced. `setTheme` falls back to
the default skin, which is right for a person picking off a list and wrong for
code reacting to an event: a gateway naming a theme the user never installed
would silently reset an appearance the caller never meant to touch. The
returned boolean doubles as the availability check.
2026-08-20 13:30:31 -05:00
hermes-seaeye[bot] 63c6d9a45c fmt(js): npm run fix on merge (#91000)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-20 18:15:16 +00:00
brooklyn! 59795c40ff Merge pull request #90994 from NousResearch/bb/updater-preserve-holder-age
fix(updater): preserve the holder's age across update-marker handoffs
2026-08-20 13:01:24 -05:00
fangliquan 065c8bd6c0 test(updater): cover handoff marker edge cases 2026-08-20 12:52:21 -05:00
fangliquanflq fe4515da69 fix(desktop): add curly braces in handoff marker test helpers
ESLint curly rule requires block bodies for if/else in runPosix and runWindows.
2026-08-20 12:52:21 -05:00
fangliquanflq 60f0a4f2db fix(desktop): coerce spawn output to string in handoff marker tests
spawnSync stdout/stderr can be string | Buffer; assert.equal expects string | Error. Wrap with String() so check:lint typecheck passes in CI.
2026-08-20 12:52:21 -05:00
fangliquanflq ee6a9f8326 fix(updater): carry acquisition age through scripts 2026-08-20 12:52:21 -05:00
fangliquan dbc2a9c8e9 fix(updater): preserve holder age across handoffs 2026-08-20 12:51:51 -05:00
brooklyn! 5e32e3aecd Merge pull request #90941 from NousResearch/bb/installer-drain-bound
fix(installer): the bootstrap installer stops waiting on pipe EOF
2026-08-20 12:50:43 -05:00
Brooklyn Nicholson 24e89883c4 fix(installer): keep the post-EOF wait cancellable
Phase 1 leaves on both pipes reaching EOF without an exit status, so the
final wait was the only thing holding the turn -- and it was a bare
child.wait(), which stranded the cancel channel against a child that
closes its pipes and lingers. Poll cancellation there too.
2026-08-20 12:41:39 -05:00
brooklyn! 0723cb6c06 fix(update): don't reinstall the editable package when the pull can't affect it (#90967)
`uv pip install -e .` never audits an editable target. It reinstalls on every
invocation and rewrites the console-script shims each time, which is the only
reason `hermes update` has to quarantine the running `hermes.exe` on Windows —
and a quarantine that loses its race is the whole `os error 32` family.

Gate the reinstall on whether the pull actually touched a file that defines the
install. It's safe to skip because the editable finder is pinned to a static
module list (`py-modules` + `packages.find.include`), so the one source-only
change that could stale it — a new top-level module or package — cannot land
without a `pyproject.toml` diff. Dependencies and `[project.scripts]` live
there too, and new submodules inside an already-mapped package resolve through
the real directory.

The predicate fails closed: no pre-pull SHA, an unresolvable one, or a failed
`git diff` all reinstall as before. On the skip path the two verifiers that
normally run inside the install run directly, so a wrong skip self-heals into a
real install rather than leaving an unchecked venv.

This is the pattern the file already uses everywhere else — `_tui_need_npm_install`
diffs node_modules against package-lock.json, and the desktop build is gated on a
content hash so `hermes update` "will skip if nothing actually changed". The
Python editable install was the one path with no such gate.
2026-08-20 12:41:33 -05:00
brooklyn! 2b1bff624e fix(update): Windows Desktop updates finish instead of parking on "Updating Hermes" (#90937)
* fix(update): bound the Windows update hand-off's step pipe drain

Invoke-HermesStep collected each step's output with ReadToEndAsync().Result.
That task does not complete when the step exits; it completes when the pipe
reaches EOF. On Windows the write end of a redirected pipe goes to the child as
an inheritable handle, so every descendant spawned without its own redirection
holds a duplicate and EOF waits for the last of them to close it. hermes update
deliberately runs its build steps with stdout inherited, so the tree under a
step is arbitrarily deep and not something this script can enumerate. When one
of those descendants is a resident gateway, the pipe stays open for the life of
the gateway and the hand-off blocks forever.

Everything the hand-off owes the Desktop is downstream of that call:
.hermes-update-result.json is never written, .hermes-update-in-progress is never
cleared, and the Desktop is never relaunched. The app sits on "Updating Hermes"
until the user kills the gateway by hand, and the stale marker then refuses the
next update too.

Read both pipes in chunks into a StringBuilder and bound the drain once the step
process itself has exited. The bound cannot truncate a slow step: the clock only
starts after the process is gone, at which point everything it wrote is already
in the pipe buffer waiting to be read, so the grace only has to cover the final
drain. Chunked reads are what make abandoning safe at all, since .Result cannot
hand back a partial read.

Also switch to the bounded WaitForExit overload. The argument-less one waits on
redirected streams as well, which is the same unbounded wait by another name.

An abandoned drain logs one line to logs/desktop-update-handoff.log naming the
cause, so a truncated step log is never mistaken for a step that printed
nothing.

Measured on Windows 11 / PowerShell 5.1 against a step whose grandchild
inherits its stdout and outlives it by 45s: 47.4s before, 4.3s after, with the
step's exit code and output preserved in both.

Fixes #90455

* test(update): prove the hand-off survives a step that leaks its pipe

Four source-level guards on Invoke-HermesStep, scoped to that function so the
legitimate WaitForExit and .Result uses elsewhere in the script cannot mask a
regression: no ReadToEndAsync, a drain bound keyed on the step having exited,
no argument-less WaitForExit, and a log line when a drain is abandoned. All
four fail against the previous drain. They are source-level for the same
reason the sibling python-handoff guard is: Linux CI cannot execute the
PowerShell hand-off.

Source-level is not enough for a deadlock, though, so the script also grows a
-SelfTestPipeDrain fixture alongside the existing -SelfTestUi one. It needs no
checkout, no install and no update: it starts a step that spawns a grandchild
with UseShellExecute = $false and no redirection, which is exactly the shape
that makes the grandchild inherit the step's stdout and stderr, then exits 7
while the grandchild sleeps on. The fixture asserts the grandchild was still
alive when Invoke-HermesStep returned, so a pass cannot be a timing
coincidence, and that the exit code and the step's output both survived the
abandonment. A windows_only test drives it, so the OS lane runs the real
drain rather than a text match.

Measured on Windows 11 / PowerShell 5.1: 4.3s with the fix, 47.4s (the
grandchild's full lifetime) with the previous drain restored.

The python-handoff guard now reads the script with its -SelfTest* blocks
removed. Those blocks exercise the machinery deliberately and exit before any
marker, venv or desktop work, so the "every step drives python.exe, never the
hermes.exe shim" rule does not apply to them. Scoping the source that way
rather than allow-listing a target keeps that rule absolute for every real
step.

Refs #90455

* fix(update): don't meter the step drain that #90455's bound introduced

Chunked reads make the bounded drain possible, but the loop idled 150ms
after every chunk it consumed, so a step's output moved at one 16 KiB
buffer per tick (~107 KB/s). The pipe then backs up, which is
backpressure on the *running* step rather than a slow read: a chatty
step blocks on write() waiting for the reader.

`hermes update` is exactly that shape -- the Electron/vite build alone
is megabytes -- so the layer that fixed "the hand-off waits forever"
would have shipped "the hand-off is slow" in its place.

Idle only when both pipes came up empty, and idle on the reads
themselves (WaitAny with the same 150ms cap) rather than on the clock:
a freshly issued ReadAsync is rarely complete by the very next pass, so
a bare `if (-not $moved)` still sleeps between chunks. WaitAny expires
on its own, so a silent step keeps the marquee animating and keeps the
abandon deadline advancing.

Measured against the drain as submitted, same harness, one variable:

  4 MiB of step stdout      38.99s -> 0.07s
  1 MiB stdout + 1 MiB err  18.22s -> 0.27s
  leaked grandchild (20s)    3.24s -> 3.20s, exit code + output kept
  quiet step, exits at 4s    4.29s -> 4.04s, 29 passes (not spinning)

* test(update): make the pipe-drain fixture cover metering, not just deadlock

The fixture proved the drain returns while a descendant holds the pipe.
It could not have caught the opposite failure -- a drain slow enough to
backpressure the step it is reading -- and that is the regression the
first version of this fix shipped.

Add a flood arm: a step that writes megabytes and holds nothing, with a
wall-clock budget far under what a sleep-per-chunk drain needs. The two
arms bracket the contract from both sides: bounded when a descendant
holds the pipe open, never slower than the step can write.

Few large lines rather than many small ones, deliberately --
Write-HandoffLog is one Add-Content per line and runs inside the
measured window, so line-heavy output would time the logger.

Also drops the four source-grep guards. Reading windows.ps1's text to
assert it contains `$abandonAt` tests the shape of the source, not its
behavior: it passes on a drain that is wired wrong but spelled right,
fails on a correct refactor, and blocks the extraction it should
survive. AGENTS.md bans the pattern outright, and all four pass on the
metered drain. The executable arms cover the same contract and actually
run the code -- the Windows lane is where this is verified either way.

---------

Co-authored-by: Jack Lau <72348727+jackulau@users.noreply.github.com>
2026-08-20 12:41:17 -05:00
Brooklyn Nicholson ae9367fc8c test(s6): put the supervise-skeleton setgid assertion on the Linux lane
test_seed_supervise_skeleton_creates_expected_layout has been failing on every
macOS checkout. The helper is correct — it chmods explicitly, so this isn't a
umask problem. BSD drops S_ISGID from a directory chmod unless the caller is
root or in the directory's group, so the same call that yields 03730 on Linux
yields 01730 on macOS.

s6 only ever runs on Linux, inside s6-overlay's stage2 as root with umask 0, so
Linux is the host whose answer matters. Split the mode assertion into its own
linux_only test rather than marking the whole case: the layout the test also
covers (dirs present, supervise/ 0755, control is a 0660 FIFO) is host-
independent and worth keeping on the machines developers actually run.
2026-08-20 12:37:16 -05:00
Brooklyn Nicholson c670464ca7 fix(anthropic): send an explicit thinking disable on the native Messages wire
Adaptive Claude models think by default, so omitting the `thinking`
parameter left thinking ON for users who had turned it off. Send
`thinking: {"type": "disabled"}` instead, and keep the omission for
reasoning-mandatory families that answer a disable with HTTP 400.
2026-08-20 12:29:11 -05:00
Brooklyn Nicholson 4d2c546a53 fix(ci): drop --locked, the installer crate has no tracked lockfile
apps/bootstrap-installer/.gitignore excludes src-tauri/Cargo.lock — a
create-tauri-app scaffold default nobody revisited. With nothing tracked,
`--locked` fails outright ("cannot create the lock file ... because
--locked was passed") and the cache key hashed an absent file.

Keyed on Cargo.toml instead. The underlying gap — a signed installer that
re-resolves its whole dependency graph on every build, in a repo whose
pinning policy is otherwise strict — is noted in the workflow and left
for its own change rather than widening this one.
2026-08-20 12:03:00 -05:00
Brooklyn Nicholson dd1e5b723d fix(ci): declare the rust lane on the detect job, and test that wiring
The lane shipped dead. `classify_changes.py` emitted `rust`, the composite
action re-exported it, and ci.yaml's `rust-tests` job gated on
`needs.detect.outputs.rust` — but the `detect` job never declared that
output, so the expression was the empty string and the job reported
"skipping" on the very PR that added it. GitHub does not error on a
reference to an output a job never declared, so nothing went red.

Adds the missing line plus the invariant that catches the whole class:
every `needs.detect.outputs.X` referenced by a job's `if` must be
declared by `detect`. Verified it fails with the line removed.

The related check — every lane reaching the composite action — is
separate on purpose: nix.yml and docker.yml own their triggers and
re-export different subsets, so `docker` and `nix` are legitimately not
ci.yaml detect outputs.
2026-08-20 11:46:30 -05:00
brooklyn! c47f0b4590 Merge pull request #90920 from NousResearch/bb/session-row-trailing
Sidebar rows own their right edge
2026-08-20 11:45:10 -05:00
Brooklyn Nicholson 5caea5e501 ci: run cargo test for the bootstrap installer
Nothing in CI compiled this crate. `.rs` lives under `apps/`, so the
change classifier matched a Rust edit as `frontend` and ran the
TypeScript matrix, which cannot notice a Rust error — the crate's 58 unit
tests had never executed once, and neither would the pipe-drain tests in
the previous commit.

Adds a `rust` lane and a Linux `cargo test --lib` job. Linux on purpose:
the pipe-drain fixtures need a real process tree whose grandchild
inherits the parent's stdout and are `#[cfg(unix)]`, so a Windows runner
would compile them out and report green over zero coverage. The Windows
half of that contract is `-SelfTestPipeDrain` on the existing Windows
lane.
2026-08-20 11:40:49 -05:00