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>
* feat(pi): support Pi slash commands from HAPI web (compact/session/model/help)
Pi runs as 'pi --mode rpc' over piped stdio, so TUI slash commands typed in
web chat previously fell through to the LLM as plain text and silently did
nothing (notably /compact).
- shared: add Pi builtin slash command list (help/compact/session/model) so
the web / menu exposes them; web test updated to match
- cli: intercept Pi builtin commands in runPi's user-message path
* /compact [instructions] -> Pi compact RPC (120s timeout, works while
streaming; summary + token delta reported back as chat messages)
* /session -> get_session_stats formatted stats
* /model [modelId] -> list/switch via set_model
* /help -> supported-commands list
* other Pi TUI builtins (/tree, /export, /reload, ...) -> explicit
terminal-only notice instead of silent LLM pass-through
* unknown slash text still passes through (extension commands, skills,
templates keep working)
- gate the prompt pump with piCompactInFlight so queued prompts are not
rejected by Pi mid-compaction; buffer commands until ready like prompts
- ListSlashCommands RPC merges HAPI builtins with Pi extension commands
- tests: parser unit tests + runPi integration tests (compact execution,
streaming steer interception, failure reporting, FIFO blocking, model
switch, unsupported commands, slash list merge)
- docs: document Pi slash command support in docs/guide/agents.md
* fix(pi): address review findings on slash command lifecycle
- compact timeout: fail the session (indeterminate outcome, runtime lease
poisoned) instead of reopening the prompt FIFO into a possibly-compacting
Pi; pump only when cleanup has not been initiated
- special commands: release the cancellation reservation before executing so
a cancel landing mid-command is not acknowledged (hub would delete the
queued row while the command still runs)
- tests: drop the duplicated slash-command describe block; add focused tests
for compaction timeout with a queued prompt and cancellation during an
in-flight special command
* fix(pi): route slash commands through the prompt FIFO and reject ambiguous models
- slash commands now share the prompt FIFO with ordinary messages: a
/compact or /model typed after a queued prompt dispatches only after it
(and after the active turn settles), instead of jumping the queue from
the preparation chain
- the pump dispatches special entries out-of-band while piSpecialCommandInFlight
keeps the FIFO blocked; steer promotion refuses slash commands
- /model <id> prefers an exact provider/modelId match and reports bare IDs
shared by multiple providers as ambiguous instead of picking the first
- tests: FIFO ordering (queued prompt before /compact), steer-delivered
/compact queued until settle, ambiguous/qualified model selection
* fix(pi): keep /compact interruptible, honor extension precedence, require token boundary
- head-of-line /compact dispatches even while Pi is streaming (Pi's
compact() aborts the active generation itself); every other queued item
still waits for the stream to settle, preserving FIFO order
- discovered extension commands / prompt templates override same-name
builtins at message time, matching the slash-list merge precedence
- parsePiSpecialCommand requires a command-token boundary, so path-like
text such as /compact.md or /model/config stays an ordinary prompt
- tests: interrupt rule, extension collision, reserved-name path prefixes,
non-compact commands waiting for stream settle
* fix(pi): honor cancellation acknowledged during slash-command discovery
A cancel arriving while the chain awaits get_commands (cold cache) was
acknowledged via the preparing reservation but never re-checked, so a
canceled /compact could still execute. Re-check the cancellation marker
after discovery and drop the message before dispatch.
* fix(pi): qualify /model selectors and report failed slash RPCs once
- /model lists provider-qualified selectors (openai/gpt-5.2) so duplicate
bare IDs remain usable and copy-pasteable; current model is qualified too
- compact/set_model failures are owned by the awaited slash/config handlers:
the common response handler no longer emits the raw Pi error a second time
- tests: qualified listing with duplicate providers, single-message failure
reporting for rejected /compact and /model
* fix(pi): consume slash-command queue row at dispatch
Special commands (/compact, /session, /model, /help) are executed by HAPI
itself and never delivered to Pi as prompts. Consumption was deferred until
the command finished, so a /compact run — an LLM summarization pass that can
take minutes — left the row stuck in the web queued bar for its whole
duration, then surfaced as a sent message. Consume the row the moment
dispatch starts; failures still surface via the explicit event message.
* fix(pi): guard special-command dispatch against unexpected rejections
* ci: retry Codex PR Review after infra failure (proxy 503)
* fix(pi): keep session queued-thinking grace during /compact dispatch
The queued-thinking grace is session-scoped, so clearing it while
acknowledging a dispatch-time /compact row also drops the grace for any
prompt queued behind it. /compact keeps running for minutes without
toggling Pi thinking state, which would leave the web session looking idle
while compaction and the following prompt are still pending. Only the
fast, synchronous commands (/session, /model, /help) clear the grace.
* fix(pi): render compaction summary as a dedicated chat block
The manual /compact RPC result was reported as two plain message
events ("📦 Compaction completed (tokens: …)" + "📦 Compaction
summary: …"), which the web chat renders as tiny centered status
lines — unusable for a real summary payload. Emit a structured
compact-summary event instead (summary + token delta) and render
it as an independent block: header with the delta and the summary
markdown in a scrollable panel.
Also emit the same structured event when importing Pi session
files (compaction entries), and queue the event lossless like
other user-visible messages so a disconnect cannot drop it.
Verified: bun typecheck clean; bun run test exit 0 (cli 2481
passed, web 2451 passed, hub/shared clean); runPi/loop/apiSession/
piSessions/presentation suites green.
* fix(pi): address HAPI Bot findings on compact dispatch and import
- Track compaction as thinking for its whole duration: /compact runs for
minutes without a Pi streaming event, so the 15s queued-thinking grace
alone left the web session looking idle while compaction and any queued
prompts were still pending (updateThinkingState around the compact RPC).
- Imported Pi compaction summaries must use the event envelope
(content.type: 'event') like the live wrapper's compact RPC result; the
codex payload envelope is dropped by the web normalizer. Extend
CodexImportedMessageSchema with the event variant.
* fix(pi): /model retries discovery when the model cache is empty
Startup model discovery can be late or fail once; using only the cached
catalog made /model report valid models as unknown. getPiModels() falls
back to the get_available_models RPC on an empty cache, used for both
listing and switching.
* fix(pi): interrupt in-flight /compact on Abort; surface startup model rejection
- The Abort action no longer waits on the runtime-mutation lease when a
manual /compact is in flight (compaction can hold it for up to 120s,
blowing the 25s abort deadline and failing closed). It sends the abort
RPC directly so Pi cancels its compaction AbortController; the compact
RPC's 'Compaction cancelled' error is not double-reported as a failure
since Pi already emits the compaction_end(aborted) lifecycle event.
- A rejected detached startup set_model now emits a visible ⚠️ event into
chat instead of only a debug log, restoring the pre-existing behavior.
* fix(pi): close the Abort race when /compact is queued on the mutation lock
Abort previously assumed an in-flight /compact always had its RPC issued;
the command is marked active at queue dispatch, but the compact RPC is sent
only after the runtime-mutation lock is acquired. An Abort landing in that
gap acknowledged success while the compact RPC still ran afterwards.
Track the compact's rpcStarted/cancelled state: Abort cancels a not-yet-
started compact in place (the queued callback skips it), and interrupts a
started one via the abort RPC as before.
* fix(pi): persist provider-qualified selection after /model switch
The success path updated currentModel/currentProvider and keepalive with a
bare model ID, leaving metadata.piSelectedModel on the previous provider.
The web picker prefers that metadata for selection, context-window
resolution, and effort options, so a switch like openai/gpt-5.2 ->
azure/gpt-5.2 was invisible. Persist piSelectedModel with the full
provider/modelId pair on every confirmed switch.
* fix(pi): retire pending extension UI requests when /compact interrupts a turn
The streaming-interrupt path sent the compact RPC without cancelling
pending extension UI requests first, unlike the Abort path. Editor
requests have no timeout, so the web could stay stuck on a stale
input/permission card and a later answer could be routed to the aborted
turn. Cancel all pending requests (with a response) before compacting.
* fix(pi): fail closed when the direct compact-abort RPC times out
The in-flight /compact abort branch awaited the abort RPC without the
ordinary Abort path's timeout handling: an unanswered abort left the
compaction outcome indeterminate (the compact RPC keeps the mutation
lease for up to 120s) while the wrapper still looked live. Fail the
session on PiRpcTimeoutError, mirroring the standard abort fail-closed
path.
---------
Co-authored-by: swear01 <swear01@users.noreply.github.com>
* feat(sessions): add on-demand AI title suggestions
* fix(sessions): address title suggestion review feedback
* fix(web): ignore stale title generation results
* feat(voice): curate dictation credential presets to ElevenLabs, OpenAI, Groq
Groq transcription was already wired end-to-end (GROQ_API_KEY,
whisper-large-v3, standard mode), but the credential onboarding panel
listed five providers with no hint that Groq is supported, so mobile
users could not discover it.
- Curate Settings > Voice > Dictation credential presets to ElevenLabs,
OpenAI, and Groq (Deepgram / OpenAI-compatible remain fully supported
via env and stay listed when configured)
- Name the three presets in the empty-state and manage hints (en + zh-CN)
- Lock the curated list in with a web preset test, a hub route test for
the Groq whisper-large-v3 proxy, and shared provider-listing coverage
- Note the presets and no-restart save behavior in voice-assistant.md
Verified: bun typecheck (cli+web+hub) and targeted suites pass; full
test gate green except pre-existing load-sensitive runner stress tests.
* fix(voice): keep legacy dictation providers manageable when configured
HAPI Bot review finding (Major): curating the onboard panel to the three
presets made settings-managed Deepgram / OpenAI-compatible credentials
impossible to rotate or clear from the UI.
- Re-add deepgram / openai-compatible to the onboard provider list
conditionally when credentials exist, restoring update/clear controls
- Fall back to the first preset if the selected provider leaves the list
- Cover the conditional list in the preset test
* fix(voice): surface partial OpenAI-compatible credentials in onboard panel
HAPI Bot follow-up finding (Major): hub marks openaiCompatible.configured
only when both base URL and model exist, so api-key-only or endpoint-only
stored settings lost the UI path to rotate or clear them.
- Gate the openai-compatible onboard entry on any stored field (base URL,
model, or API key) via hasOpenAICompatibleCredentials()
- Cover api-key-only / base-url-only / model-only cases in tests
* fix(hub): govern runner capabilities so Cursor reopen soft-fails on skew
Hub↔runner protocol drift was reported as missing Cursor chat data when
cursor-chat-store-status was unregistered. Soft-fail reopen on probe errors,
advertise required machine capabilities, surface an unmissable upgrade banner,
and stop-runner when a newer CLI binary is already on disk.
Fixes#1084
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web,hub): make runner skew banner dismissible; gate auto-upgrade
Compact the out-of-date banner (minimize + 1h snooze + per-host Restart)
so it no longer blocks the session list. Auto stop-runner on skew stays
opt-in via HAPI_AUTO_UPGRADE_RUNNERS / autoUpgradeRunners (default off).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): tolerate full sessionStorage on skew banner minimize
QuotaExceededError from setItem aborted minimize before React state
updated, leaving the banner stuck over the session list. Persist to
memory when storage fails; only enable Restart when a newer CLI is
already on disk; clarify opt-in is stop-runner only, not package push.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): drop redundant autoUpgradeRunners; runners already self-restart
CLI version handoff already reloads the runner when the on-disk binary
mtime changes. Hub-driven stop-runner on skew duplicated that. Keep the
skew banner and manual Restart only as a stuck/disabled-handoff escape.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli,hub,web): runner-only caps ads; gate Restart on supervisor
Address #1108 bot Majors on the thin tip: terminal/lazy bootstraps no
longer merge CURRENT_MACHINE_CAPABILITIES into the machine row (only
asRunner registration does). Banner Restart refuses unsupervised hosts
so stop-runner cannot leave a detached laptop offline; supervised
runners advertise supervisedRestart via HAPI_RUNNER_SUPERVISED=1.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub,cli,web): clear sticky runner ads; docs SUPERVISED; i18n skew label
Omit-means-clear on runner registration so rollback cannot leave
supervisedRestart/capabilities sticky; always advertise boolean
supervisedRestart from asRunner. Document HAPI_RUNNER_SUPERVISED=1
and localize MachineSelector UPDATE REQUIRED.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): ingest GET /share?url=&text=&title= deep links
Native companions that cannot POST via Web Share Target can open the
same session picker by synthesizing the IndexedDB transfer client-side.
When id is present, the existing SW path still wins.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): preserve share deep-link whitespace; scrub content beside id
Keep GET content strings verbatim when non-empty (match POST form-data).
When id is present with leftover url/text/title, replace to ?id= only so
payload does not linger in the address bar. Query-param contract stays —
fragments would break shipped native companions.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): ingest /share deep links via URL fragment, not query
Shared url/text/title must not appear on the HTTP request line — hub
Hono logger (and any access log) records path+query. Native companions
open /share#url=&text=&title=; the client reads the fragment, scrubs it,
and continues with the existing ?id= picker path. Query validateSearch
keeps only id/error (Web Share Target redirect).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): keep /share hash ingest across StrictMode remount
Capture the fragment in useState and reuse a single putShareTransfer
promise so the first effect's scrub + cleanup cancel does not lose the
deep-link under React.StrictMode.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(web): fetch companion fileUrl into /share transfer files
Native shares cannot put binaries in the hash fragment. Companions host a
one-shot CORS URL and pass fileUrl/fileName/fileType; the share page fetches
bytes into the same IndexedDB files[] as Web Share Target POST.
* fix(web): cap share fileUrl fetch at the composer upload ceiling
Stream fileUrl downloads with Content-Length and body size checks matching
MAX_UPLOAD_BYTES so a crafted deep link cannot buffer unbounded bytes into
IndexedDB. Align native deep-link docs on the fileUrl hand-off vs POST.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web): cast streamed fileUrl chunks to BlobPart for tsc
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(cursor): bump hub thinking on ACP harness wake
When Cursor resumes after idle (notify_on_output / mid-idle ACP activity
or a permission request), flip thinking via the existing session-alive
keepalive so the hub list matches reality. Fixes#1470.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): emit thinking true/false edges for ACP harness wake
Address Codex Major on #1487: activity listener now reports idle as
false, and the launcher only keepalives on actual thinking transitions
so streamed chunks do not spam session-alive.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): reattach activity thinking listener after session/new remap
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(settings): onboard hub transcription provider credentials in UI
Env-only keys made dictation invisible; Settings can now add/edit/clear
hub-side credentials (masked), with env still winning as override.
Refs tiann/hapi#1384.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(settings): onboard voice-assistant backends alongside dictation
Same Settings credential surface now covers ElevenLabs, Gemini Live, and
Qwen Realtime (alias env pairs), not only transcription providers.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(settings): address PR #1392 Major credential onboard findings
Alias env locks, non-destructive Save (omit empty fields), and
owner-only settings.json permissions for hub-stored provider secrets.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(settings): harden credential onboard for second-pass Majors
Owner-namespace gate, stage-then-sync env after persist, and
per-field OpenAI-compatible editability under mixed env locks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(settings): serialize settings RMW and clear partial compatible creds
Per-file settings lock for concurrent credential PUTs, and Clear shown
for partial OpenAI-compatible entries (key/url/model alone).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(settings): serialize all settings writers via updateSettings
Route credentials, relay auth, generators, server settings, and CLI
token persistence through a locked RMW helper; reset Clear form state.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(settings): share cross-process settings lock with CLI
Extract withSettingsFileLock for hub+CLI, keep owner-only 0o600
rewrites, and race hub credential updates against CLI-style writers.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(settings): keep UI secrets out of process.env; PID-own settings locks
Settings-backed provider credentials now live in an in-memory overlay
(getProviderEnvironment) so tunnel/ACP/Codex children do not inherit them.
Settings file locks record pid+token and only reclaim dead or legacy locks.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(settings): never reclaim ownerless settings lock sidecars
wx creates the lock path before the owner JSON is visible; unlinking
null owners let a waiter steal a live acquisition and collide on
settings.json.tmp (CI ENOENT). Only reclaim parsed owners with dead PIDs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(settings): reclaim dead locks via rename; clean up failed publishes
Stale reclaim renames the sidecar to a unique break path and re-verifies
the expected dead owner before deleting it, so a loser cannot unlink a
successor's live lock. Failed owner writes unlink the wx sidecar.
Reclaim uses a sync owner read so contenders do not all observe one
dead owner across an await and race the exclusive create.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(settings): reclaim dead locks under exclusive reaper sidecar
Stale reclaim now takes a fixed settings.json.lock.reap lock, re-validates
pid+token, then unlinks — so a delayed contender cannot move a successor's
live lock aside. Also document providerCredentials in settings.schema.json.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(settings): fail closed on corrupt CLI settings; backoff busy reaper
CLI updateSettings now uses a strict read that rejects invalid JSON
instead of treating errors as {}, which could wipe providerCredentials.
Settings lock reclaim sleeps when another process holds .reap so retries
are not burned synchronously.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(settings): publish locks via candidate+link; fix CLI vitest hoist
Acquire settings locks by writing a complete candidate then linkSync to
the fixed path so a crash cannot leave an empty live sidecar. Fix the
CLI persistence regression test to create its temp dir inside vi.hoisted.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(settings): replace bespoke lock with proper-lockfile; hide tenant creds UI
Codex kept finding crash windows in hand-rolled lock sidecars. Switch the
shared settings lock to proper-lockfile's mkdir + mtime lease. Hide the
owner-only credentials editor from non-default namespaces on the voice page.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(settings): adapt sessionSummaryContract to outcome updateSettings
Rebase onto main brought #1376 unique tmp + outcome-shaped writers;
wire sessionSummaryContract and the write-failure credential test to match.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore: retrigger CI after rebase onto upstream/main
Empty commit — Meta reported no checks on da0c6c258 after tip-forward rebase.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(cli): MCP list_peers + runner hub auth inheritance
Runner-spawned agents could not discover same-hub peers without
sitting on the hub host or pasting a session id. Add MCP list_peers
(in-process credentials), export HAPI_API_URL/CLI_API_TOKEN after
auth init for shell fallbacks, and clearer auth failure hints.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): do not export default hub URL into HAPI_API_URL
exportHapiHubAuthEnv was writing the implicit localhost default into
process.env, which made maybeAutoStartServer skip starting the bundled
hub. Only export HAPI_API_URL when the URL came from env or settings;
always still export CLI_API_TOKEN. Also fill missing deliveryMode on
abort restore so web typecheck matches RawSendError (main tip unblock).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): widen initializeApiUrl mock return type in test
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): never export CLI_API_TOKEN; exclude self from list_peers
Keep settings/prompt-backed hub secrets out of wrapped agent env so
shell JWT+curl cannot bypass peer-tool approval. Fresh hapi re-reads
settings; env-backed tokens already inherit. list_peers omits the
calling session from the shortlist.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): resolve peer labels via summary/path like web titles
list_peers was showing (unnamed) for ordinary sessions because titles
live in metadata.summary.text. Match web getSessionTitle and collapse
whitespace so each peer stays one agent-readable line.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli,hub): emit full peer ids and honor GET /sessions?limit
Short 8-char prefixes collide across UUID namespaces; print full ids so
resolveSessionByPrefix stays unambiguous. Honor optional limit after sort
so listPeerSessions stops loading the whole namespace for scheduled counts.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): type sessions limit test mock as Map<string, number>
CI tsc rejected Map<string, null> for getNextScheduledAtBySessionIds.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli,hub): unbounded ping resolve; peer list order=updatedAt
Keep GET /sessions?limit only for discovery callers. ping/inspect omit
limit so full UUIDs outside the first 500 stay resolvable. Peer lists
pass order=updatedAt so truncation matches newest-first. Basename
fallback splits Windows paths.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): auto-approve ACP title List Peer Sessions
Permission derivation prefers request.title; match the MCP tool title
form so default-mode ACP sessions do not prompt on discovery.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): pad list_peers fetch; split hub URL vs token hints
Fetch limit+2 when excluding the caller so overflow still surfaces at
limit=100. Clarify that auth login only saves the token, not HAPI_API_URL.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): use boolean overflow for ping-peer --list
Match MCP list_peers: fetch limit+1 and mark hasMore instead of claiming
an exact omitted count from a 200-row sample.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(hub): tolerate mocked machineCache without expireInactive
CI flake: 5s inactivity tick hit test doubles that only stubbed
getOnlineMachinesByNamespace. Optional-call + stub the method.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>