* perf(hub): gzip the SSE stream without delaying delivery
SSE payloads are plain JSON that repeat the same field names on every
event, so they compress well - measured 72-77% on real captured traffic
from a hub with 15 active sessions.
Compression could not simply be turned on, though. Hono's compress()
middleware bails out whenever Transfer-Encoding is set, which streamSSE
always sets, so mounting it is a no-op. Wrapping the body in a
CompressionStream does compress, but it buffers until the stream ends -
measured on a 10-event stream, every event arrived at once when the
stream closed. On a connection that stays open for hours that means
events never arrive at all.
So drive zlib directly and issue a Z_SYNC_FLUSH after each chunk. That
costs about one percentage point of ratio and keeps delivery immediate:
verified in a real Chromium EventSource, first event at 13ms and each
subsequent event at its own 500ms tick, with no error events.
Clients that do not send Accept-Encoding: gzip keep the uncompressed
stream. No event payload or timing changes.
* fix(hub): cancel through the reader, gate reads on demand, honour q=0
Three defects in the first version of the SSE gzip wrapper:
Cancelling the source directly threw. The wrapper holds a reader for the
whole life of the connection, and cancelling a locked stream is invalid -
in Bun it throws TypeError: Invalid state: ReadableStream is locked
synchronously out of the cancel callback. Since SSE clients disconnect
mid-stream as a matter of course, this fired on essentially every
disconnect, and the upstream cancel never ran. Cancel through the reader
instead, which is allowed to.
Reads were not gated on downstream demand. Only zlib's own buffer was
consulted, and SSE compresses well enough that a slow client can be
megabytes behind while the compressed queue still looks nearly empty: a
test with a non-reading consumer pulled 1752 chunks before stalling.
Reading now waits for desiredSize to go positive, resumed from pull().
Accept-Encoding was matched with a substring test, so "gzip;q=0" - which
means the client refuses gzip - was read as acceptance. Parse the q-value.
Re-verified that none of this costs the property the change exists for:
in a real Chromium EventSource the first event still arrives at 13ms and
each one after it on its own 500ms tick, with no error events.
* fix(web): count unseen messages by rendered block, not raw message
The "N new messages" pill counted raw DecryptedMessages while the
timeline renders folded blocks, so the two never agreed. A subagent run
is dozens of sidechain messages but a single Task card; a tool_use and
its tool_result are two messages and one card; consecutive tools collapse
into one group. The pill could read "47 new messages" when scrolling down
revealed two new rows.
collectNewUnseenIds never inspected isSidechain, and it could not: the
reducer's grouping is stateful (it needs the Task tool_use before it can
map parentToolUseId), so a per-message predicate in the store cannot
reproduce it. Adding an isSidechain check there would also invert the
error for orphan sidechain messages, which tracer.ts falls back to
emitting at the top level.
Instead, drop the store's unseen bookkeeping entirely and count what the
renderer actually produced. Watermark the visible blocks when the user
scrolls away from the tail, then count the blocks past the last one they
had seen.
The count is anchor-based rather than timestamp-based because the blocks
array is not monotonic in createdAt: messages sort by invokedAt ??
createdAt, so a queued message carries an old createdAt while sitting at
the end. Anchoring also makes prepended history free, since older blocks
land before the anchor.
Known limit, documented at the call site: once the history window fills
up, mergeIntoWindow trims incoming messages off the tail, so the pill
reports 0 instead of a count. Under-reporting is preferable here, and
returning to the tail force-refetches the latest page anyway.
* fix(web): keep unseen watermark stable across optimistic id replacement
The watermark snapshotted only block.id, but that id is not stable for
the user's own messages: mergeMessages replaces an optimistic row with a
stored row that keeps localId under a new server id, and the user block
renders with the message id. Scrolling into history while an own message
was still optimistic meant its echo anchored one block earlier and bumped
the pill by one, with no new rendered row.
Track localId alongside id in the watermark and match on either.
Reported by HAPI Bot on #1255.
* fix(web): count joined assistant cards, not pre-join blocks
visibleBlocks is still not one-to-one with rendered rows: assistant-ui
joins a run of adjacent assistant-role blocks into a single card, so a
response made of reasoning + text + a tool call was reported as three new
messages instead of one, and appending another block to an in-flight
response bumped the pill without adding a row.
Walk the blocks after the anchor and only start a new row where the
assistant run breaks.
Role assignment is the part that would drift, so rather than restating it,
visibleBlockRole moves from assistant-runtime.ts to toolGroups.ts (next to
the VisibleChatBlock definition it describes) and both the runtime and the
counter import the one copy.
Reported by HAPI Bot on #1255.
* fix(web): exclude subagent usage from the parent context indicator
The status bar's `ctx N/M` and `cache N` come from latestUsage, which
scans the normalized messages backwards for the most recent usage. That
scan includes sidechain messages, so while a Task subagent runs its
usage — describing the subagent's own, much smaller context — becomes
the parent's numerator, then snaps back when the parent resumes.
The existing `scope_role !== 'child'` guard never fired on any path.
Claude never stamps scope_role (sdkToLogConverter.ts says so outright),
and Codex drops child token_count events in the CLI before they can
reach the web layer, so no producer ever emits 'child'. isSidechain is
the signal that actually survives.
sdkToLogConverter.ts:308-313 already documents this exact reducer
behaviour, but works around only the denominator by forcing the main
session's context_window onto sidechain messages. The numerator was
left unguarded.
* fix(cli): stop stripping context_window from local-session usage
UsageSchema is a plain z.object, so Zod's default strip mode drops every
undeclared key. sessionScanner forwards parsed.data rather than the raw
line, so on the local-JSONL path usage is truncated to the five declared
fields and context_window — injected on the SDK path by
sdkToLogConverter — never survives.
The web status bar then falls back to getContextBudgetTokens, which
subtracts a 10k headroom, so the same model reports a 1.0M denominator
on a remote session and 990k on a local one.
RawMessageSchema right below already carries .passthrough() with a
comment about losing message.model and messageId the same way; the
nested usage object just never got the same treatment.
Radix Popover crashed with "Invalid hook call" because
@radix-ui/react-popover is not linked into web/node_modules and
resolves react from the repo root — a different instance than the
one app code imports. Two React copies make every hook-using third
party component throw on render and unmount the whole tree.
The VitePWA dev service worker also pulls its workbox imports only
after registration, so Vite re-optimizes deps and force-reloads the
page mid-run, which nondeterministically kills whichever e2e test is
in flight.
Machines are labelled by hostname with no way to give them a friendlier
name. `MachineMetadataSchema` has declared `displayName` all along and the
whole read path already honours it (`displayName → host → id`), but nothing
could ever write it: the CLI never sends the field, the hub exposed no route
that sets it, and the web UI had no editor.
Add the missing write path:
- `PATCH /api/machines/:id` with `{ displayName }`, guarded by the existing
`requireMachine`. An empty value removes the key so the label falls back to
the hostname; the empty string is never stored.
- `machineCache.renameMachine` merges that one key into the stored metadata
and lets `refreshMachine` publish `machine-updated`, which `useSSE` already
invalidates on — so every connected client relabels without new plumbing.
- A `/settings/machines` page listing online machines with inline rename,
placed between Voice and About so the existing preference pages keep their
order. Each row keeps the hostname visible, so a renamed machine is still
identifiable.
The merge reads the raw stored metadata rather than the cached `Machine`
view. That view is narrowed by `MachineMetadataSchema`, which strips unknown
keys and yields `null` for a row that fails validation — reachable, since the
CLI's `machine-update-metadata` handler accepts `z.unknown()`. Merging
against it would have written those fields out of existence.
The row's save is guarded by a ref rather than `isPending`: disabling the
focused input forces a blur, so Enter otherwise reaches `save` twice and
fires two PATCHes, the second of which can lose the version race and report
a failure for a rename that succeeded.
`mergeMachineMetadata` already preserves hub-side fields on CLI
re-registration, so a reconnect does not clobber the name.
Closes#1210
Claude Code injects its own user-role turns for skill bodies and compact
continuation summaries. The on-disk transcript flags them `isMeta`, which
claudeLocalLauncher drops before they ever reach the web UI. Over
stream-json the same event is flagged `isSynthetic` instead, and
sdkToLogConverter copied only `message`, dropping the flag entirely.
With no `isMeta` on the converted line, every downstream guard let it
through: OutgoingMessageQueue forwarded it, isExternalUserMessage
classified it as genuine human input (its XML-prefix allowlist does not
match a bare-markdown skill body), and the web UI rendered the full skill
document as a user bubble.
Normalize `isSynthetic` to `isMeta` in the converter so the SDK path
carries the same signal as the transcript path and the existing filters
fire. Fixes skill injections appearing as user messages in remote mode.
* fix(cli): drop unknown SDK message types instead of passing them through
The SDK-to-log converter's switch had a fail-open default that stamped any
unrecognized SDK message with transcript base fields (parentUuid/sessionId/
userType) and forwarded it. Claude Code emits a tool_progress heartbeat every
30s for long-running tools, so a single slow Bash call flooded the chat: the
web normalizer matches no known shape for those records and falls back to
rendering the raw envelope as message text.
Gate the switch on an explicit allowlist instead, bailing before the uuid is
allocated so a dropped event cannot advance sidechain/parent tracking -- the
heartbeats share one parent_tool_use_id and were overwriting the pointer a
subagent's next real message parents to. This matches the local launcher,
which already enforces the same allowlist via RawJSONLinesSchema.safeParse.
The default branch stays as a fail-closed guard so adding a type to the
allowlist without a matching case drops the message rather than leaking it.
* fix(cli): re-check reassembled text for internal event JSON at flush boundary
isInternalEventJson was only applied per incoming chunk. In delta mode
(OpenCode) every chunk is a fragment, so none of them parses as JSON on its
own and the filter never fires; the pieces accumulate and flushText emits the
reassembled envelope verbatim. The dedupe path has the same hole whenever two
chunks share no overlap.
Check again in flushText, which is the first point the complete text exists,
and tolerate surrounding whitespace so an envelope preceded by a newline is
not waved through by the leading-'{' fast path.
Genuine assistant output that happens to be JSON is unaffected: the matcher
still requires the specific { type: 'output', data: { parentUuid, sessionId,
userType } } envelope shape.
* fix(cli): fail closed on unrecognized agent message in converter
convertAgentMessage's exhaustiveness default returned the message object
itself at runtime. The never binding makes the branch unreachable today, but
every caller forwards a non-null result straight into the chat stream, so the
failure mode if it were ever reached is a raw object on screen. Keep the
compile-time check, return null at runtime.
* test(cli): cover command_lifecycle, a second unknown type seen leaking
Observed in the same session after tool_progress. The allowlist already
covered it with no code change, which is the argument for gating on known
types rather than adding a case per offender.
The skill listing only scanned ~/.agents/skills and ~/.claude/skills
for user-level skills, ignoring ~/.codex/skills where Codex users
commonly store their skills. Add ~/.codex/skills to getUserSkillsRoots()
so these skills appear in the web UI $ autocomplete.
Hidden directories (starting with .) inside the skills root are still
skipped (e.g., .system/).
via [HAPI](https://hapi.run)
Co-authored-by: HAPI <noreply@hapi.run>
* fix(web): exclude brackets from CJK autolink punctuation stripping
Fullwidth brackets and parentheses (()【】「」etc.) can appear in
valid URL paths, so they should not be stripped. Narrow the regex to
only sentence-ending punctuation: comma, period, semicolon, colon,
exclamation, question mark.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(web): always show 'Agent launched' for internal metadata results
The tool state is set to 'completed' immediately when the result
arrives, so the state-based label was always showing 'Done' for
internal launch metadata. Remove the state check and always show
'Agent launched'.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(web): handle sentence-ending punctuation followed by closing brackets
The regex was missing cases like 。) where a sentence-ender is followed
by a closing bracket. Use a pattern that matches sentence-ending
punctuation optionally followed by trailing closing brackets/parens.
A bare closing bracket without a preceding sentence-ender is still
preserved as a valid URL character.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: HAPI <noreply@hapi.run>
* fix(web): hide raw internals in Agent tool card (#480)
The Agent tool card was exposing raw JSON input (including full prompts)
and internal system messages (agentId, output_file paths, system
instructions) in the details dialog. Register dedicated views:
- knownTools: show description as title, subagent_type as subtitle
- AgentFullView: show description, type, background status (not prompt)
- AgentResultView: detect internal launch messages and show "Agent
launched" instead; render actual results as markdown for completed
agents
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix: widen Agent result redaction to catch more internal metadata variants
Use || instead of && so any single internal marker (agentId:,
output_file:, internal ID) triggers redaction, not just the combination
of all markers.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix: use structural checks for Agent metadata redaction
Replace loose substring matching (which could false-positive on
legitimate agent output) with:
1. Structural check: result object has agentId/output_file keys
2. Strict text pattern: starts with the exact launch message prefix
Also make the label state-aware: "Done" for completed, "Agent launched"
for running.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: HAPI <noreply@hapi.run>
* fix(web): strip CJK punctuation from auto-linked URLs (#478)
remark-gfm auto-links bare URLs but only handles ASCII trailing
punctuation. When a URL is followed by CJK punctuation like ,or 。
without whitespace, the punctuation gets included in the link. Add a
remark plugin that walks the MDAST after GFM and moves any trailing
CJK/fullwidth punctuation out of the link node into a sibling text node.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix: add non-null assertion for link.children in test
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix: only strip CJK punctuation from auto-linked URLs, not explicit links
Only process links where the text content matches the URL (auto-links).
Explicit markdown links like [text](url) are left untouched, preventing
unintended mutation of deliberately authored URLs.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix: add non-null assertion for textChild.value
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix: remove duplicate unicode escapes from CJK punctuation regex
7 characters were listed twice (once as literals, once as \uXXXX
escapes). Keep only the literals and the 2 unique escapes (\u3000
ideographic space, \uFF0E fullwidth full stop).
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: HAPI <noreply@hapi.run>
The test 'merges duplicate after inactivity timeout expires it' was
flaky because it asserted which specific session survives the dedup,
but the target selection depends on activeAt ordering which varies by
millisecond timing in CI. When s1's alive time and s2's creation time
fall in the same millisecond, s2 survives (test passes); when they
differ, s1 survives (test fails).
Fix by asserting that exactly one session remains after dedup, without
depending on which one is the merge target.
via [HAPI](https://hapi.run)
Co-authored-by: HAPI <noreply@hapi.run>
The copy button on assistant messages was always visible on mobile
(opacity-60), positioned as a detached row below the message content.
Hide it entirely on small screens (hidden sm:flex) and keep hover-only
behavior on desktop. Mobile users can still copy via native long-press
text selection.
via [HAPI](https://hapi.run)
Co-authored-by: HAPI <noreply@hapi.run>
On iOS PWA (black-translucent + viewport-fit=cover), opening the virtual
keyboard causes iOS to scroll the page upward, pushing the session header
behind the system status bar. Fix by resetting window.scrollTo(0, 0) when
the keyboard is detected open, and listening to visualViewport scroll
events in addition to resize to catch any deferred scrolling.
via [HAPI](https://hapi.run)
Co-authored-by: HAPI <noreply@hapi.run>
* fix(web): hide filesystem path and raw prompt in Skill tool card (#453)
The Skill tool card was exposing the full absolute filesystem path
(including username, plugin cache structure, and version numbers) and
the raw SKILL.md prompt content. Register a dedicated Skill presentation
in knownTools with a friendly title showing only the skill name, and add
a SkillResultView that displays "Skill loaded" instead of the raw output.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(web): register Skill full view to hide raw input in details dialog
Add SkillFullView to toolFullViewRegistry so the details dialog shows
only the skill name instead of falling back to renderToolInput which
would expose raw JSON input.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(web): handle non-text error payloads in SkillResultView
When the error payload is not text-extractable, fall back to a generic
"Failed to load skill" message instead of falling through to the success
path showing "Skill loaded".
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: HAPI <noreply@hapi.run>
extractSDKMetadataAsync() calls query() which sets
CLAUDE_CODE_ENTRYPOINT='sdk-ts' on the current process env.
When claudeLocal() later spawns the claude CLI, the child
inherits this env var, causing Claude Code to treat the
session as SDK-launched. This makes the session invisible
to `claude --resume`.
Strip CLAUDE_CODE_ENTRYPOINT from the child env so the
local spawn uses its own default entrypoint.
Closes#450
* fix(hub,web): deduplicate sessions by agent session ID
When multiple CLI wrappers independently resume the same Codex thread,
each generates a random tag, causing the hub to create duplicate session
records for a single underlying thread. This leads to duplicate
conversations in the web UI and messages routing to the wrong session.
Add two-layer deduplication:
- Hub: when a metadata update sets an agent session ID (codexSessionId,
claudeSessionId, etc.) that already exists on another session in the
same namespace, automatically merge the duplicate into the current
session using the existing mergeSessions logic.
- Web: deduplicate the session list display by agentSessionId as a
safety net, keeping the active/most-recent session visible.
Closes#446
* chore: add review-driven comments for dedup clarity
- Explain single-threaded assumption in before/after metadata comparison
- Document merge direction rationale (duplicate → active session)
- Document deduplicateInProgress guard as known limitation
- Add catch comment explaining web safety net fallback
* fix: address review feedback from bot, Opus, and Codex
- Skip active duplicates during hub-side dedup to avoid deleting
sessions with live CLI sockets and pending agent state
- Pass selectedSessionId into web dedup sort to prevent hiding
the session the user is currently viewing
- Add test for active-duplicate-not-merged case
* fix: retry dedup on session-end and preserve agentState in merge
- Trigger dedup when a session ends (handleSessionEnd), so active
duplicates skipped during earlier dedup get merged once they disconnect
- Preserve agentState from old session during mergeSessions when the
new session has no agentState (mirrors existing model/effort/todos
preservation pattern)
- Extract triggerDedupIfNeeded helper for reuse across trigger points
* fix(web): prefer active session over selected in dedup sort
Active session always wins the dedup tie-break so the live connection
is never hidden in favor of a selected inactive duplicate. Among
inactive duplicates the selected one is still preferred.
* fix: dedup on inactivity timeout and deep-merge agentState
- expireInactive now returns expired session IDs so SyncEngine can
trigger dedup for sessions that timed out (crash/network drop)
instead of only on explicit session-end
- mergeSessions now deep-merges agentState requests/completedRequests
from both sessions instead of only copying when new is null
* fix: exclude completed requests from merged pending set
Filter out request IDs that already appear in completedRequests when
merging agentState, preventing completed permission prompts from
resurrecting as pending after session dedup.
* fix: guard resume merge against prior auto-dedup
The automatic dedup (triggered when the spawned CLI sets its agent
session ID) can delete the old session before resumeSession reaches
its own explicit mergeSessions call. Skip the merge if the old session
no longer exists instead of failing the resume with a false error.
* test: add coverage for dedup retry paths and web dedup sort
Hub tests:
- session-end triggers dedup retry for previously-active duplicates
- inactivity timeout expiry triggers dedup retry
- agentState deep merge filters completed requests from pending set
Web tests:
- basic dedup by agentSessionId
- active session wins over inactive duplicate
- selected session preferred among inactive duplicates
- active always wins over selected inactive
- sessions without agentSessionId pass through
- independent dedup across different agentSessionIds
* fix: read latest agentState before merge write to avoid overwriting live updates
Re-read the target session's agentState right before writing the merged
result, with a version-mismatch retry loop, so concurrent update-state
events from the active CLI are not lost during dedup merge.
* fix: sort expired sessions by recency before dedup
When multiple duplicates for the same agent thread expire in a single
sweep, process the most recent one first so it becomes the merge target
and survives, rather than keeping the oldest by arbitrary iteration order.
* fix: select most recent session as merge target in dedup
deduplicateByAgentSessionId now collects all inactive candidates
(including the caller) and picks the one with the highest activeAt
(then updatedAt) as the merge target. This ensures the newest session
survives regardless of which trigger point or ordering calls the dedup.
The forced refresh added in #442 triggers a full /api/auth round-trip
on every tab focus/visibility event. Since the JWT lifetime is now
4 hours, the original minTtlMs guard (refresh only when <60s remains)
is sufficient and avoids unnecessary auth traffic.
* feat(web): add LaTeX math formula rendering with KaTeX
Add remark-math + rehype-katex to the markdown rendering pipeline
so inline ($...$) and display ($$...$$) math formulas are rendered
as proper KaTeX output in chat messages and tool results.
Closes#237
* fix(web): disable single-dollar math parsing and add KaTeX to reasoning
- Set singleDollarTextMath: false to prevent $HOME, $PATH etc from
being misinterpreted as math formulas. Only $$...$$ (display) is
parsed; inline math requires explicit \(...\) or $$...$$.
- Add rehypePlugins to the reasoning renderer so math formulas
render consistently across chat, tool results, and reasoning blocks.
* refactor(web): use satisfies for type-safe plugin exports
Replace any[] with satisfies NonNullable<MarkdownTextPrimitiveProps[...]>
to preserve type safety on the shared plugin lists without needing
eslint suppressions.
* fix(web): enable single-dollar inline math syntax
Re-enable $...$ parsing (remark-math default) so inline formulas
like $E=mc^2$ render correctly. Shell variables like $HOME typically
appear inside code spans/blocks which remark-math does not parse,
so false positives are minimal in practice.
- Extend JWT expiration from 15 minutes to 4 hours in both auth and
bind endpoints. 15 minutes was too short — browser timer throttling
in background tabs prevented the scheduled refresh from firing
before expiration, causing unexpected logouts.
- Change the visibility/focus refresh from conditional (minTtlMs) to
forced, so returning to a backgrounded tab always re-authenticates
regardless of remaining token TTL. This eliminates the race between
timer throttling and token expiration.
HAPI is a self-hosted tool, so the longer token lifetime is an
acceptable security tradeoff. The auth source (Telegram initData or
CLI access token) is still validated on every refresh.
Closes#412
* fix(hub): allow terminal re-registration after socket reconnect
When a web client reconnects (common in PWAs and after network
hiccups), it retains the same terminal ID but gets a new socket ID.
The previous code rejected the registration because the old entry
still existed, producing "Terminal ID is already in use".
Now the registry treats a different-socket registration for the same
terminal ID as a stale entry and cleans it up before re-registering.
Same-socket re-registration returns the existing entry (idempotent).
Terminal IDs are client-generated UUIDs so cross-client collisions
are not a realistic concern.
Closes#345
* fix(hub): skip terminal quota check on reconnect
When a stale terminal entry still occupies a slot, the per-session
and per-socket quota checks reject the reconnecting client before
register() can clean up the stale entry. Detect reconnects (same
terminalId + sessionId already registered) and bypass quota checks
so the stale entry is properly replaced in register().
* fix(hub): reject cross-session terminal ID reuse
Only allow stale-entry replacement when the existing entry belongs
to the same session. If a different session happens to present the
same terminal ID, reject it as before to prevent one session from
evicting another session's active terminal.
The convenience `io()` function misparses the `/terminal` path
component as part of the Engine.IO endpoint in some browser
environments, producing requests to `/terminal/socket.io/` instead
of `/socket.io/`. Using `new Manager(baseUrl)` + `manager.socket('/terminal')`
separates the transport URL from the namespace unambiguously.
Closes#251
* fix(web): allow multiline input with modifier+Enter in composer
Previously only Shift+Enter was recognized for newline insertion while
Ctrl+Enter, Alt+Enter, and Cmd+Enter all triggered message send.
This broadens the modifier check so any modifier+Enter inserts a newline.
Also sets submitOnEnter={false} to let the custom handleKeyDown manage
all Enter logic, eliminating dual-handler ambiguity with the library's
built-in submit behavior.
Closes#429
* fix(web): prevent modifier+Enter from accidentally sending messages
Only plain Enter should send; Ctrl/Alt/Cmd+Enter were incorrectly
falling through to the send path because the guard only checked
e.shiftKey. Now all non-Shift modifier combos are blocked from
sending (preventDefault + no-op).
Also keeps submitOnEnter={false} so the custom handleKeyDown is the
sole owner of Enter-key logic, avoiding dual-handler ambiguity.
Closes#429
* fix(web): restore Enter to accept autocomplete suggestions
The previous refactor made the Enter handler unconditionally return
before reaching the suggestion-selection path. Move suggestion
handling above the send/no-op block so Enter still accepts visible
autocomplete entries.
* fix(cli,hub): resolve typecheck errors in codex reasoning effort and notification test
- Cast `getModelReasoningEffort()` return (string | null) to
`ReasoningEffort | undefined` at three call sites in
codexLocalLauncher.ts and runCodex.ts where the narrower type is
expected.
- Add missing `modelReasoningEffort: null` default in
notificationHub.test.ts to satisfy the Session type contract.
These errors were introduced in 79a13d2 and have been failing CI on
main since 2026-04-10.
* fix(cli): add missing getModelReasoningEffort to test mock session
The test stub in codexLocalLauncher.test.ts was missing the
getModelReasoningEffort method added in 79a13d2, causing runtime
TypeError in CI.
The CLI wraps array-content user messages as agent output because
isExternalUserMessage rejects non-string content. On the web side,
detect text-only arrays in non-sidechain user output and emit them as
role:'user' so they display in the user lane.
Also handle sidechain user messages with mixed array content (e.g.
tool_result + text) by extracting text parts into a sidechain block.
Closes#407's original scope on top of the #402 base.
via [HAPI](https://hapi.run)
Co-authored-by: HAPI <noreply@hapi.run>
* fix(web): drop "No response requested." assistant messages
When Claude Code injects system messages (task notifications, system
reminders) as user turns, Claude responds with "No response requested."
In the HAPI web UI this appears as a reply to the user's message,
making it look like Claude is ignoring their input.
Filter these out in isSkippableAgentContent() (catches the fallback
path in normalize.ts) and in normalizeAssistantOutput() (catches the
primary path). Both checks verify the assistant message contains only
the text "No response requested." with no tool calls.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(web): filter text block instead of dropping entire message
Address bot review: dropping the whole normalized record breaks
sidechain UUID threading (parentUUID chain orphans).
Instead of returning null, suppress only the "No response requested."
text block during content extraction. The message record (uuid,
parentUUID, usage) is preserved so the tracer's sidechain grouping
continues to work.
Also remove the isSkippableAgentContent check since we no longer
need to drop the message at that layer.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(web): move "No response requested." filter to reducer layer
Address bot review: filtering in normalizeAssistantOutput() produced
empty content arrays, breaking traceMessages() which reads uuid and
parentUUID from content[0]. Sidechain child messages whose parentUUID
pointed to the filtered message became orphaned.
Fix: revert the normalizer to always emit the text block (preserving
the UUID chain for the tracer), and filter the sentinel text in
reducerTimeline.ts where text blocks become visible AgentTextBlocks.
At this point tracing is already complete.
Also adds reducer-level tests for the filter and updates the
normalize test to verify the text block is preserved.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(web): scope sentinel filter to single-block assistant messages only
Address bot review: the previous filter suppressed any text block
matching "No response requested.", which could hide legitimate replies.
Now the filter only triggers when the message has exactly one content
block (msg.content.length === 1) — i.e., the assistant response is
purely the sentinel text with no tool calls or reasoning blocks.
This prevents false positives while still catching the system-injection
auto-reply case.
Add test for the multi-block case (text + tool call) to verify the
sentinel text is preserved when other content exists.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(web): add parentUUID structural check to sentinel filter
Address bot review: raw text match alone could theoretically suppress
a legitimate reply. Add c.parentUUID !== null as a structural guard:
- Sentinel auto-replies always follow a prior assistant turn, so their
parentUUID is set (pointing to the previous message in the chain).
- A first message in a conversation has parentUUID: null and will
never be filtered.
Combined conditions: msg.content.length === 1 (sole block, no tool
calls) AND c.parentUUID !== null (not the first reply) AND exact text
match.
Add tests for the parentUUID=null escape hatch.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(web): use injected-turn UUID tracking for sentinel filter
Address review: use structural markers instead of broad text matching.
1. Pre-scan collects UUIDs from sidechain content blocks (system-
injected user turns). The sentinel filter now only triggers when
parentUUID points to one of these known injected turns.
2. Move task-notification event extraction from normalizer to reducer.
Previously, task-notifications with summary were normalized as
role:'event', losing their uuid. Now they stay as sidechain (uuid
preserved for pre-scan), and the reducer extracts the summary as
an agent-event block.
3. Remove redundant 'uuid' in c guard (always present on sidechain type).
False positive analysis: a legitimate reply is only suppressed when ALL
of: (a) sole content block, (b) parentUUID matches a sidechain-injected
turn, (c) exact sentinel text. This combination cannot occur for real
user-facing content.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(web): add parentUUID to sidechain content type for tracer linkage
The sidechain content block was missing parentUUID, so traceMessages()
could not chain system-injected user turns (task notifications, system
reminders) inside a Task sidechain back to their parent. This caused
later sidechain messages pointing to the injected turn's UUID to become
orphaned and disappear from the Task card.
Add parentUUID to the sidechain type definition and propagate it from
normalizeUserOutput() in both the isSidechain and non-sidechain paths.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(web): handle array-content sidechain user messages to prevent prompt leak
Sidechain user messages can arrive with either string content or array
content ([{type:'text', text:'...'}]) depending on how Claude Code
serialises them. The previous fix only handled the string case, causing
intermittent prompt leaks when array format was used.
Now normalizeUserOutput extracts text from array-content sidechain
messages and emits them as sidechain blocks, so the tracer can match
them to their parent Task tool call.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* test(web): verify parentUUID propagation from assistant output data
Add integration tests confirming normalizeAssistantOutput correctly
maps data.parentUuid to text block parentUUID (used by the reducer's
sentinel detection). Tests cover both present and absent parentUuid.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: HAPI <noreply@hapi.run>
* fix(cli): filter raw SSE event JSON from leaking into chat messages
Two types of internal JSON were appearing as visible text in Telegram
Mini App and web chat:
1. `rate_limit_event` — the rate limit parser returned `null` for
unknown statuses, causing raw JSON to pass through as assistant text.
Changed to `{ suppress: true }` so all rate_limit_event variants are
handled; new statuses that need display can be added explicitly.
2. `{ type: "output", data: { ... } }` — internal session metadata
envelopes leaked through the ACP text chunk pipeline. Added an
`isInternalEventJson` filter that catches JSON objects with known
internal envelope types (output, event, queue-operation) before they
enter the text buffer.
Closes#386
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(cli): narrow internal event filter to match only leaked metadata shape
Address review feedback: the broad type-based filter could suppress
legitimate assistant JSON with type "event" or "queue-operation".
Narrow the check to only match the specific leaked metadata envelope:
{ type: "output", data: { parentUuid, sessionId, userType } }
Add negative tests confirming other JSON types pass through.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(cli): suppress malformed rate_limit_event without resetsAt
Address review: rate_limit_event payloads missing resetsAt still leaked
as raw JSON because parseRateLimitText returned null before reaching the
unknown-status suppress. Move the allowed check before the resetsAt
guard and suppress malformed payloads instead of passing them through.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(cli): handle parentUuid: null in internal event filter
Root/first-message metadata envelopes have parentUuid: null rather than
a string, so the filter missed them. Accept both string and null.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* test(cli): add e2e regression tests for metadata envelope filtering
Add AcpMessageHandler tests that verify leaked { type: "output", data }
metadata envelopes (both parentUuid string and null) are dropped before
reaching the text buffer.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(cli): clear buffered prefix when cumulative metadata chunk arrives
When a leaked metadata envelope arrives as cumulative streaming chunks
(first an incomplete JSON prefix, then the full blob), the filter
dropped the full chunk but left the prefix in bufferedText. Clear the
buffer when the detected internal JSON starts with the buffered prefix.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(cli): round resetsAt to integer for pipe-delimited format
The web-side regex uses \d+ to parse the timestamp, so a float value
would silently fail to match. Apply Math.round to ensure integer output.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(cli): clear buffered prefix for cumulative rate_limit_event chunks
The prefix-clearing logic only applied to the isInternalEventJson
branch but not to the parseRateLimitText branch, so cumulative
rate_limit_event chunks could leave a raw JSON prefix in the buffer.
Hoist the prefix check before both filters and apply uniformly.
Add regression tests for suppressed and displayable cumulative
rate_limit_event scenarios.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: HAPI <noreply@hapi.run>
* fix(cli): continue execution after plan mode in YOLO/bypassPermissions
In YOLO mode (bypassPermissions), exit_plan_mode was auto-approved like
any other tool, skipping the PLAN_FAKE_RESTART injection that tells the
agent to continue. Combined with isAborted() always returning true for
exit_plan_mode, claudeRemote exited the query loop and stalled waiting
for user input.
Fix: in the bypassPermissions branch of handleToolCall, intercept
exit_plan_mode specifically — inject PLAN_FAKE_RESTART into the message
queue and return deny with PLAN_FAKE_REJECT, matching the behavior of
the normal approval flow.
Closes#172
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* test(cli): remove unused isPlanTool helper
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: HAPI <noreply@hapi.run>
The copy button was absolutely positioned (right-0 top-0) over the
entire message content area. When assistant messages contain both text
and tool calls (e.g. TodoWrite), the button overlapped the tool card UI.
Move the button from absolute positioning inside the content wrapper to
an inline flex layout after the content. This places it at the bottom-
right of the message, below all content (text + tool cards), so it never
overlaps anything. The hover-to-reveal behavior is preserved.
Also restores getAssistantCopyText to its original logic so mixed
text+tool messages remain copyable (the previous fix of suppressing the
button entirely for mixed messages was too aggressive).
via [HAPI](https://hapi.run)
Co-authored-by: HAPI <noreply@hapi.run>
The release workflow produces `hapi-linux-x64-baseline.tar.gz` (from
bun-linux-x64-baseline target), but the Homebrew formula generator wrote
the URL as `hapi-linux-x64.tar.gz`, causing `brew install` to fail on
Linux x64 with a 404.
Closes#365
via [HAPI](https://hapi.run)
Co-authored-by: HAPI <noreply@hapi.run>
* fix(hub): raise maxRequestBodySize so file uploads work
The Bun server inherited maxRequestBodySize from Socket.IO's default
maxHttpBufferSize (1 MB). The upload endpoint sends files as base64
in JSON, so any image > ~750 KB was silently rejected before reaching
the route handler. The frontend allows 50 MB uploads.
Raise the limit to at least 100 MB to accommodate 50 MB files with
base64 encoding overhead (~33%).
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(hub,web): fix file uploads — raise body limit, lower max size, show errors
Three changes:
1. hub/server.ts: Bun's maxRequestBodySize inherited Socket.IO's 1 MB
default, silently rejecting any upload. Raise to 10 MB.
2. hub/routes + web/attachmentAdapter: lower MAX_UPLOAD_BYTES from
50 MB to 5 MB (realistic for images; 5 MB base64 ≈ 6.7 MB body,
fits within the 10 MB server limit).
3. web/AttachmentItem: show "Upload failed" text and strike-through
filename on error, instead of just a tiny icon.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(hub): keep 50MB upload limit, size maxRequestBodySize to match
Bot review correctly flagged that lowering MAX_UPLOAD_BYTES to 5 MB
regresses the documented 50 MB limit. Revert to 50 MB and calculate
maxRequestBodySize properly: 50 MB × 4/3 (base64) + 1 MB (JSON
overhead) ≈ 68 MB.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: HAPI <noreply@hapi.run>
* fix(web): reconnect SSE immediately when tab becomes visible
The SSE watchdog skips heartbeat checks while the tab is hidden. If the
connection dies in the background, the user sees stale messages after
switching back and has to wait up to 10 s for the next watchdog tick.
Add a visibilitychange listener that checks heartbeat staleness
immediately when the tab becomes visible and reconnects if stale.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(web): map visibility-recovery reason in reconnecting banner
The new 'visibility-recovery' reconnect reason was not mapped in
getReasonLabel(), so the raw string would appear in the UI banner.
Add localized labels for both en and zh-CN.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: HAPI <noreply@hapi.run>
* fix(web): disable indented code blocks in markdown rendering
In CommonMark, text indented by 4+ spaces is treated as a code block.
LLM responses frequently have indented content inside numbered lists or
quoted text, causing large chunks to render as a single code block
instead of formatted markdown.
Add a remark plugin that disables the codeIndented tokenizer. Fenced
code blocks (``` … ```) continue to work normally.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(web): fix typecheck for remark plugin this binding
Use `as any` cast for the unified processor `this` context instead of
an explicit type annotation that conflicts with the Processor type.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(web): use this:unknown annotation to satisfy noImplicitThis
The previous `as any` cast on `this` still triggers noImplicitThis in
strict mode. Annotate the parameter as `this: unknown` and cast to
the required shape inside the function body. Also use the key-based
`data(key, value)` API instead of mutating the returned object.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: HAPI <noreply@hapi.run>
The rename endpoint uses PATCH /api/sessions/:id, but the CORS
middleware only allowed GET, POST, DELETE, OPTIONS. Browsers send a
preflight OPTIONS request for PATCH; without it in allowMethods the
preflight fails and the request never reaches the handler, causing
"Failed to rename" in the web UI every time.
via [HAPI](https://hapi.run)
Co-authored-by: HAPI <noreply@hapi.run>
* fix(web): filter system-injected XML tags from rendering as raw text
Claude Code injects internal messages (<task-notification>, <system-reminder>,
<command-name>, <local-command-caveat>) as user-role messages. The web UI was
rendering these as raw XML text visible to users.
- Parse <task-notification> and display as agent-event with summary text
- Silently drop <system-reminder>, <command-name>, <local-command-caveat>
- Add tests covering all injection prefixes and edge cases
* fix(web): scope system injection filtering to Claude sessions only
Address review feedback: the XML tag filtering was applied at the
generic timeline layer, which could incorrectly hide legitimate user
messages in Codex/Gemini sessions.
- Add isClaudeSession flag threaded from Session.metadata.claudeSessionId
- Only filter system-injected tags when isClaudeSession is true
- Add tests verifying non-Claude sessions pass through all messages
* fix(web): treat all string user output as sidechain to prevent prompt leaks
Restores the fix from 3cf96ab that was accidentally reverted in 2205e04.
In normalizeUserOutput(), string-content user messages arriving through
the agent output path are never real user input (real user text goes
through normalizeUserRecord). Previously, non-sidechain string messages
were emitted as role:'user', causing subagent prompts and system-injected
messages to render as user text in the web UI.
Now all string-content user messages in this path are:
- <task-notification> with summary → converted to role:'event'
- Everything else → marked as sidechain (matched to parent Task tool
call by the tracer, or harmlessly skipped by the reducer)
This provides a root-level fix that prevents ANY string user message
from the agent output path from leaking as visible user text.
* ci: retrigger CI
* fix(web): remove superseded return-null filter from upstream PR #372
The upstream `return null` filter for <task-notification> and
<system-reminder> (from PR #372) is now superseded by the comprehensive
sidechain upgrade logic. Remove it to avoid short-circuiting the new
task-notification → event conversion.
* refactor(web): remove reducer-side system injection filtering
System-injected messages are now fully handled in normalizeUserOutput()
(normalize layer), so the redundant filtering in reduceTimeline() is no
longer needed. Removing it also eliminates the risk of accidentally
hiding legitimate user messages that happen to start with XML tags.
- Remove SYSTEM_INJECTION_PREFIXES, isSystemInjectedMessage,
parseTaskNotificationSummary from reducerTimeline.ts
- Remove isClaudeSession plumbing from reducer.ts and SessionChat.tsx
- Simplify reducerTimeline.test.ts to only test pass-through behavior
* feat(web): add copy button to user messages
Add a small copy button to user message bubbles for easy text copying,
especially useful on mobile where selecting text is difficult.
- Mobile: button always visible (opacity-60)
- Desktop: button appears on hover
- Uses existing useCopyToClipboard hook with haptic feedback
- Conditionally rendered to avoid empty container spacing
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix(web): use valid CSS property in copy button transition
transition-[opacity,colors] is invalid because 'colors' is not a CSS
property (only Tailwind's utility class 'transition-colors' expands it).
Use 'background-color' instead so the hover background transition
actually works.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: HAPI <noreply@hapi.run>