1134 Commits
Author SHA1 Message Date
SSU-WEI HUANGandGitHub 53406b2e97 fix(web): sync browser tab title with session (#1034)
* test: reproduce issue #712

* fix: sync browser title with session (closes #712)
2026-07-16 12:30:21 +08:00
SSU-WEI HUANGandGitHub 553b3492f1 fix(codex): wait for manual compaction to finish (#1038)
* test: reproduce issue #982

* fix: wait for Codex manual compaction (closes #982)

* test: cover Codex compaction turn completion
2026-07-16 12:30:04 +08:00
SSU-WEI HUANGandGitHub c87720ab4d fix(cli): load extra headers from settings (#1041)
* test: reproduce issue #786

* fix: load extra headers from settings (closes #786)

* test: cover extra header precedence and redaction

* fix: redact persisted extra headers in diagnostics

* test: cover runner extra header identity

* fix: restart runner when extra headers change
2026-07-16 12:27:50 +08:00
DullJZandGitHub e87e875d72 feat(opencode): accept -s/--session as resume aliases (#1042)
Map OpenCode-native session flags to resumeSessionId so
`hapi opencode -s <id>` restores the session like --resume.
2026-07-16 12:26:19 +08:00
87f5c78ab2 fix(codex): scan transcripts incrementally (#1031)
Co-authored-by: zj1123581321 <zj1123581321@users.noreply.github.com>
2026-07-13 15:51:40 +08:00
weishu 1c834607a2 Release version 0.22.3 2026-07-13 09:03:39 +08:00
8ee04500b9 fix(hub,cli): coerce null session activeAt so resume cannot 500 (#1026)
Legacy rows and inserts left sessions.active_at NULL while SessionSchema
required a number, so CLI GET /cli/sessions/:id failed Zod and resume
surfaced HTTP 500. Persist active_at on insert, harden hub read coerce,
and nullish-transform activeAt in SessionSchema (output stays number).

Fixes #1025

Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 09:00:17 +08:00
SSU-WEI HUANGandGitHub f6ad345339 feat(web): redesign responsive settings navigation (#1027) 2026-07-13 08:42:25 +08:00
SSU-WEI HUANGandGitHub b9eed7c071 feat: add Grok Build support (#1030)
* test: define Grok Build integration behavior

* feat: add Grok Build agent integration

* test: cover Grok permissions and resume paths

* docs: add Grok Build setup guide

* fix: scope Grok ACP discovery to session cwd

* fix: align Grok permission UI semantics

* docs: clarify Grok runner setup

* test: require Grok create model and effort options

* feat: add Grok create model and effort pickers

* test: define Grok runtime parity behavior

* feat: add Grok runtime ACP controls and discovery

* fix: tighten Grok runtime controls

* fix: suppress nonfatal Grok title quota errors

* feat: support Grok Auto permission mode

* feat: forward ACP native session titles for Grok

* fix: guard Grok Windows shell arguments
2026-07-13 08:41:30 +08:00
de07643828 fix(codex): improve local session import compatibility (#995)
Co-authored-by: LIUZHIRU <ryuu@fine-net.co.jp>
2026-07-12 18:44:39 +08:00
SSU-WEI HUANGandGitHub d97b270ba8 fix(codex): bridge MCP elicitation through user input (#1008)
* fix(codex): bridge MCP elicitation through user input

* fix(codex): allow MCP elicitation in yolo mode

* fix(codex): preserve MCP form semantics

* fix(codex): accept implicit MCP form mode

* fix(codex): harden MCP elicitation prompts

* fix(codex): require valid MCP choice answers

* fix(codex): round-trip MCP array elicitation

* fix(web): require explicit MCP URL confirmation

* fix(codex): preserve MCP array item types

* fix(codex): support multi-select MCP elicitation

* fix(codex): allow MCP elicitation in read-only mode

* fix(codex): route MCP tool approvals through permissions
2026-07-12 18:42:55 +08:00
SSU-WEI HUANGandGitHub 474db94136 test(cli): stabilize runner integration timeouts (#1011) 2026-07-12 18:42:05 +08:00
73584e925a feat(cursor): multitask slash, autoReview mode, native worktree/add-dir (#1014)
* feat(cursor): multitask slash, autoReview mode, native worktree/add-dir

Close the highest-value Cursor Agent gaps for remote HAPI: expand ACP-safe
slash pass-through (/multitask, worktree, add-dir, …), add autoReview
permission mode (--auto-review spawn + mid-session slash), and route Cursor
New Session worktrees through agent --worktree instead of HAPI sibling trees.

Fixes #1013

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): accept --mode autoReview for hapi cursor

Align --mode parsing with CURSOR_PERMISSION_MODES so documented
`hapi cursor --mode autoReview` enables Smart Auto instead of silently
falling back to default.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 18:41:52 +08:00
b104c38f3a fix(web): show Codex reasoning effort in SessionHeader (#1016)
Surface the same reasoning label already shown in the composer StatusBar
in the top SessionHeader for codex/opencode sessions. Also show an
explicit Fast badge only when serviceTier is fast (#1004-aligned).

Closes #1015 (header display portion).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 18:41:10 +08:00
HaoandGitHub d0c8391261 fix(opencode): preserve ACP text deltas (#1023) 2026-07-12 18:40:18 +08:00
SSU-WEI HUANGandGitHub d9ea9db507 fix(codex): preserve native safety behavior (#1024)
* test: reproduce issue #1020

* fix: preserve Codex safety behavior (closes #1020)

* test: cover Codex safety buffering dismissal

* test: cover Codex safety retry edge cases

* fix: keep dismissed safety buffering prompts hidden
2026-07-12 18:39:56 +08:00
ejj.ccandGitHub d160203bb2 fix(codex): support dynamic reasoning efforts (#1012)
* fix(codex): support model-reported reasoning efforts

* fix(web): prevent service worker edge caching

* ci: retrigger stuck Actions run

* fix(codex): accept dynamic reasoning effort values

* fix(web): restore reasoning effort on model switch failure
2026-07-12 18:38:56 +08:00
weishu 4c76668a6c refine message actions and metadata 2026-07-12 18:36:43 +08:00
weishu 8782b8a110 fix(codex): align local transcript message projection
Use semantic events as the visible text source and preserve completed plans as ordered plan proposal cards.
2026-07-12 14:33:37 +08:00
weishu 942a1dfff8 Release version 0.21.0 2026-07-12 11:05:13 +08:00
weishu 5a377e38b9 fix(codex): defer session persistence until user activity 2026-07-12 11:00:08 +08:00
65e1708c78 feat(web): mermaid diagram lightbox on click (#741)
* feat(web): mermaid diagram lightbox on click

Click rendered mermaid blocks in chat to open a zoomable full-screen viewer.
Re-renders from source in the modal with the current theme. Closes #737.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): fit mermaid lightbox to viewport on open

Auto-scale diagrams to fill the viewer instead of opening at intrinsic
mermaid size. Reset returns to fit; zoom label is relative to fit (100%).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): fit mermaid lightbox to device screen not inner panel

Use visualViewport for fit scale, full-screen pan layer, and a floating
toolbar so the diagram can use the whole display.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): show mermaid lightbox by reusing inline SVG

Second mermaid.render on open often left a 0×0 SVG while fit scale was
computed from the loading placeholder. Reuse the inline SVG in the modal
and measure viewBox with retried fit-to-screen.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): uniquify mermaid SVG ids in lightbox clone

Inlining the same mermaid markup twice duplicates element ids and breaks
url(#ref) resolution in the modal copy. Prefix ids and hrefs for lightbox only.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): give mermaid lightbox SVG explicit dimensions

Mermaid emits width="100%" with max-width in px; that collapses to 0×0
inside the centered lightbox layer. Derive width/height from viewBox for
the uniquified lightbox clone.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): render mermaid lightbox via isolated SVG data URL

String id rewrites broke mermaid's embedded CSS so only labels appeared
zoomed. Rasterize the inline SVG to a data-URL img instead of duplicating
markup in the DOM.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): lightbox re-renders SVG for sequence diagrams

Data-URL images drop or blank some mermaid diagram types (sequence).
Re-render with a modal-specific id into inline SVG on a code-bg panel,
and add sequence theme variables for dark/light.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): mermaid lightbox uses inline SVG in shadow DOM

Reuse the inline render in an isolated shadow root so sequence CSS stays
intact, and fit the viewport from viewBox dimensions instead of the loading
placeholder or width="100%" layout.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(web): Playwright lightbox coverage per mermaid diagram type

Add e2e harness and a script that opens the lightbox for each diagram
kind (flowchart through kanban). Fit uses inline getBBox() so compact
charts like gitGraph fill the viewport.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(web): bounded Playwright via webServer, fix gantt fit sizing

Playwright owns Vite lifecycle (no agent-spawned dev server). Fit uses
viewBox unless viewBox padding is excessive (gitGraph); wide charts use
width-based coverage in e2e.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(web): gitignore Playwright test-results

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): address PR 741 bot feedback (typecheck, fit floor, gitignore)

Guard lightbox open when svg is null; allow fit scale down to 0.01 while
keeping 0.25 minimum for manual zoom; ignore Playwright test-results/ correctly.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(web): Playwright asserts click expands diagram vs inline

Measure inline vs lightbox bounding box after click; require visible
growth (area ratio or max dimension) plus dialog + shadow SVG content.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(web): Playwright against live HAPI session for mermaid lightbox

Add seed script for a dedicated chat session, live hub Playwright suite
(HAPI_LIVE=1), and dogfood doc. Live tests fail until driver serves shadow-DOM
lightbox (catches gray-box regression on stale bundles).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): undo wrapper transform in lightbox fit; carry fit floor in zoom

Resolves PR #741 review threads (HAPI Bot Major):

1. measureSvgIntrinsicSize / measureContentSize prefer intrinsic dimensions
   (viewBox -> width/height attrs -> img.naturalSize) before getBoundingClientRect.
   When the rect is the only signal, divide by scaleRef.current so the 50/200ms
   refit retries stop compounding with the wrapper's scale(...) transform.
   Large diagrams no longer jump tiny or oversize after async render completes.

2. Interactive zoom (wheel/keys/buttons/pinch) now clamps with
   Math.min(MIN_SCALE, baseScaleRef.current). A diagram fitted below the
   normal 25% floor stays reachable instead of snapping back to 25% and
   clipping. Zoom-out button disabled threshold uses the same min.

3. Add Vitest coverage for both helpers (intrinsic precedence, scale-aware
   rect fallback, divide-by-zero guard) so regressions surface without
   needing the full Playwright stack.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): mermaid seed refuses to wipe non-fixture sessions

HAPI Bot Major (PR #741): SESSION_ID is documented as overridable,
and the script unconditionally deletes every message for the target
session before seeding fixtures. If pointed at a real session id,
that's silent data loss.

Refuse to proceed when an existing session id has a tag other than
'mermaid-lightbox-e2e'. New ids and the canonical fixture session
still seed normally; real sessions throw before any DELETE runs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): normalize mermaid svg for lightbox shadow root

Mermaid emits width="100%" on every diagram. Inside a shadow root whose
host has no explicit size, that collapses to zero in Chromium for most
diagram types - only ones that ship pixel attrs (e.g. journey) happen to
render. Operator confirmed on the live driver: every diagram except
journey opened to a grey rounded square.

MermaidLightboxSvg now runs normalizeMermaidSvgForStandaloneDisplay before
injecting (strips width/height="100%", bakes viewBox dims as pixels) and
sets :host{display:inline-block} so the host sizes to the SVG. Inline svg
in chat is unchanged - only the lightbox copy is normalized.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): keep mermaid lightbox content below the toolbar

Operator screenshot showed the diagram top (e.g. pie 'Pets' title)
clipped behind the toolbar bar. Two causes:

1. getScreenFitSize used the full viewport height, so the fit scale
   sized the diagram to fill an area the toolbar overlapped.
2. The viewport (drag/zoom area) was inset-0; content centered on the
   full viewport center, not the visible region's center, pushing the
   top behind the toolbar.

Measure the toolbar with a ResizeObserver, subtract its height from
the fit calculation (clamped at zero), and start the viewport region
below the toolbar (top: toolbarHeight). Fit scale recomputes whenever
toolbar height changes.

Adds Vitest coverage for getScreenFitSize reserved-top math.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): guard ResizeObserver before constructing it

HAPI Bot Major (PR #741): Vitest jsdom does not polyfill ResizeObserver,
so the toolbar measure effect throws ReferenceError when the existing
mermaid-diagram React tests open the lightbox. Same code path is also
brittle in any browser/webview without the API.

Fall back to plain window 'resize' listener when ResizeObserver is
absent. Toolbar height won't auto-update on element resize without it,
but the lightbox still renders and the resize listener catches the
common viewport-rotation case.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): live mermaid playwright wrapper runs from repo root

HAPI Bot Minor (PR #741): the wrapper sets cwd to scripts/, but the
test:mermaid-lightbox:live npm script lives in the repo-root
package.json, so spawning npm there exited before Playwright started.
Switch cwd to the repo root and drop the unused WEB_DIR constant.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): accept signed viewBox values in mermaid lightbox normalize

HAPI Bot Minor (PR #741): the viewBox regex only matched digits, dots,
and spaces, so a valid viewBox with negative origin (e.g. '-8 -8 640 480')
returned null. normalizeMermaidSvgForStandaloneDisplay then became a
no-op and left width='100%', re-introducing the zero-sized lightbox
render this PR is meant to fix for the affected diagrams.

Switch to the bot's suggested regex (signed numbers, single or double
quotes, comma or space separators) and reject NaN parts. Adds Vitest
coverage for signed origins, single quotes, comma separators, the
malformed/no-viewBox null paths, and an end-to-end normalize test that
fails against the old regex.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): align @playwright/test on 1.60.0 across workspaces

HAPI Bot Major (PR #741): web/package.json pinned @playwright/test at
1.49.1 while the root workspace and bun.lock were on 1.60.0. The
mismatch surfaced after rebasing onto upstream/main, where the root had
already moved to 1.60.0 while my web devDependency lagged from an older
commit. A frozen install would reject the lockfile and the new web e2e
script could resolve a different Playwright than root scripts.

Bump the web devDependency to 1.60.0 and regenerate bun.lock so all
workspaces share one Playwright version.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): move mermaid playwright fixtures out of public

HAPI Bot Minor (PR #741): the e2e and smoke fixtures lived under
web/public, so Vite copied them verbatim into web/dist and the hub
asset generator embedded them in production bundles. Both pages
import Vite dev-only paths (/@react-refresh and /src/dev/...), so
the production /mermaid-lightbox-{e2e,smoke}.html routes would 404
on those imports.

Move both fixtures to web/e2e-fixtures/ to match the existing
scratchlist-fixture pattern (relative ../src/dev import, served by
Vite at /e2e-fixtures/...) and update the Playwright spec to hit the
new path. Build now ships 112 PWA precache entries instead of 114
(both fixtures excluded from dist).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 11:07:12 +08:00
a82dd49049 feat(web): markdown Source | Preview toggle in session file pane (#957)
* feat(web): markdown Source | Preview toggle in session file pane

Add Source | Preview toggle for .md/.mdx files in the session file route,
defaulting to preview with localStorage persistence. Reuse chat markdown
pipeline via MarkdownRenderer standalone mode (no assistant-ui thread).

Includes unit tests, Playwright smoke, and e2e fixture.

Closes tiann/hapi#954

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): cast standalone MarkdownRenderer components for react-markdown

Soup verify gate: defaultComponents merge type is wider than
react-markdown Components; standalone file-pane path needs explicit cast.

* fix(web): route file-pane markdown fences through SyntaxHighlighter

Standalone file preview now mirrors chat code-block rendering: fenced
blocks use SyntaxHighlighter and MARKDOWN_COMPONENTS_BY_LANGUAGE (mermaid
included) without requiring ThreadPrimitive context.

Addresses HAPI Bot Major on tiann/hapi#957.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): detect fenced vs inline code in standalone markdown preview

Move block detection to the pre override (react-markdown v10 does not pass
inline to custom code components). Add inline-code regression test.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 11:06:38 +08:00
SSU-WEI HUANGandGitHub 018bcb8eaa fix(web): clean up React tests globally (#993) 2026-07-11 10:42:13 +08:00
SSU-WEI HUANGandGitHub 58cfc330f9 fix(web): show standard for untouched Codex service tier (#1010) 2026-07-11 10:41:49 +08:00
quecai-niuandGitHub a2465c782b [codex] fix Qwen realtime compatibility (#977)
* fix: improve Qwen realtime compatibility

* fix: preserve Qwen endpoint query parameters
2026-07-11 10:41:02 +08:00
quecai-niuandGitHub afdcd92fc6 fix: normalize Windows drive roots (#979) 2026-07-11 10:40:48 +08:00
Junmo KimandGitHub 43e7b6bef7 fix(cli): compute macOS machine-health memory from vm_stat (#990)
* fix(cli): add darwin vm_stat memory percent parser

Add readDarwinMemoryUsedPercent, a pure parser that computes macOS used
memory as App Memory + Wired + Compressed (anonymous + wired-down +
occupied-by-compressor pages), matching Activity Monitor's "Memory Used"
figure. Page size is parsed from the vm_stat header rather than hardcoded,
since it differs between Apple Silicon (16KB) and Intel (4KB) Macs.

Not wired up yet; covered by unit tests, including a verbatim vm_stat
capture from a 16GB Mac mini where the pre-fix total - freemem() path
reported 99% (counting reclaimable cache as used) while App+Wired+
Compressed is 79% — the number a user sees in Activity Monitor.

* fix(cli): wire darwin memory percent into computeMemoryPercent

Add a platform() === 'darwin' branch that shells out to vm_stat
(1s timeout, guarded by try/catch) and feeds its output to
readDarwinMemoryUsedPercent. On any failure or undefined result it
falls through to the existing total - freemem() fallback, matching
the Linux branch's structure.

This fixes the Machine capacity tooltip showing a stuck ~99% "High
pressure" warning on macOS runners: os.freemem() there counts
reclaimable file cache as used, so it reports near-total usage.
Summing only App Memory + Wired + Compressed reports the same figure
Activity Monitor shows.
2026-07-11 10:40:06 +08:00
Junmo KimandGitHub e45fde51e9 fix(claude): stop 1M/200k context-window flicker in the status bar (#992)
* refactor(claude): thread session's selected model into SDKToLogConverter

Adds an optional selectedModel field to the converter's context, wired
from session.getModel() in the launcher, so a later commit can seed the
turn-1 contextWindow estimate for presets whose system/init model
arrives without the "[1m]" suffix. No behavior change yet.

* fix(claude): key contextWindow cache by model to stop 1M/200k flicker

The remote launcher re-emits system/init on every turn for the same
converter instance. Its init-time estimate only checked whether the
model string ended in "[1m]", but current claude CLI versions strip
that suffix from system/init for some 1M presets (fable[1m] arrives as
"claude-fable-5"), so the estimate guessed 200k for them. The one
authoritative value is result.modelUsage[<model>].contextWindow, which
arrives after the heuristic has already injected 200k into that turn's
assistant message and then gets clobbered back to 200k by the very
next turn's init - producing the observed 200k<->1M oscillation in the
web status bar.

Cache the authoritative contextWindow per model id instead of a single
session-wide number, and only let system/init seed a heuristic guess
for a model that has no cached value yet, so a same-model re-init no
longer downgrades an already-learned value.

Two observed facts about the CLI's model ids drive the design:
system/init.model and the result.modelUsage keys always agree with
each other within a session (both bare for plain/fable[1m], both
suffixed for opus[1m]/sonnet[1m]), while each per-turn assistant
message reports its model bare and thus can't distinguish a 200k plain
preset from its 1M "[1m]" variant on tiers where they share a base id.
So the cache is keyed on the raw id (init/result agree, no
normalization) and assistant injection looks the value up via
resolvedModel (the last init id) rather than the lossy message.model.
Keying raw keeps a plain preset and its [1m] variant on distinct
entries; looking up via resolvedModel also means sidechain (Task
subagent) messages carry the main session window rather than the
subagent's own, since the web status bar picks the most recent usage
message without filtering sidechains and would otherwise flicker to
the subagent's smaller window while it runs.

For presets whose init model arrives bare even though they are 1M
(fable[1m]), the originally-selected preset - which preserves the
"[1m]" suffix - seeds the turn-1 estimate, kept live across mid-session
model switches via updateSelectedModel() (called from the launcher on
every turn) so it never goes stale.

* fix(web): recognize [1m] suffix on full Claude model ids in budget fallback

getContextBudgetTokens already special-cased "[1m]" for short preset
values (e.g. "opus[1m]") but fell through to the default 200k budget
for full model ids (e.g. "claude-opus-4-8[1m]"), which is what the CLI
now reports once context_window isn't available and this fallback is
consulted. Check the suffix on that branch too so it stays a correct
last-resort even without a session-provided context_window.

* refactor(web): merge duplicate Claude context-budget branches

isClaudeModelPreset(trimmedModel) and the startsWith('claude-') branch
below it had become byte-for-byte identical bodies after the [1m]
suffix check was added to both. Merge them into one condition; no
behavior change.

* fix(claude): distinguish fable from fable[1m] when the CLI reports both bare

The per-model contextWindow cache keyed on the raw system/init model id,
on the assumption that a 1M preset and its plain form always land on
distinct ids. That holds for opus[1m]/sonnet[1m] (the CLI reports the
"[1m]" suffix on their init and result ids) but not for fable: the CLI
reports both "fable" and "fable[1m]" with the same bare id
"claude-fable-5". So the "seed only if not already cached" guard would
skip re-seeding when switching fable[1m] -> fable, leaving the stale 1M
in place until fable's own result arrived - the same switch flicker this
change set out to remove, just for fable specifically.

Fold the selected preset's "[1m]" back into the cache key
(computeContextWindowKey): when the init model arrives bare but the
session selected an "[1m]" preset, key the entry as "<id>[1m]" so the 1M
and plain variants stay distinct; ids the CLI already suffixed are left
as-is. Seeding, lookups, and the current-model result entry all use this
resolved key. Subagent result entries (e.g. haiku) keep their own raw id
so the session's "[1m]" is never folded onto a model that isn't the
selected one.
2026-07-11 10:39:36 +08:00
b44885ae67 feat(gemini): remove launchable Gemini CLI agent, keep old sessions readable (#953)
* feat(gemini): remove launchable Gemini CLI agent, keep sessions readable

Google sunset the consumer Gemini CLI (Pro/Ultra/free tiers stopped
serving requests 2026-06-18). This removes the ability to launch/create
Gemini CLI sessions while keeping existing stored Gemini sessions fully
readable in the web UI.

Removed (no longer launchable):
- cli/src/gemini/ runtime (runGemini, loop, local/remote launchers,
  session, ACP backend, config, scanner) + GeminiDisplay ink view
- `hapi gemini` command + registry entry + usage line
- runner spawn branch & buildCliArgs mapping now reject gemini with a
  clear error; resume dispatch throws a clear "no longer supported" error
- gemini dropped from the new-session agent selector via new
  CREATABLE_AGENT_FLAVORS, and from preferred-agent defaults

Kept (read path — existing sessions still validate, load, render):
- `gemini` in AGENT_FLAVORS / AgentFlavorSchema, FLAVOR_CAPS / FLAVOR_LABELS
- AgentFlavorIcon badge, model-option labels, ACP message normalization,
  metadata.geminiSessionId, hub session dedup/resume-id

Note: the Gemini *Live voice* backend is a separate feature and is
untouched.

Adds read-guarantee tests (stored gemini validates; excluded from
creatable). typecheck + full suite green.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(gemini): reject gemini resume before handoff (#953 review)

HAPI Bot [Major]: `hapi resume <active-gemini-session>` called
handoffSessionToLocal() — which tells the running remote agent to exit —
before reaching the gemini-unsupported throw in dispatchLocalResume, so
it could stop the live/readable session and then fail locally.

Move the gemini guard into resumeCommand.run before the handoff, so an
active Gemini session is left running/readable instead of being stopped.
Keep the dispatch-layer guard as defense-in-depth. Adds a regression test
asserting handoffSessionToLocal is not called for an active gemini target.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(gemini): harden against stale gemini input (#953 review)

Two [Minor] follow-ups from HAPI Bot:
- newSessionFormDraft: coerce a restored browse draft's agent to a
  creatable flavor, so a pre-removal 'gemini' draft cannot submit
  agent:'gemini' even though the selector no longer offers it.
- buildCliArgs: reject 'gemini' explicitly instead of silently falling
  through to the 'claude' command if the exported helper is reused
  outside the guarded spawnSession path.

Updated the buildCliArgs precedence test to a creatable agent and added
a test asserting buildCliArgs('gemini') throws.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(gemini): reset dependent draft fields when coercing stale agent (#953 review)

Follow-up [Minor]: coercing a stale gemini draft's agent to claude left
model/base/effort untouched, so a { agent:'gemini', model:'gemini-2.5-pro' }
draft restored as claude *with* a Gemini model, which handleCreate() then
sent to the runner. Now reset model / cursorSelectedBase / effort /
modelReasoningEffort to defaults whenever the agent is coerced.

Adds a regression test.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(gemini): tombstone `hapi gemini` so it errors clearly (#953 review)

HAPI Bot [Major]: after removing geminiCommand from the registry,
resolveCommand() treats `gemini` as an unknown subcommand and falls
through to the default Claude command (forwarding "gemini" as an arg),
so `hapi gemini` silently started Claude instead of reporting the sunset.

Add an explicit tombstone `gemini` command that prints the sunset error
and exits 1.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(web): assert AgentSelector hides the sunset Gemini agent (#953)

Render regression test confirming the new-session AgentSelector offers
exactly CREATABLE_AGENT_FLAVORS and never shows a Gemini radio.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 11:42:41 +08:00
26a24bb6ce feat(web,hub,cli): show machine health in session sidebar (#962)
* feat(web,hub,cli): show machine load in session sidebar

Runners attach OS health snapshots to machine-alive heartbeats; the hub
caches them and the web session list renders load or CPU between the
machine label and session count.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web,cli): show CPU and RAM pressure in machine health badge

Sidebar label now combines CPU and RAM percentages for overload
signaling; load stays in the tooltip on Unix. Prime CPU sampling so
the first heartbeat includes usage, not just memory.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web): visual machine health meters with tooltip

Replace bare CPU/RAM text with labeled mini bar gauges, chip
border tint by severity, and a HoverTooltip explaining capacity
and overload guidance.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): widen machine health tooltip with horizontal layout

Allow a generous popover width and lay CPU/RAM/load out side by side
so the capacity tooltip reads wider and less tall than the chip.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): anchor machine health tooltip to row left edge

Wide tooltip was align=end on the chip, so it grew left off-screen.
Use row-span positioning on the machine tile button instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web): machine host card with OS label and inline health

Turn the session sidebar machine row into a bordered host panel with OS
metadata and side-by-side CPU/RAM meters embedded in the tile instead
of a flat label line matching project rows.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): keep machine host tile single-row height

Collapse the machine header back to one py-1.5 row with OS and compact
inline health beside the name, and restore the original project indent
without the extra nested rail or second header line.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web): show CPU core count in machine health tooltip

When the runner reports cpuCount, the tooltip reads "CPU across all 6
cores" instead of the generic all-cores label.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: add machine health sidebar screenshots

Dogfood captures for the session sidebar machine tile and capacity
tooltip, for upstream PR review.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): clear machine-alive priming timeout on disconnect

Track the 50ms CPU priming setTimeout and clear it in stopKeepAlive so
disconnect/shutdown during the delay cannot leave a stray interval alive.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: drop dogfood screenshots from upstream PR diff

Review evidence lives in the PR discussion only; no need to ship PNGs in
the repo long-term.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): truncate long machine OS/host metadata in sidebar row

Bound the metadata span so a long hostname cannot push the health chip
or session count off-screen in narrow sidebars.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): reveal machine health tooltip on keyboard row focus

Wire MACHINE_ROW_TOOLTIP_FOCUS_CLASS and aria-describedby on the machine
header button so keyboard users can read the health tooltip like session rows.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): use MemAvailable for Linux RAM pressure on Bun

Bun's os.freemem() reflects MemFree (~1% on cache-heavy hosts), which
made sidebar RAM read ~99% while btop showed ~40% used. Parse
/proc/meminfo MemAvailable instead so used percent matches operator tools.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web,cli): show machine uptime in sidebar tiles and tooltip

Collect os.uptime() as uptimeSeconds on keepalive and render compact
up 1h 54m in the machine meta row plus an Uptime line in the health tooltip.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): anchor machine health tooltip to chip not row

align=row positioned the tooltip below the full machine header button,
so the collapsible project panel painted over it on hover. Use align=end
with a min-width panel so mouse and keyboard tooltips stay visible.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 11:41:53 +08:00
SSU-WEI HUANGandGitHub 5ade952218 fix(cursor): support ACP parameterized model picker (#969)
* test: reproduce issue #968

* fix: support Cursor parameterized model picker (closes #968)
2026-06-29 11:41:21 +08:00
8493ec92f4 fix(cli): Pi RPC parsers compatible with Zod 4 optional fields (#973)
asStrOrDef/asOpt* helpers now treat missing keys as undefined so
get_available_models/get_commands parsing works again. safeParse success
checked explicitly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 11:41:02 +08:00
Junmo KimandGitHub f51e06e8f3 fix(web): stop pinning resolved permission cards to the bottom of the chat (#974)
agentState keeps an answered permission request in completedRequests. When its
tool_use message is not in the loaded window, the permission-only synthesis
appended a card to the end of the timeline — and there is no chronological
re-sort, so the card stays pinned above the composer as a stale "answered"
card that never moves to its place in history. With several answered asks this
piles up at the bottom of the chat.

Synthesize a card only for a *pending* request (the case that needs an
answerable card when its message hasn't loaded). A resolved request is history
and renders only via its own message when that message is in the window.
2026-06-29 11:40:32 +08:00
2ab3b39887 fix(hub,cli): four hub-restart-cascade cleanup bugs (#913 #914 #916 #919) (#923)
* fix(hub,cli): four hub-restart-cascade cleanup bugs (#913 #914 #916 #919)

These four contained bugs were uncovered by a 2026-06-15 hub-restart
incident where `hapi-restart-hub` SIGTERMed 23 cursor ACP sessions.
Each fix lands independently of the architectural #915 (hub-restart
cascade-archive) and the hypothesis-pending #917 (reopen creates dead
session); audit-trail correctness and idempotency wins stand on their
own.

  Fresh ACP sessions could be SIGTERMed during the async `update-metadata`
  ACK round-trip, stranding the on-disk ACP store with no DB handle. Add
  `ApiSessionClient.flushMetadata()` and await it after `onSessionFoundWithProtocol`
  on the fresh-session branch. Resume-path pre-registration (PR #834) is
  unchanged.

  Hub-restart-cascade SIGTERMs went through the same path as web-UI
  Archive clicks, both writing archiveReason='User terminated'. New
  default is 'Hub restart'; the KillSession RPC handler (the
  authoritative user-archive signal) now explicitly stamps
  'User terminated' before cleanupAndExit. SIGINT (local-terminal Ctrl-C)
  keeps the 'User terminated' label too.

  `rpcGateway.killSession` threw a generic Error when no target socket
  was registered, and the archive route surfaced that as 500. Add typed
  `RpcTargetMissingError`, narrow on it in `syncEngine.archiveSession`,
  fall back to a hub-side `markSessionArchivedFromHub` write so
  lifecycleState still flips to 'archived'. Drop the requireActive
  guard on the route and 2xx-noop for already-archived rows.

  without refresh, producing forever-409 on rename/reopen until an
  unrelated event triggered a cache refresh. `renameSession`,
  `clearSessionArchiveMetadata`, `restoreSessionArchiveMetadata` now
  retry-with-refresh (5 attempts, then throw) mirroring the existing
  good pattern in `mergeSessions`.

Refs tiann/hapi#913
Refs tiann/hapi#914
Refs tiann/hapi#916
Refs tiann/hapi#919

AI disclosure: implementation by Claude Sonnet 4.5 (Cursor agent peer)
under operator supervision. Issue triage by a sibling discovery agent.
Per CONTRIBUTING.md AI-assisted contributions policy.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): runner-spawned children use 'Stopped by runner' as default archive reason

Addresses bot review of #923: with the #914 default-archiveReason flip to
'Hub restart', runner-driven SIGTERM paths (`hapi runner stop-session`,
webhook-timeout cleanup at run.ts:587, orphan-cleanup at run.ts:267) all
mislabel as 'Hub restart' which is also inaccurate audit-trail noise.

Smallest defensible change: parameterise the lifecycle default via
HAPI_DEFAULT_ARCHIVE_REASON env, and have the runner set
'Stopped by runner' on spawn. Terminal-launched sessions (no runner
parent, no env var) still default to 'Hub restart' since hub-restart
cascade documented at #915 is the most plausible SIGTERM source for
those. Explicit overrides via setArchiveReason (KillSession RPC, SIGINT
Ctrl-C, markCrash uncaught exception) still win.

Two new unit tests cover the env-var default and the override
precedence.

Refs tiann/hapi#914.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): markSessionArchivedFromHub surfaces persistence failures as 5xx

Addresses second-round bot review of #923 (Major): `markSessionArchivedFromHub`
silently returned on DB write errors and on exhausted version-retry
attempts, which would let `/archive` claim 200 OK while the row stayed
unarchived. That regresses the #916 acceptance criterion that non-RPC
errors during archive must still propagate as 5xx.

Both fall-through paths now throw, matching the contract of the
sibling writers in this file (renameSession, mergeSessions). The
sessionModel test suite gains two cases that spy on
`store.sessions.updateSessionMetadata` to force `error` and
`version-mismatch` shapes and asserts the helper throws. The existing
route test at `hub/src/web/routes/sessions.test.ts:1015` already
covers the route-level 500 propagation for any error thrown out of
`archiveSession`, so no new route test is needed.

Imports `spyOn` from `bun:test` to match this test file's runtime
(the rest of the hub package uses bun:test, not vitest).

Refs tiann/hapi#916.

Co-authored-by: Cursor <cursoragent@cursor.com>

* revert(cli): drop HAPI_DEFAULT_ARCHIVE_REASON env override

Reverts `1c8972a3`. Bot review round 3 surfaced that the env-on-spawn
approach (the bot's own round-1 suggestion shape) mislabels
hub-restart-cascade SIGTERMs against runner-spawned children: systemd
killcgroup on `hapi-runner.service` stop sends SIGTERM to all
runner-children directly, and those would archive as 'Stopped by runner'
instead of 'Hub restart'.

The two suggestions are mutually incompatible without adding an IPC
channel (stdio: 'ipc' on spawn) so the runner can stamp
setArchiveReason via childProcess.send() before SIGTERMing. That is a
refactor, not a smallest-defensible change.

Going back to the simple shape: SIGTERM default is 'Hub restart' for
everyone, runner-internal stop paths share that label. The
audit-trail-correctness criterion from the #914 issue is met
(SIGTERM no longer falsely labels as 'User terminated'). Finer
attribution between cascade vs runner-stop is deferred as a follow-up.

Refs tiann/hapi#914.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): clean completions get 'Session completed', not 'Hub restart'

Addresses bot review round 4 of #923 (Major): every agent runner
(runClaude, runCodex, runCursor, runGemini, runKimi, runOpencode)
calls setSessionEndReason('completed') on the natural exit path
without touching archiveReason. With the SIGTERM default flipped to
'Hub restart', clean completions were now archived as restart
cascades.

Fix: setSessionEndReason flips archiveReason to 'Session completed'
when it transitions to 'completed' AND no caller has already overridden
the archive reason. This covers all six agent runners with a single
setter change (no per-runner edits).

Two new tests cover the natural-completion default and the override
precedence (explicit setArchiveReason still wins).

Refs tiann/hapi#914.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): restore inactive-session guard on /archive except split-brain

Addresses post-rebase bot review Major on #923: dropping requireActive
entirely let normal inactive non-archived rows (completed stubs, UI
Delete/Reopen targets) fall through to archiveSession, which could stamp
archivedBy=hub on sessions that were never active.

Restore the 409 for inactive rows unless metadata.lifecycleState is
still 'running' (hub-restart split-brain cleanup case from #916).
Two route tests cover the guard and the exception.

Refs tiann/hapi#916.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): merge runnerLifecycle tests after upstream rebase

Post-rebase fix: Session completed tests referenced makeFakeSession
which was renamed to createMockApiSessionWithMetadataCapture when
merging upstream hasExplicitSessionEndReason tests with #914 archive
reason coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): pass lifecycle object to KillSession handler in Pi runner

Upstream #862 (Pi agent) landed after this branch was cut. runPi.ts
still registered the legacy bare cleanupAndExit callback, so web
Archive for Pi sessions would persist archiveReason: Hub restart
instead of User terminated. One-line fix matching the other six
agent runners.

Refs tiann/hapi#914.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 17:37:32 +08:00
a0259b531e feat(web): drag-and-drop files onto chat panel to add as attachments (#936)
* feat(web): drag-and-drop files onto chat panel to add as attachments

Closes #935

Adds a `useDragOver` hook that detects when a file is being dragged over
the browser window and suppresses the browser's default file-open
behaviour for drops outside the accept zone.

A new `DragDropZone` component wraps the inner `AssistantRuntimeProvider`
content in `SessionChat`. It shows a semi-transparent overlay (dashed
border + "Drop to attach" label) on the right-side chat panel as soon as
any file drag is detected — regardless of where the pointer is on the
page. Dropping on the right panel adds the files as composer attachments
via the existing `api.composer().addAttachment()` path. Drops on the left
sidebar are suppressed (no navigation, no attachment).

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): disable drag-drop zone when pendingSchedule is active

The backend rejects requests with both scheduledAt and attachments.
DragDropZone now respects pendingSchedule the same way paste and the
attach button do — disabled=true suppresses the overlay, sets
dropEffect='none', and skips addAttachment on drop.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): harden drag-drop default-action handling

Address HAPI Bot review on #936:

- useDragOver: cancel the browser's default file-open/navigation on the
  document-level `drop` event for file payloads, not only on `dragover`.
  Preventing default on `dragover` alone still lets the browser open a
  file dropped outside any zone (e.g. the sidebar), which could unload
  the app.
- DragDropZone: only preventDefault when the drop payload actually
  contains files, so non-file drops (e.g. dragging selected text into
  the composer) keep their default browser behaviour.

Add regression tests for both.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(web): use Simplified Chinese for composer.dropToAttach in zh-CN

Address HAPI Bot review on #936: the new zh-CN string used Traditional
Chinese forms (放開以附加檔案) in the Simplified Chinese locale, which is
inconsistent with neighbouring keys (e.g. composer.attach = 添加文件).
Use 松开以添加文件 to match.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 17:37:04 +08:00
b1910b6b2e feat(web): session header files and outline view toggles (#952)
* feat(web): session header files and outline view toggles

Files and outline icons in SessionHeader act as depressed toggles; files
view shares the session header and places refresh beside the search box.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(web): add Playwright handoff script for session view toggles

Supports new-feature-intake visual gate: files toggle pressed + refresh beside search.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dev): handoff script avoid networkidle on live hub SSE

HAPI keeps connections open on :3006; domcontentloaded is the correct wait.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): place filesystem refresh outside search field

Refresh is a sibling of the search pill, not inside it, so the control
is visually and structurally separate from file search.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 17:36:36 +08:00
02a0aa6733 fix(cursor): surface agent errors with warning styling in web UI (#871)
* test: reproduce issue #864

Assert Cursor error paths emit agent error payloads and web UI renders
them as warning-styled events instead of neutral session messages.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cursor): surface agent errors with warning styling in web UI (closes #864)

Route Cursor stderr, init, prompt, and legacy exit failures through
sendAgentMessage({ type: 'error' }) and teach the web chat layer to
render error events with a warning icon instead of neutral info text.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 10:17:57 +08:00
DolphinZZZZZandGitHub bfd0f4a376 fix(web): keep Codex restart control clear of close button (#880) 2026-06-18 10:17:44 +08:00
d1a686f8d0 fix(cursor): map base-only CLI sku to fast=false to avoid silent variant no-op (#887)
Without an explicit -fast suffix, inferSkuParamHints returned no fast hint, so
matchCliSkuToAcpWireId tied between fast=true and fast=false wires and kept
the first one. For composer-2.5 that meant the picker's "non-fast" sku silently
resolved to composer-2.5[fast=true] — the same wire the fast sku resolves to —
producing the "selected but no response" symptom in #883.

Treat absence of -fast as fast=false so base-only skus pick the slow variant
and round-trip back to the matching radio.

Fixes #883

via [HAPI](https://hapi.run)

Co-authored-by: HAPI <noreply@hapi.run>
2026-06-18 10:17:34 +08:00
8f3ea10df7 fix(web): mechanical repair of GFM tables with off-by-one separator rows (#902)
* feat(web): mechanical repair of GFM tables with off-by-one separator rows

Adds a remark plugin (remarkRepairTables) that runs after remark-gfm and
silently fixes the dominant broken-table pattern seen in agent output:
the separator row has fewer pipe-delimited cells than the header row.

remark-gfm follows the GFM spec and silently truncates the table to the
separator column count, dropping header and data cells. This plugin reads
the original source via file.value position data, detects the mismatch,
pads the separator row, and re-parses the corrected block so all columns
are preserved.

Analysis of 7 days of session data: 975 apparent table blocks, 879 flagged
broken. Of those, 744 (84.6%) were false positives (inline pipes in prose
and shell commands). The separator off-by-one pattern accounted for the
majority of genuine failures (~94 of 135 real broken tables).

The plugin is wired into MARKDOWN_PLUGINS and MARKDOWN_PLUGINS_WITH_BREAKS,
immediately after remarkGfm where position data is available.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* test(web): strengthen remarkRepairTables test suite

- Remove unused parseTableCols helper
- Assert alignment markers (:-- / --:) are preserved in repaired separator
- Add header-only table test (header + broken separator, no data rows)

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): remove dead repairCount + add structural column-count assertion

- Drop repairCount from visitTables — increment was never read at call site
- Add per-row cell count assertion to the 3-column repair test to catch
  structural regressions that content-presence checks would miss

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* test(web): add structural column-count assertions to remaining repair tests

Off-by-N (4-column), alignment-hints, and header-only tests now verify
each output row has the correct number of cells, not just content presence.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): skip escaped pipes in countSourceCells to prevent false repairs

\| inside a GFM table cell is a literal pipe character, not a cell
delimiter. The previous split('|') approach miscounted cells in headers
like | A \| B | C |, treating a valid 2-column table as 3-column and
padding the separator unnecessarily.

Replaces the split with a character-scan that tracks escape state.
Adds a test asserting the separator column count stays at 2 for tables
with escaped pipes in the header.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): re-parse repaired table with main processor to preserve inline extensions

parseTableBlock() previously created a bare remarkParse+remarkGfm processor,
so inline math (or other pipeline extensions) inside a repaired table cell was
parsed as plain text and lost after repair.

Fix: use this (the Processor instance unified passes to the plugin factory) to
re-parse the repaired block, so all registered extensions apply. Removes the
now-unused remarkParse/remarkGfm/unified imports.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): rewrite repair plugin as string preprocessor

The previous implementation visited `table` AST nodes after remark-gfm
parsed the source. But remark-gfm 4.x degrades a mismatched-separator
table (separator row has fewer cells than the header row) to a paragraph
node entirely — no `table` node is ever produced, so the visitor never
triggered and the repair was a no-op.

New approach: scan `file.value` for broken separator rows BEFORE the
AST is built, pad them in-place, then re-parse the corrected source so
remark-gfm produces proper table nodes. Export `repairMarkdownTables`
as a named function for direct testing.

Update the unit tests to actually discriminate between a repaired table
(stringified lines start with `|`) and the old broken paragraph output
(stringified lines start with `\|`, escaped by remark-stringify).

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): skip fenced code blocks and preserve indentation in repair scan

The string-level scanner was modifying table-like lines inside fenced code
blocks (``` / ~~~) — a bug reported in PR review (Major). Also preserves
original leading whitespace when replacing a separator line so indented
tables are not affected.

Add tests for fenced-code skip, ~~~ variant, and correct repair after a
fence closes.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): harden remark-repair-tables against code-span pipes and mixed fences

- countSourceCells: strip backtick code spans before counting column
  boundaries — a header like | `a | b` | c | is 2 columns, not 3
- repairMarkdownTables: track fenceChar ('`'|'~'|null) instead of a
  boolean toggle so ``` inside ~~~ no longer incorrectly flips fence state
- add 2 tests: code-span-with-pipe in header, backtick inside tilde fence
- fix stale comment in markdown-text.tsx (plugin reads file.value, not AST nodes)
- drop no-op .trimStart() (padSeparatorLine already returns a trimmed string)

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): handle double-backtick code spans and preserve tree root on re-parse

- countSourceCells: use /`+[^`]*?`+/g so double-backtick spans like
  `` `a | b` `` are also stripped before counting column boundaries
- remarkRepairTables: Object.assign(tree, newTree) instead of only
  copying children, so position/data from the root node are preserved

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): require closing fence to match opener length (GFM fence rule)

A ```` fence must not be closed by ``` — GFM specifies the closer must use
the same marker character AND be at least as long as the opener sequence.
Track fenceLength alongside fenceChar so longer-backtick fences stay open
until a closer of equal or greater length arrives.

Also tighten the fence-match regex from /^\s*/ to /^ {0,3}/ to match the
GFM spec (fences are valid with up to 3 spaces of indentation, not arbitrary
whitespace). Adds a regression test for the ```` / ``` case.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): closing fence must have only whitespace after the marker (GFM rule)

GFM §4.5: a fence closing sequence may only be followed by optional spaces.
A content line like \`\`\`ts inside a code block is not a valid closer, so we
must not clear fenceChar when the remainder of the line is non-whitespace.

Captures rest after the marker and guards the close branch with /^\s*$/.
Opening fences are unaffected (info strings on openers remain valid).
Adds a regression test: ``` opener, ```ts content line, ``` closer.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-06-18 10:16:55 +08:00
f5c0ef245b fix: active-only session filter + paginated "Show N more" (closes #901) (#903)
* test: reproduce issue #901 (active-only filter + paginated show more)

* fix: active-only session filter + paginated 'Show N more' (closes #901)

Add a persisted 'Active sessions only' toggle in Settings -> Display that
hides inactive sessions in the sidebar while keeping the selected session
visible. Change 'Show N more' to reveal one batch (preview-limit size) per
click instead of expanding every hidden session at once, with 'Show less'
to collapse back to the initial preview.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-06-18 10:16:42 +08:00
22bf7e04d2 fix(web): persist file explorer expanded tree and scroll position across navigation (#911)
* fix(web): persist file explorer expanded tree and scroll position across navigation

Expanded folder state and scroll position in the Directories tab were
stored only in local React state, so navigating to a file and back
would reset the tree to the root and scroll to top.

Now both are saved to sessionStorage (keyed by sessionId) on every
change and restored on remount, so the explorer resumes exactly where
the user left off.

Closes #910

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): key DirectoryTree by sessionId to prevent stale expanded state across sessions

When navigating between sessions, React can reuse the same DirectoryTree
instance. The useState lazy initializer only runs on first mount, so the
tree would hydrate with the wrong session's expanded set and then
overwrite the new session's storage key.

Adding key={sessionId} forces a fresh mount per session.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-06-18 10:16:32 +08:00
a8e08e8014 feat(web): add download button to file viewer (#926)
Adds a download icon button to the file viewer toolbar (next to copy-path).
Clicking it decodes the existing base64 file content into a Blob and triggers
a browser download — no new backend endpoint required. Works for text, binary,
and image files. Button is hidden until the file has loaded successfully.

Closes #924

via [HAPI](https://hapi.run)

Co-authored-by: HAPI <noreply@hapi.run>
2026-06-18 10:16:17 +08:00
2643f17840 feat(web): Web Share Target -> Android system share sheet integration (#933)
* feat(web): Web Share Target -> composer attachment preload

PWA manifest now declares a `share_target` so Android Chrome surfaces
HAPI in the system share sheet for any app (Photos, Files, browser).

Pipeline on share:
  1. Service worker intercepts POST /share, parses the multipart payload
     (title/text/url + N files), persists it in IndexedDB under a
     transfer id, and 303-redirects to /share?id=<id>. The 303 forces
     Chrome to convert the POST into a GET so the SPA route mounts.
  2. New /share route loads the transfer, previews the content, and
     lets the user pick a recent active session (top 5 by activeAt) or
     a "+ New session". Tapping a session stashes the transfer id in
     sessionStorage and navigates to /sessions/:id.
  3. SessionChat mounts a ShareSeedConsumer once the AssistantRuntime
     is up; it consumes the pending transfer once per mount, seeds
     composer text + per-file attachments via the existing
     attachmentAdapter, then deletes the IDB row so a refresh of the
     session page does not replay the upload.

The whole feature reuses the existing /sessions/:id/upload endpoint;
no hub or shared changes.

Limitations (also disclosed in the PR body):
  - PWA must be installed; Android Chrome only registers share_target
    on install. iOS Safari ignores the manifest field entirely.
  - File MIME accept list is broad (`*/*` fallback); some Chrome
    versions still filter despite this.

Tests:
  - shareTransfer.test.ts (8) covers payload parse, multi-file order,
    type fallback, ingest redirect shape and error propagation.
  - sharePendingState.test.ts (3) covers atomic consume + overwrite.

Closes: pending upstream issue (filed before PR per intake doc).
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/share): freeze picker session list at mount, sort by updatedAt, drop top-5 cap

The /share picker was visually re-shuffling under the operator's finger
as SSE events rolled in: every session metadata patch refreshed the
React Query cache, the useMemo recomputed, and items reordered (often
within a second of opening the share sheet). Sort key was activeAt,
which heartbeats every few seconds while a session is connected,
making the noise floor even higher.

Three changes:
  - Snapshot the active-session list once when sessions finish loading
    via useState + a deferred useEffect. The picker is a one-shot
    interaction; closing the share sheet and re-sharing produces a
    fresh snapshot, so freezing for the duration of the picker view is
    the right trade.
  - Sort by updatedAt desc to match SessionList's canonical "most
    recent interaction first" order. updatedAt only moves on
    user-meaningful events, not heartbeats.
  - Drop the TOP_SESSIONS=5 cap. The picker is already inside an
    app-scroll-y container, so showing all active sessions and letting
    the operator scroll matches the operator's mental model better
    than an arbitrary truncation.

Per operator dogfood report: "list of recent sessions is constantly
updating; should be just a scrollable list, from most recent
interaction to not."

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/share): base-aware share_target paths for subpath PWA deploys

Manifest share_target.action, SW POST matching, and ingest 303 redirects
were hard-coded to /share. Standalone builds with --base /<repo>/ put
scope/start_url under the subpath but left the share action at origin
root, so Chrome posted outside the SW scope and the handler never ran.

Extract shareTargetPathnameFromBase() (used at build time in
vite.config.ts and at runtime via import.meta.env.BASE_URL in sw.ts and
shareTransfer.ts). Normalizes base to a trailing slash before URL
resolution so /repo and /repo/ both resolve to /repo/share.

Addresses upstream PR #933 review (Major).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/share): defer sessionStorage arm until new session spawn succeeds

The "+ New session" picker path called setSharePendingTransfer before a
session existed. Cancel, spawn failure, or backing out left a stale id in
sessionStorage that the next unrelated SessionChat mount would consume.

Pass shareTransferId via /sessions/new search params instead; arm the
consumer only in handleSuccess after spawn, and delete the IDB row on
cancel.

Addresses upstream PR #933 review (Major).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/share): append share text to existing composer draft

ShareSeedConsumer called setText(seedText) unconditionally, clobbering
per-session drafts restored by useComposerDraft from sessionStorage.

Merge share title/text/url after any in-composer text or saved draft,
joined with a blank line. Pass sessionId into ShareSeedConsumer so
getDraft() can be consulted when the composer is still empty.

Addresses upstream PR #933 review (Major).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/share): preserve shareTransferId through /browse detour

New-session spawn from the share picker could lose shareTransferId when
the operator opened /browse to pick a folder: handleChooseFolder and
BrowsePage handleStartSession dropped the search param, so handleSuccess
never armed the composer consumer.

Thread shareTransferId through browseRoute search validation and both
navigation hops.

Addresses upstream PR #933 review (Major).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/share): consume pending transfer in effect for StrictMode

ShareSeedConsumer called consumeSharePendingTransfer during render.
React.StrictMode double-invokes render in dev; the discarded pass
deleted the sessionStorage key before the committed render seeded.

Move consume into a mount-only useEffect and gate the seed effect on
transferReady.

Addresses upstream PR #933 review (Minor).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 10:16:03 +08:00
dfb1805fd6 feat(web): OLED Black theme + per-appearance custom colors (#937)
* test: reproduce issue #866

* feat(web): OLED Black theme + per-appearance custom colors (closes #866)

Add an explicit OLED Black appearance (true #000 canvas, border-based
elevation) alongside system/dark/light, and a curated "key color"
customizer. Each key color (background, surface, text, hint, accent,
border, user bubble) cascades to its --app-* tokens and is stored per
appearance so a color tuned for light never leaks onto pure black.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-06-18 10:15:34 +08:00
ce67823fc3 feat(web,hub): rich hover tooltips on session-list attention indicators (#941)
* feat(web): rich hover tooltips on session-list attention indicators

The session-row attention dots and the future-scheduled clock icon used
plain `title=""` attributes which gave only a one-word label ("Permission
required"). Replace those with hover/focus-revealed tooltips that name
*which* tools are blocking, count background tasks, surface the
"updated Nm ago" timestamp, and explain the pending schedule.

To make per-tool copy possible without an extra round trip,
`SessionSummary` now carries a structured slice of the pending tool
requests, capped at `PENDING_REQUEST_SUMMARY_CAP = 5` oldest-first:

  pendingRequests: Array<{ id; kind; tool; since }>

`pendingRequestsCount` remains the authoritative total;
`pendingRequestKinds` is still derived from the FULL request set so a
single `'input'` request beyond the cap still surfaces its kind on the
session row.

The tooltip primitive (`HoverTooltip`) is a CSS-driven reveal — no
portal, no positioning JS — so it composes cheaply inside the existing
session-row `<button>` and stays out of the way on touch devices, which
keep getting the same `aria-label` the old `title=""` attribute provided
to screen readers.

Test coverage: shared derivation + cap + tie-break + full-set kind
behaviour; web tooltip render across all four attention kinds plus
mixed-kind overflow suppression and aria-label exposure.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web): opaque tooltip surface; drop redundant 'updated Nm ago' body

Two operator-feedback fixes on the new session-list HoverTooltip:

1. Tooltip background was bg-[var(--app-bg)] - the same variable as the
   session row underneath - so the tooltip looked translucent and the row
   text bled through. Switch to bg-[var(--app-secondary-bg)] (#2C2C2E
   dark / #f3f4f6 light, both opaque) and bump shadow-md -> shadow-lg.
   Telegram-themed clients still pick up tg-theme-secondary-bg-color so
   the tooltip stays on-theme.

2. The 'unread' attention dot tooltip rendered 'New activity / Updated 5m
   ago', but the relative-time pill ('5m ago') is already on the right
   edge of the same session row. The tooltip body just duplicated info.
   Render only the title for the unread case; drop the
   session.tooltip.unread.body i18n key from en + zh-CN.

The other tooltip kinds (permission/input list tools, background lists
task count) keep their bodies - those facts are not visible elsewhere on
the row.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web,hub): show scheduled fire time in session-list clock tooltip

The schedule clock tooltip previously said only "Will fire when due."
while the row already showed a relative updated-at pill. Extend the
session-list API with nextScheduledAt (MIN future scheduled_at per
session, same filter as futureScheduledMessageCount) and render:

- single scheduled: "Fires in 5m · Jun 16, 1:45 PM"
- multiple: "Next in 5m · Jun 16, 1:45 PM · +2 more"

Extract formatScheduledTime from QueuedMessagesBar into web/lib/
scheduledTime.ts alongside formatFutureRelativeTime and the tooltip
composer. SSE upsert preserves nextScheduledAt until the list refetch
that already runs on schedule-related events.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): wire session-row keyboard focus to HoverTooltip a11y

Address PR #941 Major review: aria-describedby and tooltip visibility
were on a non-focusable inner span, so keyboard users tabbing the session
row button never received the rich tooltip description and
group-focus-within never matched.

- Session row button owns aria-describedby (attention + schedule ids)
- Add group/session-row + SESSION_ROW_TOOLTIP_FOCUS_CLASS reveal on
  :focus-visible
- HoverTooltip takes required id; drop inner aria-label/describedby
- useSessionRowTooltipIds helper composes stable row tooltip ids
- Tests for id wiring and parent-focus reveal classes

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 10:15:22 +08:00
5f27abddd4 feat(web): in-app PWA update prompt when new service worker is available (#946)
* feat(web): in-app PWA update prompt when new service worker is available (closes #938)

User-controlled reload with a persistent banner, visibility-triggered SW
checks, and an expandable rationale. Switches registerType to prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): align vite.config with soup layers for clean driver merge

Keeps registerType prompt while matching garden IWER stubs and PWA
share_target shape expected by feat/pwa-share-target in the manifest.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Revert "fix(web): align vite.config with soup layers for clean driver merge"

This reverts commit 6f0915b0884d029a2413d8819a4dfe81d7c4e595.

* fix(web): make PWA reload apply waiting service worker updates

Handle SKIP_WAITING in injectManifest sw.ts and reload via controllerchange
with a timed fallback when vite-plugin-pwa prompt mode does not navigate.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): satisfy setTimeout mock typing in PWA reload tests

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): register PWA service worker before auth gates

Mount PwaUpdateProvider at app root and show the update banner on login
and error screens so registerSW runs for logged-out users too.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): offset PWA update banner below top status banners

Reserve top-12 when syncing or reconnecting so the reload prompt stays
visible above SyncingBanner and ReconnectingBanner.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): offset PWA update banner below voice error banner

Use PwaUpdateBannerWithStatusOffset inside VoiceProvider so voice errors
share the same top-12 reservation as sync and reconnect banners.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 10:15:10 +08:00
78155a9d27 perf(web): add staleTime to useSession to suppress focus/mount refetches (#884) (#885)
* perf(web): suppress useSession refetch storm (closes #884)

Two compounding behaviours in the React client were producing a sustained
~100 req/sec stream of GET /api/sessions/<uuid> to the hub on installs with
a moderately-sized session fleet:

1. `useSession` had no `staleTime`, so window-focus and remount each
   triggered a fresh REST round-trip even though SSE was already pushing
   the same data via `patchSessionDetail`.

2. `useSSE`'s `session-added`/`session-updated` handler unconditionally
   queued a per-session `invalidateQueries({queryKey: ['session', id]})`
   whenever the incoming SSE payload was not a structured patch, fanning
   out a refetch to every still-observed `useSession` regardless of
   whether the user was currently viewing that session detail.

Fixes:

- `useSession` now sets `staleTime: 30_000` (exported as
  `SESSION_DETAIL_STALE_TIME_MS` for testability/tuning).  SSE remains
  authoritative for freshness; the REST endpoint is a cold-start path.

- `useSSE` only queues per-session detail invalidation when an active
  observer is mounted for that session.  The new
  `hasActiveSessionDetailObserver` helper checks the TanStack query cache
  for `getObserversCount() > 0`.  List-summary invalidation is unchanged
  (sidebar still updates).

No behaviour change for the patch-path: structured `SessionPatch` events
still flow through `patchSessionDetail` + `patchSessionSummary` and update
in place.  The fallback path is what we are taming.

Measured on the reporter's box pre-fix: 31,944 GET /api/sessions/<uuid>
hits over a 5-minute idle window across 132 distinct session UUIDs
(~106 req/sec).  Expected post-fix: ~0 for sessions whose detail page
is not currently open, gated by `staleTime` for navigation thrash.

Tests:
- `useSession.test.ts` asserts `SESSION_DETAIL_STALE_TIME_MS` is set.
- `useSSE.test.ts` adds 4 cases for `hasActiveSessionDetailObserver`
  covering no-cache, cache-without-observer, mounted-observer, and
  cross-session isolation.

* fix(web): revert observer-gating in useSSE (address PR #885 review)

The Codex review on #885 correctly flagged that
`hasActiveSessionDetailObserver`-gating around the two
`queueSessionDetailInvalidation` fallback paths broke an important
correctness invariant: with `staleTime: 30_000` in place, skipping
the invalidation entirely (instead of letting TanStack mark the
cache stale) means a subsequent remount within 30s will serve the
stale cached detail without a REST recovery fetch.

This regressed real backend code paths.  Hub emits
`session-updated` events with no structured `data` field on todos /
teamState / metadata / agentState changes (see
`hub/src/socket/handlers/cli/sessionHandlers.ts:117,128,216,263`),
which hit the gated `else` branch.

Root cause of the over-correction was a misunderstanding of TanStack
v5 semantics: `invalidateQueries` with the default
`refetchType: 'active'` is *already* a network no-op for unobserved
queries — it just marks them stale.  The manual observer-count check
was structurally redundant *and* incorrectly suppressed the stale
marking.

Revert: restore the original unconditional
`queueSessionDetailInvalidation` calls on both fallback branches.
Drop the `hasActiveSessionDetailObserver` helper export and its 4
unit-test cases.  Keep Fix A (`staleTime: 30_000` on `useSession`)
intact — that change is independently safe and addresses the
focus-refetch / remount-refetch class of redundant requests.

* docs(web): correct staleTime rationale in useSession (#884)

Stand-in cold review on PR #885 caught that the comment overstated the
fix's reach.  `web/src/lib/query-client.ts:7` already sets the global
default `refetchOnWindowFocus: false` and `staleTime: 5_000`, so the
per-query `staleTime: 30_000` does NOT cut focus-refetches (there were
none) and only extends the remount/reconnect-no-refetch window from
5s to 30s.

Rewrite the comment to be accurate about scope: the change suppresses
remount refetches within a 30s window, and explicit `invalidateQueries`
(SSE fallback path, reconnect-recovery in `App.tsx`) still refetches
active observers — so live updates and recovery flows are preserved.

No code behaviour change; comment-only edit.

* fix(web): invalidate all cached session details on SSE reconnect

Codex review on PR #885 caught a real regression introduced by the
`SESSION_DETAIL_STALE_TIME_MS = 30_000` change: the reconnect-recovery
handler in `App.tsx` only invalidated the *currently-selected* session's
detail.  With per-query staleTime extended from 5s (global default) to
30s, a previously-viewed but non-selected session whose cache was still
within the freshness window could serve stale data after the SSE channel
missed updates during the disconnect.

Scenario:
1. User views session A → cache populated, fresh.
2. User switches to session B → A's observer unmounts, cache lingers
   (gcTime: 5min).
3. SSE disconnects.  Session A receives updates server-side that no
   patch event reaches the client.
4. SSE reconnects.  Old `handleSseConnect` only invalidated
   `session(selectedSessionId=B)`, NOT A.
5. User navigates back to A within 30s → useSession remounts → cache
   is still considered fresh by staleTime → no REST recovery fetch →
   user sees stale A data.

Fix: broaden the per-session invalidation in `handleSseConnect` from
`['session', selectedSessionId]` to the prefix `['session']`, which
matches every cached session-detail entry.  Active observers refetch
(same as before — only the selected session was active), inactive
cached entries get marked stale so the next remount refetches.

Performance impact: zero new fetches on reconnect (the selected
session is still the only one with an active observer in practice).
Marking inactive entries stale is metadata-only, free.

This restores the pre-staleTime invariant where every cached session
detail was either fresh (just fetched) or actively re-fetched on
reconnect, and matches the documented contract that SSE is the
authoritative freshness signal while REST is the cold-start /
reconnect-recovery path.

---------

Co-authored-by: heavygee <heavygee@users.noreply.github.com>
2026-06-18 10:13:36 +08:00
a2862a3300 docs(installation): add KillMode=process to runner systemd unit (closes #915) (#928)
The runner spawns child agent sessions with `detached: true`
(`cli/src/runner/run.ts:454`) so they survive runner restart, and
runner cleanup (`run.ts:1049`) does not iterate or kill tracked
children on shutdown. The runner is already designed as a long-lived
process whose exit leaves agent sessions intact.

But Node's `detached: true` calls `setsid()` (new process session),
which does NOT escape the parent's systemd cgroup. Without an
explicit `KillMode`, systemd defaults to `control-group`, which
SIGTERMs every PID in the runner's cgroup whenever the unit stops -
forcibly archiving every running session and discarding the detach
contract.

Adds `KillMode=process` to the reference runner unit and a note
explaining the contract. With this change, `systemctl restart
hapi-runner.service` (and any cascade-stop from `Requires=`) only
signals the main runner PID; the cleanup runs without killing
descendants; agent sessions stay alive; the new runner reconnects via
the existing socket.io reconnect path
(`cli/src/api/apiMachine.ts:385`) and re-establishes control via the
existing RPC layer.

This is the smallest fix for #915. The complementary safety net -
runner re-attaching to orphaned children on cold start when no
running runner exists - will be tracked in a separate issue and PR.

AI-disclosure (per CONTRIBUTING.md): drafted with claude-opus-4.7 as
peer agent during a fork-side post-mortem of a 7-hour outage that
this fix would have prevented.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 10:12:12 +08:00