Commit Graph
136 Commits
Author SHA1 Message Date
weishuandClaude Opus 5.5 b534a96f2f feat(ios): open notification taps on the owning hub
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>
2026-10-28 18:00:00 +08:00
weishuandClaude Opus 5.5 5942b427a9 feat(ios): rename, delete and reopen sessions
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>
2026-10-28 18:00:00 +08:00
AnanovoandGitHub 009e6d7799 feat(codex): allow historical forks while a turn is running (#2185)
* feat(codex): allow historical forks while a turn is running

* fix(web): fork user-only completed turns during generation
2026-10-28 12:15:11 +08:00
AnanovoandGitHub 575e995480 feat(hub): generate fallback titles after the first completed turn (#2075)
* 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
2026-10-28 12:14:59 +08:00
AnanovoandGitHub 577ab7546b feat(sessions): add explicit per-session title locks (#2072)
* feat(sessions): add explicit per-session title locks

* fix(sessions): preserve title locks across rename entry points
2026-10-28 12:14:48 +08:00
ffd950a220 fix(codex): drop safe-yolo from Codex permission modes (#2183)
* 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>
2026-10-28 12:12:55 +08:00
fe2fbb881d fix: list Claude background tasks from the snapshot instead of guessing (#2177)
* fix: list Claude background tasks from the snapshot instead of guessing

The status panel only had a count from `background_tasks_changed` and
reconstructed commands by regex-matching Bash results in loaded chat
history, so tasks whose start message was unloaded, auto-backgrounded
("moved to the background (ID: …)"), or non-Bash showed
"N running · exact command unavailable".

The hub now keeps the snapshot's tasks (id, type, description) on the
session alongside the count. The web panel lists them directly, using
the exact task ID to look up the Bash command when loaded and the
snapshot description otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): only attribute a Bash command from its background launch result

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(native): accept backgroundTasks in strict session patch decoders

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:12:41 +08:00
AnanovoandGitHub 78472e1c78 docs(web): document session list and search behavior (#1984)
* docs(web): document session list and search behavior

* chore: retrigger PR checks

* docs(web): clarify searched group pagination
2026-10-28 12:11:27 +08:00
AnanovoandGitHub 4b3c5a4baf docs(api): clarify session ordering and client parity (#1983)
* docs(api): clarify session ordering and client parity

* chore: retrigger PR checks
2026-10-28 12:11:16 +08:00
b450aa8236 fix(cli): discover DSH skills from the DeepSeek Harness home (#1916)
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>
2026-10-28 12:09:15 +08:00
weishu 3a9b5eefd1 docs: point users to the App Store and Google Play apps
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.
2026-10-28 10:32:21 +08:00
927b4971b9 feat(dsh): model selection for DeepSeek Harness (ACP config options) (#2030)
* 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>
2026-10-26 10:02:51 +08:00
cc1a09be0c fix(dsh): default ACP entrypoint to the official dsh CLI (#2031)
* 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>
2026-10-26 10:02:36 +08:00
62872d80fa feat(cli): HAPI_DISABLE_PEER_TOOLS — hide peer-session tools from wrapped agents (#2052)
* 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>
2026-10-26 10:02:06 +08:00
d3f7c107d5 feat(pi): display inline tool-result images (#2060)
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>
2026-10-26 10:01:55 +08:00
7b5beee6e7 fix(hub): mint a localId for user messages sent without one (#2069)
A REST send without localId was stored with local_id NULL. Shared Codex
then gave the turn a random clientUserMessageId and echoed the user
message back under it; the hub dedupes only on (session, localId), so
the echo became a second row and the message showed twice.

Mint a localId in MessageService.sendMessage when the request omits one,
so the stored row, the CLI delivery and the agent echo share an
identity. Such sends now take the normal queue/ack/cancel path, like
typed web sends.

Fixes #2068

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:01:19 +08:00
878ca2058c feat(codex): expose Approve for me as a permission mode (#2016) (#2090)
* 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>
2026-10-26 09:59:47 +08:00
AnanovoandGitHub cb2ff05c2a fix(codex): distinguish compaction waits from unknown delivery (#2115) 2026-10-25 15:34:52 +08:00
7b00df458c fix(voice): configurable ElevenLabs max conversation duration (#2017)
* 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>
2026-10-15 15:42:01 +01:00
MiroandGitHub b6a27bdc68 fix(kimi): discover configured models dynamically (#1873)
* fix(kimi): discover configured models dynamically

* fix(kimi): address dynamic model review findings

Probe settlement (Major): parse `kimi provider list --json` on the child's
'close' event instead of 'exit', so stdio is fully drained first, and run the
parse inside a guarded settle() helper. A parser throw previously escaped the
promise from an async event callback and reached the runner's uncaughtException
shutdown handler instead of listKimiModelsForCwd's catch. The helper also
settles the promise exactly once across timeout, spawn error and close, names
the terminating signal when there is no exit code, and tolerates a kill() that
throws.

Running-session discovery (Minor): add a session-scoped Kimi catalog. The
shared RPC method `listKimiModels`, a CLI handler registered on the session
connection with the probe cwd taken from the session metadata (never from RPC
arguments), hub rpcGateway/syncEngine pass-throughs, and
GET /api/sessions/:id/kimi-models gated on an active session of flavor `kimi`.
SessionChat now reads the catalog from that endpoint, so an active session
advertises its models even when no background runner holds the machine socket.
New Session keeps the machine-scoped discovery unchanged, and model switching
still goes through the existing ACP set_model path.

* fix(kimi): register session model discovery before the local/remote loop

The session catalog handler was registered inside the remote launcher, after
ACP initialization. A session started with `hapi kimi` in local mode therefore
answered the web picker's request with an unregistered-handler error, and the
failed query was not retried or invalidated when remote control was taken over.

The catalog is backend independent, so it is now registered in runKimi session
setup alongside the other session-level handlers, using the session's working
directory, and the late registration in the remote launcher is removed. This
covers local, remote and resumed sessions on the same session connection.

Adds cli/src/kimi/runKimi.test.ts asserting the handler is registered after
bootstrap and before the loop, and that the probe cwd is the session's working
directory. The launcher wiring assertion moves out of
kimiRemoteLauncher.test.ts with the registration.
2026-09-21 13:58:59 +08:00
8f9d6bd7f8 fix(hub): keepalive-only sessions reconcile to an idle lifecycle (#1820) (#1821)
* fix(hub): reconcile keepalive-only sessions to an idle lifecycle

`sessionCache.expireInactive` only expires a session when `activeAt` goes
stale, and `session-alive` refreshes `activeAt` every ~2s for as long as the
CLI socket lives. A session whose agent has done nothing for days therefore
stays `active: true` / `lifecycleState: running` forever — socket pinging was
being read as agent health.

Add the missing second signal. `active` keeps its meaning (the CLI is
reachable); `metadata.lifecycleState` moves `running` -> `idle` once the hub
has seen nothing but keepalives for `HAPI_SESSION_IDLE_TIMEOUT_MS` (default
12h, `0` disables), and back to `running` on the next real progress.

`active` is deliberately not flipped: the socket really is up, and a false
`active: false` would let `resumeSession` spawn a second agent against a live
process and would unlock dedup-merge / delete on it — the #1705 class of bug.

- `sessionCache.recordAgentProgress` tracks a progress clock that keepalives
  can never touch: messages in either direction, queued prompts, background
  tasks. Seeded from `updatedAt` so a hub restart re-derives it from disk.
- `reconcileKeepaliveIdle` runs on the existing 5s inactivity tick. Sessions
  that are thinking, running background tasks or holding a pending permission
  request are never marked; `metadata.idleReconcileExempt` opts out entirely.
- Only real progress clears `idle`, never `thinking` — Cursor ACP emits
  ambient idle/running churn (#1553) that would otherwise flap the lifecycle
  every tick.
- `isLiveLifecycleState` replaces the `=== 'running'` literals so an `idle`
  row does not read as dead to the archive route or the Cursor migrator.
- Web splits keepalive-only sessions into their own session-list bucket.

Refs #1820. Distinct from #842 (`active=false` with a stale `running` badge);
this is the inverse.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(hub): seed the idle clock from stored messages across hub restart

PR review on #1821: a cold `SessionCache` had no observed progress and fell
back to `updatedAt` alone. Assistant messages deliberately do not move
`updatedAt`, so a session that was streaming output a minute before a hub
restart read as hours idle and got marked on the very next tick — a false
`idle` that persisted until the next progress event.

Seed the clock once from `getNewestMessagePosition`, which is the durable
record of that output. `updatedAt` stays a floor on top of the seed: todos /
teamState / agentState writes bump it without routing through
`recordAgentProgress`.

Regression test recreates a SessionCache over the same store after recent
assistant output and confirms keepalives no longer cause premature idle
classification — and that the same session is still marked once the output
itself goes stale.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* test: cover the socket wake-up path and the idle session-list bucket

Follow-up review on #1821 reported no findings but flagged two uncovered
paths. Both have bad failure modes, so close them.

- Hub: an assistant message arriving through `registerSessionHandlers` must
  wake an idle session. Assistant messages never bump `updatedAt`, so the
  `onAgentProgress` hook is the only thing that can clear the mark — a
  refactor dropping that wiring would silently false-idle every actively
  working session, and nothing would have caught it.
- Web: extract the inline bucketing from the `runningSessions` useMemo into a
  pure `bucketRunningSessions`, matching how the rest of SessionList's logic
  is exported and tested. Covers idle vs quiet-but-ready separation, no
  double-bucketing, the transition back out of idle on thinking/background
  task/pending request, and the disconnected/pinned skips.

No behaviour change.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* test: cover the idle lifecycle at both isLiveLifecycleState call sites

Third review round reported no findings but named the two guards this PR
changed as uncovered. Both protect against reading a keepalive-idle row as a
dead one:

- Cursor migrator: an idle-marked row still has a live CLI behind it, so
  preflight must refuse it without `forceArchiveRunning`. A regression here
  would let the migrator transplant the ACP session directory out from under
  a running Cursor runner.
- Archive route: once a keepalive-idle session finally loses its socket it
  must stay archivable, rather than being stranded behind a 409.

Both tests were confirmed non-vacuous by reverting each guard to the
`=== 'running'` literal; each then fails.

No behaviour change.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* test: stop the restart regression leaking its temp DB on failure

The file-backed store's `rmSync` was a trailing call, so any failing
assertion above it skipped cleanup and left a temp DB behind — one per failed
run. Noticed while verifying the test was non-vacuous, which deliberately
fails it.

Wrap in try/finally. Verified: a forced failure now leaves zero
`/tmp/hapi-1820-*` directories.

Test-only.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-09-16 08:11:54 +08:00
AnanovoandGitHub 7b6c575514 fix(web): refresh cached session tails with a small page (#1847) 2026-09-16 07:26:18 +08:00
AnanovoandGitHub 88b037df10 docs(guide): document HAPI Markdown math delimiters (#1862) 2026-09-16 07:23:58 +08:00
weishu 733a960ede refactor(ios): simplify scratchlist UI and interactions 2026-09-14 13:19:21 +08:00
weishu ee46cd6fea feat(ios): integrate scratchlist with the chat composer 2026-09-14 11:55:46 +08:00
weishu 10ac8157bb fix(ios): improve manual pairing input and paste handling 2026-09-14 11:13:11 +08:00
AnanovoandGitHub b41b9293e9 fix(web): render cold sessions from a small initial page (#1809)
* fix(web): speed up cold session first paint

* test(web): update pagination fixtures for cold page

* test(web): wait for cold page before loading history

* feat(native): match cold session pagination

* test(native): cover cached re-entry page size
2026-09-14 09:07:14 +08:00
weishu 40b6ac462c fix: align directory browsing and iOS session creation 2026-09-14 08:44:02 +08:00
weishu 93d89c7cf4 fix(ios): keep latest button hidden after tool inspection at bottom 2026-09-13 19:48:14 +08:00
weishu e0db9c6815 fix(notifications): distinguish user questions from permission requests 2026-09-13 12:41:38 +08:00
weishu 917baf1201 fix(codex): sync shared plan proposals and web actions 2026-09-13 12:09:39 +08:00
weishu 2e37419a7d fix(ios): reduce back-to-latest button sensitivity 2026-09-13 11:46:10 +08:00
weishu 051dbbd7da docs: refresh native app guides and contracts 2026-09-13 10:43:59 +08:00
weishu 1de9613df6 docs: align documentation with current implementation 2026-09-12 22:19:32 +08:00
weishu 6f4aacce6b feat(android): enable default push through official relay 2026-09-12 17:52:53 +08:00
weishu 32ca6f543a feat(android): improve native chat reading and navigation 2026-09-12 16:56:41 +08:00
weishu d29713aeda fix(native): render plan proposals inline
Render ExitPlanMode and exit_plan_mode Markdown from input.plan in iOS and Android conversations. Preserve approvals, raw source and diagnostics while hiding empty output placeholders and prewarming plan documents.

Add generated protocol fixtures and native regression coverage for long plans, live updates, recycling, themes and typography.
2026-09-12 16:06:16 +08:00
weishu 0c7f557ba3 feat(ios): browse tool groups in a native inspector 2026-09-12 13:03:27 +08:00
weishu fc2fdfc25b feat(cli): add agent picker and remove Claude default
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.
2026-09-12 12:03:59 +08:00
weishu 0c4abcb3d1 feat(codex): share sessions across terminal and web
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.
2026-09-12 10:59:17 +08:00
weishu a729456682 fix(claude): answer local permission prompts from web
Bridge main-session PermissionRequest hooks without suppressing the native
terminal dialog. Reconcile replies against native results and clean up on
timeout, cancellation, mode switches, and session changes.

Keep reply IDs distinct from native tool IDs across web and native clients;
add protocol fixtures and regression tests.

Refs #1796
2026-09-11 18:54:47 +08:00
Junmo KimandGitHub fd2822bab5 fix(agy): allow switching existing sessions to newly available models (#1814)
* fix(agy): say what the model picker is actually waiting on

The spinner in the New Session AGY picker read "Checking Antigravity
authentication…", but nothing at that point checks authentication — the
machine is running `agy models`, and the sign-in prompt is a separate
branch below it, shown only when agy reports the failure.

Name the wait after the work: "Fetching available models…", the same
words agy prints while it fetches.

* refactor(agy): describe a probe by its outcome, not by its response

The probe function returned a finished `AgyModelsResponse`, so "agy could
not be reached" and "agy listed no models" both arrived as a successful
response carrying the hardcoded mirror, and the caller could no longer
tell which had happened. Every policy decision about that answer has to
live inside the probe as a result.

Hand back what the probe observed — a live catalog, an auth failure, or
nothing usable — and let the caller turn it into a response. Same
behaviour: the mirror still stands in for both failure modes, and the
60s cache still holds whatever came out.

* fix(agy): serve the model catalog stale-while-revalidate

The `agy models` probe is a whole agy invocation — around 3s on a good
day, 15s when it times out — and the 60s window meant the New Session
picker paid that again a minute after the last look.

Keep the last listing agy actually returned and answer from it: fresh for
ten minutes, then still answered while a probe refreshes behind it, until
the entry is a day old and stops standing in for the machine at all. A
probe that times out or loses auth leaves that entry alone, so a blip no
longer empties a working picker, and the hardcoded mirror is no longer
recorded as if the machine had reported it.

Three things fall out of that and are handled here. A machine whose
sign-in has actually gone bad would otherwise look healthy for a day, so
an auth failure rides along with the catalog it can still serve — and,
because nothing else would re-probe a catalog that is still fresh, a
warning riding on the answer is itself a reason to look again. A failed
probe is not repeated on the very next request either, or a machine where
agy hangs would spawn it once per poll.

An explicit refresh always costs a probe, and never rides one that was
already running when it was asked for.

* fix(agy): let Retry force a fresh model catalog probe

With the catalog held for ten minutes, Retry would otherwise hand back the
answer it was pressed to replace, so the intent travels to the machine:
`?refresh=true` on the machine route, an optional RPC param, and a
one-shot flag on the query so ordinary mount and focus refetches stay
cheap. Every hop is optional, so a hub and a runner on different versions
still talk — the older side ignores it and answers from its cache.

Retry also has to be reachable, and honest, in the state that needs it.
The machine now answers with both a usable catalog and the sign-in failure
behind it, so the picker keeps the list and says why it may be out of
date, with the button right there rather than only once there is nothing
left to show. Pressing it runs agy, which can take tens of seconds, so the
button says so while it does.

The client contract covers both: `agy-models` is the one catalog route
that can carry an `error` on a successful response, and the one that
takes a refresh parameter.

* fix(agy): use the machine catalog in the in-session model picker

New Session already asks the machine what `agy models` lists, but a
session that is already open offered the built-in list in
`shared/src/models.ts`. That list is a hand-maintained mirror, so a model
agy started offering after the last release could be picked for a new
session and not for the one already running.

Point the composer at the same machine catalog. The mirror stays as the
fallback for the moment before the machine answers, and a model the
session is already on is kept selectable — and readable, when it is one
of the known presets — even after the catalog moves on without it.

* fix(agy): announce a model catalog re-check that changed the answer

Serving the last known catalog answers the picker instantly, but a picker
that was already open kept showing that answer until the user closed and
reopened it — the machine had no way to say it had found something newer.

Say it on the stream that already carries machine changes. The machine
daemon — the only process that answers `<machineId>:listAgyModels` —
emits it, and the hub forwards it as `machine-agy-models-updated` with
nothing but the machineId. Namespace resolution, per-machine delivery and
reconnect replay all come from the existing path.

What counts as a change is what the route would answer, not what sits in
the cache. That distinction carries the cases: a sign-in that lapsed or
came back changes no models yet changes what the user is told; a machine
whose agy was signed out has been answering from the hardcoded mirror, and
its first real listing is the largest change there is, for every client
except the one awaiting it.

* fix(agy): re-read the announced machine's model catalog

On `machine-agy-models-updated`, cancel and refetch that one machine's
catalog query — the app's global connection is always subscribed, so an
open picker redraws wherever it is.

Cancelling first is what makes it correct rather than merely likely.
query-core cancels an in-flight fetch only when the query already holds
data, so a picker opening for the first time would otherwise join the
request already on its way and settle on the listing the announcement
replaced. The refetch is answered from the machine's cache, so it starts
no probe and cannot bounce another announcement back.

A reconnect the hub could not replay takes the resync path, which clears
the agy catalogs the same way — that path has no announcement to fall back
on, so it is the one that can least afford to join a stale request.

* fix(agy): keep a model the user picked when the catalog moves under them

The catalog can now change while the New Session form is open, and the
form dropped any selection the machine no longer advertised — including
one the user had just made.

Keep that one, and list it as no longer listed so the form does not imply
agy is still offering it. A model restored from a draft or a saved
preference is still dropped: it may never have been runnable here.

* fix(agy): announce uncached authentication changes
2026-09-11 14:19:37 +08:00
weishu 6c0ef32350 feat(ios): add native tool inspector and compact tool groups
Move tool output into a live sheet with group navigation and full-content copying. Keep lightweight grouped summaries inline, add agent process pages, and preserve transcript reading position.\n\nAdd real transcript and sheet regression coverage with localized summaries.
2026-09-11 11:30:13 +08:00
weishu 0dcc70e7c7 perf(native): optimize iOS transcript refresh and add frame profiling
Preserve hosted rows while propagating current SwiftUI environments and render captures. Add differential refresh regressions, opt-in real-clock iOS/Android profiling, and reproducible performance results.

Validation: typecheck; CLI/hub/web/shared/relay suites (Web rerun with NODE_OPTIONS=--no-experimental-webstorage for Node 26/jsdom compatibility); existing iOS Debug/Release and Android regression runs.
2026-09-10 16:22:36 +08:00
weishu c2e3d16b7e feat(native): improve anchored chat scrolling and history loading
Add viewport-driven paging with layout acknowledgements, bounded retries, cancellation gates, and epoch-safe history retention.

Preserve transcript anchors and expansion state, fix tool-group identity collisions, and serialize Android history coordination on Main.

Reduce per-scroll composition and layout work; add native regression tests, CI coverage, and profiling guidance.
2026-09-10 16:22:36 +08:00
weishu 98541c10ab fix(ios): add privacy policy links and clarify relay disclosures
Expose the public privacy policy from pairing and Settings, with a Simplified Chinese label. Document relay metadata, rate-limit state, operational logs, and deletion boundaries without inventing a retention period.

Validation: iOS Release simulator build, link UI checks, docs build, and staged diff checks passed. Full typecheck is blocked by existing Web assistant-ui export errors; the full test command stops at one unrelated piEventConverter CLI failure (2609 passed, 2 skipped).
2026-09-09 16:45:19 +08:00
AnanovoandGitHub 33f51fd0ab feat(web): add configurable PWA taskbar badge (#1759) 2026-09-09 09:28:57 +08:00
AnanovoandGitHub 17ee052d9a fix(web): preserve the visible chat window during rewind (#1766)
* fix(web): preserve chat window during rewind

* fix(web): scope rewind invalidation preservation

* fix(web): clear unknown rewind boundaries

* fix(web): deduplicate rewind invalidations

* fix(web): retain rewind dedupe history
2026-09-09 09:23:31 +08:00
matyasrathonyiandGitHub fa1caf7d71 docs: explain execution-host sleep in remote sessions (#1774) 2026-09-09 09:23:14 +08:00
3873e58496 fix(web): keep streamed reasoning/text block ids stable across snapshot rows (#1741)
* 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>
2026-09-06 14:20:19 +08:00