Commit Graph
398 Commits
Author SHA1 Message Date
ad038bbf2e fix(cursor): merge SKU catalog under ACP lock and refcount agent guard (#835)
Fixes incomplete cliModelSkus while agent acp holds the CLI lock (#831) and
replace single-pid ACP lock with cross-process refcount (#832). Web picker
merges machine/session catalogs and waits for SKU readiness before showing
variant labels.

Fixes #831
Fixes #832

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:30:43 +08:00
HeavyGeeandGitHub 6d2d0d4707 fix(cursor): trim #784 safety patch to marker-only on legacy stream-json path (closes #822) (#828)
* fix(cursor): drop timing heuristic from #784 intercept; scan raw payload (#801 follow-up)

PR #801 shipped a two-strategy intercept for the synthetic AskQuestion
skip response in legacy stream-json mode. Real-traffic data from a
post-merge run shows the marker-match strategy never fires (the
converter's `extractToolResult` discards the marker for tool shapes it
does not recognize, returning `{}`) and the timing-signature
defense-in-depth strategy fires only on false positives - notably the
Anthropic Vertex Claude tool calls cursor-agent surfaces in legacy
sessions, which all land as `name=unknown` with the `{}` extracted
result and frequently complete under the 500 ms threshold.

Measured on a single legacy-resumed session (`7b769423`): 1,136
`name=unknown` tool calls, 16 rewritten as `no_input_surface`, zero
actual marker strings stored anywhere in the session. The 16 rewrites
were legitimate fast tool calls (Anthropic Vertex `toolu_vrtx_*` IDs)
mischaracterized as fabricated skip responses.

Changes:
- Remove the timing-signature heuristic and its supporting state
  (started-at map, elapsed-ms calculation, latency threshold, test-only
  state reset).
- Move the marker scan from the post-`extractToolResult` output to the
  raw `tool_call` payload, so it can see the marker on stream-json
  shapes the converter does not specifically recognize. Function-shaped
  tools exclude `function.arguments` from the scan to avoid matching
  agent-controlled input. Other shapes scan the full payload (no
  agent-input field exists at the top level).
- Refresh tests: drop timing-based positive cases, add a marker-in-raw-
  payload positive case for `name=unknown` shapes, and add a regression
  that legitimate fast `name=unknown` tool calls without the marker
  pass through with `status: completed`.
- Document scope: this intercept now lives only on the legacy stream-
  json path, which only resumed pre-ACP sessions hit. New cursor remote
  sessions go through `cursorAcpBackend` and the `cursor/ask_question`
  ACP extension method (#799) - immune to this bug. The intercept
  drains with the legacy session population.

Tracking: #784. Builds on #801, complements #799.

* fix(cursor): exclude agent input from marker scan; surface top-level Anthropic tool names (Codex P2)

Codex flagged a false-positive case on the fork-stage review of this
branch (heavygee/hapi#35, P2): an Anthropic tool_use shape with a
top-level `name` (e.g. `{id, name: 'TodoWrite', input: { ... }}`) gets
labelled `name=unknown` by the converter and passes the AskQuestion
gate. If the agent's `input` quotes the synthetic-skip marker - which
happens whenever an agent edits or documents this very bug - the
intercept would rewrite a perfectly fine TodoWrite as a fabricated
skip.

Two-part fix:

1. `extractToolName` now reads the top-level `name` field as a final
   fallback. A real `TodoWrite` / `Bash` / `str_replace_based_edit_tool`
   surfaces with its actual name and is rejected by the AskQuestion
   gate before the marker scan runs. The original AskQuestion
   fabrication case still surfaces as `unknown` (per #784 issue body
   the name is stripped in the fabricated payload) and remains
   detectable.

2. Defense in depth: introduce `AGENT_INPUT_KEYS = {input, args,
   arguments}` and exclude these from the non-function shape's marker
   scan. Even if a tool reaches this code path with `name=unknown` and
   the marker buried in its `input`, the intercept won't fire on agent-
   controlled text.

Two new regression tests:

- Anthropic tool_use shape `{id, name: 'TodoWrite', input: {todos: [
  marker]}}` → passes through with `status: 'completed'`.
- `name=unknown` shape with marker only inside `input` → passes through
  with `status: 'completed'`.

All 20/20 tests pass; typecheck clean (cli + web + hub).
2026-06-08 13:30:04 +08:00
cd99cfbc25 fix(hub): preserve session metadata across archive transitions (#825)
* fix(hub): preserve flavor session ids in metadata across archive transitions

When a session ends (terminate, crash, local-launch failure, handoff),
the runner's archive write replaces sessions.metadata wholesale. If the
CLI's locally cached Metadata is null (e.g. Zod parse failed at bootstrap
and api.ts nulled it out) or stale, the spread in archiveAndClose ships
a sparse blob and the resume token (cursorSessionId, codexSessionId,
claudeSessionId, etc.) gets cleared from the row even though the
on-disk chat data is still intact.

Fix at the hub layer because update-metadata is the single chokepoint
for every metadata write surface (CLI, web, future): in the store-level
updateSessionMetadata, read the prior row's metadata inside a
transaction and carry forward a small allowlist of flavor resume tokens
when the incoming write omits them. Explicit overwrites still win.

The allowlist mirrors pickExistingSessionMetadata in sessionFactory.ts
which already preserves the same fields on bootstrap.

Closes tiann/hapi#820

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

* fix(hub): address cold-review findings on metadata merge

Three bot findings on the initial patch:

1. (P1) Sparse archive payloads still resulted in metadata blobs that
   failed MetadataSchema parse downstream — required `path`/`host` were
   not in the carry-forward set, so even though the resume token
   survived, hub session cache and CLI getSession nulled-out the row
   and resume_unavailable came back. Add PARSE_IDENTITY_FIELDS = `path`,
   `host` to the carry-forward.

2. (P2) Preserving `cursorSessionProtocol` whenever it was omitted
   carried a stale protocol over to a freshly written `cursorSessionId`,
   misrouting a future remote resume. Pair-aware logic: drop the prior
   protocol when next sets a new id; preserve the protocol only when
   next is silent on both id and protocol.

3. (P2) The successful update-metadata broadcast emitted the pre-merge
   payload to other CLIs in the session room, so even though the DB row
   was preserved, peer caches diverged. Switch the broadcast value to
   `result.value` (the persisted merged value) so live caches stay in
   sync with the truth.

Refactor preserveProtocolResumeFields into mergeSessionMetadata with
two tiers (PARSE_IDENTITY_FIELDS + SIMPLE_RESUME_TOKENS) plus the
cursor pair handler. 6 new tests cover the regressions; existing 16
still pass plus 1 new socket-level test for the broadcast.

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

* fix(hub): preserve flavor + machineId across sparse metadata merges

Bot P2 on the prior fix: PARSE_IDENTITY_FIELDS (path, host) made the
blob parseable and SIMPLE_RESUME_TOKENS preserved the chat-id, but
flavor and machineId were still being dropped by sparse archive
payloads. Consequences:

- flavor: hub/src/web/routes/sessions.ts and sync/syncEngine.ts read
  `metadata?.flavor ?? 'claude'` to pick which session id field to
  resume. With flavor missing, a Cursor/Codex/Gemini session was
  routed as Claude and the preserved cursorSessionId was ignored.

- machineId: telegram/bot.ts and the CLI's resumable listing read
  `metadata?.machineId` to scope sessions to the current host. With
  machineId missing, the row dropped out of the resume picker.

Add a third carry-forward tier ROUTING_FIELDS = [flavor, machineId]
between PARSE_IDENTITY_FIELDS and SIMPLE_RESUME_TOKENS in
mergeSessionMetadata. 3 new tests cover preservation, no-invention,
and explicit override.

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

* fix(hub,cli): support explicit-clear sentinel for carry-forward fields

Upstream cold-review (Major): the carry-forward semantics introduced
in the prior commits ("omit field → preserve from prior") collide
with cli/src/codex/session.ts resetCodexThread(), which intentionally
clears codexSessionId by deleting it from the metadata blob before
calling updateMetadata. With omit-as-preserve, the cleared id was
restored from the prior row and /clear on a Codex session no longer
dropped the persisted thread.

Add an explicit-clear sentinel: when next sets a carry-forward field
to `null`, the merge drops the key entirely from the persisted blob
(key removed; not stored as null since MetadataSchema fields are
`string().optional()`). `undefined` (key missing from next) keeps its
"carry forward" meaning. The two semantics now compose cleanly:

  - next.field = "x"   → next wins (caller sets a new value)
  - next.field = null  → drop the field (caller intentionally clears)
  - next omits field   → carry forward prior (caller didn't touch it)

Update resetCodexThread() to send `codexSessionId: null` so the
reset actually drops the persisted thread under the new merge.

4 new hub tests cover: explicit clear of a single token, clear-one-
preserve-others independence, no-op clear on a never-set field, and
the success-ack value reflects the cleared blob. cli/src/codex tests
(224/224) and hub suite (301/301) green; bun typecheck clean.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:28:00 +08:00
edc3acc3e2 fix(cursor): requeue user message on transient agent exit (auth, rate limit) (#823)
* fix(cursor): requeue user message on transient agent exit (auth, rate limit)

cursorLegacyRemoteLauncher.runMainLoop popped a user message off the queue
before spawning `agent` and silently discarded it whenever `agent` exited
non-zero (auth expiry, rate limit, transient network). The wrapper logged
the failure at debug level only, never surfaced it to the web UI, and
emitted `ready` as if a normal turn had ended.

Capture stderr from the spawned process; classify exit-1 with a transient
signature (Authentication required, rate limit, ETIMEDOUT, ECONNRESET,
EAI_AGAIN) as recoverable; re-head the message via `queue.unshift`, surface
a friendly banner via `sendSessionEvent({type:'message',...})`, and backoff
~2s before the loop picks it up again. Cap at 5 consecutive transient
failures, after which the message is dropped with a clear "resolve and
resend" event so we never spin forever on a genuinely broken auth.

Non-transient non-zero exits also surface the stderr to the UI now (instead
of only the local ring buffer), so a real crash is visible to the operator.

Backoff is overridable via CURSOR_LEGACY_TRANSIENT_BACKOFF_MS for tests.

Tests cover: success path, transient auth requeue + banner, rate-limit
banner, non-transient crash surfaced without requeue, and the 5-failure
drop cap.

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

* fix(cursor): preserve slash-command isolation on requeue; wait for stderr flush

Two findings from the cold-review bot on the requeue path:

1. `enqueueCursorUserMessage` uses `pushIsolated` for pass-through slash
   commands (e.g. `/compress`) so they never batch with sibling prompts.
   The transient-requeue path used plain `unshift`, which dropped the
   isolate bit and allowed the next collected batch to merge the slash
   command with a sibling - changing command semantics. Add
   `MessageQueue2.unshiftIsolated` and use it when the popped batch was
   isolated or when `parseCursorSpecialCommand` recognises the message.

2. `runAgentProcess` resolved on `child.on('exit', ...)`. Node may emit
   `exit` while the stderr pipe is still draining, so a fast "auth
   required" error printed-and-exited could be classified as
   non-transient with empty stderr and silently drop the user message -
   the exact bug this PR was supposed to fix. Resolve on `close` instead,
   which waits for stdio streams to flush.

Adds a unit test that requeues `/compress` after a transient auth failure
and asserts the second spawn still receives the slash command alone (not
batched with a sibling).

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

* fix(cursor): restrict transient retry to exit code 1 only

Upstream codex review #823 (Minor): the helper treated any non-zero exit
with matching stderr as transient, which could requeue a signal-killed
(SIGTERM 143, SIGKILL 137) or crashed (SIGABRT 134) process whose stderr
happens to contain a keyword like "rate limit". Documented contract is
exit-1-for-transient; tighten the classifier accordingly.

Adds regression test covering exit 143 + rate-limit stderr → no retry.

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

* fix(cursor): clean up transientBackoff abort listener on timer completion

Upstream codex review #823 (Minor): transientBackoff added an abort
listener with { once: true } but only removed it when the abort fired.
Because the launcher reuses one AbortController, repeated transient
retries accumulated stale listeners until the next abort.

Switch to a single completion path that clears the timer AND removes the
abort listener whichever side wins.

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

* fix(cursor): cap in-memory stderr capture at 8 KB

Upstream codex review #823 (Minor): runAgentProcess accumulated every
stderr chunk for the full child lifetime. A noisy `agent` failure could
grow CLI process memory without bound even though only the first 400
chars are ever displayed. Cap the retained copy at 8 KB; debug log of the
full stream is unchanged.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:27:35 +08:00
3a8693f380 feat(cursor): migrate remote sessions to ACP with model/variant pickers (#799)
* feat(cli,web,hub): migrate Cursor remote sessions to ACP with model/effort pickers

Move stream-json remote launcher to legacy path and add ACP launcher with
set_config_option model/mode sync, optimistic keepalive on config changes, and
shared catalog caching. Web gets dual base/effort Cursor pickers for session and
new-session flows; hide composer status bar when Cursor sends no usage_update.

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

* fix(cli,web,shared): Cursor model picker — ACP wires + CLI sku variants

Enrich the web/mobile picker with agent --list-models SKUs grouped under
ACP wire bases, fix session-open base highlight, and keep catalog discovery
safe while the ACP transport holds the CLI lock.

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

* fix(cursor-acp): apply ACP default model when web resets to Default

Web sends model: null for Default; push session/set_config_option with the
ACP default[] wire so Cursor backend matches hub state. Regression tests
for setModel(null) and applyModelConfig(null).

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

* fix(acp): clear stale agent-acp lock when owning process is gone

Check lock pid with signal 0; remove orphaned lock dirs after SIGKILL or
crash so listCursorModels can run cold probes again. Regression tests for
guard and catalog discovery.

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

* test(cursor): use live pid for ACP lock handler tests

Stale-lock cleanup clears dead pids; handler tests must simulate an
active lock with the current process pid to avoid cold probes/timeouts.

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

* fix(acp): scope agent CLI lock guard to Cursor agent command only

Gemini/OpenCode/Kimi ACP sessions must not register agent-acp-active;
that blocked listCursorModels while unrelated backends were running.

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

* fix(hub,web): reject Cursor model changes for local sessions

Hub returns 409 when controlledByUser is set, matching Codex. Web hides
model and variant pickers for local Cursor sessions so users do not hit
a dead RPC path. Document pre-push-review in AGENTS.md.

Verified: bun typecheck; bun run test (919 cli + 243 hub + 768 web + 46 shared).
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): send stable ids for Cursor ask_question replies

Parse and submit question.id and option.id so ACP receives keys like
{ approach: ['a'] } instead of index/label. Verified: bun typecheck && bun run test.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 19:51:35 +08:00
weishu d82ff6127d Release version 0.20.0 2026-06-05 21:49:13 +08:00
HeavyGeeandGitHub dc0d21e05b fix(cursor): intercept fabricated Questions skipped AskQuestion result in headless mode (#784) (#801)
* fix(cursor): intercept fabricated 'Questions skipped' AskQuestion result in headless mode (#784)

When cursor-agent runs under `--print --output-format stream-json` (HAPI's
current Cursor remote launcher), the CLI returns a synthetic
`Questions skipped by the user, continue with the information you already have`
response for the `AskQuestion` tool in ~zero seconds with no error flag,
because there is no IDE surface to render the question. The underlying
model can interpret this as legitimate user consent and act on it.

This patch intercepts the synthetic result in
`cli/src/cursor/utils/cursorEventConverter.ts` and rewrites the
`tool_call`/completed event to a structured `no_input_surface` failure
(`status: 'failed'`, which downstream becomes `is_error: true`).

Detection has two strategies:

1. String match - any `tool_call`/completed payload whose serialized form
   contains the synthetic-skip marker is rewritten. This is robust to
   wherever cursor-agent stuffs the marker inside the `tool_call` object.
2. Timing + name heuristic (defense in depth) - any completion that arrives
   within 500 ms of its 'started' event with a trivial result, for a tool
   call named `AskQuestion`, `askQuestion`, `ask_question`, or the
   converter's `unknown` fallback, is also rewritten. This catches the case
   where cursor-agent changes the synthetic-string text in a future release.

The converter tracks per-call timestamps in a bounded `Map` (`<= 1024`
entries, oldest evicted on overflow) and clears entries when the
corresponding 'completed' event arrives. A small test-only reset hook
isolates state between Vitest cases.

This is a transitional safety patch. It auto-deletes when #781's ACP
launcher replaces the stream-json launcher and `cursor/ask_question`
becomes a proper bidirectional ACP method where fabrication is
structurally impossible.

Scope is intentionally tiny: only `cli/src/cursor/utils/cursorEventConverter.ts`,
its colocated Vitest file, and a section in `docs/guide/cursor.md`. No
changes to `cursorRemoteLauncher.ts`, ACP code, web normalizer, or
permission UI.

Refs: tiann/hapi#781 (long-term resolution via ACP migration)
Closes: tiann/hapi#784

* fix(cursor): gate AskQuestion intercept on tool name (#784 PR #801 review)

Address regression flagged by the HAPI auto-review bot on #801:

`containsSyntheticSkipMarker` previously stringified the entire `tool_call`
payload and matched the literal marker substring. Because this PR also adds
that exact marker to `docs/guide/cursor.md` (to document the intercept), a
Cursor `read_file` of that documentation page would surface the marker
inside `readToolCall.result.content` and be rewritten as a
`no_input_surface` failure, corrupting an unrelated, legitimate result.

The intercept is now gated on the tool name resolving to an
AskQuestion-shaped call (`AskQuestion`, `askQuestion`, `ask_question`, or
the converter's `unknown` fallback for unnamed function-shaped tools).
`read_file` / `write_file` tool calls - which have explicit `read_file`
and `write_file` names from `extractToolName` - no longer fall under the
intercept, regardless of what their payload contains.

The marker check itself now walks values recursively (string / array /
object), guarded by a `WeakSet` against cycles, instead of relying on
`JSON.stringify`. Slightly tidier; behaviour is otherwise unchanged for
the AskQuestion path.

Regression tests added:

- `read_file` result whose `content` contains the marker -> passes
  through with `status: 'completed'` and no `no_input_surface`.
- `write_file` whose serialized `args` contain the marker -> same.
- A non-AskQuestion function tool (`MyCustomTool`) whose result quotes
  the marker -> same.

All 846 cli tests pass (17 in this file). `bun run typecheck` exits 0.

* fix(cursor): scope synthetic-skip check to extracted result (#784 PR #801 review-2)

Address second Major finding from the HAPI auto-review bot on #801:

After the previous fix gated the intercept on the tool name, the marker
check still recursed into the entire `tool_call` object - which includes
`function.arguments`, the agent's own prompt text. A legitimate
AskQuestion whose prompt quotes the synthetic-skip marker (e.g. an agent
debugging this exact bug, or any prompt that pastes the marker verbatim)
would have been rewritten as `no_input_surface` even when the operator
actually answered.

Changes:

1. `extractToolResult` now extracts the cursor-side response from
   function-shaped tool calls. Previously it returned `{}` for anything
   that wasn't `readToolCall` or `writeToolCall`. It now returns
   `function.result` when present, otherwise every field of `function`
   except `name` and `arguments`. This excludes the agent's input from
   what downstream sees as the tool result, and as a side effect surfaces
   the actual cursor response for function-shaped tools (which was
   previously lost - see the #784 incident note about HAPI storing
   `output: {}` for AskQuestion in the message DB).

2. `shouldRewriteAsNoInputSurface` now searches only the extracted
   `result`, not the whole `tool_call`. The bot's exact recommendation.

3. Test added: an AskQuestion whose `arguments` quote the marker but
   whose `result` is a real user answer, with elapsed time past the
   500 ms threshold so the timing heuristic does not apply. Asserts the
   tool_result passes through with `status: 'completed'` and the
   operator's actual answer.

All 847 cli tests pass (18 in `cursorEventConverter.test.ts`).
`bun run typecheck` exits 0.

The widened `extractToolResult` scope is necessary for the marker check
to actually find the synthetic string (it lives inside `function.result`
or a sibling field), and is the bot's explicit recommendation. It also
removes the long-standing data-loss bug where AskQuestion responses were
surfaced to the message DB as opaque `{}` - regardless of fabrication.
2026-06-05 21:44:37 +08:00
f9ef3a4489 feat(codex): import local Codex sessions into Hapi (#796)
* local: add Codex Desktop session sync controls

* feat(codex): import local Codex sessions into Hapi

---------

Co-authored-by: Codex Local <codex-local@example.invalid>
2026-06-04 17:53:12 +08:00
bd13fac7ae fix(claude): apply mid-turn permission mode changes to canCallTool (#764)
`PermissionHandler` stored its own `permissionMode` field and only updated
it inside `handleModeChange`, which is called when a new batch is pulled
from the queue. The `SetSessionConfig` RPC (web dropdown changes) updates
`runClaude.ts`'s `currentPermissionMode` and the session keepalive
metadata, but never reaches the handler — so switching to Yolo mid-turn
left `canCallTool` checking the stale mode and still prompting for
approval. Closes #735.

Drop the stored field and read live from `session.getPermissionMode()`,
mirroring how the OpenCode permission handler already works. Override
`Session.getPermissionMode()` in `claude/session.ts` to return the
Claude-narrow `PermissionMode`, sound because the matching
`setPermissionMode` setter only accepts that subset.

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

Co-authored-by: HAPI <noreply@hapi.run>
2026-06-01 18:50:54 +08:00
junesandGitHub 30564601ed fix(cli,web): hide Windows spawn windows and show queued attachments (#765)
* fix(cli,web): hide Windows spawn windows and show queued attachments

* fix(web): preserve attachment-only queued edit text
2026-06-01 18:50:16 +08:00
weishu 4aee1e4f84 Release version 0.19.0 2026-06-01 12:37:32 +08:00
d78cf4b171 fix(cli): Fix Codex CLI execution issue in PowerShell with Hapi Codex (#763)
* fix(cli): fixed an issue where the codex cli failed to run successfully when using hapi codex in powershell

* fix(cli): Fixes the issue of Windows Codex npm shim bypassing the launcher

---------

Co-authored-by: xhd902 <xuhang@infypower.cn>
2026-06-01 12:23:34 +08:00
weishu ec1ab23e6c Reduce automatic title update prompts 2026-06-01 12:21:33 +08:00
449cf6af0a feat(cli): wire Cursor /summarize and /clear slash builtins (#747)
* feat(cursor): wire /summarize and /clear slash builtins for remote sessions

Seed cursor builtins for web autocomplete, parse summarize/clear in
cursorRemoteLauncher (pass-through to agent -p; reject /clear with args).

Fixes tiann/hapi#738

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

* fix(cursor): isolate slash commands before message queue batching

Parse summarize/clear at enqueue time (runCursor) with pushIsolateAndClear
so waitForMessagesAndGetAsString never merges a slash with the next prompt.
Adds queue policy tests for invalid /clear + following message.

Addresses PR #747 review.

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

* fix(cursor): preserve pending messages when isolating slash commands

pushIsolateAndClear() wipes the entire queue, so a normal prompt queued
before /summarize or /clear would be silently dropped. Add pushIsolated()
- isolation without clearing - and route Cursor slash commands through
it instead. Adds queue tests covering the preserve-then-isolate path.

Addresses PR #747 review.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 12:07:57 +08:00
02d4e93178 fix(acp): drop mid-stream usage emit; OpenCode only sends usage_update at end-of-turn (#760)
PR #756 added a mid-turn emit in captureUsageUpdate to surface live
context usage via the web status bar. Testing against OpenCode 1.15.11
on a real session shows OpenCode emits a single usage_update per turn,
within ~1ms of session/prompt resolving — never during streaming.
That makes the mid-turn path dead code for OpenCode (and for any other
ACP agent that follows the same pattern). It also persists a useless
inputTokens:0/outputTokens:0 token_count message that gets immediately
overwritten by the finalize emit, churning the session history.

Drop the mid-stream emit and the activeOnUpdate plumbing it required.
Keep the finalize fallback for agents that don't return a usage block
on session/prompt (slash-handled turns, errored turns). The persistent
"live" counter requires the agent to emit usage_update during streaming;
filed upstream against anomalyco/opencode.

Refs #750

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

Co-authored-by: HAPI <noreply@hapi.run>
2026-06-01 12:07:40 +08:00
SSU-WEI HUANGandGitHub df35a8c523 fix(test): isolate integration tests from production hub via temp hub globalSetup (#734) 2026-05-31 20:31:05 +08:00
SSU-WEI HUANGandGitHub 994a820e43 fix(opencode): surface ACP context usage live to web status bar (#756) 2026-05-31 19:36:13 +08:00
SSU-WEI HUANGandGitHub 5b797bb95d feat(opencode): slash command support (#671) (#753) 2026-05-31 19:35:31 +08:00
junesandGitHub 31dd4353d4 fix(cli): replace existing runner on start (#754) 2026-05-31 19:34:58 +08:00
SSU-WEI HUANGandGitHub c09bbaed3d fix(codex): render /help and /status as markdown so web shows line breaks (#755) 2026-05-31 19:34:18 +08:00
4b24528362 fix(acp): flush straggler chunks promptly after session/prompt returns (#730)
* fix(acp): flush straggler chunks promptly after session/prompt returns

After session/prompt returns, HAPI drains buffered agentMessageChunk text
and marks the turn complete, but leaves the message handler alive. Models
with long streaming tails (DeepSeek, GPT-5.5) continue to push chunks
after that drain, causing text to accumulate in the buffer and only appear
when the next user prompt triggers the pre-prompt drain — showing up in
the wrong turn with broken markdown.

Start a 50ms interval timer after the post-prompt drain that keeps calling
drainBuffers() on the live handler for up to 6 seconds, so straggler
chunks are emitted within one poll tick instead of waiting for the next
prompt. The timer is cancelled when the next prompt starts (pre-prompt
drain replaces the handler) or on disconnect.

Fixes #609. Also applies to Gemini and Kimi which share the same
AcpSdkBackend code path.

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

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

* fix(acp): gate next turn's handler swap on previous turn's late drain

Addresses the github-actions review on #730: the fire-and-forget late-flush
timer let `prompt()` resolve while stragglers were still possibly arriving,
so a rapid follow-up prompt could either drop those chunks (during the
old null-handler gap) or leak them into the new turn's onUpdate.

Pre-prompt phase now keeps the previous turn's handler alive across the
quiet wait (bounded by LATE_FLUSH_WINDOW_MS) and swaps in a single phase
immediately before sending the new session/prompt. The post-prompt late
flush timer is unchanged — it still emits idle-window stragglers promptly
without delaying the ready signal or `setModel` / `setConfigOption`.

Adds three regression tests: late-chunk flushing within the window,
pre-prompt straggler attribution to the previous turn's onUpdate, and
disconnect cancelling the timer. Removes now-unused
PRE_PROMPT_UPDATE_DRAIN_TIMEOUT_MS.

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

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

* fix(acp): await late drain so ready never fires before stragglers emit

Follow-up to bot's repeated MAJOR on #730: even with the pre-prompt gate,
the fire-and-forget late-flush timer let `prompt()` resolve before slow
tails finished, so the launcher's `ready` signal (and any user follow-up
queued against it) raced with text still being emitted to the current
turn's onUpdate.

Replace the setInterval timer with a synchronous `drainLateBuffers()`
awaited in `prompt()`'s finally before turn_complete is sent. It polls
drainBuffers every LATE_FLUSH_INTERVAL_MS so the UI keeps streaming
smoothly during the wait, and exits early once the model has been quiet
for LATE_FLUSH_QUIET_PERIOD_MS (250 ms — adds negligible latency to fast
models like Claude whose tail is typically <100 ms) or the
LATE_FLUSH_WINDOW_MS upper bound (6 s) elapses.

Side effects:
- Restore PRE_PROMPT_UPDATE_DRAIN_TIMEOUT_MS (1200 ms): the pre-prompt
  drain is now just a safety net since the post-prompt wait guarantees
  the previous turn is quiet by the time the next prompt starts.
- Drop the `lateFlushTimer` field, `startLateFlushTimer`,
  `stopLateFlushTimer`, and their disconnect/pre-prompt cleanup calls.
- Update the "emits straggler chunks" test to assert ordering before
  turn_complete, and add a fast-path test confirming the drain exits
  promptly when the model is quiet.

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

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

* fix(acp): anchor late-flush quiet window to entry, not stale lastSessionUpdateAt

Bot's third MAJOR on #730: drainLateBuffers() compared elapsed time
against lastSessionUpdateAt, which can already be older than
LATE_FLUSH_QUIET_PERIOD_MS by the time the method starts — e.g. when the
model emits chunks early in the turn, pauses, then sends stopReason. In
that case the first loop iteration sees a stale "quiet" reading and
returns immediately, missing any straggler that arrives just after
session/prompt resolves; the chunk then sits in the buffer until the
next prompt's pre-prompt drain.

Anchor the quiet check to max(lastSessionUpdateAt, entry time) so we
always observe at least one quiet period from method entry regardless of
when the last chunk was. Adds a regression test that fires a chunk
early, awaits a 200ms pause, schedules a post-resolution straggler, and
asserts it lands before turn_complete.

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

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

* docs(acp): correct LATE_FLUSH_QUIET_PERIOD_MS comment after entry-anchor fix

The previous note claimed the 250ms quiet check "exits early for fast
models, adding negligible latency". That was true before commit 512d6a4
when the check compared against lastSessionUpdateAt; with the entry-time
anchor, drainLateBuffers always observes at least one full quiet period.
Document that this minimum wait is the price of catching post-resolution
stragglers from paused-mid-turn models.

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

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

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-05-31 10:13:00 +08:00
junesandGitHub 9e17953c11 fix(cli): resolve Windows Claude npm shim (#739) 2026-05-31 10:12:40 +08:00
c58e8cea9e fix(cursor): persist resume id early and return 409 for resume_unavailable (#745)
Remote cursor launcher now mirrors local launcher by writing cursorSessionId
to hub metadata as soon as --resume is known, before the agent init event.
POST /sessions/:id/resume maps resume_unavailable to 409 with clearer guidance.

Fixes tiann/hapi#744

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 09:40:05 +08:00
hangerandGitHub 6200ae1370 feat(web): align Claude effort options with Claude Code --effort levels (#731)
The Claude effort selector (New Session config + in-session composer) only
offered auto/medium/high/max, missing `low` and `xhigh` — yet `claude --effort`
actually accepts low/medium/high/xhigh/max. Add the two missing levels in both
places so the selector faithfully mirrors the CLI.

Extract the level list + labels into one shared constant
(@hapi/protocol: shared/src/effort.ts, mirroring CLAUDE_MODEL_PRESETS) so the
two UIs derive from a single source and can't drift again. No backend change:
the effort string is free-form end-to-end through to the --effort flag.

ultracode is intentionally excluded — it is a TUI-only /effort session setting,
not an --effort value (the CLI rejects `--effort ultracode`).
2026-05-30 12:41:00 +08:00
hangerandGitHub 485bb46db8 fix(claude): propagate real contextWindow from SDK result to web (#720)
The "Default" model in NewSession sends no --model flag, so Claude CLI
picks its own default (e.g. Opus 4.7 [1m] on Pro accounts). The web
status bar then falls back to 200K - 10K headroom = 190K because the
Claude SDK path never plumbs the real per-model contextWindow through
to the wire-level `modelContextWindow`, unlike the ACP/Codex backends.

Fix the gap in three places:

- cli/src/claude/sdk/types.ts: declare optional `modelUsage` on
  SDKResultMessage to surface what Claude CLI already emits
  (`modelUsage[<model>].contextWindow`).

- cli/src/claude/utils/sdkToLogConverter.ts: on system.init, capture
  the resolved model name (full form with `[1m]` suffix) and derive
  an initial contextWindow from the suffix. On every assistant
  message, inject the cached contextWindow into `usage.context_window`
  when absent. On result, refine the cache with the authoritative
  value from `modelUsage`.

- web/src/chat/normalizeAgent.ts: forward `context_window` through
  the assistant usage normalization, so the existing reducer path
  (reducer.ts:175 → StatusBar.tsx:175) can render the real window.

Closes #719.
2026-05-28 15:02:20 +08:00
6f2bb7d32b feat(opencode): add plan mode, reasoning effort, and status telemetry (#688)
* feat(opencode): support plan mode

* feat(opencode): support reasoning effort

* feat(opencode): surface context usage in web

Bridge OpenCode ACP usage updates into the existing token-count pipeline so the web status bar can show live context and cache information without a separate UI path.

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

* fix(opencode): restrict plan mode to remote, rollback reasoning effort on failure

- Block local OpenCode plan startup (tools not enforced in local path)
- Allow remote OpenCode plan only (ACP permission handler denies tools)
- Guard web /permission-mode endpoint for local OpenCode plan sessions
- Rollback session reasoning effort when OpenCode rejects set_config_option
- Wire rollback callback through opencodeLoop to runOpencode closure
- Add tests: local plan rejected, remote plan allowed, web guard, effort rollback

* fix(web): auto-retry OpenCode models query to populate model selector without refresh

- Retry early failures (RPC may still be registering on new sessions)
- Poll briefly until availableModels is non-empty
- Stop polling once model options are discovered
- Add tests for retry/poll/stop policy

* fix(opencode): cap model discovery polling

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 11:17:24 +08:00
HeavyGeeandGitHub d5a67b717c feat(voice): dynamic settings voice picker with safe fallback + preview (#690)
* feat(voice): dynamic settings voice picker with safe fallback + preview

* fix(voice): honor picker with configured agent and stop preview on unmount

* fix(voice): apply PR review feedback for agent selection and preview cleanup
2026-05-27 11:17:01 +08:00
NightWatcher314andGitHub de5dc97988 feat(cli): add image display MCP tool (#700) 2026-05-27 11:16:17 +08:00
SSU-WEI HUANGandGitHub 1d03f186d6 feat(cursor): support model selection (#684) 2026-05-26 16:13:38 +08:00
NightWatcher314andGitHub c417330d55 fix(skills): scope completions by session flavor (#667) 2026-05-24 10:59:32 +08:00
763f45acdd feat: add support for Kimi Code CLI and fixed some bugs (#659)
* Add Kimi agent support via ACP protocol

Add full integration for the Kimi Code CLI agent using the standard
Agent Client Protocol (ACP). Includes:

- kimi command and CLI registry wiring
- Local launcher spawning kimi directly
- Remote launcher with ACP stdio transport via AcpSdkBackend
- Session management with resume support
- Permission handler supporting all Kimi permission modes
- Terminal UI display component
- Runtime config resolving model from env and ~/.kimi/config.toml

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

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

* Fix Kimi ACP tool call input decoding on web

Kimi streams tool arguments as JSON text inside the content array
(e.g. {\"command\": \"df -h\"}) instead of rawInput/kind. The handler
now extracts input from three sources in priority order:

1. rawInput (Claude/Codex path)
2. kind + title fallback (Gemini path)
3. content JSON text (Kimi path)

Also handles:
- rawInput: null no longer blocks the kind+title fallback
- Title prefixes like \"Shell: free -h\" are stripped to extract args
- Stale placeholder inputs are re-derived when the title updates
- Normalized kind aliases (shell, run, read_file, write, etc.)

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

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

* Add kimi support to web UI

* Fix some bugs

* fix(kimi): dedupe repeated tool_call display in terminal UI

* fix(web): keep tool block immutable so React detects input/state changes

* fix(web): recognise Kimi subagent titles like 'Agent: ...' as subagent tools

* fix(web): allow-for-session for ACP agents (kimi, cursor)

PermissionFooter treated all non-codex sessions as Claude, sending
Claude-specific acceptEdits/allowTools to ACP agents that don't
support them. Hub rejected acceptEdits for kimi, and the ACP
PermissionAdapter ignored allowTools.

- Only show 'allow all edits' for Claude sessions
- Send decision: approved_for_session for non-Claude ACP agents
- Update status display to check decision field

* fix(web): lookup subagent sidechains by tool-call id instead of msg id

* fix(web): don't trim newest messages when loading older history

fetchOlderMessages was using trimVisible(merged, 'prepend') which kept
the oldest 400 messages and dropped the newest ones. This caused:
1. Latest messages to disappear when user loaded older history
2. User to see no visible change when new old messages were drowned
   in the 400-message window.

Remove the incorrect trim so all fetched older messages are retained
alongside the current window. Subsequent ingestIncomingMessages
(append mode) will naturally keep the window bounded when new agent
messages arrive.

* fix(cli): route Kimi session resume to runKimi instead of runCursor

Kimi was present in AGENT_FLAVORS but dispatchLocalResume had no branch
for it, so resuming a Kimi session fell through to the Cursor launcher.

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

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

* fix(cli): pass selected model to Kimi ACP backend via KIMI_MODEL env

createKimiBackend was ignoring opts.model and only setting KIMI_PROJECT_DIR.
Use buildKimiEnv so the selected model reaches the subprocess as KIMI_MODEL.

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

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

* fix(web): bound message window on older loads with dedicated larger cap

fetchOlderMessages was keeping all messages unbounded, causing
sessionStorage bloat on repeated pagination. Reintroduce trimming
with OLDER_LOAD_WINDOW_SIZE (800) so growth is capped while the
newest messages are still preserved for far longer than before.

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

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

* fix(web): revert sidechain lookup to message id, matching tracer/grouping pipeline

tracer.ts sets sidechainId to the parent message id, and reducer.ts groups
by sidechainId. A prior commit changed reducerTimeline.ts to look up by
tool-call id (c.id), which broke sidechain attachment. Revert to msg.id
so the lookup matches the actual grouping key end-to-end.

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

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

* fix(cli): gate ACP title prefix stripping to known tool-kind labels

extractTitleArgument stripped at the first colon unconditionally,
corrupting commands/paths like curl http://localhost:3000 or
Windows paths. Now it only strips when the prefix normalizes to
the same tool kind as the event, verified via regex.

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

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

* fix(shared): include kimi in isCodexFamilyFlavor for ACP permission UI

Kimi is an ACP-style agent that supports the abort decision, but
isCodexFamilyFlavor excluded it, so PermissionFooter rendered the
non-Codex Allow/Deny UI without the Abort button.

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

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

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-05-22 10:08:14 +08:00
weishu 6aa7274851 Release version 0.18.4 2026-05-22 09:04:13 +08:00
b06251bd53 fix: stream opencode reasoning updates (#661)
Emit throttled ACP reasoning snapshots with stable stream ids so OpenCode reasoning updates render live without one row per token.

Collapse matching reasoning snapshots in the web timeline and avoid stale session 404 redirects during refetch.

Tests: targeted Vitest suite and bun typecheck.

Co-authored-by: twshe <twshe@outlook.com>
2026-05-22 08:57:50 +08:00
CoColateandGitHub 611c4eec49 fix(codex): honor yolo for MCP elicitation (#655)
Route Codex app-server MCP elicitation decisions through the live session permission mode. HAPI bridge elicitation remains accepted, non-HAPI elicitation stays cancelled in non-yolo modes, and yolo accepts future non-HAPI prompts after mode changes.\n\nAdds adapter and launcher seam tests covering default -> yolo -> default behavior.\n\nTests:\n- bunx vitest run src/codex/utils/appServerPermissionAdapter.test.ts src/codex/codexRemoteLauncher.test.ts\n- bun run typecheck
2026-05-21 17:09:10 +08:00
weishu 1284385d38 refactor: share remote agent command parsing 2026-05-21 14:42:40 +08:00
weishu b0a3397601 refactor: share session config RPC handling 2026-05-21 14:40:00 +08:00
weishu e88a9075df refactor: share core REST payload types 2026-05-21 14:35:47 +08:00
weishu 7c26c1e749 refactor: reuse shared machine socket types 2026-05-21 14:32:28 +08:00
weishu 9704227ce2 refactor: share machine runner schemas 2026-05-21 14:29:42 +08:00
weishu 7d5a5ee919 chore: remove obsolete spawn leftovers 2026-05-21 14:25:10 +08:00
weishu b79f50f815 Share RPC method constants 2026-05-21 10:53:31 +08:00
weishu 2e1e2d39db Share slash command definitions 2026-05-21 10:36:25 +08:00
weishu d6f97065c1 Share REST and RPC response types 2026-05-21 10:31:50 +08:00
weishu 9698aa9f4c Unify agent flavor definitions 2026-05-21 10:23:57 +08:00
weishu 15113668cc Release version 0.18.3 2026-05-20 20:50:49 +08:00
weishu 64d1a4de1c Cap resume picker redraw height 2026-05-20 20:44:12 +08:00
weishu 30c9d34cf8 Size resume picker rows by terminal height 2026-05-20 20:43:04 +08:00
weishu e90ef52078 Show more resume picker rows 2026-05-20 20:41:57 +08:00
weishu f41be3a420 Reduce resume picker redraw flicker 2026-05-20 20:41:05 +08:00
weishu 5264599908 Recover first prompt for resume sessions 2026-05-20 20:35:03 +08:00