Commit Graph
232 Commits
Author SHA1 Message Date
weishu a6cf8fd078 Release version 0.33.1 2026-10-29 10:13:33 +08:00
weishu ee273464f4 Release version 0.33.0 2026-10-29 08:51:11 +08:00
weishuandClaude Opus 5.5 23122ccbc0 feat(hub): persist agent turns and report them through hapi usage
Whether a session's agent is thinking lived only in the session cache,
so once a turn ended nothing could say how long the agent had worked,
and a hub restart forgot even the turn in progress. Tools that track
time (Weft, for one) had no way to see when agents on other machines
were busy.

Schema v28 adds session_turns: a row opens when a session starts
thinking (a prompt queued through the hub, the agent reporting it,
or a patch) and closes when it stops, the session ends, or its
keepalives expire, at the last keepalive. Rows are only written on
those transitions, never per keepalive. Turns move with a session
when it is merged away.

At startup the hub closes turns left open by the previous process at
the session's newest message. If the agent carries on thinking within
10 minutes of the restart, the same turn continues, so the work between
that message and the restart isn't lost. The deadline counts from the
restart, and a keepalive or patch reporting idle, the session ending,
or a new prompt ends the chance: those start a new turn.

GET /api/activity returns, for the caller's namespace, each session's
turns in a window (default 24 h, at most 31 days) with what identifies
it (flavor, agent session id, machine, path, title) and the permission
prompts and questions it waited on, taken from agent_state.

