* fix(codex): drop safe-yolo from Codex permission modes
Every Codex session runs on the native shared runtime (runCodex is
runSharedCodex), which rejects safe-yolo. The mode stayed in
CODEX_PERMISSION_MODES and six filters hid it again in the web picker,
composer, hub permission-mode route, hapi set-mode and both native
apps. API callers that validate against the flavor catalog (e.g. a
spawn with permissionMode: 'safe-yolo') still passed it through and
only failed inside the runner.
Remove it from the Codex catalog (shared, iOS, Android, fixture) and
delete the filters. autoReview is the native replacement. Safe Yolo
stays for Gemini, Kimi and Copilot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(ios): Codex option list now has four modes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix: list Claude background tasks from the snapshot instead of guessing
The status panel only had a count from `background_tasks_changed` and
reconstructed commands by regex-matching Bash results in loaded chat
history, so tasks whose start message was unloaded, auto-backgrounded
("moved to the background (ID: …)"), or non-Bash showed
"N running · exact command unavailable".
The hub now keeps the snapshot's tasks (id, type, description) on the
session alongside the count. The web panel lists them directly, using
the exact task ID to look up the Bash command when loaded and the
snapshot description otherwise.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): only attribute a Bash command from its background launch result
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(native): accept backgroundTasks in strict session patch decoders
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(claude): apply mid-turn switches to auto/plan in the running process
Claude enforces auto and plan natively, so switching a remote session from
Manual to Auto during a turn only took effect after the next message
restarted Claude; the running turn kept asking for approval. Send
set_permission_mode to the live process whenever the native mode changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(claude): surface refused or fallen-back permission modes
Claude rejects set_permission_mode when auto is unavailable (e.g. disabled
by settings) and silently starts in default when launched with an
unavailable auto/plan. Both left the UI showing a mode that was not in
effect. Revert the session to the mode Claude actually runs and post the
reason in the chat.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(claude): ignore permission mode rejections superseded by later switches
A delayed rejection of auto could restore an older mode after the user had
switched away and back again, because staleness was checked by mode value.
Track each transition with a sequence number instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ping-peer now sends a ping-peer- localId and reports hub-accepted instead
of delivered; the hub exempts those rows from the session-end synthetic
sweep so only a real runtime ACK confirms them; web shows pending vs
delivered badges only on peer rows, excluding failed, indeterminate and
scheduled states.
* fix(runner): prevent Windows process probes from blocking session lifecycle
* perf(cli): reduce Windows process probes during session shutdown
* test(cli): update PowerShell fixtures for complete process snapshots
Content growth and mobile-keyboard resizes increase the distance from the
bottom without any user gesture. handleScroll treated that as the user
leaving the tail and disabled auto-follow, so the session feed stopped
following new messages until it was scrolled down manually.
While tail-follow is active, treat a non-upward scroll as layout churn
and re-pin to the newest message instead of dropping out of tail mode.
Add regression tests for transcript growth and viewport shrink.
GET /api/sessions runs countFutureScheduledBySessionIds and
minFutureScheduledAtBySessionIds over every session id in the
namespace. No existing index matches the full predicate set
(invoked_at IS NULL AND local_id IS NOT NULL AND scheduled_at IS
NOT NULL AND scheduled_at > ? AND delivery_state = 'queued'), so
SQLite probes idx_messages_local_id — one pass over every local
message, twice per session-list refresh, even though the matching
set is almost always empty.
Add a partial index on (session_id, scheduled_at) covering every
constant predicate (schema v26→v27, base schema + step migration),
turning both lookups into a near-empty index range scan that also
serves the single-session variant. Measured on a long-lived hub
DB (~20k messages, 37 sessions): ~8-10ms per query → ~0.04ms.
Co-authored-by: Claude Code <noreply@anthropic.com>
* feat(shared): derive Claude model choices from the CLI catalog
The choices are the rows of the claude CLI's own /model picker, in its
order and with its display names. The stored value is what
claude --model receives: an alias is resolved again each time the
session's claude process starts, a full id stays on its model, so
following and staying need no new field.
Effort support is read per model so a model switch can drop an effort
level the next model does not offer.
* feat(cli): discover Claude models from the CLI's initialize response
A machine RPC starts claude in stream-json mode, sends only an
initialize control request and reads the models it reports, so no turn
runs and no transcript is written.
claude --print runs a directory's project hooks and .mcp.json servers
without a trust prompt, so the probe starts from the home directory
with user settings only, no MCP servers and hooks disabled.
Successful catalogs are cached per claude executable for five minutes
and concurrent requests share one probe; failures are not cached.
* feat(hub): expose the Claude model catalog per machine
GET /machines/:id/claude-models forwards to the runner's
listClaudeModels RPC. A runner that predates the handler answers with
the stable rpc_target_missing code so clients can keep their built-in
list.
* feat(web): offer the machine's Claude models in the model pickers
The composer and the create-session form list the rows the machine's
claude CLI shows in its own /model picker, as it shows them. Built-in
presets the catalog does not list remain selectable after it.
Until the catalog arrives, or on a runner without discovery, the
built-in presets are shown as before. A saved Claude model is no longer
reset to Default when the built-in list lacks it; the create-session
form keeps it and marks it as no longer listed when the catalog
dropped it.
* feat(hub,web): keep Claude effort within what the selected model offers
For Claude sessions the model endpoint accepts an optional effort, so a
model switch and the effort it keeps reach the session as one config
change. Other flavors are refused: Pi, for one, commits the model
before its thinking level. When the machine's catalog says the next
Claude model does not offer the current effort, the composer clears it
in that same request. The create-session form drops such an effort
however the model was set, including a restored preference or draft.
Effort choices in both places narrow to the selected model's levels;
without a catalog every level stays available as before.
* fix(web): retry Claude model discovery after a failed probe
The machine answers a failed probe with success:false over HTTP 200.
The query cached that as an answer for its stale time, so a user who
signed in and reopened a picker within a minute still saw only the
built-in models. A failed probe is now a query error, which is not
kept, and the next picker to mount asks the machine again.
* fix(web): list only the Claude CLI's models once its catalog is known
The built-in presets were kept after the catalog so that a partial list
could not hide Opus. The catalog is the CLI's own /model list, though,
not a partial one, and the presets added entries the CLI does not show
(Sonnet 1M, Opus 1M, Fable 1M). With a catalog the list is now exactly
its rows; a saved model it does not list stays as the current model,
and New Session marks it as no longer listed. Without a catalog the
built-in presets are shown as before.
* fix(shared,web): list earlier Claude versions under Other models
Claude Code's model sheet shows each family's newest model first and
every earlier version under an "Other models" heading. The CLI's
catalog lists models in that order but does not say where the split
is, so the first row of each family is taken as its newest and later
rows of the same family as earlier versions.
The composer's model list and New Session's model select now show the
same two groups, keeping the CLI order within each. Nothing is folded:
every row stays one click away, as it does in Claude Code.
* test(web): pin that Claude 1M variants come from the CLI catalog
Claude Code 2.1.105, 2.1.200, 2.1.240 and 2.1.274 list a 1M choice as
its own catalog row (opus[1m], claude-fable-5[1m]); 2.1.303 and 2.1.310
list none, and on 2.1.310 opus and sonnet run with a 1M context window.
The catalog-only list therefore offers a 1M variant exactly when the CLI
does. Pin that and record why the built-in [1m] presets are not appended.
* feat(cli): report the CLI process start marker in session metadata
The session webhook only names hostPid. A PID alone does not tell the
runner which process generation reported it: once the CLI exits, the OS
can hand the same PID to any other process, including another
runner-spawned CLI.
Add metadata.hostStartMarker, the reporting process's own start marker
(getProcessStartMarker(process.pid), the same probe the runner uses for
its resume records and the stop-session generation check).
buildSessionMetadata is the only place that sets hostPid, so every
bootstrap path (fresh, existing, lazy, shared Codex) reports it. The
runner uses it to decide whether an untracked runner webhook may be
adopted (decideUntrackedRunnerWebhook).
* fix(runner): adopt untracked runner webhooks only from the reporting process generation
Since #1911 the runner adopts an untracked `startedBy: 'runner'` webhook
(runner restart before the webhook; Claude has no HAPI id on argv) and
persists getProcessStartMarker(pid) of whatever currently owns hostPid,
so that a later stopSession can tree-kill that generation.
If the webhook is late - the reporting CLI has exited and the OS has
already reused its PID - the persisted marker belongs to a foreign
process, possibly another runner-spawned CLI. The generation check in
stopSession then passes and the foreign process tree is killed.
The webhook now has to prove the generation itself: the CLI reports its
own marker as metadata.hostStartMarker and decideUntrackedRunnerWebhook
adopts only when it matches the live PID. A marker that names another
generation, or a failed probe, is ignored without killing anything,
also on the webhook-timeout path: a proven mismatch means the PID is not
the ghost. A webhook without a marker (older CLI) is left untracked
instead of adopted blindly; the timeout kill for those CLIs is unchanged.
The integration test drives a real runner over its control server: a
webhook carrying the live marker is adopted, listed and stoppable; a
webhook with a stale or missing marker leaves the PID untracked and
alive, and stop-session reports unknown.
Follow-up to #1911 (#1910).
* fix(cli): probe the process start marker only for runner-started sessions
buildSessionMetadata probed getProcessStartMarker(process.pid) on every
bootstrap, terminal starts included. The runner reads
metadata.hostStartMarker only in the untracked `startedBy: 'runner'`
path (decideUntrackedRunnerWebhook); a terminal webhook never consumes
it, and neither do hub or web.
The probe is a synchronous spawn: `ps` on POSIX, `tasklist` plus a
PowerShell Get-CimInstance call on Windows, each with a 10 s timeout.
A terminal `hapi` start on Windows therefore blocked on a PowerShell
cold start before the session row existed, for a value nobody reads.
With shared Codex the probe also ran per /new and /fork on the event
loop shared by all sibling roots.
Set hostStartMarker only when startedBy is 'runner'; that is the one
case the runner evaluates. This narrows the earlier commit: every
bootstrap path reports the marker for runner-started sessions, not for
terminal ones.
* fix(codex): keep hostStartMarker in the shared runtime's runner webhook
The shared Codex runtime bootstraps its roots with reportStarted: false
and reports them to the runner itself, from root.session.getMetadata().
That is the hub's view of the metadata: the hub parses stored metadata
through MetadataSchema, so a hub that predates hostStartMarker strips
the field and the webhook arrives without it. The hub's update-metadata
merge never returns it either, so the loss lasts for the root's
lifetime.
With the generation check, an untracked runner-started webhook without
a marker is ignored instead of adopted. A runner restart (for example
the version handoff of a single-host upgrade, where the hub process
keeps running the old code) followed by /new or /fork in a long-lived
shared root therefore left the root unlisted and untracked, the very
situation the adoption from #1911 addresses. The Claude and lazy
bootstrap paths are unaffected: they report the local metadata.
Take the marker from the local bootstrap metadata instead. The opt-in
runtime integration test models the stripped hub view in its session
mock and asserts the reported payload still carries the marker. The
mock also gains the `on` subscription that SharedCodexRoot requires
since #1911, without which the suite's bind test could not run.
* test(runner): run the late-webhook suite in the serial integration project
lateRunnerWebhook.integration.test.ts starts a real runner and a
detached child process per case, but the default vitest project picked
it up and `bun run test` ran it in a parallel worker. Tests that spawn
runner process trees belong to the serial integration project
(vitest.integration.config.ts, CI job `integration`), as the config
comments and .github/workflows/test.yml require.
Include the file in the integration project, exclude it from the
default one, and remove the temporary HAPI_HOME of each case, which
was left behind in the temp directory.
---------
Co-authored-by: Benedict Groppe <195190800+BGGBTAC@users.noreply.github.com>
* fix(cli): release stdin before handing terminal to local-mode child
hapi ships as a Bun-compiled executable, and Bun's tty.ReadStream keeps
an in-flight read on fd 0 even after process.stdin.pause()
(https://github.com/oven-sh/bun/issues/29126).
Remote mode reads stdin (Ink UI in raw mode, for the double-space
switch-to-local gesture). When switching back to local mode,
spawnWithTerminalGuard only paused stdin before spawning the agent TUI
with stdio:'inherit'. The parent's stale pending read kept racing the
child for console input and won: after a remote -> local switch the
agent TUI rendered but received no keystrokes at all — the input box
was dead and even Ctrl-C never reached it. Instrumented at local spawn
time: dataListeners=0, isPaused=true, but _readableState.reading=true.
Fix: destroy stdin outright before spawning the local child
(releaseStdinForChild) — destroy reliably stops the pending read where
pause() does not — and re-create process.stdin via
`new tty.ReadStream(0)` when remote mode starts again
(ensureLiveStdin, called from RemoteLauncherBase.setupTerminal).
hasTTY is now computed lazily since stdin may legitimately be
destroyed at launcher construction time.
Verified end-to-end on Windows with `hapi kimi` by injecting console
input via WriteConsoleInput and checking the agent session wire log:
remote -> double-space -> local -> typed message reaches the agent;
switch to remote again (double-space detection works on the
re-created stdin) -> local -> input works again. Direct local launch
unaffected. `bun run typecheck:cli` and the cli vitest suite pass.
* fix(cli): preserve piped stdin when releasing terminal for local child
Addresses review feedback on #1932: releaseStdinForChild() destroyed
stdin unconditionally, but destroying a piped stdin closes fd 0 and
starves the child spawned with stdio:'inherit'. The Bun stale-read
workaround is only needed for a TTY, so skip destroy() when stdin is
not a terminal; pause() suffices for pipes. Adds regression coverage
for the piped case.
---------
Co-authored-by: Naitian LIU <Liunaitian@users.noreply.github.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>
* fix(cursor): in-place relaunch when switching live session to Auto
When ACP does not advertise literal auto, switch to Auto by respawning
the same hub session under --model auto + session/load instead of
rejecting. Keeps #1819 CLI Auto pin semantics. Fixes#1908.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): widen in-place relaunch spawn model typing
Allow null/undefined rollback spawn models from resolveCursorSpawnModel.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): re-pin ACP session id after in-place relaunch load
If session/load returns a different agent id, update hub metadata the
same way as a fresh session so resume stays recoverable.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): coordinate message loop with in-place Auto relaunch
Await relaunch completion before prompting, re-read the live backend
after model apply, and publish this.backend only after session/load.
Addresses tip bot Majors on #1909.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): harden Auto relaunch against prompt races and cleanup
Hold promptInFlight before mode apply, end the loop if backend restore
fails instead of requeue-spinning, and discard late replacement backends
when session teardown has started. Addresses tip bot Majors on #1909.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): avoid continue-in-try circular typing; finish relaunch harden
Refactor mismatch requeue off continue-inside-try so typecheck passes,
keeping promptInFlight hold and teardown discard for #1909 tip bot Majors.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): preserve queued turns and Auto-review across relaunch failure
Requeue dequeued batches before ending when restore fails, and still
record Auto-review (slash after publish) when mode flips while backend
is null mid-relaunch. Addresses tip bot Majors on #1909 @ 9e5f241f1.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): defer hub ack until Auto relaunch has a live backend
Avoid acknowledging queued turns before a usable ACP process exists, and
reset Auto-review slash bookkeeping on in-place process replacement.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): recompute Auto-review spawn flag for replacement ACP
Slash bookkeeping after in-place relaunch must use the replacement
process's --auto-review arg, not the prior process's sticky flag.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): drop queued Auto-review slash when replacement has --auto-review
Cursor treats /auto-review as a toggle; leave no leftover slash for a
spawn that already enabled Auto-review via the CLI flag.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): discard held Auto-review toggle after --auto-review relaunch
Mark internal toggles so queue reconcile and post-relaunch dispatch drop
only HAPI-generated /auto-review slashes, preserving user-sent commands.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
A pending permission whose sidechain tool_use never makes it into the
rendered block tree leaves the session blocked with no answerable UI: the
attention indicator shows a pending request, but no card renders anywhere.
Root cause: the tracer drops sidechain messages it cannot attribute to a
parent Task/Agent call — no parentToolUseId on the message and no
prompt-holding sidechain root in the window (observed with background
subagents whose transcripts live in separate subagents/*.jsonl files and
whose initial prompt message never reaches the hub). The orphan-card
fallback in reduceChatBlocks then checked *raw transcript* tool ids, so
the dropped message still suppressed synthesis.
Check the final rendered tree instead (recursively, including blocks
nested under Task/Agent cards): any pending permission that is not
rendered anywhere gets exactly one synthesized, answerable card. This is
the approach suggested in #1073 itself.
Verified against a live session where 48 of 69 messages were dropped by
the tracer, including the pending WebFetch tool_use; with this change the
request renders as an orphan card and can be approved from the chat.
Refs #1073, refs #587.
Co-authored-by: zx-shi <shi@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
* fix(cli): flush buffered ACP assistant text after an idle gap
ACP gives no "text segment finished" signal for a turn's last text segment and AcpMessageHandler only emits buffered text at explicit boundaries or through the prompt-lifecycle drains. When neither arrives - a text-only turn whose prompt() never returns - the answer is held until the next user prompt.
Emit buffered text once the stream has been silent for TEXT_IDLE_FLUSH_INTERVAL_MS (1000ms). The idle flush defers while a reasoning block is open (keeps Reasoning above the answer) and while the buffer may be an unfinished control envelope (keeps the internal-event filter complete). A later boundary emits only the remainder, so no character is emitted twice.
* fix(cli): stream idle ACP text as snapshots under one stream id
An id-less idle emission could render a reordered or duplicated answer when a dedupe agent rewrites its buffer with a prefix, and a pause inside an open Markdown fence split the rendered answer into separate blocks.
Stream idle emissions as full-text snapshots under a stable stream id and finalize with the same id at the boundary, mirroring flushReasoning; the partial-watermark computation is removed. The control-envelope guard no longer assumes type is the first JSON key.
* fix(cli): skip interim idle text snapshots in local launcher displays
* fix(test): use portable `ps axeww` in test-process audit
The audit ran `ps eww` which is terminal-scoped on macOS (and exits 1
without a controlling terminal, failing the whole CLI suite via the
global-teardown audit). The previously suggested `-eww` also breaks the
scan: macOS treats `-e` in SysV form as "all processes" and silently
omits the environment dump, so a marked test child is never detected.
Use the BSD full-dump form `ps axeww` (all users + tty-less + env +
unlimited width), which ports across darwin and procps-ng. Add a
regression test that spawns a real marked child and asserts the audit
finds it.
* test(audit): skip live-process detection test on Windows
The scan function short-circuits to an empty list on win32 where ps
axeww is unavailable, so the assertion that finds a live child can
never pass on that platform. Skip it conditionally.
Every assistant message that ends with an AGENT_NOTIFY_SUMMARY footer
re-ingests the work_ad cause from the session message store. When a
session runs many turns without a new invoked inbound (automation-style
runners), the sticky path copied the previous cause verbatim, which also
froze the resume point (causeSeq + causeCursorMessageId) at the original
inbound. Each subsequent notify then re-loaded and re-parsed the entire
session tail since that inbound — content decode is zstd + JSON.parse
per row, so the cost grows without bound as the session grows and
blocks the hub event loop for seconds per notify.
Measured on a self-hosted hub with a ~10k-message session whose last
consumed inbound sat at seq 891 (v0.29.1): every notify footer stalled
/health for ~4.7s and cost ~5000 page reads, repeating per notify. With
this change the first notify pays a one-time catch-up scan; the resume
point then advances past each assistant turn and subsequent notifies
complete in the low milliseconds.
- resolveWorkAdCause: on the sticky path, advance causeSeq and
causeCursorMessageId to the current assistant message while keeping
the cause identity (messageId/text/kind). The advance is skipped
while unconsumed queued or unmatured-scheduled inbounds still sit
below the watermark — those can mature into a later cause and must
stay inside the scan window.
- loadMessagesForCause: when the cursor row was trimmed away by
retention, resume after the numeric causeSeq watermark (validated
against the session's current minimum seq) instead of degrading to a
full session read on every later notify. A history move
(mergeSessionHistory) empties the session and restarts its seq
space, so a stale low watermark still selects the full read there.
- ingestNotifySummaryFromMessage: resolve the assistant seq by message
id instead of scanning the loaded window, which after the watermark
advance no longer contains the notify-bearing row.
- store: add getMinSeq helper for the watermark validation.
Co-authored-by: HAPI <noreply@hapi.run>
* docs(agents): add a bounded PR review-wait and auto-fix loop
Document how an agent should behave after opening or updating a PR:
wait on a per-HEAD window (30 min, 60s poll) for checks, reviews and
comments; auto-fix clear local feedback and push to the same branch;
stop and ask before product/scope/security/force-push changes; and
report honestly when a wait times out instead of burning context.
* docs(agents): clarify loop entry condition and unresolved-feedback carryover
---------
Co-authored-by: HongChenGG <HongChenGG@users.noreply.github.com>
* fix(web): keep machine health visible for single-machine layouts
The sidebar redesign gated the machine filter bar behind >= 2 machines,
so a single-machine setup lost its only surface for machine CPU/RAM
health and session counts (#1259).
With exactly one machine there is nothing to filter, so render a compact
non-interactive summary row in the bar's slot instead: machine label,
session count, and inline CPU/RAM metrics — visible on touch layouts too,
since no hover is needed. Pointer users additionally get the same
capacity popup as the filter chips.
Fixes#1259
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(web): show machine summary when the sole machine has no sessions
Address the review finding on #1664: the summary row was derived solely
from session groups, so a single-machine setup with zero visible
sessions (fresh install, or "active only" filtering) still hid machine
health — the exact regression #1259 reports.
Fall back to the sole machine from the machines list in that case; the
row then shows its label, a zero count, and inline CPU/RAM metrics.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: yellowdog01 <yellowdog01@users.noreply.github.com>
Co-authored-by: HAPI <noreply@hapi.run>
* fix(claude): use native ai-title for remote session titles
Remote mode talks to Claude over the SDK stream, which never carries the
ai-title Claude Code writes to its transcript, so the first-result fallback
titled the session from the formatted prompt. With an attachment that prompt
starts with "@/tmp/hapi-blobs/..." and the 80-char cut left only the path.
On the first result, read the latest ai-title from the transcript; fall back
to the first prompt as typed, without attachment @paths.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(claude): derive fallback title from the delivered prompt
Recording the first enqueued text could title the session from a prompt
that was cancelled or edited before delivery. Strip the upload @path
references from the prompt actually delivered instead; upload filenames
have whitespace replaced, so each reference is a single token.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Claude Code moves the session transcript to the new cwd's project dir
when the session enters or leaves a git worktree. The local-mode scanner
pinned the launch project dir, so nothing reached the web until the
session left the worktree. Look the transcript up by session id across
project dirs when the known path is gone.
Fixes#1950
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Accepted agent-state writes (Codex usage snapshots, controlledByUser,
request completion) set updated_at = now, so idle sessions showed
"just now" and reordered the web active-session list. Only advance
updated_at when the update adds a new permission request.
Fixes#1855
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- Use the split navigation on foldable iPhones once UIHingeInteraction
(iOS 27.1+) reports a hinge: unfolded shows sidebar + chat, folded
collapses to the phone stack without recreating the chat.
- Side-by-side sidebar: drop the truncated inline "Sessions" title and
move the filter beside the hub menu (also fixes iPad).
- Folded outer screen: drop the home title so the empty bar disappears,
use the leading-aligned system title/subtitle in chat, minimize the
chat title on scroll, and let the composer sit 16pt closer to the home
indicator while the text field is unfocused.
- Qualify Markdown.Document, which is ambiguous with the iOS 27.1 SDK.
- test-transcript.sh picks the newest runtime that supports the device
type (iOS 27.1 only ships iPhone Duo); split tests run on iPhone Duo.
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.
* fix(claude): report thinking for local-mode sessions
Local Claude sessions have no SDK stream, so nothing ever set the
session's thinking flag and the web UI showed them idle mid-turn.
Derive it from the local hooks instead: UserPromptSubmit/PreToolUse
turn it on, Stop/StopFailure/SessionEnd turn it off. Esc interrupts
fire no Stop hook, so the transcript's interrupt marker also clears
it, and leaving local mode always resets it.
Fixes#1864
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(claude): ignore interrupt markers from an earlier local turn
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): resolve relative Markdown links and images in file preview
The standalone Markdown preview rendered without the previewed file's path,
so relative .md links became inert and relative images resolved against the
web page URL. Pass the session file to the renderer and resolve relative
hrefs/srcs against its directory: links open the sibling file preview,
images load through the session file-read API. HTTPS images are unchanged
(direct browser load); paths that escape the root stay inert.
Fixes#1917
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): bound preview image reads and check workspace containment
Only read supported image types whose directory-listed size is at most
10 MiB, and require absolute resolved paths to stay inside the session
workspace so escapes remain inert.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): keep session links and browsing context in Markdown preview
Resolve session references before relative file paths, and carry the
preview's origin/tab/query into relative file links so Back returns to
where the user entered from.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Move npm publishing from the local release script to the tag-triggered
Release workflow, authenticated with npm Trusted Publishing (OIDC) so no
npm token is needed. Local `release-all` now only bumps versions, updates
the lockfile, commits, tags, and pushes.
- Add cli/scripts/publish-npm.ts: publish platform packages, wait until
they are live, then publish the main package; skip versions already on
npm so a failed run can be re-run.
- Drop the platform optionalDependencies from cli/package.json. The
published main package gets them from prepare-npm-packages; the
workspace copy only made bun.lock depend on not-yet-published versions.
* feat(android): add session date-range filter with activity-aware picker
- Pure SessionDateRangeFilter port of the web SessionList.tsx semantics:
strict YYYY-MM-DD parsing with calendar round-trip validation,
[start local midnight, end local midnight + 1 day) windows anchored to
the injected zone, session.updatedAt as the activity field
- M3 DateRangePicker dialog (opens on the selected start's month, Clear
resets without closing) plus a lightweight recent-activity legend —
declared divergence: M3 exposes no per-day decoration API, so the web's
in-cell "has session activity" emphasis surfaces beside the picker
instead; explicit Apply replaces the web's close-after-second-click
- Wired as a transient, unpersisted lens (web useState parity) applied
before the machine scope; the activity-day set and machine chips both
derive from the unfiltered list so filtering never hides alternatives
- Top-bar calendar action with active dot badge, active-range summary
row with clear, date-specific empty state; strings in en/zh-rCN/ru
(en-ru key order KEYSYNC clean)
- JVM tests: predicate window edges, DST spring-forward, zone-anchored
midnights, strict parsing, VM lens combination and pre-filter
activity-day derivation
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(android): refresh session date filters after timezone changes
* Deduplicate Android sessions before applying date filters
---------
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: weishu <twsxtd@gmail.com>
* feat(android): add scratchlist row move, copy, and delete-confirm actions
Align the scratchlist entry card with the web client's per-row actions
(ScratchlistPanel inventory):
- Move up/down: adjacent swap, disabled at the list edges, local-only
like web v2 — no protocol request; the next refetch restores hub
`createdAt DESC` order (pure `ScratchlistEntryOps.moveEntry`).
- Copy: copies only entry.text via ClipboardManager (FullTextAction
precedent), swaps the icon to a check for 1500 ms on success, single
per-list copied state, silent no-op on failure.
- Delete confirm: texts strictly longer than 100 characters (web
SCRATCHLIST_CONFIRM_DELETE_THRESHOLD, untrimmed length) confirm via
AlertDialog before delete, both from the row and the editor sheet;
shorter notes delete directly.
New en/ru/zh-rCN strings ship in the same commit.
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(android): guard saved scratchlist notes and restart copy feedback
* test(android): uniquely select scratchlist confirmation actions
* Fix Android scratchlist editor test targeting
---------
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: weishu <twsxtd@gmail.com>
* feat(android): remember new-session launch defaults per machine and agent
Port of the web launch-preference persistence
(`hapi:newSession:launchSettings:v1:<machine>:<agent>`): model, effort,
reasoning effort and — for shared-permission-mode flavors only — the
permission mode of a successfully spawned session are remembered per
(machineId, agentFlavor) dimension and prefill the next create form for
that dimension (fresh screen, agent switch, machine switch).
- Storage: NewSessionPrefsData gains a nested `launchSettings` map inside
the existing `hapi_prefs` DataStore blob; legacy blobs without the
field decode to empty defaults, corrupt shapes keep the pre-existing
whole-blob default degrade (DirectoryExpandPrefs contract).
- Capture happens at spawn success only (web saves on every
create-success path); grok renders the native select but its mode is
deliberately not remembered (web `usesSharedPermissionModeState`).
- Restore: resolvePreferredLaunchSettings validates claude's static model
and effort lists, drops `xhigh` from opencode's reasoning efforts and
coerces a removed permission mode to `default` — never a stale toggle;
codex's dynamic model passes through for the existing settled-catalog
reconciliation.
- A restored in-progress draft outranks the remembered defaults until the
user switches agent or machine (web `preserveRestoredDraftRef`).
- Machine switches now also reset effort/reasoning effort/shared mode to
the new dimension's remembered values (or sentinels), matching the web
restore effect that re-runs on machineId change.
Divergences from web: no `cursorSelectedBase` (no Android picker yet);
agy's static model presets are not re-validated on Android (no v1 picker,
the stored value is always `auto`).
Tests: prefs round-trip/cold-start, legacy-blob tolerance, corrupt blob
and corrupt-entry fallback (real temp-file DataStore, pure JVM); capture
gating and resolve coercion logic; VM prefill per dimension, machine and
agent switching, spawn-success capture, and draft precedence.
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(android): preserve launch selections across machine heartbeats
---------
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: weishu <twsxtd@gmail.com>
* feat(android): settled permission verdict details
Port the web PermissionFooter verdict classification so a settled
permission row explains HOW a request was decided, not just that it was.
Behavior vs web (web/src/components/ToolCard/PermissionFooter.tsx:11-63):
- codex family sessions (flavor family + Codex/Gemini/OpenCode/Copilot/
Cursor tool prefixes): approved + decision approved_for_session shows
"Approved for session"; denied + decision abort shows "Aborted" with
the wire reason appended when present.
- claude sessions: mode acceptEdits shows "Approved: allow all edits"
(previously the raw mode id was appended); decision approved_for_session
or an allowedTools hit — plain tool name or the `Bash(command)`
qualifier built from the tool input command/cmd — shows
"Approved for session" (previously indistinguishable from a plain
approval).
- canceled now appends the wire reason (previously dropped).
- resolved/pending/denied unchanged in shape; denied keeps the error tone
and aborted shares it.
Deliberate divergence: web's own settled footer renders only a minimal
red reason box (PermissionFooter.tsx:207-220) — its formatPermissionSummary
classification is unreachable there. Android renders the classified
verdict line in the existing subdued row style instead, as intended.
Implementation: new pure JVM classifier
feature/chat/permissions/PermissionVerdict.kt (exhaustive status ->
PermissionVerdictLabel mapping, unit-tested in PermissionVerdictTest);
ToolCallBlockView.PermissionStateRow becomes a thin renderer over it.
Strings added in en/zh-rCN/ru (ru mirrors en key order; zh values from
web locales).
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* feat(android): persistent permission-mode chip + inactive gating
Item 2 — persistent permission-mode chip above the composer, mirroring
the web StatusBar mode span (StatusBar.tsx:253-265, 389-393):
- New PermissionModeChip composable + PermissionModeChipRule pure object:
resolve the raw wire mode, keep it only when the flavor offers it, and
hide `default` everywhere except copilot (model=auto sessions still
surface the chip). Unknown wire ids hide (no raw fallback).
- Tone mapping follows the web PERMISSION_TONE_CLASSES (StatusBar.tsx:
43-48): neutral=onSurfaceVariant, info=primary, warning=tertiary,
danger=error. Labels stay en catalog strings — web mode labels are
hardcoded English, not i18n.
- The chip consults the unfiltered flavor catalog: the concurrent-clients
Safe-Yolo carve-out only narrows the switcher, never the chip.
- Deliberate divergence: the web span is not clickable (its switcher is
the composer settings sheet / Shift+Tab). Android has no Shift+Tab, so
tapping the chip opens the session config sheet that hosts the
switcher — same surface as the top-bar gear, no new top-bar icon
(ChatScreen keeps its two-icons-max rule).
Item 4 — permission-mode gating aligned with web (SessionChat.tsx:
2079, 2138-2163; HappyComposer.tsx:536-537, 1544-1549, 1960-1995):
- Inactive sessions stay switchable, matching web: SessionChat passes
allowSendWhenInactive unconditionally, so configurationControlsDisabled
never trips for inactive threads and the offline notice stays
informational only (the send path auto-resumes).
- The permission section now disappears for copilot sessions under local
terminal control — web withholds onPermissionModeChange there and the
whole section then hides (HappyComposer.tsx:1595). Other flavors keep
switching while terminal-controlled.
- ChatViewModel.setPermissionMode gains the matching belt-and-suspenders
guard; the controlledByUser derivation is extracted into
terminalControlled() (controlledByUser && !concurrentClients, same as
SessionChat.tsx:996) and shared with buildConfigUi.
Tests: PermissionModeChipTest (6 cases — default/codex/copilot/pi/unknown/
safe-yolo), ChatViewModelInteractionTest +3 (withheld for terminal
copilot; survives terminal claude + shared-clients copilot; inactive
stays switchable). No new user-visible strings (chip label is catalog
en, contentDescription reuses chat_config_permission_mode).
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* test(android): derive the dsh chip expectation from the flavor catalog
The hardcoded "dsh offers no modes" assumption breaks on trees where dsh
gained the codex-style mode list — derive the expectation from the
catalog so the test pins the actual contract (the chip mirrors the
flavor's offered list).
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* feat(android): permission input semantics parity (editor inputType, URL confirm, grok auto gate)
Three behavior alignments with the web app's permission surfaces:
1. Editor inputType (web requestUserInput.ts:152-153, 191-195):
a required text question with `inputType === 'editor'` is always
answered and its note is pushed untrimmed (even the literal
"user_note: "); non-editor notes stay trimmed and optional.
Android: PermissionInputs.requestUserInputAnswerValues /
isRequestUserInputAnswered now take the question, gate on the
literal "editor" inputType; the footer's note field grows to
minLines = 2 to invite multi-line answers.
2. request_user_input URL confirmation (web requestUserInput.ts:46-70,
129-141; RequestUserInputFooter.tsx:142-152): the sign-in url only
survives when it parses as http/https; submitting requires the
CLI-synthesized __mcp_url_confirmation option (description === url)
to be selected, and submission opens the url before approving.
Android: parseRequestUserInput + normalizeRequestUserInputUrl +
isRequestUserInputUrlConfirmed; the footer enforces the confirm
guard, then opens the url via ACTION_VIEW. Divergences: the raw
wire string is compared (the CLI synthesizes the description from
the same value web would re-normalize, so they are equivalent for
every CLI-produced request), and the open-failure message is
platform-adapted (no popup blocker on Android).
3. Grok Auto permission gate (web NewSessionForm index.tsx + grok
models probe): when the machine reports
`autoPermissionModeSupported === false` for the chosen cwd, the Auto
option is disabled with an " (unavailable)" suffix, a hint line is
shown, and an existing Auto selection resets to Default. The gate is
grok-only (claude and other agents never gate) and tri-state — null
(unknown, probe pending or failed) never disables.
Android: GrokModelsResponse wire type (nullable field),
HapiApi.getMachineGrokModelsForCwd, NewSessionViewModel probes on
agent/cwd/machine changes (deduped, cancellable, existence-gated),
OptionItem gains `enabled`, the permission dropdown honors it and
renders the hint. In-session switching stays ungated, matching web.
New strings in en / zh-rCN / ru (KEYSYNC: en and ru key order
identical).
Tests: RequestUserInputTest covers editor/untrimmed-note/url
parsing/confirmation semantics; NewSessionViewModelTest covers gate
disable+reset+hint, tri-state never-gate cases, and agent/cwd gating;
HapiApiTest covers the cwd query param and nullable decoding.
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(android): accept browser-valid sign-in URLs
---------
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: weishu <twsxtd@gmail.com>
* android/protocol: carry hasConversationContent through the session projection
The hub sends `hasConversationContent` on both `SessionSummary` and the
full `Session`; the sidebar's empty-stub hiding (web `SessionList.tsx`)
depends on it. Decode it on both wire models and project it through
`toSessionSummary` exactly like shared/src/sessionSummary.ts does
(`session.hasConversationContent ?? false`), so SSE upserts land with the
same signal REST lists already carry. Optional-with-default keeps older
hub payloads decodable.
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* android: session-list sidebar parity — agent-session dedup, stub hiding, relevance search
Port the web sidebar's list body to the Android session list:
- Collapse duplicate views of one agent session (resume/reopen chains)
into a single row — winner by active → selected → pin → recency —
keyed on `flavor:agentSessionId` (web `getSessionDedupKey`).
- Hide fresh auto-created "new session" stubs: inactive, conversation-
less, no title signal and no agent id (web `isSidebarEmptySessionStub`);
selected/active/pinned rows are never hidden.
- Live search box with the web's relevance ranking (field weights,
boundary + title-phrase bonuses, wildcard `*`/`?`, AND across terms)
and a distinct no-results state. Pinned rows stay contiguous while
searching.
Pure logic lives in `SessionListFilters` / `SessionListSearch` (Kotlin
objects, JVM-tested against the web reference cases); the ViewModel
pipeline is visible → dedup+stub → canonical sort → search filter+rank.
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* android: ru translations for session search parity
Upstream landed values-ru after this stack's merge base; new strings
need ru entries or :app:lintDebug fails with MissingTranslation.
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(android): preserve global pins in session search
---------
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: weishu <twsxtd@gmail.com>