The message-received branch of shouldSend() in hub/src/sse/sseManager.ts
checks only connection.sessionId === event.sessionId, ignoring the
connection.all flag. As a result, any SSE connection subscribed with
all: true (to observe events across every session in the namespace)
silently never receives message-received events, even though every
other event type below this branch honors connection.all.
Closes#506
Co-authored-by: huchenxi <huchenxi@lattebank.com>
* fix(web): use global pointer listeners for sidebar resize handle
The current implementation attaches pointermove/pointerup to the
resize handle element via setPointerCapture. When the cursor moves
fast enough to leave the narrow 4px handle, the browser may not
deliver subsequent pointer events to the element, causing:
- Cursor stuck as col-resize even after releasing the mouse
- Sidebar stops tracking the pointer, requiring a page reload
Switch to document-level pointermove/pointerup/pointercancel listeners
that are added on drag start and cleaned up on drag end. This
guarantees events are captured regardless of cursor position.
Also removes onPointerMove and onPointerUp from the hook's return
value (and the JSX props in router.tsx) since they are no longer
needed — the hook manages everything internally via useEffect.
* ci: retrigger CI (flaky AcpSdkBackend test)
* fix: scope drag listeners to the initiating pointer ID
Address review feedback: filter pointermove/pointerup/pointercancel
by the pointer that started the drag, so a second finger or stylus
cannot interfere with the resize.
---------
Co-authored-by: huchenxi <huchenxi@lattebank.com>
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>
* feat(hub,cli): forward permissionMode on session resume
When a session is resumed, the cached permissionMode is now forwarded
through the Hub → Runner → CLI pipeline via a new --permission-mode
flag. Previously the mode was lost on resume, resetting to 'default'.
Each CLI flavor validates the flag value against its own allowed
permission modes (e.g. CLAUDE_PERMISSION_MODES) and rejects unknown
values. The existing --yolo flag is preserved as a shorthand.
* refactor(cli): extract buildCliArgs from startRunner
Extract the CLI argument construction logic into a standalone
exported function so it can be unit-tested independently.
No behavior change.
* test(cli): add buildCliArgs unit tests for --permission-mode
Verify that the runner correctly forwards valid permission modes
via --permission-mode, rejects invalid values, and falls back to
--yolo when no permission mode is set.
* fix(cli): let --permission-mode take precedence over --yolo
When both flags are present, --permission-mode was silently
overwritten by a later --yolo. Guard legacy flag branches with
a hasExplicitPermissionMode check so the explicit flag wins.
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>
* feat(cli): support extra headers for hub requests
* fix(types): normalize missing session fields to null
* refactor(cli): simplify socket extra headers config
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.
* feat(web): persist composer draft across session switches
Switching between sessions now preserves the text typed in the
composer. Drafts are stored per-session in sessionStorage and
restored when the user navigates back.
- Add composer-drafts utility (sessionStorage, in-memory cache)
- Restore draft on HappyComposer mount, save on unmount
- Clear draft on message send
- Evict oldest drafts when exceeding 50 entries
- Add unit tests for composer-drafts
Fixes#231
* fix(web): add key prop to HappyComposer for explicit remount on session switch
* fix(web): move clearDraft to SessionChat after send validation
Prevents draft loss when Codex rejects an unsupported slash command.
* fix(web): remove explicit clearDraft, rely on unmount save
Successful sends clear the composer text, so the unmount save
naturally persists an empty string which deletes the draft entry.
This avoids clearing the draft when the send is blocked or fails.
* fix(web): clear draft on successful send via onSuccess callback
Move draft clearing to the send-success path so drafts are only
removed after the message is actually accepted by the server.
* fix(web): pass session ID to onSuccess to clear correct draft
The previous version used the current route's sessionId, which could
clear the wrong draft if the user switched sessions before the send
completed.
* test(web): add useSendMessage onSuccess callback tests
Verify that onSuccess receives the correct session ID (including
resolved IDs), and is not called on send failure or block.
* refactor(web): extract useComposerDraft hook with unit tests
Extract the draft save/restore logic from HappyComposer into a
dedicated useComposerDraft hook. Adds 6 unit tests covering:
- mount: restores saved draft via rAF
- mount: skips restore if composer already has text
- mount: skips restore if no saved draft
- unmount: saves current text after rAF has fired
- unmount: skips save before rAF (draftReady guard)
- no-op when sessionId is undefined
* fix(web): clear both route and resolved session drafts after send
When resolveSessionId swaps the session (e.g. inactive → resumed),
the sent ID differs from the route's session ID. Extract
clearDraftsAfterSend so both are cleared and unit-testable.
* fix(web): refresh eviction order when updating an existing draft
Delete the key before re-inserting so Object.keys() reflects the
most recent write, preventing a recently edited draft from being
evicted first.
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
The process is spawned with `shell: process.platform === 'win32'` while including dynamic values (e.g., `opts.sessionId`) in `args`. On Windows, shell invocation can introduce command parsing/injection risks if arguments are not strictly validated/escaped.
Affected files: opencodeLocal.ts
Signed-off-by: tuanaiseo <221258316+tuanaiseo@users.noreply.github.com>
* 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.
- Enter no longer inserts newline when sending is blocked (no-op instead)
- Shift+Enter now inserts newline (standard convention)
- Allow appending messages while agent is running (remove threadIsRunning
gate from canSend) — matches Claude Code CLI behavior where users can
queue messages during execution
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>