hapi kimi local mode spawned the kimi TUI with no transcript sync, so
terminal conversations never reached the hub and the web UI stayed empty.
After the kimi-code rewrite (data moved from ~/.kimi to ~/.kimi-code),
model resolution also broke: hapi read the gone ~/.kimi/config.toml and
fell back to the invalid hardcoded default kimi-k2, and the KIMI_MODEL /
KIMI_PROJECT_DIR env vars it set no longer exist upstream.
Local sync (mirrors the codex transcript scanner):
- kimiWireLocator: derive the kimi-code workspace id
(wd_<slug>_<sha256(cwd).12>, ported verbatim from upstream workdir-slug),
poll for the session dir created by the just-spawned process, and watch
its agents/main/wire.jsonl. Pre-existing sessions are snapshotted and
excluded (awaited before spawn) so a retry cannot bind to a stale
session; multiple fresh candidates are refused as ambiguous.
- kimiWireScanner: incrementally read wire.jsonl and convert events into
hapi messages (user prompts/steers, assistant text/thinking, tool
call/result incl. is_error, step.end usage with cached input summed
into inputTokens).
- kimiLocalLauncher: attach locator+scanner, report kimiSessionId on
discovery (enables web resume and local<->remote handoff).
Model handling:
- config.ts: read <KIMI_CODE_HOME|~/.kimi-code>/config.toml (legacy
~/.kimi fallback); drop the hardcoded kimi-k2 default and the dead
KIMI_MODEL env source - when nothing is configured, omit --model so
kimi-code uses its own default_model.
- kimiBackend/kimiLocal: stop setting KIMI_MODEL and KIMI_PROJECT_DIR
(both unused by new kimi-code).
- kimiRemoteLauncher: apply the resolved model over ACP after session
creation (session/set_model, falling back to the advertised model
config option), and display the agent-reported current model instead
of the env guess.
Verified against live kimi-code 0.26.0: ACP initialize/session-new/
prompt probes, locator discovery of a running session, and converter
robustness over a real 800-line wire.jsonl.
The ChatToolCall fixture in ToolCard.test.ts does not set execStartedAt
and execCompletedAt, which became required fields, so bun typecheck fails
on main with TS2739.
The fixture was added while those fields did not exist yet, and the change
that introduced them landed a minute earlier, so each side type-checked
against its own base and the gap only appears once both are on main.
Set both to null, matching the sibling fixtures in ToolGroupCard.test.ts
and groupedPresentation.test.ts, since these cases do not exercise tool
execution duration.
MessageInfoPopover's trigger button carried the same
happy-message-actions-desktop-only class as the other hover-reveal
actions from 4c76668a, so it never rendered on touch-only viewports
(no hover: hover match). Switch it to the always-visible flex pattern
the sibling copy button already uses, matching desktop's hover-reveal
opacity animation on the parent row.
Also widen the action row's desktop-only-row guard to stay reachable
when a tool-only response (no copyable text) still carries model/
duration metadata from its first tool block, so the info popover isn't
hidden behind an empty row on mobile.
* feat(web): show subagent's executed model in Task/Agent card header
Task/Agent trace cards previously gave no indication of which model a
subagent actually ran under, even though the model can differ from the
calling session's (e.g. main session on opus, subagent on haiku) and
can even change mid-run when --fallback-model kicks in under overload.
The data already reaches the frontend: each child block produced from
a subagent's own sidechain carries the model of the assistant message
it came from. Derive it in getSubagentModel() from the tool call's own
children (not the parent ToolCallBlock.model, which reflects the
calling session and would misattribute the model), collecting distinct
raw values in first-seen order and joining them the same way
aggregateResponseGroups already does for top-level multi-turn message
metadata.
Full SDK model ids (e.g. claude-sonnet-4-5-20250929) are long and not
great for a compact label, so formatSubagentModelLabel() extends this
repo's existing "friendly label, else raw fallback" idiom
(getClaudeModelLabel(model) ?? model in claudeModelOptions.ts, which
only covers the short preset aliases) with a narrow second fallback
that extracts just the name and version from the SDK id shape and
drops the date suffix (-> "Sonnet 4.5"). Anything else is left as-is.
Renders the result as a small chip in the header's existing right-side
meta cluster (next to ElapsedView/status icon), always visible without
opening the detail dialog.
* fix(web): cap subagent model badge width to avoid squeezing the card header
Addresses HAPI Bot review on #1045: formatSubagentModelLabel() returns
unrecognized model ids (Gemini, Codex, future formats) unchanged, and
those can be long. Bound the chip with max-w + truncate so it can't
push the title/status area off narrow cards, with a title attribute
so the full value is still reachable on hover.
* refactor(web): export formatDuration for reuse
* feat(web): show tool call duration in the detail dialog
Show a completed tool's execution duration at the top of its detail
dialog. The value is derived from the Claude entry's own timestamps
(the execution machine's wall clock) rather than the hub's
message-receive time, and is used only when both the tool_use and
tool_result entries carry a real timestamp — otherwise it falls back to
the hub receive times on both sides, so the two clocks are never mixed.
Running/pending tools show nothing, the running-state live timer is
unchanged, and clock skew is guarded against. Reuses the existing
formatDuration formatter. No schema changes.
* fix(web): backfill hub startedAt on reorder so duration isn't 0.0s
When a tool_result entry is reduced before its tool_use, the tool block
is created from the result, so the hub startedAt is the result receive
time. The tool_use path only lowered the exec start, not the hub
startedAt, so a timestamp-less pair (no exec duration available) fell
back to startedAt === completedAt and the detail dialog showed 0.0s.
Lower the hub startedAt to the earlier tool_use receive time as well.
Use provider-qualified model lookup (provider + modelId) to resolve the
correct context window for the Pi status bar, falling back to legacy
modelId when selected-model metadata is absent. This prevents showing
the wrong context window when two providers share the same modelId.
* feat(pi): add 'max' thinking level
Pi's --thinking flag accepts 7 levels: off, minimal, low, medium, high,
xhigh, max. The shared constant and UI only exposed 6 levels (missing max).
Add 'max' to PI_THINKING_LEVELS and PI_THINKING_LEVEL_LABELS. Like xhigh,
max requires explicit opt-in via the model's thinkingLevelMap — models that
support it will include max in their map and the UI will show it
accordingly.
* fix(pi): close max thinking-level branch
* 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
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>
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>
* 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>
* 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.
* 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.
* 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>
* 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>
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>
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.
* 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>