* fix(codex): drop safe-yolo from Codex permission modes
Every Codex session runs on the native shared runtime (runCodex is
runSharedCodex), which rejects safe-yolo. The mode stayed in
CODEX_PERMISSION_MODES and six filters hid it again in the web picker,
composer, hub permission-mode route, hapi set-mode and both native
apps. API callers that validate against the flavor catalog (e.g. a
spawn with permissionMode: 'safe-yolo') still passed it through and
only failed inside the runner.
Remove it from the Codex catalog (shared, iOS, Android, fixture) and
delete the filters. autoReview is the native replacement. Safe Yolo
stays for Gemini, Kimi and Copilot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(ios): Codex option list now has four modes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix: list Claude background tasks from the snapshot instead of guessing
The status panel only had a count from `background_tasks_changed` and
reconstructed commands by regex-matching Bash results in loaded chat
history, so tasks whose start message was unloaded, auto-backgrounded
("moved to the background (ID: …)"), or non-Bash showed
"N running · exact command unavailable".
The hub now keeps the snapshot's tasks (id, type, description) on the
session alongside the count. The web panel lists them directly, using
the exact task ID to look up the Bash command when loaded and the
snapshot description otherwise.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): only attribute a Bash command from its background launch result
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(native): accept backgroundTasks in strict session patch decoders
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Content growth and mobile-keyboard resizes increase the distance from the
bottom without any user gesture. handleScroll treated that as the user
leaving the tail and disabled auto-follow, so the session feed stopped
following new messages until it was scrolled down manually.
While tail-follow is active, treat a non-upward scroll as layout churn
and re-pin to the newest message instead of dropping out of tail mode.
Add regression tests for transcript growth and viewport shrink.
* feat(shared): derive Claude model choices from the CLI catalog
The choices are the rows of the claude CLI's own /model picker, in its
order and with its display names. The stored value is what
claude --model receives: an alias is resolved again each time the
session's claude process starts, a full id stays on its model, so
following and staying need no new field.
Effort support is read per model so a model switch can drop an effort
level the next model does not offer.
* feat(cli): discover Claude models from the CLI's initialize response
A machine RPC starts claude in stream-json mode, sends only an
initialize control request and reads the models it reports, so no turn
runs and no transcript is written.
claude --print runs a directory's project hooks and .mcp.json servers
without a trust prompt, so the probe starts from the home directory
with user settings only, no MCP servers and hooks disabled.
Successful catalogs are cached per claude executable for five minutes
and concurrent requests share one probe; failures are not cached.
* feat(hub): expose the Claude model catalog per machine
GET /machines/:id/claude-models forwards to the runner's
listClaudeModels RPC. A runner that predates the handler answers with
the stable rpc_target_missing code so clients can keep their built-in
list.
* feat(web): offer the machine's Claude models in the model pickers
The composer and the create-session form list the rows the machine's
claude CLI shows in its own /model picker, as it shows them. Built-in
presets the catalog does not list remain selectable after it.
Until the catalog arrives, or on a runner without discovery, the
built-in presets are shown as before. A saved Claude model is no longer
reset to Default when the built-in list lacks it; the create-session
form keeps it and marks it as no longer listed when the catalog
dropped it.
* feat(hub,web): keep Claude effort within what the selected model offers
For Claude sessions the model endpoint accepts an optional effort, so a
model switch and the effort it keeps reach the session as one config
change. Other flavors are refused: Pi, for one, commits the model
before its thinking level. When the machine's catalog says the next
Claude model does not offer the current effort, the composer clears it
in that same request. The create-session form drops such an effort
however the model was set, including a restored preference or draft.
Effort choices in both places narrow to the selected model's levels;
without a catalog every level stays available as before.
* fix(web): retry Claude model discovery after a failed probe
The machine answers a failed probe with success:false over HTTP 200.
The query cached that as an answer for its stale time, so a user who
signed in and reopened a picker within a minute still saw only the
built-in models. A failed probe is now a query error, which is not
kept, and the next picker to mount asks the machine again.
* fix(web): list only the Claude CLI's models once its catalog is known
The built-in presets were kept after the catalog so that a partial list
could not hide Opus. The catalog is the CLI's own /model list, though,
not a partial one, and the presets added entries the CLI does not show
(Sonnet 1M, Opus 1M, Fable 1M). With a catalog the list is now exactly
its rows; a saved model it does not list stays as the current model,
and New Session marks it as no longer listed. Without a catalog the
built-in presets are shown as before.
* fix(shared,web): list earlier Claude versions under Other models
Claude Code's model sheet shows each family's newest model first and
every earlier version under an "Other models" heading. The CLI's
catalog lists models in that order but does not say where the split
is, so the first row of each family is taken as its newest and later
rows of the same family as earlier versions.
The composer's model list and New Session's model select now show the
same two groups, keeping the CLI order within each. Nothing is folded:
every row stays one click away, as it does in Claude Code.
* test(web): pin that Claude 1M variants come from the CLI catalog
Claude Code 2.1.105, 2.1.200, 2.1.240 and 2.1.274 list a 1M choice as
its own catalog row (opus[1m], claude-fable-5[1m]); 2.1.303 and 2.1.310
list none, and on 2.1.310 opus and sonnet run with a 1M context window.
The catalog-only list therefore offers a 1M variant exactly when the CLI
does. Pin that and record why the built-in [1m] presets are not appended.
* fix(cursor): in-place relaunch when switching live session to Auto
When ACP does not advertise literal auto, switch to Auto by respawning
the same hub session under --model auto + session/load instead of
rejecting. Keeps #1819 CLI Auto pin semantics. Fixes#1908.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): widen in-place relaunch spawn model typing
Allow null/undefined rollback spawn models from resolveCursorSpawnModel.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): re-pin ACP session id after in-place relaunch load
If session/load returns a different agent id, update hub metadata the
same way as a fresh session so resume stays recoverable.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): coordinate message loop with in-place Auto relaunch
Await relaunch completion before prompting, re-read the live backend
after model apply, and publish this.backend only after session/load.
Addresses tip bot Majors on #1909.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): harden Auto relaunch against prompt races and cleanup
Hold promptInFlight before mode apply, end the loop if backend restore
fails instead of requeue-spinning, and discard late replacement backends
when session teardown has started. Addresses tip bot Majors on #1909.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): avoid continue-in-try circular typing; finish relaunch harden
Refactor mismatch requeue off continue-inside-try so typecheck passes,
keeping promptInFlight hold and teardown discard for #1909 tip bot Majors.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): preserve queued turns and Auto-review across relaunch failure
Requeue dequeued batches before ending when restore fails, and still
record Auto-review (slash after publish) when mode flips while backend
is null mid-relaunch. Addresses tip bot Majors on #1909 @ 9e5f241f1.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): defer hub ack until Auto relaunch has a live backend
Avoid acknowledging queued turns before a usable ACP process exists, and
reset Auto-review slash bookkeeping on in-place process replacement.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): recompute Auto-review spawn flag for replacement ACP
Slash bookkeeping after in-place relaunch must use the replacement
process's --auto-review arg, not the prior process's sticky flag.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): drop queued Auto-review slash when replacement has --auto-review
Cursor treats /auto-review as a toggle; leave no leftover slash for a
spawn that already enabled Auto-review via the CLI flag.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): discard held Auto-review toggle after --auto-review relaunch
Mark internal toggles so queue reconcile and post-relaunch dispatch drop
only HAPI-generated /auto-review slashes, preserving user-sent commands.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
A pending permission whose sidechain tool_use never makes it into the
rendered block tree leaves the session blocked with no answerable UI: the
attention indicator shows a pending request, but no card renders anywhere.
Root cause: the tracer drops sidechain messages it cannot attribute to a
parent Task/Agent call — no parentToolUseId on the message and no
prompt-holding sidechain root in the window (observed with background
subagents whose transcripts live in separate subagents/*.jsonl files and
whose initial prompt message never reaches the hub). The orphan-card
fallback in reduceChatBlocks then checked *raw transcript* tool ids, so
the dropped message still suppressed synthesis.
Check the final rendered tree instead (recursively, including blocks
nested under Task/Agent cards): any pending permission that is not
rendered anywhere gets exactly one synthesized, answerable card. This is
the approach suggested in #1073 itself.
Verified against a live session where 48 of 69 messages were dropped by
the tracer, including the pending WebFetch tool_use; with this change the
request renders as an orphan card and can be approved from the chat.
Refs #1073, refs #587.
Co-authored-by: zx-shi <shi@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
* fix(web): keep machine health visible for single-machine layouts
The sidebar redesign gated the machine filter bar behind >= 2 machines,
so a single-machine setup lost its only surface for machine CPU/RAM
health and session counts (#1259).
With exactly one machine there is nothing to filter, so render a compact
non-interactive summary row in the bar's slot instead: machine label,
session count, and inline CPU/RAM metrics — visible on touch layouts too,
since no hover is needed. Pointer users additionally get the same
capacity popup as the filter chips.
Fixes#1259
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(web): show machine summary when the sole machine has no sessions
Address the review finding on #1664: the summary row was derived solely
from session groups, so a single-machine setup with zero visible
sessions (fresh install, or "active only" filtering) still hid machine
health — the exact regression #1259 reports.
Fall back to the sole machine from the machines list in that case; the
row then shows its label, a zero count, and inline CPU/RAM metrics.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: yellowdog01 <yellowdog01@users.noreply.github.com>
Co-authored-by: HAPI <noreply@hapi.run>
* fix(web): resolve relative Markdown links and images in file preview
The standalone Markdown preview rendered without the previewed file's path,
so relative .md links became inert and relative images resolved against the
web page URL. Pass the session file to the renderer and resolve relative
hrefs/srcs against its directory: links open the sibling file preview,
images load through the session file-read API. HTTPS images are unchanged
(direct browser load); paths that escape the root stay inert.
Fixes#1917
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): bound preview image reads and check workspace containment
Only read supported image types whose directory-listed size is at most
10 MiB, and require absolute resolved paths to stay inside the session
workspace so escapes remain inert.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): keep session links and browsing context in Markdown preview
Resolve session references before relative file paths, and carry the
preview's origin/tab/query into relative file links so Back returns to
where the user entered from.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(web): send terminal buffer or selection to agent composer
Add a To agent control on the session terminal page that appends the
xterm buffer (or current selection) to the session composer draft and
navigates back to chat. Button label flips when a selection is active.
Fixestiann/hapi#2011
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): FUE on composer Terminal for send-to-agent
Advertise To agent / Selection to agent on the composer Terminal icon
with feature id composer-terminal-to-agent. First click shows the
explainer; Got it acknowledges and navigates (avoids unmounting the
callout mid-read).
Part of tiann/hapi#2011
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): wrap-aware terminal extract and safe markdown fences
Preserve logical lines across xterm isWrapped rows (keep wrap-boundary
spaces) and size the excerpt fence longer than any backtick run in the
body so cat'd Markdown cannot close the block early.
Addresses review on tiann/hapi#2012.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): fixed session order display setting
Add a per-device "Fixed session order" toggle (default off, Settings →
Display → Sessions). When on, the session list freezes: sessions sort
by creation time only (newest first) instead of re-sorting on
activity, workspace groups stop jumping (ordered by the newest session
created inside them), and the pin-in-progress running buckets are
paused. Manual pins still float to the top; search-relevance ordering
is unchanged.
- shared: SessionSummary gains an optional createdAt (toSessionSummary
copies it). Older hubs simply omit it and the web falls back to
updatedAt, degrading to the legacy activity order.
- web: sessionOrder lib (stable key + comparator), useFixedSessionOrder
localStorage hook, useSSE cache sort via ref (no EventSource teardown
on toggle), SessionList in-group/group/machine sorts plus bucket
gating, settings switch, en/zh-CN/ru strings.
- tests: sessionOrder unit, useFixedSessionOrder hook, useSSE
fixed-order cache cases, SessionList grouping cases, settings page
switch, shared createdAt passthrough.
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(web): tie-break equal order keys on session id
Review follow-up: sessions created in the same instant compared as equal,
so their relative order came from the incoming array order — which the
hub may derive from activity, making tied sessions visibly swap after
every refetch. Tie-break on session id to make the comparator a total
order. Two tests cover the comparator and the refetch-stability view.
Co-Authored-By: Claude Code <noreply@anthropic.com>
* fix(web): tie-break equal group creation instants on the group key
Review follow-up: the directory- and machine-level sorts compared only
the groups' latest creation instants, so groups created in the same
instant followed the incoming array order and could shuffle after every
refetch. Route both fixed-order group sorts through a shared
compareDescWithKeyTiebreak helper that falls back to the group's stable
key (directory groups: machine::path key; machine groups: machineId).
Test covers both incoming orders of two tied groups.
Co-Authored-By: Claude Code <noreply@anthropic.com>
---------
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
* fix(codex): persist Round duration across session reloads
* fix(codex): preserve completed plan summaries between turns
* test(ios): make plan transcript recycling deterministic
* fix(ios): preserve reading anchors with stale sizing attributes
* perf(web): stop hidden and unchanged sidebar rows from re-rendering
Collapsed directory groups only hid their rows with CSS, so every row stayed
mounted, and SessionItem re-rendered whenever SessionList did. Machine
heartbeats replace the machines query every ~10-20s per runner, which
re-rendered every row (each with ~15 mutation observers) even though nothing
in the row changed.
- Unmount collapsed panel contents after the close transition ends; expand
still animates because rows mount before data-open flips the grid.
- Memoize SessionItem and pass a stable onSelect from the sessions route so
unchanged rows skip list re-renders.
With 1226 sessions / 47 collapsed groups against a live hub, idle main-thread
long tasks over 60s went from 7 (~390ms) to 0 and the sidebar DOM from ~4.9k
to ~1.5k nodes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): keep memoized session rows' time labels current
Memoized rows no longer re-render on every list render, so the relative
time and the Codex import marker label went stale. The row summary now
ticks every minute and subscribes to import markers itself; the list-level
subscription that only forced a full re-render is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): keep a collapsing row mounted while its dialog is open
A group can collapse on its own when its last active session disconnects.
Unmounting its rows then discarded an open Rename (unsaved name) or Export
dialog. Rows with an open menu or dialog now hold the panel mounted until
it closes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(acp): flatten Zed-style grouped configOptions choices
DeepSeek Harness 0.2.x (and Zed-style agents generally) nest select
choices one level under group entries that carry no `value` of their
own:
options: [{ group, name, options: [{ value, name }] }]
Both the per-session config-option descriptor and the model-metadata
merge previously read only flat `{ value, name }` entries, so grouped
pickers surfaced zero choices. Flatten recursively in a shared helper;
flat options are unaffected.
Co-Authored-By: Claude Code <noreply@anthropic.com>
* feat(dsh): runtime model switching through ACP config options
dsh advertises its model catalog as a Zed-style grouped config option
(provider groups with JSON-encoded provider+model tuple values) instead
of session/set_model, so wire it through the generic config-option rail:
- shared: ListDshModels/ListDshModelsForMachine RPC methods, response
types, and grant dsh the ModelChange flavor capability
- cli: DshMode becomes {model} and rides the queued-batch mode like
other flavors; the launcher applies model switches per-turn via
session/set_config_option, discovers the live current model from the
config option after newSession, rolls back (and warns) when a switch
is rejected, and serves ListDshModels from session metadata. A
machine-scoped probe (60s single-flight cache) backs the new-session
catalog; --model is now accepted by the dsh command
- hub: dsh-models passthrough routes (machine + session scope)
Co-Authored-By: Claude Code <noreply@anthropic.com>
* feat(web): dsh model selector for new sessions and in-chat switching
New-session form gets a Default-plus-catalog picker fed by the machine
probe (opaque tuple values verbatim, friendly names as labels, stale
draft picks dropped when the catalog rotates). Running sessions get the
same catalog over the session-scoped hook; switching rides the existing
generic model-change path now that dsh advertises ModelChange.
Co-Authored-By: Claude Code <noreply@anthropic.com>
* fix(dsh): honor explicit Default reset and cover pending catalog discovery
The launcher now captures the server default before applying any spawn
override, and a queued `model: null` (the picker's "Default" after an
explicit switch) switches back to it instead of silently keeping the
last applied model; DshMode keeps null distinct from undefined all the
way from the config RPC through the queue (runDsh no longer collapses
it). The web model-option helpers get explicit dsh branches mirroring
opencode: pending, failed, or empty ACP discovery renders no Claude
presets and the Ctrl/Cmd+M cycler keeps the current model. NewSession
restores dshSelectedModel from preferred launch settings alongside
agy/opencode (the existing catalog-validation effect drops stale tuples)
with remount, forward-on-create, and fallback tests.
Co-Authored-By: Claude Code <noreply@anthropic.com>
* fix(dsh): keep unset model preferences distinct from Default resets
Two review follow-ups:
- currentModel collapses "no preference yet" into null, so a session
started without --model on a server that reports no current model
queued model:null on every ordinary turn — the launcher read each one
as an explicit "Default" reset and emitted 'Cannot reset the DSH
model' every turn. The preference is now three-state (undefined =
nothing requested, null = picker's explicit Default, string = model),
encoded through dshModeForPreference() so preference-less turns carry
no model field at all.
- ListDshModels was registered only after initialize/session/new and
discovery finished; a picker opened during startup hit an
unregistered-method rejection with no retry path and stayed empty
until remounted. The handler is now registered before initialization
and parks on a readiness promise resolved once the session exists and
discovery has settled (rejected with the startup error otherwise).
Co-Authored-By: Claude Code <noreply@anthropic.com>
* docs: DSH advertises model selection over ACP
The capability blurbs still said HAPI exposes no model pickers for DSH and
that the ACP server has no model switching. Update them to the wire reality:
models are listed via ListDshModels and applied per turn with
session/set_config_option. Resume remains out of scope on this branch.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Code <noreply@anthropic.com>
---------
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
Claude Code writes failed API calls ("Prompt is too long", usage limits,
logged out) as synthetic assistant records whose usage carries
`service_tier: null`. The schema only accepted undefined, so the local
session scanner dropped every one and the web session just looked stalled.
- Accept null for the usage fields the Messages API types as nullable.
- Keep the top-level `error`/`isApiErrorMessage` on assistant records in
local mode, and `error` in the SDK converter for remote mode.
- Render assistant records carrying `error` as the existing error event,
so the all-zero synthetic usage no longer resets the context gauge.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(codex): expose Approve for me as a permission mode (#2016)
Add autoReview to the Codex permission catalog. It keeps the
workspace-write sandbox with on-request approvals and routes escalations
to Codex's native reviewer (approvals_reviewer = "auto_review"), the same
config as `hapi codex --approve-for-me`. It works at launch and mid-session
via thread/settings/update; explicit modes now reset the reviewer to user,
and sessions report autoReview from the native reviewer setting.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(native): expect Auto-review in the Codex permission lists (#2016)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(modes): label autoReview as Auto, matching Claude's auto mode (#2016)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): give the rich composer its own undo history (#2085)
Plain-text paste, selected-text deletion, cut, Enter, and mention or
suggestion insertion rebuild the contenteditable DOM, which the browser's
native undo stack cannot follow, so Ctrl+Z silently did nothing for them.
The composer now records a snapshot (serialized value + selection) before
every edit, groups consecutive native typing into one step, and handles
Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z, Ctrl+Y and beforeinput historyUndo/Redo
itself. Undo restores the prior selection, including ranges. An external
value change (send, draft restore) starts a fresh history.
Fixes#2085
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): keep composer undo snapshots as segments
Reparsing a serialized snapshot turned literal `[title](/sessions/<id>)`
text into a mention chip (and normalized its title) on undo. Store the
segments themselves so undo restores the exact draft.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): load one older page when the cold tail is tool-only
A cold session open fetches the latest 20 raw messages. When they reduce
to only ordinary tool activity, the thread showed a single collapsed tool
group until viewport-coverage loading ran after the 1.8s initial scroll
settling window.
Before committing the cold page, check its reduced/grouped blocks; when
they are tool-only and older history exists, fetch one 200-message older
page (bounded by a 500ms wait) and commit both together, keeping the
existing skeleton meanwhile. Timeout, failure, or an epoch change falls
back to the tail page. Cached re-entry and reset paths are unchanged.
Fixes#2045
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): limit tool-only context fetch to cold windows and ignore queued prompts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Archived sessions keep their stale pending requests in agentState. The
sidebar still treated them as live: the collapsed folder preview forced
them visible and trimmed active sessions to make room, so a folder could
look like it had no active sessions. They also kept the permission/input
attention dot and the "Pending N" badge.
Only count pending requests on active sessions for preview pinning,
attention, and the badge. The state itself is unchanged.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix: show Claude sessions waiting on background tasks (#2100)
backgroundTaskCount was always 0 for Claude: the hub parsed the
"Command running in background" tool result only from tool_result /
assistant records (Claude sends it as a user record), and waited for a
<task-notification> user record that current Claude Code never emits.
Set the count from Claude's system/background_tasks_changed snapshot,
which the CLI already forwards and which lists every running background
task (empty once the last one finishes).
In the session list, waiting on background work now shows a blue
spinner instead of a static blue dot (too close to the "needs input"
dot) or the green "running" pulse used for selected and pinned rows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(cli): forward background_tasks_changed snapshots to the hub
OutgoingMessageQueue only sends chat-visible records, so Claude's
background task snapshots never left the CLI. Forward that one hidden
subtype, and have the hub apply it as session state without storing a
hidden message row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): draw the background spinner with the thinking spinner code
Both row spinners now render through one SessionSpinner; background work
only switches the color to blue and no longer adds a separate Running label.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Paste only accepted image/* files, silently dropping PDFs, archives,
etc. copied from the file manager, while drag-and-drop and the attach
button accept any file. Remove the image-only filter from both paste
handlers so paste matches drag-and-drop. The scheduled-send guard is
unchanged.
via [HAPI](https://hapi.run)
Co-authored-by: HAPI <noreply@hapi.run>
* fix(web): stop session toolbar search/clear overlapping neighbors
Collapsed search used min-w-0 and crushed under shrink-0 icons, so clear
painted over filter/BlockedJumpPill on narrow widths. Keep search chrome
shrink-0 and wrap the header actions group instead (#2039).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): size session toolbar actions to content so they wrap
flex-1 min-w-0 kept the action group beside a long search chip in a 280px
sidebar, so Browse/Settings overflowed onto the calendar. Use w-max shrink-0
and keep the router header-action cluster from shrinking (#2040).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): keep session toolbar icons in one slab when the row wraps
max-w-full plus inner flex-wrap let the action group split, so some icons
stayed beside search and the rest dropped underneath. The group is nowrap
and content-sized, so a tight row moves every icon together.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): shed secondary session toolbar icons instead of wrapping
Quest was burning a full toolbar line when the icon slab did not fit
beside collapsed search. Keep one nowrap row; hide browse/settings (and
similar headerActions) below a container-query width, with a higher bar
when the query chip is present (#2039).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): satisfy typecheck for session header-row test
CI failed because optional chaining left headerRow possibly null when
reading className. Use getByTestId instead.
* fix(web): put shed session toolbar actions in a More menu
Hiding headerActions on narrow headers removed Settings with no
alternative. Keep one toolbar row, but surface secondary actions in an
accessible ⋯ popover below the container-query threshold (#2039).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): fold primary toolbar into More when query chip is active
At ~320px the collapsed chip plus unread/filter/New still overflowed past
More/Settings. When searchQuery is non-empty, put primary controls in the
same overflow menu so the row is [chip][⋯] (#2039).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): pack session toolbar actions beside search
ml-auto shoved the action slab to the right edge and left a Quest-width
gap between the search icon and BlockedJumpPill (#2039).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): widen machine menu clamp and width-gate toolbar More
MachineFilterMenu near the left edge (inside More) got a tiny right-only
maxWidth. Clamp to the wider horizontal pocket and left-align when that
side wins. Fold primaries into More only when a query chip is present and
the header is under 32rem (#2039 / #2040).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): fold toolbar into More on narrow headers without a query
Empty query still forced toolbarCrowded=false, so at 320px / 130% font
with machines+unread the More control went offscreen. Crowd from header
width in both query states (#2039 / #2040).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): stop Quest-width empty query collapsing to search+More
A flat 32rem crowd gate folded every primary into More on typical Quest
sidebars (~400px), leaving [search][⋯] and a desert of empty space. Empty
query now crowds only below ~22rem (still covers 320px/130% font); query
chip keeps the higher 32rem bar (#2039 / #2040).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): pin toolbar More to the trailing edge on Quest widths
Left-packing every action left a dead zone after New while Settings hid
in More. Primaries stay beside search; secondary/More get ml-auto on a
flex-1 actions row so the chrome reads [search][primaries] … [⋯]
(#2039 / #2040).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): shed session toolbar icons by keep rank
A single width gate either hid the whole row or left a hole before More.
Drop the lowest-priority controls first (browse, mark-all, settings) and
keep search, new, and the unread lens on the row while they fit (#2039).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): size toolbar shed from actions box, not a search guess
Active query reserved 200px for a max-w-[9rem] chip and collapsed the
row to search+More. Measure the actions flex box and each control;
keep daily icons until width is known (#2039).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): remeasure toolbar shed after search collapse
The actions box unmounts while search is expanded, so a one-shot observer
left the next layout on a stale width. Fit math now reattaches on the
new node, subtracts a visible machine menu, and uses the measured More
width and column gap.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): let query chip yield so toolbar More stays on-screen
At 320px and 130% type a long chip plus calendar left too little room
for the machine menu and More. An active query now uses min-w-0 on
search and shrink-0 on actions so only the chip text truncates.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): stop desktop toolbar using an infinite shed budget
Empty-query first paint treated the actions gutter as infinite, so every
shrink-0 icon stayed inline and painted past the split. Use a finite
fallback, cap the budget by the real row gutter, and give the sidebar
min-w-0 so --sidebar-w actually constrains the flex item.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): use one-gap slack instead of leaving an icon-sized hole
The shed refused the next icon when the gutter was only a column-gap
short, so the row kept an empty slot the size of that icon.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): fill the toolbar gutter before opening More
The shed was budgeting a guessed remainder, so More stayed open while
a wide empty stretch sat before the split. The budget is now the pixels
from the actions' left edge to the row's right edge. If every icon fits,
overflow is empty.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): size the toolbar budget from the flex leftover, not the icons
shrink-0 made the actions box as wide as whatever was already showing, so
search lost its clear and calendar and a collapsed query could hide every
action. The actions box is flex-1 again, and a query chip cannot shrink
below its icon, clear, and calendar.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): reserve machine menu and More before search width
A long 9rem query chip at 320px / 130% left the actions box under 50px
while machine menu + More need ~94px, so More slid off-screen. Cap the
collapsed search group to the row minus those trailing controls.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): 48px session search clear with calendar left of it (#2019)
Quest/touch: expanded search uses a min 48x48 clear end-cap as the
furthest-right control, with the calendar embedded immediately to its
left inside the field. Collapsed search stays shrink-0 with a larger
clear chip so it does not steal the toolbar row.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): anchor date picker to search group; let collapsed chip shrink (#2019)
HAPI Bot: mount the calendar popup on the search control (not the 48px
button) so left/right align stays on-screen at narrow widths. Drop
shrink-0 on the collapsed group so the query chip can yield space while
calendar/clear stay fixed hit targets.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(voice): configurable ElevenLabs max conversation duration
Auto-created ConvAI agents never set conversation.max_duration_seconds, so
ElevenLabs applied its 600s default and cut voice conversations off.
Add ELEVENLABS_MAX_DURATION_SECONDS (default 1800, validated 60-7200). The cap
is applied when the hub creates an agent and reconciled onto existing
hub-managed agents (found by name) once per process. Operator-supplied agents
(ELEVENLABS_AGENT_ID / voice map / customAgentId) keep their own cap. Invalid
values fail the token request with a clear message instead of an API 422.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* feat(voice): Settings UI for ElevenLabs max conversation duration
Extend the hub-owner credentials panel (Settings -> Voice -> manage
providers) with a numeric field for ELEVENLABS_MAX_DURATION_SECONDS,
reusing the same env-wins-over-Settings overlay that already backs the
ElevenLabs API key field. A Settings save takes effect on the very next
token request in the same process; the env var still requires a restart.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(voice): reconcile max duration on cached agents, not just once per process
Codex review on heavygee/hapi#230 caught this: getOrCreateAgentIdForVoice's
agentIdCache short-circuited before ever reaching ensureAgentOverrides, and
ensureAgentOverrides's overridesEnsuredAgents gate was a one-time-forever
marker that didn't account for the configured duration changing after the
first reconciliation. Together, any voice already used once in the hub
process would keep its old cap until a restart, contradicting the Settings
UI's 'takes effect immediately' claim.
Track the last-applied max duration per agent and compare it against the
live setting on every call, including agentIdCache hits, so a Settings
change reaches an already-cached agent on its very next token request.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(voice): address tiann/hapi#2017 review findings
- Fix TS2769 typecheck failure: test assertions casting the credentials
response narrowed the type to omit 'editable', which upstream CI's
bun typecheck catches and this fork's (currently broken) CI did not.
- An invalid ELEVENLABS_MAX_DURATION_SECONDS env value threw out of
buildMaxDurationStatus(), which is shared by every provider's
GET/PUT credentials status — breaking visibility into OpenAI/Deepgram/
Groq settings too, not just ElevenLabs. The token-issuance hot path
(getConfiguredElevenLabsMaxDurationSeconds) still throws there by
design; only the status/update endpoints now degrade gracefully.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(voice): interpolate duration range in the Settings hint
Nit from tiann/hapi#2017 follow-up review: the hint under the max-duration
field rendered the literal '{min}-{max}' placeholders instead of '60-7200'.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* feat(web): add Russian locale
- Add web/src/lib/locales/ru.ts
- Register 'ru' in the locale registry, i18n context, language switcher and settings
- Format outline timestamps with the day.month convention for 'ru'
* feat(web): detect initial locale from Telegram and browser
The Telegram Mini App runs in its own web view, so a language chosen in
the browser does not carry over and the app always opened in English.
Pick the first session locale from an explicit stored choice, then the
Telegram user's language_code, then navigator.language, then English.
* feat(web): translate the newly localized strings
Russian values for the keys added by the hardcoded-string fix (see the
separate web i18n PR). Keeping them on this branch so the locale PR is
complete when both land.
* feat(web): add Russian for the newly localized strings
* fix(web): recheck the Telegram language after a slow SDK load
* fix(web): keep a manual language choice over late Telegram detection
---------
Co-authored-by: mrudy <mrudy@local>
* feat(web): add Ctrl/Cmd+Shift+D dictate keyboard shortcut
Toggle composer dictation or voice assistant via the same path as the
mic buttons, with dialog/input guards and tooltip documentation.
Fixestiann/hapi#1900
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): address dictate hotkey bot findings on PR 1902
Ignore key repeat on the session listener and align assistant-mode
invoke guards with UnifiedButton send mode (canSend).
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): change_title updates metadata.name for visible renames
Agent change_title was writing metadata.summary only, so spawn --name
sessions kept showing the old title. Route explicit renames through
metadata.name (web rename semantics) across MCP, Codex, and Pi.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): voice context prefers metadata.name for session title
Voice bootstrap/full formatters only read summary.text, so after
change_title sets name the spoken context kept a stale title. Use the
same name→summary precedence as getSessionTitle.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: polish title rename tests after cold review
Rename the Codex root title test to match last-writer-wins behavior,
and skip the voice full-context summary header when no title signal
exists (avoid literal undefined).
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(kimi): discover configured models dynamically
* fix(kimi): address dynamic model review findings
Probe settlement (Major): parse `kimi provider list --json` on the child's
'close' event instead of 'exit', so stdio is fully drained first, and run the
parse inside a guarded settle() helper. A parser throw previously escaped the
promise from an async event callback and reached the runner's uncaughtException
shutdown handler instead of listKimiModelsForCwd's catch. The helper also
settles the promise exactly once across timeout, spawn error and close, names
the terminating signal when there is no exit code, and tolerates a kill() that
throws.
Running-session discovery (Minor): add a session-scoped Kimi catalog. The
shared RPC method `listKimiModels`, a CLI handler registered on the session
connection with the probe cwd taken from the session metadata (never from RPC
arguments), hub rpcGateway/syncEngine pass-throughs, and
GET /api/sessions/:id/kimi-models gated on an active session of flavor `kimi`.
SessionChat now reads the catalog from that endpoint, so an active session
advertises its models even when no background runner holds the machine socket.
New Session keeps the machine-scoped discovery unchanged, and model switching
still goes through the existing ACP set_model path.
* fix(kimi): register session model discovery before the local/remote loop
The session catalog handler was registered inside the remote launcher, after
ACP initialization. A session started with `hapi kimi` in local mode therefore
answered the web picker's request with an unregistered-handler error, and the
failed query was not retried or invalidated when remote control was taken over.
The catalog is backend independent, so it is now registered in runKimi session
setup alongside the other session-level handlers, using the session's working
directory, and the late registration in the remote launcher is removed. This
covers local, remote and resumed sessions on the same session connection.
Adds cli/src/kimi/runKimi.test.ts asserting the handler is registered after
bootstrap and before the loop, and that the probe cwd is the session's working
directory. The launcher wiring assertion moves out of
kimiRemoteLauncher.test.ts with the registration.
Links in a session open via window.open(_blank). For an installed
standalone-display PWA, navigating to a URL outside the manifest's scope
is a platform behavior (Chromium/Edge show it as a minimal overlay with a
close affordance), not custom code here — but closing it can mean the
underlying page had a real top-level teardown, not just an in-app remount.
useComposerDraft only ever persisted the draft in its effect's cleanup —
reliable for a React-managed unmount (switching sessions), but browsers
don't guarantee that cleanup runs to completion before a genuine
top-level navigation completes, so the save could simply never happen.
Extracts the existing save logic into persistNow() and calls it both from
the unmount cleanup (unchanged) and a new visibilitychange listener (when
document.visibilityState becomes 'hidden'), mirroring the pattern already
used elsewhere in this codebase (useSSE.ts, useAuth.ts, useSyncingState.ts,
usePwaUpdate.ts, useVisibilityReporter.ts) for the same class of problem.
Fixes#1882
Co-authored-by: Ian Stevenson <is@lockhouse.systems>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat(web): setting to open external links in a new tab
Left-clicking an external http(s) link renders it with no explicit
target, defaulting to _self. In an installed/standalone PWA this gets
intercepted by the browser and shown as a captive in-app overlay
instead of leaving the app; in a normal tab it navigates away from
HAPI entirely. Right-click already opens in a new tab, but that's not
a fair ask for every link.
Add an opt-in Settings > Display > Links toggle that forces
target="_blank" rel="noopener noreferrer" on genuine external http(s)
links only (relative/SPA hrefs and other schemes like mailto are left
untouched). Defaults to off, preserving today's behaviour, since the
overlay may be intentional/liked by some users.
Vibe Coding disclosure: implemented with Claude Sonnet 5 (Claude Code).
* fix(web): simplify link-toggle setting description per review
The description mentioned the target attribute (an implementation
detail, not user-facing) and asserted the in-app overlay is what every
installed PWA shows, which was only actually confirmed on Chromium/
Edge. Describe the user-visible behaviour only, without promising a
specific rendering across all installed-app environments.
* fix(web): stop promising specific behaviour when installed as an app
Per follow-up review: describing the setting as "always" opening links
in a new tab "even when installed as an app" still guarantees a
specific outcome across every browser's PWA handling, which isn't
something this change can verify or promise. State only what the
setting does (open in a separate tab), not how every installed-app
environment will render that.
---------
Co-authored-by: Ian Stevenson <is@lockhouse.systems>