READMEs, contributor guides, AGENTS.md files, skills and the website (with
the zh-Hans translations) give pnpm commands for the workspaces; the
website itself is still an npm project.
pnpm 12.15.0 (native executable) installs every JS workspace from one
pnpm-lock.yaml, imported from package-lock.json with the same resolved
versions. website/, the WhatsApp bridge and the photon sidecar stay separate
npm projects.
Configuration moves from .npmrc and the root manifest into
pnpm-workspace.yaml: the 14-day minimumReleaseAge and its excludes,
overrides, allowBuilds (the old allowScripts set, with strictDepBuilds
failing any unlisted build script), engineStrict, sideEffectsCache: false
(in-place rebuilds must not write into the store) and
verifyDepsBeforeRun: false (a `pnpm run` never installs).
The pnpm version is pinned in pm/lock.json and in root devEngines.
pmOnFail: ignore keeps that pin out of pnpm-lock.yaml: a leading pin
document makes osv-scanner read zero packages. scripts/build/node-deps.mjs
enforces the exact version instead, and every lock reader refuses a
multi-document lock. A root preinstall refuses `npm install`; devEngines
onFail is warn so npx (MCP servers, the tsc linter) keeps working inside
the checkout.
User-machine installs run --frozen-lockfile --trust-lockfile (release age is
checked when locking, in CI, not per user). node-deps removes npm-era
node_modules trees before the first pnpm install. Nix builds the store with
fetchPnpmDeps and a hash file that CI checks.
Scripts outside the declaring workspace found these through the hoisted root node_modules:
mock-server and connector-rehearsal (whose desktop-local Electron path did not exist under npm),
ui-tui's visual harness, the eval harnesses, and the tests/install/e2e-assets drivers, which import
tests-js's @playwright/test and zod. They now resolve from apps/desktop or tests-js; the typed
e2e-assets files go through a tests-js re-export module so their types resolve the same way.
npm honours overrides only in the root package.json, so ui-tui's ink-text-input > ink alias never
took effect: the lockfile resolves ink-text-input's ink peer to the upstream ink 7.1.1. Removing it
changes nothing that is installed, and pnpm ignores workspace overrides the same way.
Every workspace imported packages it never declared and found them only because npm hoists the
whole tree to the root node_modules: desktop (app-builder-lib, @electron/osx-sign, @electron/asar,
plist, @tailwindcss/node, remark-gfm, remark-parse), bootstrap-installer (@nanostores/react),
shared (vitest), ui-tui (chalk, strip-ansi) and tests-js (@types/node, vite, esbuild). Each is
pinned to the version npm already resolves for it, so the installed tree is unchanged apart from a
tests-js copy of @types/node matching the other workspaces' 22.20.5.
The submit path queued every argument, so those verbs were sent to the
model. Route them like the classic CLI and write the change through the
pending-input journal.
The /rewind picker (Esc Esc on an idle, empty composer) gains Codex-style
backtrack navigation:
- f = Fork from here: a `session.mutate branch` through the last stored row
before the picked prompt (the same primitive Desktop's message-level branch
sends), switch to the child, prefill the prompt unsent. The original session
is untouched. The first prompt has nothing before it, so it opens a plain new
session with the prompt prefilled.
- The list shows a preview (first lines + prompt number) of the selection.
- While the list is open the transcript scrolls to the selected prompt and
marks it, so Up/Down jumps between earlier user messages.
While the steer is in flight every answer to that approval is refused (a
repeated Enter no longer steers twice, an allow can no longer release the
command); afterwards only the promised deny may still be sent.
steerOrQueue takes the destination captured before awaiting session.steer
and passes it to enqueue, so a late refusal never lands in whichever session
is selected when the reply arrives; its status line only prints while that
session is still on screen.
Nothing cut: the edit goes back into the composer. Cut committed but the
repaint fetch failed or the user switched sessions: the edit lands in that
session's pending queue as its next prompt.
The Ink TUI could only undo or retry the newest turn. `/rewind` (and Esc Esc on an idle,
empty composer) now lists every earlier text prompt; Enter opens the picked one for editing and
Enter again cuts the session back to just before it and sends the edit. Nothing is cut until the
edit is sent, and Esc steps back (edit -> list -> closed).
The cut is the same revision-fenced `session.mutate rewind` that /undo and /retry use, aimed at
the picked row: the owner's form of the `prompt.submit truncate_before_row_id` Desktop's
edit-and-resend sends. A media turn keeps its number but is not offered (no lossless replay,
the /retry rule). Salvages the /rewind UX of #25074 / #23445 onto main's rewind primitives.
Approval prompts gain "No, and tell Hermes what to do instead" wherever Deny is offered. Picking
it keeps the command held while the user types; Enter steers the instruction into the running
turn first (so it rides on the blocked tool result), then answers the gateway with a plain deny.
A refused steer (the turn already ended) keeps the words as the next prompt, the /steer fallback.
Esc on the typing row returns to the choices; Esc on the choices still denies outright.
Co-authored-by: SaguaroDev <74339271+SaguaroDev@users.noreply.github.com>
The slash worker is killed after 45 s, which could cut a clone/scan/PM sync mid-update.
The TUI now calls the same update_all RPC Desktop uses (600 s budget) and renders its rows.
A TUI attached with HERMES_TUI_GATEWAY_URL to a listener that is not a
session authority (an older or remote gateway) fell back to the legacy
session.save RPC for every /save. That RPC ignores the format, filename
and redact arguments and writes default-named, unredacted JSON on the
gateway host, so `/save md notes.md redact` silently produced a raw JSON
copy, secrets included, on another machine.
Match the CLI (_save_on_older_gateway) and Desktop (saveLegacyRefused):
only a plain /save (json) may use the legacy RPC, and its notice now says
the file is raw JSON on the gateway host. A redact flag, a filename or a
non-JSON format is refused with a message naming why and what to do.
The /save help line now lists the arguments.
Review fixes on the paste expand/sections feature:
- Folding an opened paste puts its token back at its own position among
same-label tokens, so two pastes whose chips look the same (same first and
last lines and line count, different middles) still expand to their own
bodies on submit. Before, the folded token was appended and expandTokens
swapped the two bodies in what the model received.
- A paste made while the cursor is inside an expanded paste joins it as plain
text, and a span that holds another chip is never folded into a token, so
the model never receives a nested chip label in place of its content.
- Sent-paste sections are keyed by the exact text the model gets (the same
text whoever paints the user row holds: submit, local queue, or the
authority starting a busy-time admission), not a process-wide label FIFO,
so a row can never show another message's body. Bounded by entry count and
total size; the newest entry always stays.
- Opening or folding a paste places the cursor at the paste's end instead of
the end of the whole draft.
- The composer viewport and Ctrl+P follow the caret (the moving end of a
selection), not the selection's lower bound.
Ctrl+O/P/T become a chord table; the composer viewport math and the
Ctrl+P hint move out of ComposerPane, and the transcript paste sections
own their own null case, so no function grows past its value on main.
A large paste collapses to a [[ … [N lines] … ]] chip, and until now the
user could neither see nor change what was behind it, and the sent message
showed only the chip.
Composer: Ctrl+P expands the chip under the cursor (or the last one) back
into ordinary editable text; the composer shows a window of rows that
follows the cursor so a 60-line paste never pushes the caret off screen.
Ctrl+P again, or Enter, folds the span back into a chip whose payload is
the text as edited, so what the model receives is the edited full text,
through the same expandTokens path as before.
Transcript: a sent user row keeps showing the chip and gets one accordion
per paste ("Pasted text #1 · 60 lines"). Click toggles one; Ctrl+P with no
chip in the composer toggles them all. Payloads ride from submit to the
row through a small FIFO keyed by chip label, so a busy-time row painted
later still gets its own payload.
Greptile on #148880:
- Background questions are skipped: on the shared gateway /new (and a switch) away from a busy
session detached this view's subscription, and with no viewer left the owner answers clarify,
sudo, secret and vault requests as unanswered at once. The view now keeps the subscription of a
session it leaves mid-turn; an idle switch still detaches as before.
- Returning erases deferred questions: the channel re-delivers open_requests before the activation
resolves (another session still on screen, so the card is deferred) and the activation's reset
clears what was open. hydrateSharedPrompts now re-delivers open_requests on both transports
after the reset, and a replayed request for another session is deferred like a live one.
- Notices use an old focus: a session.active_list read that resolves after the poll effect ended
or the focus moved is dropped without advancing the watch.
- Fast turns finish without notices: the handoff itself marks the left session shown and busy.
The shared gateway's live session.list reports only running/idle, so a kept-running
session blocked on an approval read as still working. While a session this view
showed is off screen, the poll also reads session.active_list, which reports
'waiting'.
Community feedback comparing against other agent TUIs asked for /new to open a
fresh session without pausing the one that is busy. On main /new refused mid-turn
("interrupt the current turn before trying to switch sessions").
- /new while a turn runs starts a new LIVE session (the switcher's `+ new` path):
nothing is closed or interrupted, so no destructive confirm. The old session
keeps its turn and stays in the session switcher (Ctrl+X). /new on an idle
session and /clear are unchanged (the idle one is closed and stays resumable,
so live agents do not pile up one per /new).
- Approval/clarify/sudo/secret/vault requests from a session that is not on
screen no longer open over the focused session's view: they stay open on the
backend and come back as open_requests when that session is activated.
- The live-session poll announces a background session this view showed when
its turn finishes or it starts waiting for an answer.
The inline-images and zero-height fixes added lines to useMainApp, which is
already over the 300-line target and may only shrink. Move the height-cache
bucket key and the measured-height copy into named helpers above it.
The render cache keys on (path, revision, cols), and only tool images
carried a revision; user attachments relied on staged paths being
unique. The legacy attach path (image.attach) keeps the user's own file
path, so re-attaching a file overwritten in place reused the previous
turn's pixels with no new image.render request.
beginOwnedTurn now stamps each user turn that carries images with its own
imageRevision, so every occurrence asks the gateway once and remounts of
that turn still hit the cache.
An image-only transcript row is estimated at 16 rows until Yoga measures
it. When the gateway answers `off` (previews disabled, unreadable file) the
row really lays out at 0 rows, but useVirtualHistory rejected every
non-positive measurement (`h > 0`, from the first virtualizer, and later
validVirtualItemHeight), so the estimate stuck: 40 disabled image rows
kept 640 virtual rows and a 624-row top spacer.
A measurement now reads NaN when there is no Yoga node, so "not
measured" and "0 rows" stay distinct; a measured or cached 0 is kept,
while estimates keep their positive floor. The useMainApp cache sync no
longer drops a 0 either.
With `display.inline_images: off` (or YAML's bare `off` -> false) the TUI
now reserves no image rows and draws no images at all, without asking
the gateway; the height cache bucket is keyed on the setting.
ImageRenderResponse hand-copied the generated contract type; import
ImageRenderResult from @hermes/shared/gateway-events like the other
RPC results. The CLI's _MAX_COLS/_MAX_ROWS and the TUI's
INLINE_IMAGE_MAX_* are the same budget in two languages: say so on both.
One gateway serves many clients by design, but image.render's `auto`
mode read KITTY_WINDOW_ID/TERM/TMUX from the daemon's own os.environ,
and the TUI only sent `graphics: isTTY`. A kitty TUI attached to a
daemon Desktop started got half-blocks; a daemon started from kitty sent
placeholders to an iTerm/VS Code/dashboard TUI, which paints tofu.
pet.cells had the same flaw (detect_terminal_graphics in
_pet_kitty_cells), so it gets the same field.
- ui-tui lib/terminalGraphics.ts picks kitty/unicode from the TUI's own
env (TERM, TERM_PROGRAM, KITTY_WINDOW_ID, GHOSTTY_RESOURCES_DIR,
WezTerm, VS Code, the dashboard PTY, and TMUX/STY: screen swallows the
APC too) and sends it as `terminal` on both RPCs.
- resolve_inline_mode takes the remote painter's pick as `detected`
(keyword-only; the classic CLI keeps reading its own env). A client
that declares nothing gets half-blocks; a pinned `kitty` stays the
user's explicit opt-in.
- ImageRenderParams.graphics is `bool = False` like PetCellsParams;
contracts regenerated.
estimatedMsgHeight counted a kind 'image' row as a text line plus a
one-row gap plus INLINE_IMAGE_MAX_ROWS per image, but the row renders
without `spaced` and has no text, so every tool image was reserved two
rows too tall before Yoga measured it. Attachments under a user turn
keep their gap.
Also pins the first-frame seed (the render cache commit): a virtual
scroll remount of a resolved image paints with its real height
synchronously instead of estimate -> 0 -> real.
Every size of a file shares one image id (the placeholder color), and
`a=T,U=1,i=` replaces the terminal's single virtual placement for that
id. The transmit dedupe was keyed (id, size), so a resize A -> B -> A,
or the same file as an attachment and a tool image (different gutters,
different widths), skipped the re-send and painted A's cells with B's
grid.
Track the size the terminal holds per id instead, write inside the
single-flight render, and treat a cached kitty render whose size was
displaced as stale, so its next mount fetches a fresh transmit.
The process-wide render cache was keyed (path, width) and never evicted:
a second screenshot to the same path, or an image regenerated in place,
painted the first picture, and a cached `off` stuck even after the file
appeared or the config changed.
- Key on (path, revision, width); a tool image's revision is its tool
call id (Msg.imageRevision), so each call asks the gateway once and the
engine's mtime-based kitty id takes effect. Attachments keep no
revision: each is staged under a fresh uuid path.
- Never keep `off` (or a dropped request); the next mount asks again.
- A 48-entry LRU, and the kitty transmit (often 50-500 KB of base64) is
dropped from the entry once written; the write moved into the
single-flight `.then`, so every mount of a key shares it.
appendToolShelfMessage walked back past a kind 'image' segment (role
system, empty text) and merged the next tool call into the trail above
it, so the picture ended up under a call that did not make it, live
(groupedSegments) and in settled history alike. Treat images as a
barrier, like diffs.
The TUI shows the same images as the CLI: a user turn's attachments under
its row, and a tool's image in its own transcript row under the trail line
that made it (anchored like inline diffs).
- tool.complete carries image_path, from both the sidecar gateway
(tool_progress) and the shared gateway's local-viewer fields
(local_viewer_events.complete_fields), so canonical sessions get it too.
- image.render lays an image out for the client: kitty placeholders when the
TUI owns a terminal that paints them, half-block cells otherwise, nothing
when display.inline_images is off or the file is not a readable image.
- <InlineImage> paints both with the generic cell painters. Renders are
cached across virtual-scroll remounts and each kitty image goes over the
tty once per process.
The kitty/iTerm2/sixel/half-block encoders, terminal detection and the kitty
Unicode-placeholder grid lived in agent/pet/render.py, though nothing in them
is pet-specific. Move them to agent/terminal_image.py so inline images can use
them too; the pet keeps its spritesheet decode and PetRenderer.
The Ink half-block and placeholder painters get generic names for the same
reason (petSprite.tsx -> cellImage.tsx: HalfBlockImage, KittyImage).
No behaviour change; the detection tests move with the code.
Moves the ui-tui direct pin and the root undici@^6/@^7 overrides, plus
the photon sidecar's direct pin and override, to the patched releases
in each line. Both were published 2026-10-15, past the 14-day npm
release-age gate.
Advisories: GHSA-2m5v-pch6-74hx, GHSA-4qv8-2p9j-2685,
GHSA-4w7w-5xrc-9h26, GHSA-55wc-3pg8-jfw6, GHSA-9fm3-97px-rjf6,
GHSA-f72v-6w45-q472, GHSA-qrxm-fqhp-jwvm, GHSA-xjj6-f33q-929c
The Ink approval panel showed the first 10 wrapped lines and said the rest
was "(full text above)", which was not true: a dangerous line 11 never
reached the screen. The panel now pages the command with v (wrapping back
to the top) and says which lines are on screen and how to see the rest.
prompt.approval.moreLines is replaced by pageMore/pageEnd in every bundled
TUI pack, so no translation keeps the false claim.
The Ink TUI, Desktop and the gateway-attached CLI send /model as a
session.mutate model mutation straight to the session owner, so the
earlier handlers never saw it and `reset` was frozen into the policy as
a model name (the provider then got `model: reset`).
prepare_model now treats `reset` as a pick of the owning profile's
configured model, read fresh from config.yaml (session-scoped, never
written back, not held behind the cost ask). A session already on that
default gets a `noop` with an "already on the configured default" note
and nothing is committed. `reset` with any other selection field is
refused like on every other surface. A messaging chat viewed through
the owner clears its override through the gateway's own reset; a reset
queued behind a running turn shows the default it will adopt. The TUI,
Desktop and the attached CLI print the owner's note.
The backend sentence, the TUI/dashboard hint and the Desktop/Webapp card name the Hermes desktop
app and `hermes model` -> Local models instead of the Desktop app alone; the card says Retry only
while the server can still come up (retryable), and the Python hint keys its no-retry wording on
the surface's code and retryable flag rather than rewriting the text. The localRuntimeUnavailable
TUI keys are translated into every bundled locales/*.tui.yaml pack.
The turn-failure tables had no entry for local_runtime_unavailable, so the TUI and
dashboard fell back to 'Your custom model endpoint did not answer'. The resolver's
sentence (the messaging reply and every client's raw detail) pointed at Settings →
Providers → Local models, a pane only Desktop has; it now names /model instead.
The held trailing Enter decides on a 250 ms timer. When the event loop
stalls past that window with the next paste chunk already in stdin, the
timer ran before the chunk was read and submitted the first line alone:
- Ink: Node runs the timers phase before the poll phase, and
stdin.readableLength cannot see bytes libuv has not read yet. The flush
now hops to setImmediate (after this turn's poll) and runs only if no
read replaced keyParseState in between.
- Classic: a signal handler that interrupts the selector past its deadline
returns no events (PEP 475), so the due call_later ran before the reader.
The settle now runs two call_soon hops later, after the next selector
pass's readers. Text arriving while an Enter is held also keeps the paste
burst alive however late it is read, so the paste's own trailing Enter
stays a newline instead of submitting the joined text.
Paste detection expired BURST_END_QUIET_S after the last pasted newline,
not after input went quiet. A non-bracketed paste delivered in reads
100 ms apart, with newlines only in the first and last read, therefore
treated its trailing Enter as a scripted text+Enter and submitted (and
cleared) the multi-line draft. Any input inside the quiet window now
keeps the paste burst alive, in the classic CLI (note_text_change) and
the Ink parser (pasteBurstAt). One-write text+Enter and a lone Enter
still submit.
A kitty Enter (CSI 13 u) is not held by the parser, so text, Enter and the
next keystroke can share one React batch. The key after Enter was applied to
the submitted draft before the parent's submit response rendered: with fast
echo the forced re-sync then dropped it (`hello\e[13ux` left an empty
composer), and on the Ink path it was appended to the submitted text
(`hellox`).
Keys arriving after a submit now queue until an effect declared after the
value sync replays them, so they edit whatever draft the parent settled on:
`x` after a clearing parent, `hellox` after a parent that keeps its value.
Terminals with the kitty keyboard protocol send Enter as CSI 13 u, which is no
CR, so the burst hold does not apply: the text and the submit land in one React
batch, the parent value ends where it started and the local draft kept the
submitted text. TextInput re-syncs its draft on every submit (restores the
re-sync this PR's review round reverted).
Review round on the one-write text + Enter submit:
- Classic: the provisional newline goes in at the cursor, so settling restores
the exact pre-Enter document (text + cursor) instead of dropping the last
character. A scripted edit inside a draft now submits the whole draft.
- Ink: the decision is kept across stdin reads. A single-line read ending in CR
emits its text and holds the CR in the parser state; App's flush timer
releases it as return after BURST_END_QUIET_MS (250 ms) of no input, and any
read inside the window turns it into paste content. A CR-ending read within
250 ms of a pasted line break stays content. A paste split across reads (both
directions) no longer submits itself. The textInput submit re-sync is no
longer needed (text and return land in separate batches) and is reverted.
- Tests: injected clock (cli_enter_burst.monotonic, also read by
_tui_on_text_changed); the single-write test proves the delayed callback
path; new mid-draft and split-read cases.
Scripted drivers deliver the text and its Enter in ONE read (`tmux send-keys
'text' Enter`, a PTY write of `text\r`). The classic CLI's #10994 guard treats
every Enter within 50 ms of a buffer change as a pasted newline, so the burst
never submitted; the Ink TUI routed the CR-carrying token through the paste path
and dropped it.
Classic: an Enter with more keys of the same read queued, or the tail Enter of a
burst that already held one, stays a newline (unbracketed multi-line paste is
unchanged). The lone Enter that ends a burst inserts a provisional newline and
submits once input stays quiet for 250 ms; input in that window (a paste split
across reads) keeps the newline. New sibling hermes_cli/cli_enter_burst.py.
Ink: a single-line text token ending in CR peels the CR into its own return key;
multi-line tokens keep it as paste content. TextInput re-syncs its local draft on
every submit, since text + submit in one React batch left the parent value
unchanged and the submitted text stuck in the composer.
Review follow-ups on #145566:
- Policy checkpoint: turn.deferred receipts (explicit user retries) are bounded on
their own, so a discussion configured to its full max_rounds no longer stalls when
a deferred turn is retried while it runs.
- Both routes run a member's file preparation through prepare_before_submit: a file
the member can never take (over gateway.max_inbound_media_bytes, unbound, expired)
settles the turn as failed; a transient failure is proven not admitted and is
requeued with backoff. Neither pauses the room as uncertain execution.
- Ink TUI: the backends' "not available on this Hermes backend" answer is no longer
read as version skew; it renders a translated methodUnavailable line. The
invalid-params skew warning and older backends' bare wording are unchanged.
Refs #144648 (WA-20)
Rows the gateway bills to a subscription the account holds (billing_mode:
subscription) keep their free usage level, so a full allowance never dims
them, but the pickers no longer label them "Free": that subscription pays.
The ids ride on plan_usage.subscription_model_ids for Desktop/TUI; CLI and
messaging pickers use usage_tag_levels().