* feat(web): add workspace browser for multi-directory navigation
Add /browse route with a folder browser that lets users navigate
filesystem directories on connected machines and launch sessions
from any folder. Supports saved workspace paths and direct path
input. The "Start Session" action pre-fills the NewSession form.
- CLI: register machine-level `list-directory` RPC handler
- Hub: add POST /machines/:id/list-directory route
- Web: add WorkspaceBrowser component with git repo detection
- Web: add /browse route with navigation from sessions sidebar
- Web: support initialDirectory/initialMachineId in NewSession
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add --workspace-root opt-in scoping for /browse and session spawn
Adds a single new flag, \`--workspace-root <path>\` (with \`~\` / \`~/foo\`
expansion), on \`hapi runner start\` and \`hapi runner start-sync\`.
When set:
- The runner reports the path in machine metadata.
- The list-directory and spawn-session RPC handlers reject paths outside
the root, so the web UI can't escape the configured tree even if
someone crafts a request manually.
- The /browse page in the web UI auto-opens that root, restricts the
breadcrumb / go-up to its subtree, and shows directory entries with
git-repo annotations.
- The /sessions/new form keeps its existing free-text directory input
plus autocomplete + recent-paths chips, and gains a small "Browse"
button (next to the input) that opens /browse for picking a folder.
- Reconnect-time metadata sync ensures stale records get the field
filled in (or cleared when the flag is dropped on a later restart),
so the hub state matches the CLI's intent.
When unset:
- Runner behaves like the legacy hapi (no scoping, no browse feature).
- /browse renders an informative state pointing at the flag instead of
blocking the user.
- The /sessions/new form looks identical to the pre-change behavior;
the "Browse" button is hidden.
Includes a startup banner so \`runner start-sync\` no longer looks like
it hung, and surfaces the workspace-root sync result on stdout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(hub): preserve workspaceRoot when rehydrating machines from store
MachineCache.refreshMachine() rebuilt the metadata object from an
explicit field allowlist, so any field not in the list (including the
new workspaceRoot) was silently dropped on every read — even though it
was correctly written to the store.
Add workspaceRoot to the zod schema, the Machine interface, and the
hand-rolled projection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(web): friendlier empty state on /sessions
When there are zero sessions the page used to be a vast blank
rectangle with just the "0 sessions in 0 projects" caption. Render a
centered empty state instead: a calendar/agenda icon, a short heading
and hint, and two buttons — "Start a session" (→ /sessions/new) and
"Browse workspace" (→ /browse).
SessionList gains an optional onBrowse prop. Router wires it on the
sessions page so the secondary button resolves; other callers can leave
it unset to hide that button.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: document --workspace-root flag in cli/README and root README
Add a short paragraph under "Runner management" in cli/README.md
explaining what \`--workspace-root\` enables (scoped /browse tree,
list/spawn enforcement, tilde expansion) and that omitting it keeps
the legacy behavior. Mention the workspace browser in the top-level
README's Features list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address PR #526 review feedback
Three findings from the review bot:
1. [Major] Workspace-scope check was lexical only. With workspaceRoot
= /safe, a symlink such as /safe/out -> /etc would pass the relative-
path test and let list-directory / spawn-happy-session reach paths
outside the configured root. realpath the workspaceRoot at construction
time, and resolve every incoming path through realpath (walking up to
the nearest existing parent for spawn targets that haven't been
created yet) before the containment check.
2. [Minor] \`hapi runner start --workspace-root\` with no value used to
drop the flag silently and start the runner unscoped. Now treats a
missing or flag-shaped next argument as an error.
3. [Minor] /sessions/new's "Browse" button always opened /browse using
localStorage's last-used machine, ignoring the user's current
selection. NewSession already passes machineId in its callback;
forward it through the /browse search params and seed
WorkspaceBrowser with it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): gate list-directory RPC behind --workspace-root opt-in
Without a configured workspaceRoot, isWithinWorkspaceRoot() returns
true unconditionally, leaving the new list-directory RPC able to
enumerate any path on the runner. The Web UI already hides Browse
for these machines, but the backend should enforce the opt-in too.
Refuse the RPC up front when no workspace root is configured.
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(runner): prevent ghost sessions from orphaned spawn webhooks
spawnSession() registers a tracked session entry before the child's
"Session started" webhook arrives. When the 15s webhook timeout fires,
only pidToAwaiter / pidToErrorAwaiter are cleared; the
pidToTrackedSession entry is left in place and the detached child
keeps running. If the child eventually starts and reports its webhook,
onHappySessionWebhook() still finds the stale tracking entry and
promotes the orphan into a normal runner-managed session, surfacing
a message-less "ghost session" in the web UI.
This is easy to reproduce on opus[1m] --resume: observed real-world
case where four rapid "resume" clicks produced four orphan children
that all reported their webhooks ~60 minutes later, creating four
ghost sessions on the dashboard.
Three-part fix:
1. Make the webhook timeout configurable via
HAPI_RUNNER_WEBHOOK_TIMEOUT_MS (default unchanged at 15_000). Users
on slow models / large resumes can raise the ceiling so the
timeout never fires in the first place.
2. On timeout, also delete the pidToTrackedSession entry and SIGTERM
the child, so a late webhook cannot promote the orphan.
3. Defence in depth: if onHappySessionWebhook() receives a webhook
from a PID that is not tracked but whose payload claims
startedBy: 'runner', ignore it and SIGTERM the child. Genuine
terminal-launched children correctly report
startedBy: 'terminal', so this branch cannot false-positive on
them.
* fix(runner): use tree-kill on timeout and clean up worktree for orphans
Address review feedback on the kill path and worktree cleanup:
1. Timeout handler: replace bare `happyProcess.kill('SIGTERM')` with
`killProcessByChildProcess(happyProcess)` so the entire process tree
(wrapper + detached agent grandchildren) is reaped, matching the
existing `stopSession()` behaviour.
2. Orphan webhook handler: replace bare `process.kill(pid, 'SIGTERM')`
with `killProcess(pid)` for proper SIGTERM → SIGKILL escalation.
A ChildProcess reference is unavailable here (tracking entry already
removed), so tree-kill is not possible — but the timeout handler
should have already tree-killed the group; this is defence-in-depth.
3. Worktree leak: when a worktree session times out, register a
one-shot `exit` listener on the child process to run
`cleanupWorktree()` after the child actually exits. Previously
`maybeCleanupWorktree('spawn-error')` would skip cleanup because
the child was still alive at that point, and `onChildExited()` had
no worktree awareness after the tracking entry was deleted — so the
worktree leaked permanently.
---------
Co-authored-by: fengtian <fengtian@users.noreply.github.com>
crypto.randomUUID is only exposed in secure contexts (HTTPS or
localhost). When the web app is served over HTTP on a LAN IP the
attachment adapter, toast provider, message localId helper, file
attachment metadata and terminal id creation all call
crypto.randomUUID() synchronously and throw TypeError, so the UI
silently does nothing (e.g. the file picker opens and closes with no
chip).
Add a small web/src/lib/randomId helper that tries crypto.randomUUID
first, then falls back to crypto.getRandomValues-derived UUID v4,
and finally to a Date.now/Math.random string for very old
environments. Route all five call sites through it. Output format is
identical for secure contexts and UUID v4 for the getRandomValues
path, so existing DB/SSE/RPC consumers see the same shape.
* refactor(agent): extend AgentMessage and CodexMessage unions with reasoning variant
Add a reasoning variant to the shared AgentMessage union that flows
out of the ACP backend, and pass it through to CodexMessage so the
existing web reducer (which already renders { type: 'reasoning' }
parts as collapsible blocks) can consume ACP-sourced thoughts
identically to Codex.
No behavior change yet: the ACP handler still drops thought chunks,
and the remote launchers receive the new variant as a no-op. The
behavior is wired up in the following commit.
* feat(acp): forward agent_thought_chunk as reasoning message
Route ACP thought chunks to the session as reasoning AgentMessages so
OpenCode and Gemini thinking output reaches the web UI's Reasoning
block, matching the existing Codex behavior.
Thought chunks are emitted inline without flushing the pending text
buffer — text and thought live on independent interleave lanes, so
splitting a live text segment on every thought arrival would be
wrong. The inline-emit ordering is documented alongside the test
that depends on it.
extractTextContent is not reused for thought content: its
assistant-audience filter is correct for regular message chunks but
would silently drop thoughts annotated with a non-assistant audience,
which have no meaningful audience to filter against. A direct text
block shape check handles the narrower need.
In the remote launchers, reasoning is surfaced to the local terminal
buffer as a truncated system-role hint prefixed with [Thinking],
matching how the Codex flavor already displays reasoning chunks
in-terminal without mixing them into the assistant reply stream.
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