* feat(cli): spawn-peer with required remit and MCP spawn_peer
Machine spawn creates an empty composer; stuffing message into that
JSON is stripped. Add hapi spawn-peer and MCP spawn_peer that spawn,
rename, deliver via the ping-peer path, and fail closed unless the
remit is on the new session. Reject message/prompt/text on machine
spawn with 400. Do not auto-approve spawn_peer in default/read-only.
Fixes#1509
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): reject overlong spawn-peer names before spawn
Hub rename max is 255; continuing past PATCH 400 left a live session under a different title. MCP/steer copy now says the hub 400s a spawn-body remit instead of stripping it.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): wait for fresh spawn-peer child instead of resuming it
Machine spawn can return while the hub row is still inactive. ping-peer then POSTed /resume, which can launch a second agent because the original spawn has no existingSessionId for runner dedupe.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): reject flavor-illegal permissionMode before spawn-peer
Codex + bypassPermissions (and Pi + any mode) used to reach the runner and fail after a child existed, or silently drop the mode. Check isPermissionModeAllowedForFlavor when both agent and mode are explicit.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): treat omitted spawn-peer agent as Claude for permissionMode
Hub and runner default agent to claude when spawn omits it. Validate permissionMode against that effective flavor so yolo/read-only fail closed before a child starts.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): resolve spawn-peer dir locally and archive failed children
Relative --dir values were forwarded unchanged, so the runner resolved them against daemon cwd. Remit delivery failures also left the new session running, so a retry spawned a duplicate. Resolve in the caller and best-effort archive the child before rethrowing.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): verify remit before archiving a failed spawn-peer child
A timed-out ping-peer POST can still land the remit. Check the transcript before archive, and only say the child was archived when the hub returns ok.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): swallow spawn-peer transcript GET errors and skip unapplied names
A thrown messages GET after spawn skipped archive and leaked a live child. Treat transport errors as a miss so the verify loop can retry, then clean up. Also do not return a requested name the hub never applied.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(spawn-peer): hub peerSpawnDefaults + model/effort spawn surface
Add hub settings peerSpawnDefaults (agent, permissionMode, per-flavor models)
with stock claude/yolo/sonnet fallbacks. spawnPeer resolves explicit args →
hub settings → stock, maps yolo to flavor equivalents, and forwards model/effort
into machine spawn. Settings UI under General → Agents; MCP/CLI optional overrides.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(hub): use cursor-valid permission mode in peer spawn defaults test
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): teach spawn_peer that omit permissionMode means yolo
MCP copy said "pass permissionMode only when the operator asked", so agents
omitted it and got hub/stock whatever was stored — often approval-gated
default. Clarify omit = yolo via peerSpawnDefaults/stock; pass only to tighten.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub,web): apply peerSpawnDefaults to machine spawn and New Session
POST /api/machines/:id/spawn now resolves omitted agent/permissionMode/model
from hub Settings, and New Session seeds from the same peerSpawnDefaults so
UI create and peer spawn share one authoritative default (stock yolo).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): NewSession hub seed respects sticky prefs + QueryClient in tests
Sticky localStorage last-used agent wins over hub seed; machine spawn still
applies peerSpawnDefaults server-side. Tests provide QueryClientProvider.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(web): mock CREATABLE_AGENT_FLAVORS for settings general peerSpawn UI
Co-authored-by: Cursor <cursoragent@cursor.com>
* test(web): stabilize NewSession tests after hub peerSpawnDefaults seed
Wrap remaining mounts in QueryClientProvider, pre-seed hub settings so a
late useQuery resolve cannot wipe in-test model picks, and use neutral
stock permission defaults in the fixture.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(spawn-peer): address tip Codex Majors for peerSpawnDefaults
Preserve explicit yolo booleans on machine spawn, fail closed when hub
defaults cannot load, validate permission modes against the resolved
agent, remap auto-approval across flavors, and stop late hub settings
from wiping New Session edits. Reject retired agents in spawn defaults
and restore the CLI executable build docs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(spawn-peer): address follow-up tip Codex Majors
Stop absent Yolo localStorage from beating hub defaults, track New Session
edits before late hub seed, reject incompatible machine-spawn permissions,
resolve MCP spawn_peer paths against the session working directory, and
hide unsupported Codex safe-yolo from Settings spawn defaults.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(spawn-peer): clear tip-2 CI and follow-up Codex Majors
Omit permissionMode for Pi/DSH, preserve explicit Yolo-off across delayed
hub seed, fix settings test mocks for launch permission helpers, and
restore unrelated CLI README lifecycle/ACP docs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(spawn-peer): tip-3 Majors for seed edits and launchable modes
Skip hub agent seed after permission edits, resolve inherited modes via
the launch catalog (AGY request-review fallback), preserve hub Cursor
models through preference restore, and restore Codex auth README nuance.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): tip Minors for Cursor/Grok hub permission seeding
Give Cursor the native permission select so Plan/Ask survive machine
spawn, and re-apply Grok hub permissions after agent-init resets.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(spawn-peer): tip Minors for Cursor select and hub seed remap
Map hub permission through resolvePermissionModeForFlavor, keep native
Default when agent is explicit without a model, and assert Cursor Plan/Ask Mode labels.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): tip Minors for delayed hub seed and Default model
Apply hub permission/model to unset fields even with a sticky agent,
preserve explicitly saved auto models, and seed OpenCode/AGY selectors
from hub defaults.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(spawn-peer): tip Minors for model edit sticky and clear override
Mark OpenCode/AGY model picks as edited so delayed hub seed cannot
overwrite them, and treat empty hub model values as clear sentinels.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(spawn-peer): tip Majors for models patch typing and remit page scan
Type the models patch as NonNullable and paginate transcript verification
oldest-ward so a busy child is not archived when the remit leaves page 1.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): tip Minor — keep agent after explicit model pick
Do not apply a late hub default agent when the operator already chose a
model; otherwise preference restore drops that selection.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(spawn-peer): tip Cursor YOLO migrate and Codex effort map
Bridge Cursor's saved YOLO toggle (incl. false) into native Default over
hub yolo, and send Codex/OpenCode --effort as modelReasoningEffort.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): tip Majors — scoped YOLO bridge and catalog fallback
Keep unsupported saved permission modes at Default instead of hub yolo,
and apply delayed YOLO migration only when the sticky bridge agent matches.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): drop rebase conflict markers in startHappyServer
Both sessionDisplayRename and spawnPeer imports are required after
rebasing onto upstream rename work; markers had slipped into tip.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): wrap Kimi NewSession tests in QueryClientProvider
Hub settings useQuery requires a provider; upstream Kimi catalog tests
rendered bare and broke after the spawn-peer rebase onto main.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(peer): rename-proof Parent UUID stamp on spawn remits
Orchestrated spawn_peer / spawn-peer remits now auto-stamp a durable
[title](/sessions/<uuid>) Parent chip (fail-closed in-session). MCP copy
forbids inventing titles; UUID stays identity after parent rename.
Refs heavygee/hapi#175
Co-authored-by: Cursor <cursoragent@cursor.com>
* style(peer): plain hyphen in Parent-stamp warning and test comment
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(peer): reject blank remit before Parent stamp
Codex review on #1922: stamping first turned empty remits non-empty and
bypassed the idle-session guard. Also only treat ## Parent blocks as
already-present so bare /sessions citations still get the chip.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(peer): gate New Session Create on hub defaults; require Parent chip
Codex tip review on #1922: block Create while getHubSettings is pending so
stock mount defaults cannot bypass restrictive hub peerSpawnDefaults; skip
Parent stamp only when ## Parent already has a markdown session chip; clarify
codex resume takes a native thread id.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(peer): resolve MCP spawn cwd; Agents settings section
Codex Major on #1922: spawn_peer relative directories now prefer the
launcher cwd (Codex --cd), then skillLookup, then session metadata.path.
Move peerSpawnDefaults UI into the unused Agents settings section.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(peer): pass session cwd to MCP spawn_peer; Agents settings section
Codex tip review on #1922: resolve relative spawn_peer directories from the
launcher working directory (Codex --cd), skillLookup, then metadata.path;
move peerSpawnDefaults UI into the Agents settings section.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(peer): skip archive on unread transcript; validate startingMode after hub agent
Codex tip review: do not archive a child when remit verification cannot read
the transcript; run AGY/DSH remote-only startingMode checks against the
resolved hub agent; update MCP bridge test expectations for workingDirectory.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(peer): treat exhausted remit pagination as unverifiable
Hitting the messages page cap with hasMore still true must not count as an
observed-empty transcript — that would archive a child whose remit may still
exist on older pages.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(cli): list HAPI_API_URL as optional with localhost default
Codex tip review: the README had moved HAPI_API_URL under Required even
though configuration defaults it to http://localhost:3006 (and settings can
supply a URL). Also drop trailing whitespace flagged by git diff --check.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(peer): re-verify empty transcript before archiving failed spawn
An earlier observed-absent page plus later unread GETs must not archive a
child whose remit may have landed after the first empty read.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(peer): never auto-archive on spawnPeer failure path
Timed-out remit POSTs and empty-read/archive TOCTOU races can still land
work on the child. Leave the session running with its id for inspection
instead of terminating an in-flight peer.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(peer): classify empty_session from final remit verify
An earlier empty transcript read must not stick as empty_session once
later message GETs become unavailable — report verify_failed instead.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(peer): recognize escaped brackets in Parent chip labels
Titles with ] were escaped in the chip label but the idempotency regex
stopped at the first raw bracket and re-stamped a second Parent block.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(peer): avoid Parent chip regex backtracking on backslashes
Exclude \\ from the single-char label alternative so escaped pairs have
one parse path — long nonmatching backslash runs no longer explode.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(cli): MCP/CLI spawn_peer --machine for cross-host remits
Wire optional machine (UUID or hostname via GET /api/machines) through
MCP spawn_peer and hapi spawn-peer into existing spawnPeer({ machineId }).
Skip local directory existence checks for cross-host targets to match the
estate wrapper; keep fail-closed remit verify.
Fixestiann/hapi#1931
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(peer): reject ambiguous spawn --machine label matches
Tip bot Major on #1922: hostname/displayName selectors that match more
than one online runner now fail closed with ambiguous + UUID guidance.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(peer): preserve cross-OS absolute dirs for spawn --machine
path.resolve on the caller OS was rewriting foreign absolute roots
(C:\… on Linux, /srv/… on Windows) before the hub spawn POST. Detect
POSIX or Windows absolute paths and only resolve relative directories.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(peer): resolve spawn dirs after local vs remote is known
Defer directory resolution until the target machine is selected. Remote
spawns still preserve foreign absolute roots; local spawns use native
path.resolve so Windows drive-less paths like \repo bind to the caller
drive.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): apply delayed DSH model defaults without replacing edits
* fix(web): preserve hub permission defaults across form remounts
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: weishu <twsxtd@gmail.com>
Persist and backfill the last user-message timestamp, and use it for deterministic session ordering across web and native clients while preserving manual pins.
Remove the fixed-order setting and guard ordering against transcript echoes, replayed messages, and stale REST/SSE updates. Add regression coverage and regenerate the SSE fixture.
Pin the build toolchain to Bun 1.4.4 so CI includes the darwin-arm64 signing fix required by macOS 27.
Reject stale optional platform packages before launching, document clean reinstall recovery, and add launcher regression coverage.
The iPhone Duo layout uses UIHingeInteraction, which only exists in
the iOS 27.1 SDK. CI ran Xcode 16.4 (iOS 18.5 SDK) on macos-15, so the
app build failed: #available is a runtime check and cannot hide a
symbol the SDK lacks. ChatView's iOS 26/27 modifiers would have failed
next.
Both jobs now run on the xcode-27 runner image (Xcode 27.1 by default),
the toolchain releases are built with. The READMEs state Xcode 27.1 as
the minimum.
Swift 6.4 also timed out type-checking a long `+` chain in
MultipartEncoderTests, so `swift test` failed to build; the expected
body is now joined from an array.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hub's fork, rewind and export routes had no iOS UI. The chat's
menu now offers:
- Fork Conversation: when the agent advertises forkCurrent, the
session is live and no Codex turn is running (the web gate). The new
session opens in place of this chat.
- Rewind or Fork…: a sheet listing loaded user messages that have a
native point in metadata.conversationHistoryPoints. Each row offers a
confirmed Fork from Here or Rewind to Here, gated like web: forks
need an idle turn except on Codex, and rewinds always need one. The
actions live in a sheet, not on the transcript bubbles, because those
keep text selection and cached row heights. After a rewind the tail
resyncs with a history reset. An ambiguous native boundary (409
ambiguous_native_boundary_fork_safe) offers a fork from the same
point instead.
- Export Conversation…: saves the hub's lossless JSON payload through
the Files save panel, using the web download's filename. A large
export asks before retrying with force; a 413 explains the limit.
Markdown export stays web-only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iOS showed scheduled messages but could not create them. A clock
button now sits next to the composer's attachment button. It offers
the web presets (in 5 min, 30 min, 1 h, 4 h) or a specific time up to 7
days ahead. A chip above the input shows the pending time; its × sends
normally again.
Presets resolve when the user taps Send, so "in 5 minutes" counts from
the send. Validation follows the web: a 30 s grace period for past
times and a 7-day maximum.
A scheduled send is text-only and queued. The hub rejects scheduledAt
with attachments or steer, so the clock is disabled while attachments
are present and the steer menu is hidden. The schedule clears once the
send dispatches. Sending reuses the existing optimistic path, and a
retry keeps the row's scheduledAt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Typing / in the iOS composer offered nothing; web and Android show the
session's commands. While the input is a lone /token, a list above the
card shows matching commands. A tap inserts "/name " so the user can
add arguments.
As on Android, the sources are metadata.slashCommands plus the
/slash-commands RPC. The RPC is fetched once, when the input first
enters slash mode, and its entries override bare names. A failed fetch
retries on the next /. There is no copy of the shared built-in table,
so the CLI stays the source of truth. Ranking follows web: exact,
prefix, contains, then near-miss typos. The list is hidden in
Scratchlist mode.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The iOS session list could only filter by machine. Web also has text
search, an unread-only filter and Mark All as Read.
- Search: a .searchable field ranks matches with the web relevance
rules, ported from sessionListSearch.ts via Android as
SessionListSearch. It searches title, worktree, summary, agent,
machine, path and id; every word must match, with */? wildcards.
Results keep the global/project pin sections, so the Pinned header
still applies.
- Filters menu:
- It now holds an Unread Only toggle that keeps the iPad's selected
chat listed.
- The machine section appears only with two or more machines, as
before; the menu itself shows whenever the hub has sessions.
- Mark All as Read advances the local last-seen watermarks after a
confirmation, as on web.
- Empty list: an empty search or filter result gets its own empty state
instead of "No sessions yet".
getSessionTitle moves into HapiKit so search and the rows share it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Push payloads do not name a hub, so a tap always opened the session
against the active hub. With several paired hubs, a session from
another hub then failed to load until the user switched hubs by hand.
When the active hub's list does not know the session, the tap now
looks it up with GET /api/sessions/:id. Hubs are tried in the same
active-first order, and 404/403 have the same meaning, as for the
notification actions. If another hub owns the session, the app
switches to it first; the new home screen then opens the chat. A miss
or an unreachable hub opens against the active hub, as before.
Single-hub installs skip the lookup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API wrappers existed but iOS had no UI for these actions, while
web and Android did. The session row's context menu and the chat's …
menu now offer Rename for every session. Active sessions keep Archive.
Inactive sessions get Reopen and a confirmed Delete instead of
Archive, which the hub rejects with 409 for them.
The store applies renames and deletes optimistically. A failed rename
refetches server state; a failed delete restores the row and the
cached detail. Reopen marks the returned session active right away.
When reopen returns a superseding id, the chat seeds the new window,
moves the draft and navigates to it, as send-resume already does. A
reopen from the list also moves the draft and opens the session. A
422 shows the hub's message with the missing fields.
On iPhone, an authoritative removal (delete, or archive elsewhere) now
pops that chat, matching the iPad split, which already clears the
selection.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(hub): generate fallback titles after the first completed turn
* fix(hub): recognize batched first-turn title prompts
* fix(hub): track heartbeat-only first turns for title fallback
* fix(hub): preserve title turn identity across restarts
* 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>
For the `dsh` flavor HAPI's `$` skill menu only scanned `~/.agents/skills`
and `<dir>/.agents/skills`, so skills installed under the DeepSeek Harness
home (for example `~/.dsh/skills/<skill>`, where the clawhub CLI installs
them) never appeared in the completion list even though DSH's own
skill-filesystem provider loads them.
Add the roots that provider uses, plus the `$DSH_HOME` override the other
supported agents already have: `$DSH_HOME/skills` for user skills and
`<project>/.dsh/skills` for project skills. The DSH integration (#1632) did
not extend the per-flavor skill roots, unlike the earlier per-agent
additions (#288/#482/#487 Claude and Codex, #1030 Grok, #1245 Copilot).
Verified with a temporary-home Red/Green check, three new unit tests, a real
DSH session in a Full test environment (its skills endpoint now returns the
previously missing skill), typecheck, build, and the Root
terminal-wrap-fidelity Playwright suite.
via [HAPI](https://hapi.run)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
The native iOS and Android apps are now published. Replace the
build-from-source install path with store links, mark the native apps as
paid and the Web/PWA as the free client, and add store buttons to the
website hero. Source builds remain documented for developers.
* 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>
* fix(dsh): default ACP entrypoint to the official dsh CLI
The default spawn command was `dsh-acp-demo` — the standalone legacy
demo package. Since `@deepseek-ai/dsh` 0.2.0 the ACP profile ships with
the main CLI, so `dsh --profile acp` is the maintained entrypoint and
the one a plain `npm i -g @deepseek-ai/dsh` install provides. The old
default required every self-hoster to also discover and pin the demo
package (whose npm `latest` tag is stale) before `hapi dsh` worked at
all.
- resolveDshAcpCommand defaults to `dsh --profile acp` when no DSH env
is set; any explicit `HAPI_DSH_ACP_COMMAND` keeps bare `[]` args so
custom wrappers never silently receive the official CLI's flags
- DEFAULT_COMMANDS/availability follow, so the PATH probe now checks
`dsh` itself
- the demo package remains fully supported via explicit
`HAPI_DSH_ACP_COMMAND=dsh-acp-demo`; docs updated accordingly
Co-Authored-By: Claude Code <noreply@anthropic.com>
* Apply config-only DSH overrides through the official CLI's patch overlay
HAPI_DSH_ACP_CONFIG without HAPI_DSH_ACP_COMMAND fell through to the
default official CLI but passed `--config <path>`, a flag the official
dsh CLI does not have — and dropped the required `--profile acp`, so the
boot would run the wrong entrypoint with an unknown flag. A config-only
override now composes the path as a `--patch` overlay on top of the ACP
profile (verified against dsh 0.2.0-rc.2 --dump-config). The `--config`
form remains available for legacy community servers pinned via
HAPI_DSH_ACP_COMMAND, and both READMEs now document the split.
Co-Authored-By: Claude Code <noreply@anthropic.com>
---------
Co-authored-by: Claude Code <noreply@anthropic.com>
* feat(cli): add HAPI_DISABLE_PEER_TOOLS to hide peer-session tools from agents
Wrapped agents with peer tools can browse the session list and message
other sessions, polluting peer contexts on busy runners. Setting
HAPI_DISABLE_PEER_TOOLS=1 (true/yes also accepted) in the runner
environment now removes the peer surface entirely instead of
advertising it and refusing:
- the session MCP server does not register list_peers / inspect_peer /
ping_peer, and they are dropped from the toolNames consumed by
Claude --allowedTools and the codex/generic bridge --tools lists
- the stdio bridge filters peer names even when a stale --tools value
lists them explicitly
- flavor system prompts (claude/codex/opencode) swap the peer steer
block for a short notice that keeps the hub-path-is-not-a-file guard
while pointing citations at the user
- the peer core (pingPeer/inspectPeer/listPeerSessions) refuses with a
new 'disabled' error code before any network call, so shell
`hapi ping-peer` / `inspect-peer` from inside a wrapped agent fails
loudly as defense in depth
Operators scope the variable to the runner (systemd/launchd/guard env);
an interactive terminal without it keeps full CLI access.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Code <noreply@anthropic.com>
* test(cli): cover peer-tool prompt steer and default bridge tool list
Adds regression tests for the two surfaces the kill switch mutates beyond
the explicit --tools path:
- happyMcpStdioBridge: default tool list (no --tools) keeps the peer tools
as a positive control, then HAPI_DISABLE_PEER_TOOLS=1 filters them out
of the same default seed.
- New cross-flavor prompt test: the citation steer baked into the
claude/codex/opencode prompt consts swaps to the disabled notice under
the env flag and keeps the flavor-specific "Call \"<tool>\"" instruction
by default (module consts evaluate env at import, so each mode is
imported fresh via vi.resetModules + dynamic import).
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Code <noreply@anthropic.com>
* test(cli): keep peer-tools env set through prompt assertions
Keeps HAPI_DISABLE_PEER_TOOLS applied until after the assertions and
restores it at test end. Some upstream shapes resolve the citation steer
at call time (getSystemPrompt(env)) rather than baking it into the
import-time const, so restoring the env inside the import helper made
those shapes observe the enabled steer and fail.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Code <noreply@anthropic.com>
---------
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: Claude Code <noreply@anthropic.com>
Build on tiann/hapi#1831, retain bounded image reads, and support current
Pi nested and failed tool results through the existing generated-image path.
Keep conversion synchronous to preserve transport ordering without a new
publication queue.
Co-authored-by: Debin <luodeb@outlook.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>
* 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>
Separate top-level help and version flags from agent arguments. Require explicit agents in scripts and preserve command argument boundaries.
Validation: bun typecheck, bun run test, targeted runner integration tests, and PTY/source/compiled argv smoke checks.
Use one native app-server for terminal, Web and phone clients while retaining the existing CLI and Runner lifecycle.
Synchronize native queues, permissions, question history and steering state; preserve explicit permission precedence and per-turn usage models. Resume inactive clear commands through Runner and reject independent child cold resumes.
Add shared-runtime regression tests, generated protocol fixtures and lifecycle documentation.
* fix(web): keep streamed reasoning/text block ids stable across snapshot rows
Streaming snapshots of one stream (pi/codex reasoning and text) arrive as
separate message rows, and the window store retires older rows as newer
snapshots land. The timeline derived the block id from whichever row was
first seen, so the id (and the threadMessageId built from it) churned on
every snapshot, remounting the rendered reasoning panel mid-stream and
replaying its open animation — the panel visibly flashed/re-rendered on
every snapshot tick.
Derive the block id from the stream id when present (unique per stream,
stable across snapshot rows) so the block is updated in place and the
smooth streaming keeps appending to the previous text. Row-derived ids
remain the fallback for content without a stream id.
Also rerun gen:fixtures to refresh the two golden fixtures affected by
the new id shape.
* fix(ios,android): mirror stream-stable block ids in native chat ports
The native HapiKit (Swift) and protocol (Kotlin) chat pipelines are ports
of the web reducerTimeline and are pinned by the same golden fixtures in
shared/fixtures/chat. After the web-side change to derive streamed
reasoning/text block ids from the stream id, the ports still produced
row-derived ids, so the iOS/Android fixture conformance suites went red
on the two refreshed fixtures.
Apply the same streamId-first id derivation (row-derived fallback kept)
to both ports so all three pipelines project identical block ids.
* fix(web,ios,android): reject blank stream ids as block identity
Blank ('' or whitespace-only) stream ids are not streams per the wire
semantics in shared/src/messages.ts (readReasoningStreamId trims before
accepting). The previous nullish fallback let accepted payloads carrying
blank ids through, so every such row shared one empty block id: the
merge maps collided and assistant-ui occurrence suffixes churned with
list position, reintroducing remounts.
Normalize with a trim guard in all three pipelines (web, HapiKit,
protocol) and add a web regression test covering both empty and
whitespace-only ids.
* fix(ios): use normalized stream id for block construction identity
The blank-id guard was applied to lookup and map insertion but block
construction still read the raw optional, so accepted payloads carrying
blank/whitespace ids produced blocks sharing one blank SwiftUI identity
instead of falling back to row-derived ids (web/Android already used the
normalized local). Hoist the nonBlankStreamId result and reuse it for
lookup, block identity, and insertion in both the text and reasoning
branches.
Also add native coverage for stream identity: stream-id derivation for
text/reasoning plus blank ('' and whitespace-only) fallbacks, which the
golden fixtures do not exercise.
* fix(web): pin blank stream-id identity contract in golden fixtures
Update the two stale fixture descriptions (stream-keyed blocks are now
keyed by the stream id, not the first message) and add a generated
conformance fixture covering empty and whitespace-only codex data.id
values for both reasoning and text: blank ids are not stream identities,
so each payload keeps its own row-derived block id instead of collapsing
onto a shared blank identity. Web, iOS, and Android all run this same
golden fixture.
* feat(hub): make title provider max_tokens and timeout env-tunable
Reasoning models used as title providers (e.g. GLM thinking models) need
more than 64 completion tokens and more than the hardcoded 10s timeout to
emit a title, and the only workaround was patching the compiled binary
after every install.
Expose both knobs via HAPI_TITLE_PROVIDER_MAX_TOKENS and
HAPI_TITLE_PROVIDER_TIMEOUT_MS, following the existing
HAPI_TITLE_SUGGESTION_RATE_LIMIT pattern; defaults are unchanged.
* docs(hub): document title provider max_tokens/timeout env knobs
Add the two new HAPI_TITLE_PROVIDER_* variables to the title-provider
configuration table in the installation guide, and extend the provider
test to cover the timeout abort path (the signal fires and rejects the
in-flight request).
---------
Co-authored-by: HongChenGG <HongChenGG@users.noreply.github.com>