`hapi usage [--since] [--until] [--json]` prints a summary or a
hapi.activity.v1 report for other tools, with distinct exit codes for
bad arguments (2), missing or refused credentials (3) and a hub too
old to answer (4). The summary counts only the part of each turn
inside the window and subtracts overlapping waits once. It reuses the
peer commands' token exchange.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:48:06 +08:00
AnanovoandGitHub 575e995480 feat(hub): generate fallback titles after the first completed turn (#2075)
* feat(hub): generate fallback titles after the first completed turn

* fix(hub): recognize batched first-turn title prompts

* fix(hub): track heartbeat-only first turns for title fallback

* fix(hub): preserve title turn identity across restarts
2026-10-28 12:14:59 +08:00
AnanovoandGitHub 577ab7546b feat(sessions): add explicit per-session title locks (#2072)
* feat(sessions): add explicit per-session title locks

* fix(sessions): preserve title locks across rename entry points
2026-10-28 12:14:48 +08:00
Junmo KimandGitHub ae147e85db feat(opencode): import local session history (#1677)
* feat(cli): list local OpenCode sessions over machine RPC

* feat(hub): import OpenCode sessions into HAPI

* feat(web): import OpenCode history from new-session picker

* fix(cli): derive OpenCode imported message ids from part ids

Random ids made every scan differ, so delta classification flagged
unchanged transcripts as diverged on re-import.

* fix(cli): surface unreadable OpenCode db as rpc error

A locked or unopenable opencode.db now fails the listing instead of
reporting an empty success; summaries normalize timestamps and the web
import toast counts only created or actually-appended sessions.

* fix(hub): fail closed on opencode re-import past live HAPI continuation

Imported entries must remain a strict prefix of the native transcript.
Insertions ahead of the imported boundary or new native messages after
the session continued in HAPI now fail closed instead of appending
duplicated or reordered history.

* fix(web,hub): apply selected launch config when importing OpenCode history

Pass the model, reasoning effort, and permission mode chosen in the
new-session picker through the import request and persist them on the
imported HAPI session via applySessionConfig, so reopen launches with
the visible selection instead of stale or default values.

* fix(hub,cli): preserve explicit launch resets and skip streaming parts on import

- Launch config keys are applied by presence, so explicit null/default
  selections from the picker must reach applySessionConfig to clear
  stale model, effort, or permission values on re-import. Requests that
  omit the fields entirely still leave persisted config untouched.
- Assistant text parts are mutable while a response streams. Mirror the
  live scanner gate and import only finished (time.end set), synthetic,
  or user text so an in-progress row can never be stored and later
  finalized as transcript_diverged.

* test(hub): fix launch-config reset assertions to satisfy strict types

* fix(cli): fail opencode transcript reads on query errors instead of partial snapshots

Swallowed SQLite errors could return a truncated message list that the
hub later rejects as transcript_diverged with no repair path. Full
transcript reads now propagate query failures; summary lastUserMessage
extraction stays resilient per message.

* test(cli): cover unreadable-part-table transcript read stays non-partial

* fix(hub): scope launch-config failures to the per-session import result

A thrown applySessionConfig call used to escape the bulk handler as an
unstructured 500 even though transcripts were already persisted. Catch
it per session and report it as a config_failed result so remaining
sessions in a bulk request still import.

Also cover the mid-transcript part-query failure with an injected
opener that rejects instead of returning a holey transcript.

* fix(cli): read opencode transcripts inside a deferred snapshot transaction

Messages and their parts were read in separate statements, so a
concurrent OpenCode write could leave a hole in the imported sequence
that positional re-import checks then reject forever. Wrap each full
transcript read in BEGIN DEFERRED/COMMIT with rollback on failure.

* fix(cli): follow machine path policy for OpenCode imports

* fix(web): use unified permission mode for OpenCode imports

* fix(opencode): harden session import consistency

* fix(hub): preserve metadata on rejected OpenCode imports

* fix(hub): refresh OpenCode import state under lock

* fix(hub): resolve OpenCode imports after locking

* fix(opencode): bind imported resume to source machine
2026-10-28 12:14:18 +08:00
ffd950a220 fix(codex): drop safe-yolo from Codex permission modes (#2183)
* fix(codex): drop safe-yolo from Codex permission modes

Every Codex session runs on the native shared runtime (runCodex is
runSharedCodex), which rejects safe-yolo. The mode stayed in
CODEX_PERMISSION_MODES and six filters hid it again in the web picker,
composer, hub permission-mode route, hapi set-mode and both native
apps. API callers that validate against the flavor catalog (e.g. a
spawn with permissionMode: 'safe-yolo') still passed it through and
only failed inside the runner.

Remove it from the Codex catalog (shared, iOS, Android, fixture) and
delete the filters. autoReview is the native replacement. Safe Yolo
stays for Gemini, Kimi and Copilot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(ios): Codex option list now has four modes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:12:55 +08:00
fe2fbb881d fix: list Claude background tasks from the snapshot instead of guessing (#2177)
* fix: list Claude background tasks from the snapshot instead of guessing

The status panel only had a count from `background_tasks_changed` and
reconstructed commands by regex-matching Bash results in loaded chat
history, so tasks whose start message was unloaded, auto-backgrounded
("moved to the background (ID: …)"), or non-Bash showed
"N running · exact command unavailable".

The hub now keeps the snapshot's tasks (id, type, description) on the
session alongside the count. The web panel lists them directly, using
the exact task ID to look up the Bash command when loaded and the
snapshot description otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): only attribute a Bash command from its background launch result

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(native): accept backgroundTasks in strict session patch decoders

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:12:41 +08:00
Junmo KimandGitHub cd0b8e3589 fix(claude): stop locking Claude sessions to three hard-coded model aliases (#1948)
* feat(shared): derive Claude model choices from the CLI catalog

The choices are the rows of the claude CLI's own /model picker, in its
order and with its display names. The stored value is what
claude --model receives: an alias is resolved again each time the
session's claude process starts, a full id stays on its model, so
following and staying need no new field.

Effort support is read per model so a model switch can drop an effort
level the next model does not offer.

* feat(cli): discover Claude models from the CLI's initialize response

A machine RPC starts claude in stream-json mode, sends only an
initialize control request and reads the models it reports, so no turn
runs and no transcript is written.

claude --print runs a directory's project hooks and .mcp.json servers
without a trust prompt, so the probe starts from the home directory
with user settings only, no MCP servers and hooks disabled.

Successful catalogs are cached per claude executable for five minutes
and concurrent requests share one probe; failures are not cached.

* feat(hub): expose the Claude model catalog per machine

GET /machines/:id/claude-models forwards to the runner's
listClaudeModels RPC. A runner that predates the handler answers with
the stable rpc_target_missing code so clients can keep their built-in
list.

* feat(web): offer the machine's Claude models in the model pickers

The composer and the create-session form list the rows the machine's
claude CLI shows in its own /model picker, as it shows them. Built-in
presets the catalog does not list remain selectable after it.

Until the catalog arrives, or on a runner without discovery, the
built-in presets are shown as before. A saved Claude model is no longer
reset to Default when the built-in list lacks it; the create-session
form keeps it and marks it as no longer listed when the catalog
dropped it.

* feat(hub,web): keep Claude effort within what the selected model offers

For Claude sessions the model endpoint accepts an optional effort, so a
model switch and the effort it keeps reach the session as one config
change. Other flavors are refused: Pi, for one, commits the model
before its thinking level. When the machine's catalog says the next
Claude model does not offer the current effort, the composer clears it
in that same request. The create-session form drops such an effort
however the model was set, including a restored preference or draft.

Effort choices in both places narrow to the selected model's levels;
without a catalog every level stays available as before.

* fix(web): retry Claude model discovery after a failed probe

The machine answers a failed probe with success:false over HTTP 200.
The query cached that as an answer for its stale time, so a user who
signed in and reopened a picker within a minute still saw only the
built-in models. A failed probe is now a query error, which is not
kept, and the next picker to mount asks the machine again.

* fix(web): list only the Claude CLI's models once its catalog is known

The built-in presets were kept after the catalog so that a partial list
could not hide Opus. The catalog is the CLI's own /model list, though,
not a partial one, and the presets added entries the CLI does not show
(Sonnet 1M, Opus 1M, Fable 1M). With a catalog the list is now exactly
its rows; a saved model it does not list stays as the current model,
and New Session marks it as no longer listed. Without a catalog the
built-in presets are shown as before.

* fix(shared,web): list earlier Claude versions under Other models

Claude Code's model sheet shows each family's newest model first and
every earlier version under an "Other models" heading. The CLI's
catalog lists models in that order but does not say where the split
is, so the first row of each family is taken as its newest and later
rows of the same family as earlier versions.

The composer's model list and New Session's model select now show the
same two groups, keeping the CLI order within each. Nothing is folded:
every row stays one click away, as it does in Claude Code.

* test(web): pin that Claude 1M variants come from the CLI catalog

Claude Code 2.1.105, 2.1.200, 2.1.240 and 2.1.274 list a 1M choice as
its own catalog row (opus[1m], claude-fable-5[1m]); 2.1.303 and 2.1.310
list none, and on 2.1.310 opus and sonnet run with a 1M context window.
The catalog-only list therefore offers a 1M variant exactly when the CLI
does. Pin that and record why the built-in [1m] presets are not appended.
2026-10-28 12:10:36 +08:00
2b63091421 fix(runner): adopt untracked runner webhooks only from the reporting process generation (#1943)
* feat(cli): report the CLI process start marker in session metadata

The session webhook only names hostPid. A PID alone does not tell the
runner which process generation reported it: once the CLI exits, the OS
can hand the same PID to any other process, including another
runner-spawned CLI.

Add metadata.hostStartMarker, the reporting process's own start marker
(getProcessStartMarker(process.pid), the same probe the runner uses for
its resume records and the stop-session generation check).
buildSessionMetadata is the only place that sets hostPid, so every
bootstrap path (fresh, existing, lazy, shared Codex) reports it. The
runner uses it to decide whether an untracked runner webhook may be
adopted (decideUntrackedRunnerWebhook).

* fix(runner): adopt untracked runner webhooks only from the reporting process generation

Since #1911 the runner adopts an untracked `startedBy: 'runner'` webhook
(runner restart before the webhook; Claude has no HAPI id on argv) and
persists getProcessStartMarker(pid) of whatever currently owns hostPid,
so that a later stopSession can tree-kill that generation.

If the webhook is late - the reporting CLI has exited and the OS has
already reused its PID - the persisted marker belongs to a foreign
process, possibly another runner-spawned CLI. The generation check in
stopSession then passes and the foreign process tree is killed.

The webhook now has to prove the generation itself: the CLI reports its
own marker as metadata.hostStartMarker and decideUntrackedRunnerWebhook
adopts only when it matches the live PID. A marker that names another
generation, or a failed probe, is ignored without killing anything,
also on the webhook-timeout path: a proven mismatch means the PID is not
the ghost. A webhook without a marker (older CLI) is left untracked
instead of adopted blindly; the timeout kill for those CLIs is unchanged.

The integration test drives a real runner over its control server: a
webhook carrying the live marker is adopted, listed and stoppable; a
webhook with a stale or missing marker leaves the PID untracked and
alive, and stop-session reports unknown.

Follow-up to #1911 (#1910).

* fix(cli): probe the process start marker only for runner-started sessions

buildSessionMetadata probed getProcessStartMarker(process.pid) on every
bootstrap, terminal starts included. The runner reads
metadata.hostStartMarker only in the untracked `startedBy: 'runner'`
path (decideUntrackedRunnerWebhook); a terminal webhook never consumes
it, and neither do hub or web.

The probe is a synchronous spawn: `ps` on POSIX, `tasklist` plus a
PowerShell Get-CimInstance call on Windows, each with a 10 s timeout.
A terminal `hapi` start on Windows therefore blocked on a PowerShell
cold start before the session row existed, for a value nobody reads.
With shared Codex the probe also ran per /new and /fork on the event
loop shared by all sibling roots.

Set hostStartMarker only when startedBy is 'runner'; that is the one
case the runner evaluates. This narrows the earlier commit: every
bootstrap path reports the marker for runner-started sessions, not for
terminal ones.

* fix(codex): keep hostStartMarker in the shared runtime's runner webhook

The shared Codex runtime bootstraps its roots with reportStarted: false
and reports them to the runner itself, from root.session.getMetadata().
That is the hub's view of the metadata: the hub parses stored metadata
through MetadataSchema, so a hub that predates hostStartMarker strips
the field and the webhook arrives without it. The hub's update-metadata
merge never returns it either, so the loss lasts for the root's
lifetime.

With the generation check, an untracked runner-started webhook without
a marker is ignored instead of adopted. A runner restart (for example
the version handoff of a single-host upgrade, where the hub process
keeps running the old code) followed by /new or /fork in a long-lived
shared root therefore left the root unlisted and untracked, the very
situation the adoption from #1911 addresses. The Claude and lazy
bootstrap paths are unaffected: they report the local metadata.

Take the marker from the local bootstrap metadata instead. The opt-in
runtime integration test models the stripped hub view in its session
mock and asserts the reported payload still carries the marker. The
mock also gains the `on` subscription that SharedCodexRoot requires
since #1911, without which the suite's bind test could not run.

* test(runner): run the late-webhook suite in the serial integration project

lateRunnerWebhook.integration.test.ts starts a real runner and a
detached child process per case, but the default vitest project picked
it up and `bun run test` ran it in a parallel worker. Tests that spawn
runner process trees belong to the serial integration project
(vitest.integration.config.ts, CI job `integration`), as the config
comments and .github/workflows/test.yml require.

Include the file in the integration project, exclude it from the
default one, and remove the temporary HAPI_HOME of each case, which
was left behind in the temp directory.

---------

Co-authored-by: Benedict Groppe <195190800+BGGBTAC@users.noreply.github.com>
2026-10-28 12:10:21 +08:00
AnanovoandGitHub 5d7e13fc5d fix(hub,cli): keep inactive file previews available through runner (#1727)
* fix(hub,cli): keep inactive file previews available through runner

* fix(cli): bound runner workspace file reads

* fix(cli): constrain runner git fallback pathspecs

* fix(cli): allow dot-prefixed workspace paths
2026-10-28 12:07:18 +08:00
AnanovoandGitHub 0aec975434 fix(codex): gate history actions until native thread is ready (#1604)
* fix(codex): gate history actions until native thread is ready

* fix(codex): clear history capabilities during bootstrap

* style(codex): normalize CLI file line endings

* fix(codex): await history capability persistence

* fix(codex): restore goal status coverage

* fix(codex): await bootstrap metadata persistence

* chore: retrigger PR review

* fix(codex): scope metadata readiness flush

* fix(codex): gate metadata flush by stale history
2026-10-28 12:06:08 +08:00
weishu 85ccab1ac6 Release version 0.31.3 2026-10-27 14:23:48 +08:00
weishu 7bf0e5bc35 Release version 0.31.2 2026-10-27 08:32:28 +08:00
8121d0df6e feat(web): fixed session order display setting (#2033)
* feat(web): fixed session order display setting

Add a per-device "Fixed session order" toggle (default off, Settings →
Display → Sessions). When on, the session list freezes: sessions sort
by creation time only (newest first) instead of re-sorting on
activity, workspace groups stop jumping (ordered by the newest session
created inside them), and the pin-in-progress running buckets are
paused. Manual pins still float to the top; search-relevance ordering
is unchanged.

- shared: SessionSummary gains an optional createdAt (toSessionSummary
  copies it). Older hubs simply omit it and the web falls back to
  updatedAt, degrading to the legacy activity order.
- web: sessionOrder lib (stable key + comparator), useFixedSessionOrder
  localStorage hook, useSSE cache sort via ref (no EventSource teardown
  on toggle), SessionList in-group/group/machine sorts plus bucket
  gating, settings switch, en/zh-CN/ru strings.
- tests: sessionOrder unit, useFixedSessionOrder hook, useSSE
  fixed-order cache cases, SessionList grouping cases, settings page
  switch, shared createdAt passthrough.

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): tie-break equal order keys on session id

Review follow-up: sessions created in the same instant compared as equal,
so their relative order came from the incoming array order — which the
hub may derive from activity, making tied sessions visibly swap after
every refetch. Tie-break on session id to make the comparator a total
order. Two tests cover the comparator and the refetch-stability view.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(web): tie-break equal group creation instants on the group key

Review follow-up: the directory- and machine-level sorts compared only
the groups' latest creation instants, so groups created in the same
instant followed the incoming array order and could shuffle after every
refetch. Route both fixed-order group sorts through a shared
compareDescWithKeyTiebreak helper that falls back to the group's stable
key (directory groups: machine::path key; machine groups: machineId).
Test covers both incoming orders of two tied groups.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
2026-10-26 10:27:52 +08:00
927b4971b9 feat(dsh): model selection for DeepSeek Harness (ACP config options) (#2030)
* feat(acp): flatten Zed-style grouped configOptions choices

DeepSeek Harness 0.2.x (and Zed-style agents generally) nest select
choices one level under group entries that carry no `value` of their
own:

    options: [{ group, name, options: [{ value, name }] }]

Both the per-session config-option descriptor and the model-metadata
merge previously read only flat `{ value, name }` entries, so grouped
pickers surfaced zero choices. Flatten recursively in a shared helper;
flat options are unaffected.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* feat(dsh): runtime model switching through ACP config options

dsh advertises its model catalog as a Zed-style grouped config option
(provider groups with JSON-encoded provider+model tuple values) instead
of session/set_model, so wire it through the generic config-option rail:

- shared: ListDshModels/ListDshModelsForMachine RPC methods, response
  types, and grant dsh the ModelChange flavor capability
- cli: DshMode becomes {model} and rides the queued-batch mode like
  other flavors; the launcher applies model switches per-turn via
  session/set_config_option, discovers the live current model from the
  config option after newSession, rolls back (and warns) when a switch
  is rejected, and serves ListDshModels from session metadata. A
  machine-scoped probe (60s single-flight cache) backs the new-session
  catalog; --model is now accepted by the dsh command
- hub: dsh-models passthrough routes (machine + session scope)

Co-Authored-By: Claude Code <noreply@anthropic.com>

* feat(web): dsh model selector for new sessions and in-chat switching

New-session form gets a Default-plus-catalog picker fed by the machine
probe (opaque tuple values verbatim, friendly names as labels, stale
draft picks dropped when the catalog rotates). Running sessions get the
same catalog over the session-scoped hook; switching rides the existing
generic model-change path now that dsh advertises ModelChange.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(dsh): honor explicit Default reset and cover pending catalog discovery

The launcher now captures the server default before applying any spawn
override, and a queued `model: null` (the picker's "Default" after an
explicit switch) switches back to it instead of silently keeping the
last applied model; DshMode keeps null distinct from undefined all the
way from the config RPC through the queue (runDsh no longer collapses
it). The web model-option helpers get explicit dsh branches mirroring
opencode: pending, failed, or empty ACP discovery renders no Claude
presets and the Ctrl/Cmd+M cycler keeps the current model. NewSession
restores dshSelectedModel from preferred launch settings alongside
agy/opencode (the existing catalog-validation effect drops stale tuples)
with remount, forward-on-create, and fallback tests.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(dsh): keep unset model preferences distinct from Default resets

Two review follow-ups:

- currentModel collapses "no preference yet" into null, so a session
  started without --model on a server that reports no current model
  queued model:null on every ordinary turn — the launcher read each one
  as an explicit "Default" reset and emitted 'Cannot reset the DSH
  model' every turn. The preference is now three-state (undefined =
  nothing requested, null = picker's explicit Default, string = model),
  encoded through dshModeForPreference() so preference-less turns carry
  no model field at all.

- ListDshModels was registered only after initialize/session/new and
  discovery finished; a picker opened during startup hit an
  unregistered-method rejection with no retry path and stayed empty
  until remounted. The handler is now registered before initialization
  and parks on a readiness promise resolved once the session exists and
  discovery has settled (rejected with the startup error otherwise).

Co-Authored-By: Claude Code <noreply@anthropic.com>

* docs: DSH advertises model selection over ACP

The capability blurbs still said HAPI exposes no model pickers for DSH and
that the ACP server has no model switching. Update them to the wire reality:
models are listed via ListDshModels and applied per turn with
session/set_config_option. Resume remains out of scope on this branch.

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

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
2026-10-26 10:02:51 +08:00
62872d80fa feat(cli): HAPI_DISABLE_PEER_TOOLS — hide peer-session tools from wrapped agents (#2052)
* feat(cli): add HAPI_DISABLE_PEER_TOOLS to hide peer-session tools from agents

Wrapped agents with peer tools can browse the session list and message
other sessions, polluting peer contexts on busy runners. Setting
HAPI_DISABLE_PEER_TOOLS=1 (true/yes also accepted) in the runner
environment now removes the peer surface entirely instead of
advertising it and refusing:

- the session MCP server does not register list_peers / inspect_peer /
  ping_peer, and they are dropped from the toolNames consumed by
  Claude --allowedTools and the codex/generic bridge --tools lists
- the stdio bridge filters peer names even when a stale --tools value
  lists them explicitly
- flavor system prompts (claude/codex/opencode) swap the peer steer
  block for a short notice that keeps the hub-path-is-not-a-file guard
  while pointing citations at the user
- the peer core (pingPeer/inspectPeer/listPeerSessions) refuses with a
  new 'disabled' error code before any network call, so shell
  `hapi ping-peer` / `inspect-peer` from inside a wrapped agent fails
  loudly as defense in depth

Operators scope the variable to the runner (systemd/launchd/guard env);
an interactive terminal without it keeps full CLI access.

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

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

* test(cli): cover peer-tool prompt steer and default bridge tool list

Adds regression tests for the two surfaces the kill switch mutates beyond
the explicit --tools path:

- happyMcpStdioBridge: default tool list (no --tools) keeps the peer tools
  as a positive control, then HAPI_DISABLE_PEER_TOOLS=1 filters them out
  of the same default seed.
- New cross-flavor prompt test: the citation steer baked into the
  claude/codex/opencode prompt consts swaps to the disabled notice under
  the env flag and keeps the flavor-specific "Call \"<tool>\"" instruction
  by default (module consts evaluate env at import, so each mode is
  imported fresh via vi.resetModules + dynamic import).

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

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

* test(cli): keep peer-tools env set through prompt assertions

Keeps HAPI_DISABLE_PEER_TOOLS applied until after the assertions and
restores it at test end. Some upstream shapes resolve the citation steer
at call time (getSystemPrompt(env)) rather than baking it into the
import-time const, so restoring the env inside the import helper made
those shapes observe the enabled steer and fail.

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

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

---------

Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-26 10:02:06 +08:00
878ca2058c feat(codex): expose Approve for me as a permission mode (#2016) (#2090)
* feat(codex): expose Approve for me as a permission mode (#2016)

Add autoReview to the Codex permission catalog. It keeps the
workspace-write sandbox with on-request approvals and routes escalations
to Codex's native reviewer (approvals_reviewer = "auto_review"), the same
config as `hapi codex --approve-for-me`. It works at launch and mid-session
via thread/settings/update; explicit modes now reset the reviewer to user,
and sessions report autoReview from the native reviewer setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(native): expect Auto-review in the Codex permission lists (#2016)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(modes): label autoReview as Auto, matching Claude's auto mode (#2016)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:59:47 +08:00
AnanovoandGitHub cb2ff05c2a fix(codex): distinguish compaction waits from unknown delivery (#2115) 2026-10-25 15:34:52 +08:00
7b00df458c fix(voice): configurable ElevenLabs max conversation duration (#2017)
* feat(voice): configurable ElevenLabs max conversation duration

Auto-created ConvAI agents never set conversation.max_duration_seconds, so
ElevenLabs applied its 600s default and cut voice conversations off.

Add ELEVENLABS_MAX_DURATION_SECONDS (default 1800, validated 60-7200). The cap
is applied when the hub creates an agent and reconciled onto existing
hub-managed agents (found by name) once per process. Operator-supplied agents
(ELEVENLABS_AGENT_ID / voice map / customAgentId) keep their own cap. Invalid
values fail the token request with a clear message instead of an API 422.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* feat(voice): Settings UI for ElevenLabs max conversation duration

Extend the hub-owner credentials panel (Settings -> Voice -> manage
providers) with a numeric field for ELEVENLABS_MAX_DURATION_SECONDS,
reusing the same env-wins-over-Settings overlay that already backs the
ElevenLabs API key field. A Settings save takes effect on the very next
token request in the same process; the env var still requires a restart.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(voice): reconcile max duration on cached agents, not just once per process

Codex review on heavygee/hapi#230 caught this: getOrCreateAgentIdForVoice's
agentIdCache short-circuited before ever reaching ensureAgentOverrides, and
ensureAgentOverrides's overridesEnsuredAgents gate was a one-time-forever
marker that didn't account for the configured duration changing after the
first reconciliation. Together, any voice already used once in the hub
process would keep its old cap until a restart, contradicting the Settings
UI's 'takes effect immediately' claim.

Track the last-applied max duration per agent and compare it against the
live setting on every call, including agentIdCache hits, so a Settings
change reaches an already-cached agent on its very next token request.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(voice): address tiann/hapi#2017 review findings

- Fix TS2769 typecheck failure: test assertions casting the credentials
  response narrowed the type to omit 'editable', which upstream CI's
  bun typecheck catches and this fork's (currently broken) CI did not.
- An invalid ELEVENLABS_MAX_DURATION_SECONDS env value threw out of
  buildMaxDurationStatus(), which is shared by every provider's
  GET/PUT credentials status — breaking visibility into OpenAI/Deepgram/
  Groq settings too, not just ElevenLabs. The token-issuance hot path
  (getConfiguredElevenLabsMaxDurationSeconds) still throws there by
  design; only the status/update endpoints now degrade gracefully.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(voice): interpolate duration range in the Settings hint

Nit from tiann/hapi#2017 follow-up review: the hint under the max-duration
field rendered the literal '{min}-{max}' placeholders instead of '60-7200'.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:42:01 +01:00
86c88df93b fix(runner,hub): cross-platform orphan reap on archive / tracking loss (#1911)
* fix(runner,hub): reap detached driver CLI on archive and tracking loss

Archive always verifies via stopRunnerSession; runner stopSession scans
argv orphans and awaits webhook-timeout tree-kill so PPID=1 children
cannot accumulate after KillSession miss or map drop.

Fixes #1910

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

* fix(cli): keep --hapi-session-id reap-only; test orphan kill path

Claude/cursor swallow the stamp without bootstrap reuse, and
reapRunnerSpawnedOrphans is unit-tested as the stopSession map-miss path.

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

* fix(cli,hub): address #1911 CI and bot Majors

Use Vitest in orphanReap tests, add Pi session.on mock, refuse archive
when machine StopSession is unreachable, trust full tree-kill results,
and argv-sweep orphans after every mapped stop.

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

* fix(runner): gate late-webhook kill on timeout orphans; fail closed on scan error

Track webhook-timeout PIDs separately so recovered shared roots are not
killed, and treat ps-list failures as still_alive rather than empty.

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

* fix(runner): keep shared Codex wrapper alive when archiving one root

StopSession must detach a single shared root without tree-killing the
wrapper PID while sibling roots remain, including after KillSession has
already marked the runtime inactive.

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

* fix(runner): keep shared Codex siblings after restart tracking loss

Before persisted-PID or argv orphan kills, consult the durable runtime
registry for active sibling roots on the same wrapper PID so archiving
one root after a runner restart cannot tree-kill live shared sessions.

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

* fix(hub,cli): deliver hub-authored archive metadata to reconnecting CLIs

When KillSession misses and archive is written via markSessionArchivedFromHub,
emit Socket.IO update-session to the session room. On reconnect, ApiSessionClient
reconciles GET /cli/sessions/:id metadata so hub-archived still fires.

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

* fix(runner): argv-scan orphans even when shared siblings remain

Remove session-wide early returns that skipped orphan reaping whenever any
shared wrapper had active siblings. Keep only the PID-specific filter so
older untracked CLIs with the same HAPI id are still reaped.

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

* fix(runner): protect tracked shared wrappers when runtime registry is empty

Argv orphan sweeps must exclude in-memory shared wrapper PIDs that still
host sibling roots, not only durable registry siblings — otherwise a missing
runtime record lets the sweep tree-kill the wrapper while archiving one root.

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

* fix(runner): adopt untracked nonshared CLIs after runner restart

When a runner-spawned Claude webhook arrives with no TrackedSession (typical
after restart mid-bootstrap), durably adopt the PID instead of ignoring it so
StopSession can reap. Keep killing only nonshared timeout orphans; never kill
shared Codex wrappers on late webhooks.

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

* fix(runner): keep recovered shared wrappers for registry siblings

After restart adoption tracks only a newly reported Codex root, archiving
it must consult the durable registry before tree-killing the wrapper PID
so older active sibling roots survive.

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

* fix(hub): do not archive on KillSession ack when StopSession is unknown

KillSession returns before cleanupAndExit finishes. When the runner cannot
find the PID (unknown), wait for the session KillSession handler to drop
before archiving; refuse if the CLI socket remains reachable.

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

* fix(hub,cli): confirm KillSession pid via StopSession before archive

KillSession now returns the CLI pid. When session-id StopSession is
unknown, archive confirms that OS pid (without treating socket loss as
exit) and still refuses when confirmation stays unknown or still_alive.

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

* fix(runner,hub): tree-kill adopted CLIs and require PID start markers

Adopted post-restart runner sessions now use killProcessTreeByPid when no
ChildProcess handle exists. KillSession carries a process-start marker so
raw PID confirmation cannot tree-kill a reused OS pid.

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

* fix(runner,hub): verify adopted PID markers and hub-archive after stop

Adopted sessions compare the persisted start marker before tree-kill and
drop stale tracking on mismatch. After confirmed StopSession, hub-authors
archive metadata when the CLI did not finish its flush.

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

* fix(runner): refuse unconfirmed shared/adopted stops

Keep shared wrappers when siblings remain, but return unknown unless
runtimeControl already ended the root. Adopted PIDs without a start
marker no longer tree-kill by tracked PID alone.

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

* fix(runner): require inactive binding before sibling-kept archive

Siblings alone must not flip StopSession to stopped on retry, while an
acknowledged inactive Codex registry binding remains stop proof. Handle
PID- markers before any tracked kill so reuse cannot nuke strangers.

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

* fix(runner): distinguish orphan reap from unknown keep-wrapper base

Pass unknown into finishWithOrphanSweep so a successful argv reap stays
stopped instead of being overwritten by an absent registry binding.

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

* fix(runner): honor orphan reap when adopted PID lacks a start marker

finishWithOrphanSweep with an unknown base lets a successful argv kill
report stopped instead of forcing unknown after the marker probe fails.

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

* fix(hub): confirm archive via metadata.hostPid tombstone

When StopSession(sessionId) is unknown and KillSession supplies no
confirmable pid+marker, probe PID-<metadata.hostPid>. already_gone
allows archive; still_alive/unknown still refuse (#1911 Peer #1820).

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

* fix(cli): escalate Windows taskkill to /F like SIGTERM→SIGKILL

Soft taskkill /T is refused on win32 console trees; orphanReap left
orphans still_alive. waitForProcessToDie now force-escalates on both
platforms so cross-platform reaping matches.

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

* fix(cli): widen process.kill mock signal to string | number

Match @types/node process.kill(pid, signal?: string | number) so the
Windows taskkill escalation mocks typecheck on tip 54f141368.

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

* fix(cli): tasklist liveness + immediate /F after soft taskkill refuse

Windows orphan reap still failed in 5ms on Teemo: soft taskkill refused
but escalate waited on an unreliable process.kill(0) probe. Prefer
tasklist for win32 liveness and escalate to /F as soon as the PID is
still live after soft kill.

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

* fix(cli): coerce win32 ps-list PIDs and escalate soft taskkill to /F

String PIDs from ps-list made Number.isFinite fail so orphan kill
returned still_alive without calling taskkill. Coerce PIDs, prefer
tasklist liveness, and escalate soft taskkill to /F like SIGTERM→SIGKILL.

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

* fix(cli): Windows orphan scan via CIM CommandLine + taskkill /F escalate

ps-list/fastlist is missing CommandLine and often missing from single-exe
bundles, so Teemo orphan reap returned scan_failed (logged as live PIDs).
List via Win32_Process CIM, coerce PIDs, escalate soft taskkill to /F.

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

* fix(cli): grace-wait after successful soft taskkill before /F

Escalate immediately only when soft taskkill is refused. When soft
succeeds but the PID is still draining, honor the existing grace period
before force (Codex Major on #1911).

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

* fix(runner): re-verify start marker before orphan argv tree-kill

Capture getProcessStartMarker after the argv match list, re-check
immediately before killTree, and skip on mismatch/null-while-alive so
PID reuse cannot tree-kill an unrelated process on the orphan fallback.

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

* fix(hub): preallocate HAPI id before fresh machine spawn

Fresh POST /machines/:id/spawn omitted existingSessionId, so argv never
got a reap stamp. Preallocate the hub row (fork/clear pattern) and pass
that id so a runner restart before the first webhook remains reapeable.

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

* fix(cli,hub): close three post-prealloc unreapable paths

Pass --hapi-session-id into Claude/cursor/kimi/copilot bootstrap so the
preallocated hub row is used. Treat null marker re-check while alive as
still_alive (not stopped). Keep the preallocated stub when spawn-fail
StopSession cannot confirm the child is gone.

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

* fix(cli): adopt-stub via reservedSessionId, not existingSessionId

`--hapi-session-id` binds a fresh create bootstrap to a hub-preallocated
UUID (getOrCreateSession id) without taking the reopen path. Non-UUID
stamps stay reap-only to match hub create schema. Restores integration
spawn invariants broken by mapping hapi→existingSessionId.

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

* fix(cli,hub): close Opus cold-read B1–B3 for #1911

B1: capture start markers from the same process snapshot as argv
match (POSIX ps lstart / Win32 CreationDate); re-check before kill.
Continue other orphans when one target is unconfirmed.

B2: Windows killProcessTreeByPid verifies every collected descendant,
not root-only — taskkill /T exit 0 is signalled, not gone.

B3: runner sets childStarted:false on pre-exec rejection; hub deletes
the preallocated stub without StopSession. Post-exec ambiguity still
keeps the row until stop confirms. Terminal archives no longer refuse
when no runner is connected.

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

* fix(cli,hub): explicit adopt for hub-preallocated session stubs

Fresh machine spawn preallocates with tag machine-spawn:<uuid>; CLI
create with a different tag and the same id 409'd on identity conflict.
Add adoptPreallocatedSession (hub-internal stub check) and POST
/cli/sessions { adopt: true, id } so create binds the reserved row and
overwrites tag/metadata without guessing the hub tag. Tests fail-first
on the real prealloc→CLI create path.

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

* fix(cli): preserve snapshot markers + unify Windows CIM start markers

Runner stopSession was still on findOrphans (PID-only), discarding same-snapshot
markers and reopening the PID-reuse kill window. Switch both call sites to
findTargets. Force UTC ISO 'o' CreationDate for list and single-PID probe so
ConvertTo-Json cannot disagree with getProcessStartMarker on WinPS 5.1.

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

* fix(cli): exercise production orphan sweep wiring in tests

Extract findStopSessionOrphanTargets as the stopSession discovery path both
run.ts sweeps call. Add tests that drive that helper end-to-end (not an
injected findOrphans seam), assert run.ts source wiring, and cover CIM-list
ISO vs probe ISO agreement plus the broken WinPS shape mismatch.

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

* fix(hub,cli): never delete live spawn on RPC timeout; release stub tags

Ambiguous machine-spawn failures (timeout / unset childStarted) no longer
call stopRunnerSession or deleteSession — that path killed healthy late
boots and CASCADE-wiped transcripts. Pre-exec delete still requires a real
machine-spawn stub (store tag check). Metadata updates release the stub
tag so reopen flavors are not permanently adoptable; adopt refuses
archived stubs. POSIX pgrep ENOENT fails closed instead of root-only verify.

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

* fix(cli): parseable Windows tree scan + fail closed on CIM failure

Space-joined PowerShell for collectWindowsProcessTree was a WinPS parse
error ($seen=@{} $bfs=@()), so every scan fell open to [root] and B2
false-stopped again. Join with newlines; return scan_failed on error/empty;
killProcessTreeByPid refuses root-only verify. Assert on generated command
text (Teemo-confirmed) so mocks cannot hide the next malformed script.

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

* fix(cli,hub): reopen stamps existing-session-id; scan-fail signals root

Opus Critical on #1911: runner always stamps --existing-session-id for
known hub ids so Claude/Kimi/Copilot reopen does not hit adopt-stub 409.
On pgrep/CIM tree-scan failure still signal the verified root but never
claim stopped. Archive when both KillSession and machine RPC are missing.
Empty Win process-list stdout and strict Codex registry reads fail closed.

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

* fix(cli,hub): OK: sentinel for Windows tree scan; adopt CAS

CIM failure under SilentlyContinue printed root-only with exit 0 —
byte-identical to a healthy childless tree, so scan_failed never fired
and killProcessTreeByPid false-stopped (#1911 Overseer B1). Require
ErrorActionPreference=Stop, trap exit 1, and an OK: success sentinel;
parse anything else as scan_failed. Same Stop/trap on the machine-wide
list command. Execute the generated scripts under Docker pwsh in CI
when the image is present. Treat pgrep status=null as scan_failed;
coarsen Windows tasklist poll; wrap adoptPreallocatedSession in a
transaction with stub-tag CAS.

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

* fix(cli,hub): reservedSessionId for fresh prealloc; signal Win survivors

Fresh machine-spawn stubs must not stamp --existing-session-id — that
sends Codex down reopen without a thread binding (#1911 bot Major) and
re-broke local HTTP non-UUID hints as reopen (round-4). Hub passes
prealloc as reservedSessionId; buildCliArgs stamps --hapi-session-id
for reserved/sessionId and --existing-session-id only for live reopen.
Codex parses --hapi-session-id and adopts via bootstrapSession.

Windows tree kill now taskkills surviving pre-scan PIDs after /T misses
broken intermediate links.

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

* fix(cli,hub): fail-loud Docker pwsh CI; scope Codex registry; stub leak

CI skipped all four windowsProcessTree.pwsh tests on 48bb (image inspect
without pull). Pull the image in test.yml and throw under CI when missing.
Scope strict runtime-registry failure to Codex stop contexts so one bad
JSON cannot block archive machine-wide. Stamp childStarted:false on
machine RPC outside_workspace_roots. Add Codex reservedSessionId prealloc
and runSharedCodex regression coverage for the #1911 Critical.

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

* fix(cli,hub): single-use reservedSessionId; skip UUID HTTP hints

Consume the machine-spawn reservation on first prepare() so a second
Codex root/fork cannot re-adopt a consumed stub (409). Stop stamping
UUID local-HTTP sessionId as --hapi-session-id (adopt hard-fail). Update
stale hub spawn mocks for the reservedSessionId arity.

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

* fix(hub): archive never-started machine-spawn stubs on unknown

Ambiguous keep-stubs (startedBy=runner, no hostPid, still machine-spawn
tagged) made StopSession return unknown forever while the runner was
online, so archive threw permanently. Treat unknown + prealloc stub +
no hostPid as already_gone. Also reject spawn success when the runner
reports a different id than the preallocated stub.

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

* fix(hub,cli): refuse dual-RPC-miss archive; generation-check Win survivors

Both KillSession and StopSession missing is not proof a detached CLI exited —
keep the row unconfirmed until the runner reconnects. Windows tree kill now
re-checks process-start markers before individually signalling survivors so
PID reuse is not killed.

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

* fix(cli): refuse archived reopen in bootstrapExistingSession

Hub-archived rows could be resurrected when a late-booting child hit
--existing-session-id: buildSessionMetadata stamped lifecycleState=running
and updateMetadata overwrote the archive. Adopt already rejected archived
stubs; reopen now matches. Intentional revive still goes through hub
reopenSession first. Correct stub-hatch comment (no post-archive argv sweep).

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

* fix(hub,cli): reject un-archive in updateSessionMetadata CAS

Hub-side transactional guard (Discovery #1911 M1): refuse metadata writes
that clear archived lifecycle unless allowUnarchive (reopen clear). Client
stops infinite updateMetadata backoff when mismatch applies archived.
Clear archive immediately before spawn so Pi/PTY attempt snapshots stay valid.

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

* fix(hub,cli): success+preserve hub archive; M3 reservedSessionId split

Replace version-mismatch refuse (livelock in CAS backoff) with success +
merge-preserve of hub-archived fields unless allowUnarchive. Ack path
routes through noteHubArchived so the CLI exits (criterion 6). Clear-
before-spawn remains the authorized revive. Thread --hapi-session-id to
reservedSessionId for shared-parser flavors (adopt≠reopen).

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

* fix(cli): fail-closed orphan ps signal; PID- registry; spawn timeouts

Overseer B1: signalled ps (status null) is scan_failed, not empty table.
B2: unreadable registry on PID-* stops returns null (no soft []).
B3: 10s timeout on process/orphanReap spawn.sync. Narrow bootstrap
archived belt to archivedBy=hub to match hub merge-preserve.

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

* fix(cli,hub): hubArchived latch; PTY archiveSnapshot durability

Criterion 6: noteHubArchived latches hubArchived so late
registerKillSessionHandler registrations still EXIT (EventEmitter
does not replay). Mirror Pi: persist archiveSnapshot on
ptyResumeAttempt across clear-before-spawn and quarantine, restore
on failed-resume cleanup (#1911 Discovery Majors).

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

* fix(cli): Codex hub-archived exit; AC6 latch test production order

C2: SharedCodexRoot registers hub-archived (and latch check) beside
its private KillSession — Codex never called registerKillSessionHandler.
C1: keep latch+subscribe; test write-then-register order Discovery proved.

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

* fix(cli): type Codex hub-archived end mock for tsc

vi.fn(async () => {}) inferred zero-arg calls; RootHost['end']
signature unblocks end.mock.calls[0][0] (TS2493).

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

* fix(cli): catch hub-archived Codex host.end rejection

void this.host.end() discarded archive-RPC failures as unhandled
rejections; Codex has no unhandledRejection handler, so Node can
exit the shared wrapper and kill sibling roots mid-turn.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-25 09:15:08 +01:00
MiroandGitHub b6a27bdc68 fix(kimi): discover configured models dynamically (#1873)
* fix(kimi): discover configured models dynamically

* fix(kimi): address dynamic model review findings

Probe settlement (Major): parse `kimi provider list --json` on the child's
'close' event instead of 'exit', so stdio is fully drained first, and run the
parse inside a guarded settle() helper. A parser throw previously escaped the
promise from an async event callback and reached the runner's uncaughtException
shutdown handler instead of listKimiModelsForCwd's catch. The helper also
settles the promise exactly once across timeout, spawn error and close, names
the terminating signal when there is no exit code, and tolerates a kill() that
throws.

Running-session discovery (Minor): add a session-scoped Kimi catalog. The
shared RPC method `listKimiModels`, a CLI handler registered on the session
connection with the probe cwd taken from the session metadata (never from RPC
arguments), hub rpcGateway/syncEngine pass-throughs, and
GET /api/sessions/:id/kimi-models gated on an active session of flavor `kimi`.
SessionChat now reads the catalog from that endpoint, so an active session
advertises its models even when no background runner holds the machine socket.
New Session keeps the machine-scoped discovery unchanged, and model switching
still goes through the existing ACP set_model path.

* fix(kimi): register session model discovery before the local/remote loop

The session catalog handler was registered inside the remote launcher, after
ACP initialization. A session started with `hapi kimi` in local mode therefore
answered the web picker's request with an unregistered-handler error, and the
failed query was not retried or invalidated when remote control was taken over.

The catalog is backend independent, so it is now registered in runKimi session
setup alongside the other session-level handlers, using the session's working
directory, and the late registration in the remote launcher is removed. This
covers local, remote and resumed sessions on the same session connection.

Adds cli/src/kimi/runKimi.test.ts asserting the handler is registered after
bootstrap and before the loop, and that the probe cwd is the session's working
directory. The launcher wiring assertion moves out of
kimiRemoteLauncher.test.ts with the registration.
2026-09-21 13:58:59 +08:00
ca1fd80afd fix(cursor): pin Auto to CLI --model auto instead of account default (#1819)
* fix(cursor): pin Auto to CLI --model auto instead of account default

Selecting Auto was treated as unset/ACP default[] after the Default→Auto rename. Persist and spawn `auto` so the picker matches `agent --list-models`.

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

* fix(cursor): keep live ACP model until Auto actually applies

Pin session.model to auto for the next `--model auto` spawn, but do not claim the live backend switched when ACP only advertises default[].

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

* fix(cursor): reject live Auto unless ACP auto or spawn confirmed it

Do not persist Auto when the running catalog has no auto option. Clear the spawn-auto flag after a concrete model apply so Auto → concrete → Auto cannot fake a live switch.

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

* fix(cursor): keep ACP wire ids spawn-safe and apply effort

Cursor's parameterized picker (`clientCapabilities._meta.parameterizedModelPicker`)
advertises bare model bases plus separate `fast` / `thought_level` config options.
`buildCursorModelsSnapshotFromAcp` synthesized every base as
`${base}[fast=${fast}]`, and Cursor rejects any bracket id whose parameter set is
not that model's complete set, so 74 of the 76 published picker ids failed at ACP
`session/new` with `Cannot use this model` (#1818).

- Stop synthesizing wire ids: keep the advertised values verbatim (bare bases are
  accepted by `--model`, defaults applied). Parameter sets differ per model, so a
  synthesized id cannot be validated from the options alone.
- Flag such catalogs as `parameterized` so variant CLI skus still attach to their
  base rows — the ACP apply path expresses them through config options.
- Apply effort in `applyParameterizedCursorModel` (ids `effort`, `reasoning`,
  `reasoning_effort`), applied against the config options Cursor re-advertises for
  the newly selected model, and skipped on models without that axis.
- Match CLI sku bases across Cursor's legacy `cursor-` family prefix
  (`cursor-grok-4.6-high` → ACP base `grok-4.6`) so those variant rows are not
  dropped.
- Version the shared on-disk catalog cache (v2) so runners that already cached the
  rejected synthesized ids re-probe instead of serving them.

Verified with live `cursor-agent 2026.09.10-fd3934a` ACP runs: the catalog no
longer contains bracket ids, every advertised base and sampled variant sku spawns,
and in-session `claude-opus-4-8-low` / `cursor-grok-4.6-low` set the matching
`effort`/`reasoning` option.

* test(cursor): cover aliased effort ids and thinking coexistence

Covers the branches the HAPI bot review called out for #1818: the
`xhigh` → `extra-high` alias on `gpt-5.5` (`reasoning`), the
`reasoning_effort` id used by `gemini-3.8-flash`, and switching effort on a
model that also advertises Claude's separate `thought_level` `thinking`
toggle.

* test(cursor): cover legacy sku base and post-switch effort option

Adds the two apply-path cases the HAPI bot review called out for #1818:
resolving `cursor-grok-4.6-high` onto an advertised `grok-4.6` base, and
reading the effort option Cursor advertises for the newly selected model
(`reasoning` for gpt-5.5 → `effort` for claude-opus-4-8) instead of the
previous model's options.

* test(cursor): create the cache dir in the migration test

The version-envelope test wrote $HAPI_HOME/cache/cursor-models.json without
creating the directory, so running it alone (or filtering with -t) failed with
ENOENT before the assertion; it only passed because sibling tests in the same
file had already created the path. Reported in the HAPI bot review of #1822.

Verified: `vitest run ... -t 'ignores pre-versioned'` → 1 passed, whole file → 4 passed,
and every other changed test file passes when run standalone.

* Clarify Cursor Auto live-switch limitation in session pickers

* Reconcile partial Cursor model changes before acknowledging Auto

* Serialize Cursor model changes across clients with the existing async lock

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 08:12:16 +08:00
8f9d6bd7f8 fix(hub): keepalive-only sessions reconcile to an idle lifecycle (#1820) (#1821)
* fix(hub): reconcile keepalive-only sessions to an idle lifecycle

`sessionCache.expireInactive` only expires a session when `activeAt` goes
stale, and `session-alive` refreshes `activeAt` every ~2s for as long as the
CLI socket lives. A session whose agent has done nothing for days therefore
stays `active: true` / `lifecycleState: running` forever — socket pinging was
being read as agent health.

Add the missing second signal. `active` keeps its meaning (the CLI is
reachable); `metadata.lifecycleState` moves `running` -> `idle` once the hub
has seen nothing but keepalives for `HAPI_SESSION_IDLE_TIMEOUT_MS` (default
12h, `0` disables), and back to `running` on the next real progress.

`active` is deliberately not flipped: the socket really is up, and a false
`active: false` would let `resumeSession` spawn a second agent against a live
process and would unlock dedup-merge / delete on it — the #1705 class of bug.

- `sessionCache.recordAgentProgress` tracks a progress clock that keepalives
  can never touch: messages in either direction, queued prompts, background
  tasks. Seeded from `updatedAt` so a hub restart re-derives it from disk.
- `reconcileKeepaliveIdle` runs on the existing 5s inactivity tick. Sessions
  that are thinking, running background tasks or holding a pending permission
  request are never marked; `metadata.idleReconcileExempt` opts out entirely.
- Only real progress clears `idle`, never `thinking` — Cursor ACP emits
  ambient idle/running churn (#1553) that would otherwise flap the lifecycle
  every tick.
- `isLiveLifecycleState` replaces the `=== 'running'` literals so an `idle`
  row does not read as dead to the archive route or the Cursor migrator.
- Web splits keepalive-only sessions into their own session-list bucket.

Refs #1820. Distinct from #842 (`active=false` with a stale `running` badge);
this is the inverse.

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

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

* fix(hub): seed the idle clock from stored messages across hub restart

PR review on #1821: a cold `SessionCache` had no observed progress and fell
back to `updatedAt` alone. Assistant messages deliberately do not move
`updatedAt`, so a session that was streaming output a minute before a hub
restart read as hours idle and got marked on the very next tick — a false
`idle` that persisted until the next progress event.

Seed the clock once from `getNewestMessagePosition`, which is the durable
record of that output. `updatedAt` stays a floor on top of the seed: todos /
teamState / agentState writes bump it without routing through
`recordAgentProgress`.

Regression test recreates a SessionCache over the same store after recent
assistant output and confirms keepalives no longer cause premature idle
classification — and that the same session is still marked once the output
itself goes stale.

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

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

* test: cover the socket wake-up path and the idle session-list bucket

Follow-up review on #1821 reported no findings but flagged two uncovered
paths. Both have bad failure modes, so close them.

- Hub: an assistant message arriving through `registerSessionHandlers` must
  wake an idle session. Assistant messages never bump `updatedAt`, so the
  `onAgentProgress` hook is the only thing that can clear the mark — a
  refactor dropping that wiring would silently false-idle every actively
  working session, and nothing would have caught it.
- Web: extract the inline bucketing from the `runningSessions` useMemo into a
  pure `bucketRunningSessions`, matching how the rest of SessionList's logic
  is exported and tested. Covers idle vs quiet-but-ready separation, no
  double-bucketing, the transition back out of idle on thinking/background
  task/pending request, and the disconnected/pinned skips.

No behaviour change.

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

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

* test: cover the idle lifecycle at both isLiveLifecycleState call sites

Third review round reported no findings but named the two guards this PR
changed as uncovered. Both protect against reading a keepalive-idle row as a
dead one:

- Cursor migrator: an idle-marked row still has a live CLI behind it, so
  preflight must refuse it without `forceArchiveRunning`. A regression here
  would let the migrator transplant the ACP session directory out from under
  a running Cursor runner.
- Archive route: once a keepalive-idle session finally loses its socket it
  must stay archivable, rather than being stranded behind a 409.

Both tests were confirmed non-vacuous by reverting each guard to the
`=== 'running'` literal; each then fails.

No behaviour change.

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

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

* test: stop the restart regression leaking its temp DB on failure

The file-backed store's `rmSync` was a trailing call, so any failing
assertion above it skipped cleanup and left a temp DB behind — one per failed
run. Noticed while verifying the test was non-vacuous, which deliberately
fails it.

Wrap in try/finally. Verified: a forced failure now leaves zero
`/tmp/hapi-1820-*` directories.

Test-only.

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

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

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-09-16 08:11:54 +08:00
weishu facb66ee03 Release version 0.30.7 2026-09-15 09:08:33 +08:00
6c69e2f315 fix(cli,hub): finalize canceled permission requests and surface stale answers (#1854)
* fix(cli,hub): finalize canceled permission requests and surface stale answers

When Claude Code sends control_cancel_request for a pending AskUserQuestion,
the abort handler only removed the local pendingRequests entry and rejected
the promise — it never finalized the request in agentState the way the
session-level cancelPendingRequests path already does. A late answer to the
now-stale widget would still pass the hub's requests[id] check, forward via
RPC, land on a CLI that had already forgotten the request, and get silently
absorbed with no error anywhere.

Two changes, needed together:
- permissionHandler.ts's abort handler now calls finalizeRequest with
  status: 'canceled', so cancellation is reflected in agentState the same
  way a session-level cancel already is. Once synced to the hub, the
  existing requests[requestId] check in permissions.ts naturally rejects a
  late answer with 404 before it ever reaches the CLI.
- For the narrower race where an answer's RPC reaches the CLI before that
  sync lands, handleMissingPendingResponse now throws instead of only
  logging. RpcHandlerManager.handleRequest already turns a thrown error
  into a `{ error }` RPC response; RpcGateway.approvePermission/
  denyPermission now check for that and throw a typed
  PermissionRequestNotFoundError, which the hub route turns into a 409
  instead of silently returning { ok: true }.

Fixes #1735

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(cli,hub): match the specific not-found message instead of any RPC error

approvePermission/denyPermission treated any error on the Permission RPC
method as "request not found", reasoning only in a code comment that
nothing else on that path currently throws. Share the exact message via a
new PERMISSION_REQUEST_NOT_FOUND_MESSAGE constant in shared/rpcMethods.ts,
imported by both sides, so a future unrelated throw in handlePermissionResponse
surfaces as itself instead of being relabeled as a stale request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Ian Stevenson <is@lockhouse.systems>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 09:01:05 +08:00
weishu 6d44996a59 Release version 0.30.6 2026-09-14 15:05:49 +08:00
weishu 8cefb0f04c Release version 0.30.4 2026-09-13 21:22:11 +08:00
weishu 95b06bd502 Release version 0.30.3 2026-09-13 13:04:33 +08:00
weishu 917baf1201 fix(codex): sync shared plan proposals and web actions 2026-09-13 12:09:39 +08:00
weishu dd9cfdcee5 Release version 0.30.2 2026-09-12 19:44:56 +08:00
weishu 2c738b6730 Release version 0.30.1 2026-09-12 18:01:49 +08:00
weishu d18c01b4b6 revert(codex): remove Luna Reserve fallback (#1780)
Revert 8357da0a9d and its later shared-runtime integration.

Remove automatic model fallback, account usage polling, and the related UI, protocol fields, tests, and documentation without adding replacement quota handling.

Keep generic model pagination and method probing required by the current shared-session architecture. Cover idle sessions staying online without usage polling or agent-state churn.
2026-09-12 14:36:45 +08:00
weishu c75cf8154f Release version 0.30.0 2026-09-12 13:38:58 +08:00
weishu 0c4abcb3d1 feat(codex): share sessions across terminal and web
Use one native app-server for terminal, Web and phone clients while retaining the existing CLI and Runner lifecycle.

Synchronize native queues, permissions, question history and steering state; preserve explicit permission precedence and per-turn usage models. Resume inactive clear commands through Runner and reject independent child cold resumes.

Add shared-runtime regression tests, generated protocol fixtures and lifecycle documentation.
2026-09-12 10:59:17 +08:00
weishu a729456682 fix(claude): answer local permission prompts from web
Bridge main-session PermissionRequest hooks without suppressing the native
terminal dialog. Reconcile replies against native results and clean up on
timeout, cancellation, mode switches, and session changes.

Keep reply IDs distinct from native tool IDs across web and native clients;
add protocol fixtures and regression tests.

Refs #1796
2026-09-11 18:54:47 +08:00
SSU-WEI HUANGandGitHub 68422ed6d3 fix(web): use conversation content for session reference eligibility (#1800)
* fix(web): base session reference eligibility on conversation content

* fix(hub): refresh replacement content after clear abort

* fix(hub): refresh content on idempotent clear aborts
2026-09-11 14:22:16 +08:00
Junmo KimandGitHub fd2822bab5 fix(agy): allow switching existing sessions to newly available models (#1814)
* fix(agy): say what the model picker is actually waiting on

The spinner in the New Session AGY picker read "Checking Antigravity
authentication…", but nothing at that point checks authentication — the
machine is running `agy models`, and the sign-in prompt is a separate
branch below it, shown only when agy reports the failure.

Name the wait after the work: "Fetching available models…", the same
words agy prints while it fetches.

* refactor(agy): describe a probe by its outcome, not by its response

The probe function returned a finished `AgyModelsResponse`, so "agy could
not be reached" and "agy listed no models" both arrived as a successful
response carrying the hardcoded mirror, and the caller could no longer
tell which had happened. Every policy decision about that answer has to
live inside the probe as a result.

Hand back what the probe observed — a live catalog, an auth failure, or
nothing usable — and let the caller turn it into a response. Same
behaviour: the mirror still stands in for both failure modes, and the
60s cache still holds whatever came out.

* fix(agy): serve the model catalog stale-while-revalidate

The `agy models` probe is a whole agy invocation — around 3s on a good
day, 15s when it times out — and the 60s window meant the New Session
picker paid that again a minute after the last look.

Keep the last listing agy actually returned and answer from it: fresh for
ten minutes, then still answered while a probe refreshes behind it, until
the entry is a day old and stops standing in for the machine at all. A
probe that times out or loses auth leaves that entry alone, so a blip no
longer empties a working picker, and the hardcoded mirror is no longer
recorded as if the machine had reported it.

Three things fall out of that and are handled here. A machine whose
sign-in has actually gone bad would otherwise look healthy for a day, so
an auth failure rides along with the catalog it can still serve — and,
because nothing else would re-probe a catalog that is still fresh, a
warning riding on the answer is itself a reason to look again. A failed
probe is not repeated on the very next request either, or a machine where
agy hangs would spawn it once per poll.

An explicit refresh always costs a probe, and never rides one that was
already running when it was asked for.

* fix(agy): let Retry force a fresh model catalog probe

With the catalog held for ten minutes, Retry would otherwise hand back the
answer it was pressed to replace, so the intent travels to the machine:
`?refresh=true` on the machine route, an optional RPC param, and a
one-shot flag on the query so ordinary mount and focus refetches stay
cheap. Every hop is optional, so a hub and a runner on different versions
still talk — the older side ignores it and answers from its cache.

Retry also has to be reachable, and honest, in the state that needs it.
The machine now answers with both a usable catalog and the sign-in failure
behind it, so the picker keeps the list and says why it may be out of
date, with the button right there rather than only once there is nothing
left to show. Pressing it runs agy, which can take tens of seconds, so the
button says so while it does.

The client contract covers both: `agy-models` is the one catalog route
that can carry an `error` on a successful response, and the one that
takes a refresh parameter.

* fix(agy): use the machine catalog in the in-session model picker

New Session already asks the machine what `agy models` lists, but a
session that is already open offered the built-in list in
`shared/src/models.ts`. That list is a hand-maintained mirror, so a model
agy started offering after the last release could be picked for a new
session and not for the one already running.

Point the composer at the same machine catalog. The mirror stays as the
fallback for the moment before the machine answers, and a model the
session is already on is kept selectable — and readable, when it is one
of the known presets — even after the catalog moves on without it.

* fix(agy): announce a model catalog re-check that changed the answer

Serving the last known catalog answers the picker instantly, but a picker
that was already open kept showing that answer until the user closed and
reopened it — the machine had no way to say it had found something newer.

Say it on the stream that already carries machine changes. The machine
daemon — the only process that answers `<machineId>:listAgyModels` —
emits it, and the hub forwards it as `machine-agy-models-updated` with
nothing but the machineId. Namespace resolution, per-machine delivery and
reconnect replay all come from the existing path.

What counts as a change is what the route would answer, not what sits in
the cache. That distinction carries the cases: a sign-in that lapsed or
came back changes no models yet changes what the user is told; a machine
whose agy was signed out has been answering from the hardcoded mirror, and
its first real listing is the largest change there is, for every client
except the one awaiting it.

* fix(agy): re-read the announced machine's model catalog

On `machine-agy-models-updated`, cancel and refetch that one machine's
catalog query — the app's global connection is always subscribed, so an
open picker redraws wherever it is.

Cancelling first is what makes it correct rather than merely likely.
query-core cancels an in-flight fetch only when the query already holds
data, so a picker opening for the first time would otherwise join the
request already on its way and settle on the listing the announcement
replaced. The refetch is answered from the machine's cache, so it starts
no probe and cannot bounce another announcement back.

A reconnect the hub could not replay takes the resync path, which clears
the agy catalogs the same way — that path has no announcement to fall back
on, so it is the one that can least afford to join a stale request.

* fix(agy): keep a model the user picked when the catalog moves under them

The catalog can now change while the New Session form is open, and the
form dropped any selection the machine no longer advertised — including
one the user had just made.

Keep that one, and list it as no longer listed so the form does not imply
agy is still offering it. A model restored from a draft or a saved
preference is still dropped: it may never have been runnable here.

* fix(agy): announce uncached authentication changes
2026-09-11 14:19:37 +08:00
weishu de93d7b977 Release version 0.29.1 2026-09-09 09:44:45 +08:00
Junmo KimandGitHub 7031d60eb4 fix(opencode): expose model-specific reasoning effort options (#1716)
* fix(cli): discover opencode thought_level via set_config_option on model switch

* fix(cli): apply and refresh opencode model switches so thought_level stays discoverable

* fix(web): track opencode effort options across model switches

* feat(cli,hub,web): dynamic opencode effort options in new-session form

* test(cli): avoid platform-specific process event narrowing

* fix(opencode): address variant discovery review findings

* fix(opencode): synchronize effort options with model targets

* fix(opencode): roll back rejected model targets

* fix(cli): guard opencode variant probe workspace paths

* fix(web): clear stale opencode effort on model switch

* fix(cli): clear stale opencode effort metadata

* fix(web): reset stale effort options on model switch

* fix(web): reset opencode effort poll budget

* test(web): enforce opencode effort poll budget
2026-09-09 09:31:30 +08:00
SSU-WEI HUANGandGitHub 8357da0a9d feat(codex): support backend-authorized Luna Reserve fallback (#1780)
* feat(codex): reconcile Luna Reserve fallback and conditional usage

* fix(codex): preserve queued settings and reconcile Reserve sessions
2026-09-09 09:27:40 +08:00
AnanovoandGitHub 17ee052d9a fix(web): preserve the visible chat window during rewind (#1766)
* fix(web): preserve chat window during rewind

* fix(web): scope rewind invalidation preservation

* fix(web): clear unknown rewind boundaries

* fix(web): deduplicate rewind invalidations

* fix(web): retain rewind dedupe history
2026-09-09 09:23:31 +08:00
AnanovoandGitHub aa0c1dc808 fix(codex): fail closed on ambiguous Web Rewind boundaries (#1707)
* fix(codex): fail closed on ambiguous Web Rewind boundaries

* fix(codex): reject malformed native rewind history

* fix(codex): reject duplicate rewind identifiers

* feat(codex): offer Fork fallback for ambiguous rewind

* fix(codex): gate rewind Fork fallback on exact native boundary

* fix(codex): reject unresolved rewind boundaries safely

* chore: trigger PR checks

* fix(codex): gate safe rewind fallback on fork support

* fix(codex): require complete user ids for rewind fallback
2026-09-06 14:18:40 +08:00
Junmo KimandGitHub ec08959f07 fix(agy): show Gemini 3.7 Flash in the agy model list (#1585)
* refactor(agy): extract the agy models probe from the fetch flow

One invocation and the decision of what to do with its output were
tangled in a single promise. Split them so the probe can be given
different arguments, and run more than once, without duplicating the
stream and timeout handling.

No behaviour change: the same argument vector, timeout, stream
handling and fallbacks remain, and the existing tests are untouched.

* fix(agy): read the model list from agy's structured output

The picker recovered ids and labels from `agy models`, whose table is
meant for people to read: it has emitted display names only, then
bare ids, then tab-separated id/label pairs over the 1.1.x line, and
each shape change silently sent the probe back to the hardcoded
mirror. Since agy started offering Gemini 3.7 Flash, that mirror is
what users see, so the three new entries never appear.

agy publishes the same listing as a structured payload, so read that
instead: `agy --output-format=json models` returns
`command.data.models[] = {id, label}`. The flag is global, so it goes
before the subcommand and takes its `=` form.

Releases that predate the flag ignore it and print the table, so the
text parser stays as the fallback, and it now also understands the
tab-separated shape. Every release checked, 1.0.16 through 1.1.13,
ignores the flag rather than rejecting it; a build that rejected it
would emit no models at all, so ask once more without the flag when
the first invocation yields nothing either parser can read.

* fix(agy): follow the Gemini 3.7 Flash row in the model picker

agy now lists Gemini 3.7 Flash as the top row of the `/model` TUI
picker, but the hardcoded row table still starts at 3.6 Flash. A
session whose current model is that row can never change its model:
the picker cannot identify the current row and the change is
rejected.

Add the 3.7 row to MODEL_ROWS/TARGETS. Every existing row shifts
down by one, which leaves their relative deltas, and therefore the
navigation keys emitted between them, unchanged. Mirror the three
new ids into AGY_MODEL_LABELS so the session pickers offer them too.
2026-08-26 09:37:58 +08:00
weishu e5a8212f4a feat(session): validate agents and browse workspace directories 2026-08-25 16:12:29 +08:00
SSU-WEI HUANGandGitHub be1ef2a2e4 feat(dsh): integrate DeepSeek Harness through ACP (#1632)
* feat(dsh): add DeepSeek Harness ACP flavor

* fix(dsh): update mobile flavor catalogs

* fix(dsh): keep mobile spawn policy managed

* fix(dsh): keep managed policy and prompt retry

* fix(dsh): suppress unsupported runner policy flags

* fix(dsh): align native managed-policy UX
2026-08-22 12:37:28 +08:00
SSU-WEI HUANGandGitHub fdf4fdaa38 feat(web,hub): make large conversation export warning-only (#1648)
* feat(export): warn before large session downloads

* fix(export): surface server size errors
2026-08-20 11:11:43 +08:00
Junmo KimandGitHub 0aebf39c78 fix(opencode): keep one stored message per reasoning stream (#1643)
* fix(acp): carry the live reasoning marker on the wire payload

ACP agents stream thoughts a token at a time, so the handler coalesces
them into a buffer and re-sends the whole buffer under a stable stream
id every 250ms. The converter dropped the marker that says a payload is
one of those throttled snapshots, leaving the hub unable to tell a
replaceable snapshot from the settled message that closes the stream.

Mirrors how the text variant already forwards streamSnapshot.

* fix(hub): keep one stored message per reasoning stream

OpenCode reasoning arrives as a series of growing snapshots sharing one
stream id, and every snapshot was persisted as its own message. A 26h
session reached 48,844 rows and 63MB, and because the web budgets a
fixed number of messages, its 400-message window covered barely three
minutes of conversation — scrolling up walked through duplicate
snapshots instead of history.

Retire a stream's earlier live snapshots once their replacement is
stored. Sweeping only after the insert matters: the two statements are
separate transactions, so clearing first would leave a window where a
crash takes the whole stream. Only rows marked live are eligible and the
replacement is spared, so a stream always keeps at least one row and the
settled message that closes it is never removed.

Live rendering is unchanged: the web still receives every snapshot and
already folds them by stream id.

* fix(web): spend the message window on conversation, not repeated snapshots

The window budgets raw messages, but a reasoning stream renders as a
single folded block no matter how many snapshots it arrived in. On
sessions recorded before the hub started retiring them, those snapshots
fill the window on their own: in one 26h session the newest 400 messages
covered 202 seconds, so scrolling up paged through duplicates instead of
history.

Collapse each stream to its newest snapshot before trimming. Rendering
is unchanged — the timeline already folds them by stream id — and rows
without a stream id are never touched.

* fix(ios,android): port reasoning-snapshot compaction to the native windows

The window logic in HapiProtocol and :core:protocol is a one-to-one port
of the web store, so collapsing superseded reasoning snapshots only on
the web left the native windows budgeting raw snapshot rows. The hub
stores one row per stream now, but a client that already holds the older
snapshots still spends its window on them.

Add the same stream-id reader and compaction to both ports, in the shape
each already uses for agent-run rows, and pin the behaviour with a
pagination fixture. Both fixture suites enumerate shared/fixtures/pagination
from disk, so the ports cannot drift from the web again without CI saying
so.
2026-08-20 08:55:21 +08:00
SSU-WEI HUANGandGitHub 0f7a3da68b feat(cursor): mid-turn Steer via concurrent ACP session/prompt (#888) (#1609)
* feat(shared): steer capability gates and live steered signal schemas

- STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession gate which
  agents can deliver queued messages into the active turn (pi, codex,
  cursor ACP; legacy stream-json cursor excluded)
- AgentState.steeringActive, DecryptedMessage.steered and
  messages-consumed  live signal (never persisted by the hub)

* feat(cli): queue reservations and steered messages-consumed option

- MessageQueue2 gains takeByLocalId/restoreReservation/
  beginReservationDispatch/commitReservation so an async steer can reserve
  a queued row without racing the main loop's turn/start drain
- emitMessagesConsumed accepts steered: true to mark mid-turn delivery

* feat(codex): mid-turn steer via app-server turn/steer (#888)

- CodexAppServerClient.steerTurn + TurnSteerParams/Response types
- CodexRemoteLauncher registers the steer-queued-message RPC handler:
  reserves the queued row, validates it against the active turn (no
  control commands, matching mode hash), injects via turn/steer with an
  epoch guard that invalidates in-flight steers on abort/cleanup
- steeringActive agent state tracks the active-turn window
- hub syncEngine gate opens to codex; messages-consumed relays steered

* feat(web): Steered badge and steer gating for codex sessions

- HappyUserMessage shows a ↳ Steered badge fed by the live
  messages-consumed steered signal, preserved across server echoes and
  refetches (mergeMessages carries the optimistic marker)
- SessionChat gates canSteer via isSteeringSupportedForSession instead of
  the pi-only check
- clearStaleQueuedStatus normalizes a queued status on an invoked message
- fix(web): drop duplicate showSessionSummaryInChat in markdown test
  (upstream typecheck breakage)

* feat(acp): split request dispatch from completion and add soft steer

- AcpStdioTransport.sendRequestWithDispatch separates stdin-accepted
  dispatch from the JSON-RPC response, keeping sendRequest behavior
  unchanged
- AcpSdkBackend tracks concurrent session/prompt requests with an
  activePromptRequests counter (main prompt + soft steers); response
  completion stays pending until every concurrent prompt settles
- beginSoftSteerPrompt kicks off a concurrent session/prompt (Cursor GUI
  Send semantics — no cancel, no handler swap) returning {dispatched,
  completed}; softSteerPrompt awaits the full response for direct callers

* feat(cursor): mid-turn soft steer via concurrent session/prompt (#888)

- CursorAcpRemoteLauncher registers the steer-queued-message RPC handler:
  reserves the queued row, rejects control commands and mode mismatches,
  then soft-injects via beginSoftSteerPrompt without canceling the
  in-flight turn
- Acks the hub once stdin accepts the inject (not on turn completion) to
  stay inside the 30s RPC window; the launcher stays busy until the
  concurrent prompt settles so handlers are not swapped mid-inject
- steeringActive agent state mirrors the active-turn window; abort and
  cleanup reset it and invalidate pending steers
- Legacy stream-json Cursor sessions register a steer handler that
  reports unsupported

* fix(codex,shared): address bot findings on steer gate and ambiguous turn/steer

- STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession advertise
  codex and pi only; cursor joins when its soft-steer handler lands (#1609)
- turn/steer now splits dispatch (stdin accepted) from completion (turn
  finished): the hub RPC acks once dispatch succeeds — never on the
  concurrent turn's completion, which can exceed the 30s RPC window
- queue row commits only after the turn settles; a rejected/aborted steer
  restores the row so the message still delivers via turn/start, and a
  dispatched steer is never restored (no duplicate delivery)
- steer carries clientUserMessageId (echoed as userMessage.clientId) so
  ambiguous transport failures can reconcile the thread later
- client tests cover dispatch/complete split and stdin-write failure

* feat(shared): advertise cursor in the steer gate now that its handler lands

Cursor ACP sessions pass the web and hub steer gates; legacy stream-json
cursor sessions stay excluded.

* fix(codex): reconcile dispatched steers before restoring; align error copy

- A dispatched turn/steer whose completion fails (disconnect / protocol
  error) is now reconciled via thread/read by clientUserMessageId before
  the queued row is restored — the instruction is only re-delivered by
  turn/start when the thread never received it
- Reconcile targets the pinned steer thread, not whichever turn is
  current when completion fails
- syncEngine unsupported-flavor error now matches the capability gate
  (Pi and Codex only until the cursor handler lands)
- launcher tests cover steer success (ack on dispatch), reconcile-accepted
  and reconcile-rejected outcomes

* fix(codex): consume the row at dispatch; drop background reconcile

- The hub RPC acks and the queue row is consumed as soon as stdin accepts
  turn/steer; completion is background-only logging. A dispatched steer is
  never restored, so the same localId cannot be re-delivered via turn/start
  after the caller was told the steer succeeded
- Dispatch failure (stdin write error) still restores the row and reports
  failure
- steer.completed rejection is always handled (no unhandled rejection on
  the dispatch-failure path)
- tests updated: completion failure after dispatch keeps the row consumed;
  dispatch failure restores it

* fix(cursor): consume the row at dispatch; keep waiters for prompt gating

- The hub RPC acks and the queue row is consumed as soon as stdin accepts
  the concurrent session/prompt; completion is background-only. A
  dispatched steer is never restored (no duplicate via the next prompt)
- softSteerWaiters are registered before awaiting dispatch so the main
  loop's finally cannot start the next prompt mid-inject; they still gate
  prompt handover on completion
- tests updated: post-dispatch ACP rejection keeps the row consumed

* fix(cursor,hub): never hang teardown on unresolved soft steer; align diagnostics

- Prompt-finally waits for soft-steer completion only when not exiting;
  the outer finally no longer waits at all — cleanup() disconnects the
  ACP transport, which rejects pending requests and settles the waiters
- syncEngine gate diagnostics and JSDoc name all supported flavors
  (Pi, Codex, Cursor ACP)
- regression test: Switch with an unresolved soft-steer completion still
  reaches teardown

* fix(codex): distinguish definite rejection from indeterminate completion

- Transport-level failures (timeout, abort, disconnect, spawn, protocol)
  carry an indeterminate marker; explicit JSON-RPC error responses do not
- After a dispatched steer, turn completion resolves → commit + consumed;
  a definite app-server rejection restores the row (instruction was never
  accepted, so turn/start cannot duplicate it); an indeterminate outcome
  leaves the row reserved so it can never be delivered twice
- Completion handling registers before awaiting dispatch so the
  dispatch-failure path cannot leak an unhandled rejection
- client/launcher tests cover explicit rejection (restore), indeterminate
  outcome (row stays reserved) and dispatch failure

* fix(codex): reconcile indeterminate steers instead of a permanent reservation

- After an indeterminate completion (disconnect/protocol), reconcile the
  thread by clientUserMessageId immediately: accepted → commit + consumed,
  provably rejected → restore, still unreadable → keep the reservation and
  retry from the main-loop top on later passes (post-reconnect)
- A row never sits in dispatching forever: the hub cannot stamp it invoked
  while the instruction may never have been accepted
- tests: indeterminate keeps reserved while thread unreadable; accepted
  reconciliation consumes; rejected path restores

* fix(cursor): abort drops soft-steer waiters so the next prompt never blocks

- Ordinary Abort (shouldExit false) now clears softSteerWaiters: the
  prompt finally cannot wait forever on a soft steer whose completion is
  unbounded; the ACP cancel rejects in-flight requests, and cleanup()
  settles leftovers on session end
- regression test: unresolved soft-steer completion after Abort no longer
  blocks the next prompt

* fix(codex): accept all thread item shapes; retry reconcile; ack through abort

- Reconcile matcher accepts userMessage/user_message with clientId/
  client_id, matching the shapes the thread parser supports — an accepted
  steer can no longer be misclassified as rejected
- A pending reconciliation schedules a wakeLoop retry, so a temporary
  app-server outage cannot strand the reservation behind waitForTurnOrRecovery
- The success-path ACK no longer checks the steer epoch: the hub already
  reported steered on dispatch, so commit + messages-consumed must reach
  it even when an abort resets the queue in between

* fix(cursor,acp): abort force-settles soft-steer bookkeeping

- AcpSdkBackend.abortSoftSteers() drops the concurrent-prompt counter and
  notifies response-complete so the next turn's waitForResponseComplete()
  cannot block on a soft steer that will never settle after abort
- handleAbort calls it before clearing the waiters; the main prompt's own
  finishPromptRequest stays guarded by Math.max(0, ...)
- unit tests cover counter release and no-op when idle

* fix(codex): reinit reconnected app-server; keep reconcile retries alive

- thread/read after a disconnect auto-connects a fresh app-server, which
  must be initialized before any request — reconcile now ensures
  connect + initialize (isConnected getter added to the client)
- every still-unknown loop-top reconciliation schedules the next retry,
  so recovery without external traffic is eventually observed
- launcher mock gains isConnected

* test(acp): match finishPromptRequest epoch signature in whitebox test

* fix(codex): timer-driven reconciliation; init tracking; abort-safe ACK

- Reconciliation runs on a self-rescheduling 1s timer independent of the
  main loop (wakes it too), so idle loops and waitForTurnOrRecovery still
  observe app-server recovery; abort clears nothing implicitly — the ACK
  path commits and consumes even when the reservation was cancelled
- Absence of a durable client id is ambiguous: unmatched reads stay
  'unknown' and keep retrying instead of restoring the row
- CodexAppServerClient tracks initialized state (reset on disconnect/exit)
  so ensureAppServerInitialized re-initializes a fresh process before
  thread/read; initialize failures leave the flag false for the next retry
- tests: accepted reconciliation via scheduled timer, indeterminate
  keeps reserved, explicit rejection restores

* fix(codex): bind reconciliation to the launcher lifecycle

- runSteerReconciliation clears any armed retry timer on entry and never
  installs a second one, so loop-top and timer-driven passes cannot
  multiply
- shuttingDown is set when the main loop ends: timers are cleared and the
  pending map is dropped, so an unresolved steer can never respawn an
  app-server after cleanup (remote-to-local switch included)

* fix(cursor): abort releases an in-progress soft-steer wait

- The prompt-finally wait races Promise.allSettled against the abort
  signal: an Abort that clears the waiters now also releases a wait that
  already started, so the launcher always reaches the next queued prompt

* fix(codex): report steered only after app-server acceptance

- The handler now awaits steer.completed (the inject-acceptance response):
  an explicit JSON-RPC rejection surfaces as failed and restores the row
  for the normal turn/start path instead of a false steered
- Transport failure after dispatch reports 'Steer outcome is being
  reconciled' and keeps the row reserved while the timer-driven thread
  reconciliation runs
- dispatch-failure path also swallows the paired completion rejection

* fix(cursor,acp): commit on ACP acceptance; distinguish transport failures

- AcpStdioTransport marks transport-level failures (timeout, closed,
  stdin write) as indeterminate; explicit JSON-RPC error responses are not
- The steer handler commits + consumes on completion (ACP acceptance) and
  restores the row on an explicit rejection; an indeterminate transport
  failure keeps the row reserved so a delivered instruction is never
  re-sent, and the ACK reaches the hub even when abort reset the queue
- launcher/transport tests updated for the three outcomes

* fix(steer): tri-state cancel, clear-safe reservations, bounded acceptance wait

- MessageQueue2.cancelByLocalId returns 'in-flight' for a dispatching
  steer reservation: the hub neither deletes the row nor stamps invoked_at
  (new CancelMessageResponse 'busy' status; web restores the optimistic
  row); pushIsolateAndClear and reset/close share cancelReservations so
  /clear-style commands cannot have a rejected steer resurrect a discarded
  prompt
- turn/steer acceptance wait bounded at 25s (< hub 30s RPC timeout): a
  lost response is indeterminate and funnels into thread reconciliation
  instead of stranding the reservation
- tests updated for the tri-state cancel contract

* test(cursor): match tri-state cancel contract for dispatching steers

* fix(cursor): drop duplicate promptInFlight declaration after upstream merge

* fix(codex,web): busy-aware edit flow; bound reconciliation reads

- QueuedMessagesBar edit flow treats a 'busy' cancel as unsuccessful: it
  never prefills the composer when the row is inside an async steer, so a
  second client cannot send a duplicate
- reconcileSteerByClientId bounds thread/read with a 5s timeout so a
  connected-but-silent app-server cannot hold the reservation in-flight
  indefinitely

* fix(steer): inFlight-dominated cancel acks; bounded reconciliation

- hub cancel-queued-message acks check inFlight before removed: a stale
  duplicate socket reporting removed can no longer delete the durable row
  while another socket is dispatching the steer
- reconciliation entries expire after 60s and mark delivered: after the
  rejection window, a dispatched steer that the app-server never proved
  (client ids dropped on restart) is committed instead of polling
  thread/read forever
- pre-dispatch failures (abort before write included) never enter
  reconciliation — they restore the row and report failure

* fix(cursor,acp,web): indeterminate close marks, steer gating precision

- AcpStdioTransport.rejectAllPending marks close/protocol failures
  indeterminate, so an accepted-but-close-interrupted soft steer restores
  nothing (no duplicate delivery)
- the abort race in the soft-steer wait removes its listener in finally
  (no accumulation across repeated waits)
- SessionChat gates the Steer button on agentState.steeringActive for
  codex/cursor instead of the queued-grace thinking flag, so Steer is not
  exposed before the launcher can accept it
- codex pre-dispatch abort never enters reconciliation (merged from #1606)

* fix(steer): persist indeterminate outcomes without replay

* fix(cursor): hold ambiguous steers for explicit resolution

* fix(steer): make ambiguous delivery restart-safe

* fix(cursor): make ambiguous delivery restart-safe

* fix(steer): recover crash-held rows and preserve retry dedup

* fix(steer): ack retries and bound stdin dispatch

* fix(cursor): reject steers when prompt generation changes

* fix(steer): reconcile indeterminate dispatches and serialize retries

* fix(cursor): preserve soft-steer reservations across abort

* fix(codex): classify stdin callback failures as indeterminate

* fix(steer): recheck indeterminate cancels after ACK

* fix(steer): close retry and abort races

* fix(cursor): hold ambiguous dispatch failures

* fix(steer): serialize live retries and abort admission

* fix(steer): distinguish live dispatching from unknown

* fix(cursor): bound ACP dispatch acknowledgements

* fix(steer): keep ACK failures held and reconcile busy cancel

* fix(cursor): preserve state when dispatch ACK is uncertain

* fix(steer): distinguish held cancel from removal

* fix(store): combine schema v24 migrations

* fix(cursor): distinguish held cancel from removal

* fix(store): reserve schema v25 for steer delivery state

* fix(cursor): suppress late ACP updates after abort

* fix(steer): keep held cancel state and notify requeue

* fix(cursor): isolate late updates after abort

* fix(steer): release explicitly cancelled unknown reservations

* test(cursor): cover explicit held cancellation

* fix(codex): reject cancelled reservations before native steer

* fix(cursor): reject cancelled reservations before ACP steer

* fix(codex): make reservation restore atomic with state

* fix(cursor): make reservation restore atomic with state

* fix(codex): terminate abandoned transport writes

* fix(cursor): hard-stop abandoned writes and update native queue state

* fix(steer): own abandoned app-server lifecycle and consume races

* fix(cursor): isolate aborts and add native retry resolution

* fix(codex): confirm dispatch and recover abandoned turns

* test(codex): mock abandoned transport callback

* fix(native): reconcile retry responses

* fix(codex): clear visible turn state on transport loss

* fix(steer): claim retries and cover native delivery state

* fix(native): resync busy cancel outcomes

* fix(native): preserve indeterminate state on Android hydration

* fix(steer): make retry claims single-winner

* fix(cursor): hold restore failures for explicit resolution

* fix(steer): serialize concurrent retry claims

* fix(socket): tolerate missing steer-state ACK callbacks

* fix(native): serialize retry operations

* docs(web): document unknown steer delivery and retry controls

* fix(steer): handle retry failures and abort-before-connect

* fix(cursor): drain foreground prompt after soft-steer abort

* fix(steer): reinitialize after transport loss and finish iOS retry errors

* fix(steer): preserve indeterminate rows across reconnect gaps

* test(web): mock indeterminate queued recovery state

* fix(steer): recover consumed ACK tombstones

* fix(steer): expose consumed cancel tombstones

* fix(cursor): drain soft steers before handler replacement

* fix(cursor): preserve buffered output on abort
2026-08-20 08:53:41 +08:00
weishu 1f5602ad6a Release version 0.29.0 2026-08-19 21:34:51 +08:00