* 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>
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>
* 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>
* refactor(web): extract normalizeTimestamp helper in presentation
Extract the shared seconds-vs-milliseconds normalization logic into
a private `normalizeTimestamp()` helper. No behavior change —
`formatUnixTimestamp()` produces identical output.
* refactor(web): return AgentEvent from parseClaudeUsageLimit
Change return type from `number | null` to `AgentEvent | null` so
the caller doesn't need to construct the event object. No behavior
change — the same `limit-reached` event is produced.
* feat(cli): convert rate_limit_event to standardized text format
Parse undocumented Claude `rate_limit_event` JSON in the CLI adapter
layer (AcpMessageHandler) before it reaches the web.
Converted text format (pipe-delimited):
- "Claude AI usage limit warning|{ts}|{pct}|{rateLimitType}"
- "Claude AI usage limit reached|{ts}|{rateLimitType}"
Status handling:
- `allowed_warning` → warning text with utilization and limit type
- `rejected` → reached text with limit type
- `allowed` → silently suppressed (noise)
- unknown statuses → passed through as-is (forward-compatible)
* feat(web): display rate limit warnings with limit type
Parse standardized pipe-delimited text from the CLI adapter into
`limit-warning` and `limit-reached` events, displaying the rate
limit type (5-hour, 7-day) when available.
- `limit-warning`: "⚠️ Usage limit 90% (5-hour) · resets 2:00 PM"
- `limit-reached`: "⏳ Usage limit reached (5-hour) until 4/2/2026"
- Backward compatible: `limit-reached` without limitType still works
The `reached` regex uses `(?:\|([^|]*))?$` to optionally match the
limitType field, maintaining compatibility with the existing format.
* refactor(cli): move rate limit parsing out of flushText
Remove rate limit detection from flushText() back to plain buffer
flush. The next commit will re-add parsing at the chunk level
(handleUpdate) where it can intercept before buffer merging.
Includes failing tests that demonstrate the mixed-chunk bug:
when a rate_limit_event chunk arrives in the same turn as normal
text, the JSON leaks into the merged buffer.
* fix(cli): intercept rate_limit_event at chunk level, not flush
Move rate limit detection from flushText() to the agentMessageChunk
handler so it fires before the chunk enters the shared text buffer.
Previously, a rate_limit_event chunk arriving in the same turn as
normal text would merge into bufferedText and leak as raw JSON.
Now the chunk is intercepted individually, the existing buffer is
flushed first (preserving prior text), and the converted message
is emitted separately.
* fix(cli): skip flush when suppressing allowed rate_limit_event
Only flush the text buffer when the parsed event will actually be
displayed. Suppressed events (e.g. status: 'allowed') now return
immediately without flushing, preventing a text → allowed → text
sequence from splitting one answer into two agent-text blocks.
* fix(web): include limitType in limit-reached reconcile key
Without this, reprocessing a message from the old format (no
limitType) to the new typed format reuses the stale block and
the (5-hour)/(7-day) suffix never appears.
* 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>
* feat: improve spawn error handling and reporting across full stack
- Return error result instead of throwing in apiMachine spawn handler
- Add lastSpawnError field to RunnerState for persistent error tracking
- Add error awaiter system for early process exit/error detection before webhook
- Build detailed webhook failure messages with exit code, signal, and stderr tail
- Report spawn outcomes to hub via runner state updates
- Handle more spawn result types in rpcGateway with better error messages
- Display runner last spawn error in web UI (NewSession & SpawnSession)
- Extract shared formatRunnerSpawnError utility to avoid duplication
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(cli): narrow spawnResult type check to fix TS2339 error
Use `type === 'error'` instead of `type !== 'success'` to properly
narrow the discriminated union, allowing TypeScript to infer errorMessage.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: Add Claude Code Agent Teams support
- Add TeamState schemas and types for team collaboration
- Extract team state from TeamCreate, SendMessage, Task tools
- Add database migration V3→V4 for team_state storage
- Add TeamPanel component to display team members, tasks, messages
- Add team tool icons and presentation rules
- Support vite proxy configuration via VITE_HUB_PROXY env var
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix: Add timestamp protection for team_state updates
Prevent old messages from overwriting newer team state by checking
team_state_updated_at before updating.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix: Extract team tasks from Task/TaskCreate/TaskUpdate tools
- Enhance processTaskToolWithTeam to also generate task entries from
the Task tool's description field when spawning teammates
- Add processTaskCreate handler for TaskCreate tool calls
- Add processTaskUpdate handler for TaskUpdate tool calls
- Register both new tools in the extraction switch statement
This fixes the gap where the Tasks section in TeamPanel could never
populate because team task data was not being extracted from the
message stream.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* fix: Skip orphan TaskUpdate without title to prevent schema validation failure
When TaskUpdate arrives before TaskCreate (message ordering), skip inserting
incomplete tasks that lack required title field, preventing entire teamState
from being dropped by schema validation.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* test: Add unit tests for orphan TaskUpdate handling
Verify that applyTeamStateDelta correctly skips inserting tasks without
title field (orphan TaskUpdate) while still allowing normal task creation
and updates to existing tasks.
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: tfq <tfq@gmail.com>
Co-authored-by: HAPI <noreply@hapi.run>
* feat(web): add appearance setting (follow system / dark / light)
Add user-facing appearance preference to the settings page, allowing
users to choose between Follow System, Dark, and Light themes. The
preference is persisted to localStorage and takes priority over
automatic detection in the existing theme pipeline.
* fix(web): update theme on cross-tab appearance change
The storage event handler only updated React state without calling
updateScheme(), leaving data-theme and useTheme subscribers stale.
* fix(web): move cross-tab appearance sync to global initializeTheme
The storage event listener was inside useAppearance(), which is only
mounted on the settings page. Other pages never received cross-tab
theme updates. Move the listener into initializeTheme() so all pages
respond to appearance changes from other tabs.
Replace hardcoded English strings with translation function calls
in SpawnSession.tsx and router.tsx. Add newSession.title key for
the NewSessionPage header to match the newSession.* namespace.
Add project-level slash command discovery with recursive nested command scanning, pass workingDirectory through slash-command handlers, and align hub/web source unions to include project commands.
* feat(cursor): add support for Cursor Agent CLI integration
- Introduced new command `hapi cursor` to start Cursor Agent sessions.
- Added functionality for resuming sessions and managing permission modes.
- Updated documentation to include Cursor Agent usage and installation instructions.
- Enhanced existing codebase to accommodate Cursor as a recognized agent flavor.
- Implemented local and remote session handling for Cursor Agent.
This update expands HAPI's capabilities by integrating support for the Cursor Agent, allowing users to leverage its features alongside existing agents.
* Remove TODO.md file as it is no longer needed following the integration of Cursor Agent CLI support. This cleanup helps streamline project documentation and reflects the completion of the associated tasks.
* feat(cursor): implement remote mode and fix --hapi-starting-mode
- Consume --hapi-starting-mode in cursor command (do not forward to agent)
- Implement cursorRemoteLauncher: spawn agent -p with stream-json, --trust
- Add cursorEventConverter for NDJSON parsing (system/assistant/tool_call/result)
- Multi-turn via --resume session_id
- Update docs: cursor supports both local and remote modes
Made-with: Cursor
* fix: type error
* fix(cursor): address PR review - model UI, sessionId metadata, duplicate flags
- HappyComposer: use isClaudeFlavor for model mode (cursor has no model modes)
- cursorLocalLauncher: call onSessionFound for resume so cursorSessionId in metadata
- cursorCommand: do not forward parsed flags to cursorArgs (avoid duplicates)
Made-with: Cursor
On iPadOS with a hardware keyboard, `isTouch` is true (via pointer:coarse)
so submitOnEnter is disabled and there's no keyboard-based way to send.
Add Shift+Enter as a send shortcut that works on all platforms without
changing existing behavior — desktop Enter-to-send is preserved.
Closes#175
via [HAPI](https://hapi.run)
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>