Commit Graph
63 Commits
Author SHA1 Message Date
5ff897a8bf feat: customizable push notification copy (web push)
Server-level title/body templates with {variable} placeholders for web
push notifications, configured via settings.json `notificationCopy` and
an admin-only editing section on the Notifications settings page with
live preview and variable chips. Empty templates fall back to the
hardcoded defaults; the channel also now delivers session-completion
pushes, which the existing preference toggle previously had no web push
effect for.

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

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 11:16:08 +08:00
dd1cd24a46 feat: per-user notification preference toggles
Add per-namespace notification preferences so users can choose which
event types trigger push notifications (permission requests, session
ready, task notifications, session completion). Adds the
notification_preferences table (schema v20), GET/PUT
/api/notification-preferences, POST /api/push/test for test pushes, and
a Notifications settings page in the web app with a confirm dialog for
disabling permission requests. Defaults keep all event types enabled.

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

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 11:16:08 +08:00
KorenKritaandGitHub 021b5c194b feat(pi): complete RPC parity, native steer, and history controls (#1353)
* feat(pi): complete RPC interaction parity

* feat(pi): integrate native conversation history

* fix(pi): harden RPC lifecycle boundaries

* fix(pi): address review lifecycle and upload boundaries

* fix(pi): release history transaction on rollback deadline

* fix(pi): isolate preflight and timed-out mutations

* fix(pi): preserve retry and editor boundaries

* fix(pi): disable unavailable history synchronization

* fix(pi): gate fallback readiness on history baseline

* fix(pi): bind uploads and retire extension requests

* fix(pi): preserve canceled and legacy stream boundaries

* fix(pi): preserve native fork runtime state

* fix(pi): persist dialogs and preserve select values

* fix(pi): keep upload authorization path-stable

* feat(pi): preserve native steer semantics

Route ordinary sends during an active Pi main turn through native steer while keeping explicit queue delivery on the existing composer gestures. Persist the delivery contract across Hub replay and Web retries, and guard stale steer dispatch with streaming generations and ordered prompt fallback.

* fix(pi): queue deferred steer deliveries

Keep native steer only for the initial live emit. Reconnect replay, CLI backfill, clear-gate release, and mature delivery now downgrade turn-scoped steer intent to the durable HAPI queue without mutating stored provenance.

* fix(pi): retain abort guard through preflight miss

Treat an immediate no-active abort rejection as a possible async-preflight race. Keep the existing abort boundary alive so a late agent_start receives the compensating abort before queued work is released.

* fix(pi): queue stale steer retries

A failed send no longer reuses turn-scoped steer intent after its original Pi generation is lost. Text restoration, attachment retry, and legacy retry provenance all enter the durable HAPI queue while fresh ordinary sends retain native steer behavior.

* fix(pi): invalidate rejected abort generation

After a no-active preflight abort waits through late-start compensation, mark the target stream idle while the runtime mutation lease is still held. Waiting native steers therefore fall back instead of entering the aborted generation.

* fix(pi): queue idempotent steer retries

Track whether a localId insert created a new row. Initial inserts may retain live Pi steer, while duplicate-localId retries deliver a queue-safe view of the stored row without overwriting its original provenance.

* fix(pi): sync command-only history before fallback

Read the Pi append log before retiring a successful prompt that produced no agent lifecycle. Preserve FIFO history associations across missing entry events, and fail the wrapper closed if that mandatory synchronization cannot be completed.
2026-08-04 11:01:00 +08:00
Junmo KimandGitHub e35c06b36a feat(agy): add Antigravity as an interactive PTY agent (#1320) 2026-08-04 10:50:03 +08:00
f10fbc7496 feat(cli): add GitHub Copilot CLI agent support via ACP (#1245)
* feat(cli): add GitHub Copilot CLI agent support via ACP

Wrap `copilot --acp --stdio` for remote sessions and spawn the native TUI locally, with full hub/web integration for spawn, resume, and permissions.

Fixes tiann/hapi#362

Co-Authored-By: HAPI <noreply@hapi.run>
Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(copilot): agent modes, models, slash/file UX, local session sync

Add Interactive/Plan/Autopilot (fleet is slash-only), subscription-aware
model discovery, web StatusBar/permission UX, @ file mentions, and fix
local Safe Yolo plus session-id locator for handoff/resume.

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

* chore: re-trigger Codex PR review after auth outage

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

* chore: retry Codex PR review

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

* fix(copilot): preserve agent mode on resume and apply via ACP set_mode

Resume was dropping copilotAgentMode so Plan/Autopilot reset to interactive.
Also switch local/remote mode application to --mode / session set_mode instead of slash prompts.

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

* fix(copilot): wake remote loop when agent mode changes

Empty isolated queue tick lets setMode apply without inventing a user prompt.

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

* fix(copilot): confirm mode changes before persisting

Await Copilot mode changes and expose discovered models so session state reflects backend acceptance.

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

* fix(copilot): guard mode discovery and slash updates

Keep model probes within runner roots and preserve active sessions when mode switching is unavailable or rejected.

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

* fix(copilot): preserve resume and auto semantics

Deduplicate Copilot resume rows, apply Auto explicitly, and fail closed on denied permissions.

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

* fix(copilot): close permission and model discovery gaps

Keep write-capable commands pending in read-only mode, extend model probe RPCs, and preserve explicit model validation before session creation.

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

* fix(copilot): persist runtime model and agent mode

Fallback to ACP model options when direct model switching is unavailable and retain Copilot agent mode across hub restarts.

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

* fix(copilot): normalize composer auto selection

Use the null session sentinel for Copilot Auto so the composer selects and resets default models consistently.

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

* fix(copilot): reject local permission mode changes

* style(copilot): remove trailing blank line

* fix(copilot): secure local config handoffs

* fix(copilot): reject local agent mode slashes

* fix(copilot): reject mode changes during turns

* fix(copilot): consume rejected slash updates

* fix(copilot): preserve thinking across slash handling

* fix(copilot): stabilize async config changes

* fix(copilot): roll back rejected startup model

* fix(copilot): preserve cancellation and file mentions

* fix(copilot): hide local permission controls

* fix(deps): support clean workspace installs

* test(copilot): account for spawn mode argument

* fix(copilot): attribute usage to active model

---------

Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 08:19:12 +08:00
3c3bffdfbd feat: message-level conversation fork and rewind (#1263)
* feat: add message-level conversation fork and rewind

Expose native Codex/Grok/Claude history controls through hub REST+RPC and web ConfirmDialog actions, without file rewind or composed forks. Also reconcile the duplicate hub V14→V15 migration so typecheck can pass.

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

* fix: hydrate fork transcript and consume Claude --fork-session

Forked HAPI children now copy the source transcript prefix so navigation is not a blank thread, and Claude drops --fork-session after the first launch so relaunches do not branch again.

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

* fix: harden fork/rewind concurrency and durable history points

Skip pending scheduled rows when hydrating fork transcripts, serialize fork/rewind per session, and persist conversation history points/indexes across existing-session bootstrap and Grok relaunches.

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

* fix: close remaining fork/rewind races and UI anchoring

Block sends and scheduled maturation while history actions run, order fork prefixes by invocation time, inherit history locators into children, and only offer Fork current on the live tail boundary.

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

* fix: address remaining fork/rewind bot findings

Materialize Claude --fork-session before the first child prompt, validate
HAPI history boundaries before native RPC, expose forkCurrent on a latest
user boundary, fully demote unsupported conversationHistory capabilities,
and fix the truncate test setup order.

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

* fix: align fork-current ids and Claude fork bootstrap

Compare the latest fork boundary in assistant-ui threadMessageId space,
spawn Claude forks with the persisted session mode, and preserve
forkedFrom across existing-session bootstrap.

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

* fix: close fork/rewind consistency holes at the contract layer

Hold the source history lock until Claude child binds a distinct native
id, persist Codex localId→turnId locators, and mark/block diverged
sessions when native rewind outruns HAPI truncate.

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

* fix: use Codex stable lastTurnId for historical fork

Map HAPI's exclusive boundary to the previous turn's inclusive
lastTurnId so native fork context matches the hydrated transcript.

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

* fix: require exact Grok native resume for fork children

Reject newSession fallback when forkedFrom is set, and keep the hub
history lock until the child binds the forked grokSessionId.

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

* fix: kill active fork children before failed-fork cleanup

Bind/readiness failures can leave the child process running; deleteSession
rejects active rows, so terminate first then remove the HAPI session.

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

* fix: close remaining fork lock, hydrate, and todos gaps

Reject mode switches during history actions, batch-copy fork
transcripts in one SQLite transaction, and rebuild todos after
fork hydrate / rewind truncate.

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

* fix: allow Codex historical fork before the first turn

Use experimental beforeTurnId when there is no previous turn for the
stable inclusive lastTurnId boundary.

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

* fix: mark Grok history busy immediately after dequeue

Hub idle checks clear once messages-consumed fires; hold the busy flag
across permission sync and rewind-points lookup before prompt starts.

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

* fix: encode copied conversation history content

* fix(web): hide local conversation history actions

* style(codex): remove trailing whitespace

* fix(fork): preserve children when cleanup is unconfirmed

* fix(history): confirm cleanup and guard rewind divergence

* fix(history): probe capabilities before advertising

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 09:28:01 +08:00
07db10f86d fix(web): expose Codex Fast and Plan on Create Session (#1017)
* fix(web): expose Codex Fast and Plan on Create Session

Wire serviceTier and collaborationMode through spawn so Create can set
the same Codex options chat Settings already supports (#1015).

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

* fix(cli): forward collaborationMode through machine spawn RPC

Create Session Plan was accepted by the hub but dropped in apiMachine
before buildCliArgs; also preserve collaborationMode on resume spawn.

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

* fix(cli): correct stopSession mock type in spawn RPC test

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

* fix(web): keep Fast mode across Create draft restore while models load

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

* fix(web): preserve pending Fast selection

* fix: apply Fast and Plan to imported Codex sessions

* test: narrow imported Codex session id

* fix: forward explicit Standard service tier

* fix: integrate create-session controls with current main

* test: close Codex RPC suite

* fix: preserve existing session spawn field

* fix(web): integrate Codex controls with current New Session form

* fix(web): reconcile draft types and submit state

* fix(hub): integrate spawn arguments with current resume flow

* test(cli): isolate spawn RPC suite

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:13:29 +08:00
AnanovoandGitHub df36cec01e feat(web): sort file search results (#1109) 2026-07-24 10:58:07 +08:00
64834467e3 feat(codex): import and resume sessions from runners (#1088)
* fix codex import resume flow

* fix hub restart session active state

* fix codex transcript workspace scoping

* Address Codex import review findings

* Fix Codex import machine selection

* Update Codex sessions error test

* Address Codex import review findings

* Preserve forked Codex session id on sync

* Make Codex duplicate cleanup source-aware

* Handle Codex archive failures

* Limit existing session flag to Codex

* Preserve Codex import machine binding

* fix: rebase runner Codex import onto current main

* fix: preserve runner-scoped Codex import behavior

---------

Co-authored-by: syy <815728149@qq.com>
2026-07-19 14:14:42 +08:00
SSU-WEI HUANGandGitHub 520c3f511a fix: verify Cursor chat store before reopen (#1037)
* test: reproduce issue #841

* test: cover Cursor chat store discovery

* fix: verify Cursor chat store before resume (closes #841)

* test: preserve non-Cursor resume behavior

* test: cover conservative Cursor resume gating

* fix: gate Cursor reopen until store verification

* test: cover legacy Cursor drawer fallback

* fix: scan unique legacy Cursor store drawer

* test: preserve raw Cursor workspace path hashing

* fix: hash raw Cursor workspace path

* test: pin Cursor probe owner and machine

* fix: probe Cursor store on recorded owner

* test: normalize Cursor probe owner home

* fix: normalize Cursor probe owner home
2026-07-16 12:34:41 +08:00
SSU-WEI HUANGandGitHub b9eed7c071 feat: add Grok Build support (#1030)
* test: define Grok Build integration behavior

* feat: add Grok Build agent integration

* test: cover Grok permissions and resume paths

* docs: add Grok Build setup guide

* fix: scope Grok ACP discovery to session cwd

* fix: align Grok permission UI semantics

* docs: clarify Grok runner setup

* test: require Grok create model and effort options

* feat: add Grok create model and effort pickers

* test: define Grok runtime parity behavior

* feat: add Grok runtime ACP controls and discovery

* fix: tighten Grok runtime controls

* fix: suppress nonfatal Grok title quota errors

* feat: support Grok Auto permission mode

* feat: forward ACP native session titles for Grok

* fix: guard Grok Windows shell arguments
2026-07-13 08:41:30 +08:00
26a24bb6ce feat(web,hub,cli): show machine health in session sidebar (#962)
* feat(web,hub,cli): show machine load in session sidebar

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: add machine health sidebar screenshots

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

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

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

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

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

* chore: drop dogfood screenshots from upstream PR diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 11:41:53 +08:00
ce67823fc3 feat(web,hub): rich hover tooltips on session-list attention indicators (#941)
* feat(web): rich hover tooltips on session-list attention indicators

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 10:15:22 +08:00
e23ae1b265 feat: add Pi Coding Agent support (#862)
* docs: spec for hapi-pi-agent-backend

* docs: spec retrospect for hapi-pi-agent-backend

* docs: plan for hapi-pi-agent-backend

* docs: plan retrospect for hapi-pi-agent-backend

* feat(pi): add hapi pi command with JSONL transport and event converter

- PiTransport: spawn pi --mode rpc, JSONL stdio, ENOENT/EPIPE handling
- PiEventConverter: Pi AgentEvent → HAPI AgentMessage conversion
- runPi: session lifecycle, dual-track event routing, model switching
- pi command: CLI registration with PI_PERMISSION_MODES
- Shared: add 'pi' to AGENT_FLAVORS, FLAVOR_CAPS, FLAVOR_LABELS

30 tests passing (15 transport + 15 converter)

* fix(pi): add Pi RPC types, fix double-cleanup/double-start/converter safety net

- Add cli/src/pi/types.ts with PiAgentEvent/PiResponseEvent discriminated unions
- PiTransport: constructor uses options object, double-start guard, drop log
- PiEventConverter: typed events via type assertions, top-level try/catch
- runPi: safeCleanup guard prevents double-cleanup race, sendAgentMessage
  for converted events, keepAlive() for session pings
- 33 tests passing

* docs: dev phase reviews and test results for hapi-pi-agent-backend

- Business logic review: pass (0 must_fix)
- Standards review: pass (0 must_fix)
- Taste review: P0 types issue fixed in code
- Robustness review v2: pass (v1 3 MUST_FIX all fixed)
- Integration review: pass (0 must_fix)
- Test results: 33 passing, all type errors resolved

* docs: taste review v2 pass after type definition fixes

* docs: dev retrospect for hapi-pi-agent-backend

* test: test execution for hapi-pi-agent-backend (20/20 pass)

* fix: add taste_review symlink for gate pattern match

* docs: test retrospect for hapi-pi-agent-backend

* fix(web): add pi to MODEL_OPTIONS Record type

* ci: PR and CI evidence for hapi-pi-agent-backend

* docs: overall retrospect for hapi-pi-agent-backend (all 5 phases)

* test(pi): add buffer split, missing fields, and handleResponse tests

- PiTransport: buffer cross-chunk reassembly test
- PiEventConverter: tool_execution_end with missing result/toolCallId
- handleResponse: 10 tests covering all branches (error, get_state,
  set_model, new_session, abort, prompt, unknown command)
- Extract handleResponse to accept onUpdate callback for testability
- Total: 46 tests passing (was 33)

* fix(pi): set requiresRuntimeAssets to false — pi runs as subprocess, no native tools needed

* refactor(cli): lazy import ensureRuntimeAssets to reduce startup overhead

* docs: add 15 manual E2E protocol test cases (TC-4-xx) based on real Pi RPC capture

- TC-4-01 to TC-4-15: manual tests covering tool execution, thinking
  lifecycle, multi-turn, abort, error scenarios, model switch, cleanup
- Priority: P0 (tool fields, failure, thinking, multi-turn, abort)
  > P1 (basic conversation, write tool, model switch, usage) > P2 (edge cases)
- Includes actual Pi RPC event sequence from live capture as reference
- e2e-test-plan.md updated with test environment setup instructions
- Total test cases: 35 (6 unit + 14 integration + 15 manual)

* test: E2E protocol test results for hapi-pi (11/15 pass)

P0/P1 automated tests (8/8 pass):
- TC-4-01: Basic text conversation ✓
- TC-4-02: Tool read (field names verified) ✓
- TC-4-03: Tool write (file created) ✓
- TC-4-04: Tool failure (isError=true) ✓
- TC-4-05: Thinking lifecycle + usage ✓
- TC-4-06: Multi-turn context retention ✓
- TC-4-07: Abort generation ✓
- TC-4-14: Token count ✓
- TC-4-15: Extension UI events ignored ✓

P2 results:
- TC-4-10: Invalid token → 401 ✓
- TC-4-12: Ctrl+C cleanup, no orphans ✓
- TC-4-08: ENOENT (harness issue, exit code correct)
- TC-4-11: set_model not supported by Pi (success=false)
- TC-4-13: Pi crash (harness output capture issue)

* test: fix TC-4-11 result — Pi set_model works with correct provider/modelId

Previous test used invalid provider='' + modelId='deepseek-chat'.
Re-tested with provider='deepseek' + modelId='deepseek-v4-flash':
- set_model success=true
- model switched glm-5.1 → deepseek-v4-flash
- subsequent prompt confirmed working

Final E2E results: 12/15 PASS, 2 FAIL (test harness), 1 SKIP

* chore: remove .xyz-harness/ from git tracking, add to .gitignore

Local harness workflow artifacts should not be tracked in the repo.

* fix(pi): resolve web UI bugs for hapi-pi integration

Five bugs fixed for end-to-end pi session via hapi web UI:

1. runner buildCliArgs: add 'pi' branch to spawn correct command
   (was falling back to 'claude', launching wrong agent)
2. runPi: implement real keep-alive (2s interval) to prevent hub
   30s timeout marking session inactive
3. runPi: bump keep-alive to active state during agent/turn_start
4. sessionResume: add 'pi' to flavor switch and resume condition
   (was returning undefined, causing 'cannotResume' on inactive session)
5. PiEventConverter: emit codex-compatible {type:'message',message:...}
   /{type:'reasoning',message:...} with streamId; dedup by skipping
   text_start/text_end (only send deltas) to avoid triple-rendered text
6. PiTransport: fallback to stdout 'end' event when child process
   close event doesn't fire (bun spawn quirk)

Verified end-to-end: web UI shows pi reasoning + reply correctly,
session stays online, no duplicate text.

* fix(pi): address 4 web UI display bugs in hapi-pi integration

Three of four follow-up bugs reported after the initial fix (6c28949):

1. Stuck in 'queued' status — fix
   Pi's runner doesn't use MessageQueue2, so the base session's
   onBatchConsumed hook never fires. Add a FIFO of pending localIds
   in runPi and emit messages-consumed on agent_start. turn_start
   is intentionally skipped (it can fire multiple times per agent
   run after tool calls). A prompt rejection from Pi also consumes
   the localId so the next prompt isn't poisoned.

2. AI thinking only displays ':' — fix
   Pi emits pure incremental deltas (text_delta / thinking_delta)
   per token. The web reducer dedupes reasoning by streamId WITHIN
   one message's content array only — separate wire messages
   produce separate renders. Without accumulation, 50 deltas = 50
   reasoning renders, of which the reducer keeps only the last
   delta (a single character like ':').

3. Output text on separate lines — fix
   Same root cause as #2 but for text: the reducer appends each
   text AgentMessage as a new agent-text block (no dedup), so 50
   deltas become a 50-row character-by-character column.

4. Tool call execution status (in_progress -> completed)
   The tool result wire CodexMessage type is 'tool-call-result'
   (with callId + is_error?); the internal AgentMessage 'tool_result'
   is converted to that. Status mapping is preserved.

Implementation: extract a PiMessageAccumulator class (testable in
isolation) that mirrors codex's ReasoningProcessor pattern:
- message_start resets state and streamId
- text_delta / thinking_delta append to internal text / reasoning
- text_start/thinking_start/text_end/thinking_end ignored (they
  carry full partial state — would duplicate)
- message_end flushes (max 1 reasoning + 1 text message, in order)
- turn_end safety net flushes if active
- flushIfActive() exposed for transport close / crash

The converter now routes AgentMessage through convertAgentMessage
so the wire format is codex-shaped (matches opencode/gemini/kimi
path). AgentMessage 'text' and CodexMessage 'message' both gain
optional id; convertAgentMessage preserves caller-provided id for
streamId-based dedup on the web side.

Tests: 16 new PiMessageAccumulator tests + 5 updated
PiEventConverter tests + 4 messageConverter tests, all passing.
Full suite: 909/910 (1 unrelated macOS path normalization). tsc
clean.

* fix(pi): review round 1 - 1 must-fix issue

The web session-resume helper referenced metadata.piSessionId, but the
shared MetadataSchema does not define the field, and the back-end has no
path to populate it (Pi session resume is out of scope per spec.md).
This caused web typecheck to fail and would also have produced a
runtime 'resume_unavailable' from the hub if a user tried to resume a Pi
session that had any user messages (the stale 'flavor === pi' branch in
inactiveSessionCanResume claimed resume was supported).

Revert the two early Pi branches from the web resume helper. Add a
comment pointing at the spec and noting what to undo when back-end
resume ships (re-add 'case pi' + 'piSessionId' on MetadataSchema +
extend hub resolveAgentResumeId).

* fix(pi): review round 2 - 4 must-fix issues

1. cli/src/runner/run.ts buildCliArgs: stop forwarding --resume to the pi
   binary. Pi session resume is out of scope (no piSessionId on
   Metadata), so forwarding would create an orphan session the hub can't
   track. Hub already returns null from resolveAgentResumeId for
   flavor='pi' and falls through to fresh spawn; this just hardens the
   runner layer to match.

2. cli/src/pi/runPi.ts: cache currentProvider from get_state and use it
   for subsequent set_model RPCs. Pi's set_model requires both provider
   and modelId, but the bootstrap-time code emitted provider: '' which
   Pi rejects. The bootstrap-time model is still applied by Pi at
   startup, so suppressing set_model until get_state arrives is a no-op
   for same-model configs rather than a wrong-model emit.

3. web/src/components/AssistantChat/modelOptions.ts: add explicit pi
   branches to getModelOptionsForFlavor and getNextModelForFlavor.
   Without them, Pi sessions fell through to the Claude preset cycler,
   which would push sonnet/opus ids into a Pi session via
   set-session-config. Mirrors the opencode handling introduced earlier.

Tests added/updated: buildCliArgs covers pi + claude resume; handleResponse
mirror test covers provider caching; modelOptions tests cover pi
no-fallback behavior for both option list and cycler.

* fix(pi): add session resume support and fix review issues

- Add piSessionId to MetadataSchema (shared/src/schemas.ts)
- Persist piSessionId from get_state response to metadata (cli/src/pi/runPi.ts)
- Pass --session-id to Pi spawn on resume (cli/src/pi/runPi.ts)
- Add pi branch to resolveAgentResumeId (hub/src/sync/syncEngine.ts)
- Add case 'pi' to resolveAgentSessionIdFromMetadata (web/src/lib/sessionResume.ts)
- Replace pi resume skip guard with --session-id forwarding (cli/src/runner/run.ts)
- Preserve piSessionId in pickExistingSessionMetadata (cli/src/agent/sessionFactory.ts)
- Add pi badge to AgentFlavorIcon (web/src/components/AgentFlavorIcon.tsx)
- Fix transport.onClose crash-marking on normal shutdown (cli/src/pi/runPi.ts)

* fix(pi): review round 1 - 3 must-fix issues

- resume.ts: add pi branch to dispatchLocalResume() so hapi resume
  dispatches to runPi instead of falling through to cursor
- runPi.ts: accept existingSessionId and use bootstrapExistingSession
  when resuming, matching other agents' pattern
- agentCommandOptions.ts: parse --session-id in addition to --resume
  so runner-spawned pi resume actually forwards the session ID
- types.ts: export PiPermissionMode alongside other agent permission
  mode types for consistent import convention

* fix(pi): review round 2 - 2 must-fix issues

* refactor(workflow): improve pi-adaptation-review-loop robustness

- Switch from structured output to file-based JSON output for reliability
- Replace per-round file limit (20→30) with clear wording (remove misleading split-commits instruction)
- Return { data, error } from readResultFile() to surface parse/validation failures in abortReason
- Fix lastMustFix sentinel: initialize to null, use ?? for explicit N/A reporting
- Add getAgentDirs() to dynamically discover agent dirs from cli/src/
- Document rollbackTo() atomic-round design intent
- Add isValidIssue() validation, runFinalCleanup() helper, git repo pre-check

* test(pi): add coverage for pi flavor across shared, cli, and web

- shared/flavors.test.ts: pi/kimi capability, label, known, supports
- shared/modes.test.ts: PI_PERMISSION_MODES contract, per-mode checks
  (7-mode allowed/denied matrix)
- web/AssistantChat/modelOptions.test.ts: pi shortcut vs Claude
  cycler, normalize filter (auto/default/whitespace), kimi/cursor/
  opencode cross-flavor consistency
- web/lib/sessionResume.test.ts: piSessionId resolver, cross-flavor
  stale-id protection, inactiveSessionCanResume for pi, regression
  coverage for all 6 other flavors
- web/components/AgentFlavorIcon.test.tsx: pi badge styling
  (bg-[#5b21b6]), Un fallback, case/whitespace normalize,
  className override
- cli/commands/agentCommandOptions.test.ts: --session-id
  (pi-specific flag), --resume alias, PI mode validation,
  --yolo vs explicit-mode priority

137 new test cases, all passing. Full suite: 96 files / 933 tests
green (unrelated apiMachine.test.ts macOS /private/var path issue
remains as documented in handoff).

* feat(pi): implement P0 — context budget bar + dynamic model discovery

P0-1: Context Budget Bar
- Add pi branch to modelConfig.ts getContextBudgetTokens()
- Conservative 200K default context window for Pi sessions

P0-2: CLI-side model discovery
- Add get_available_models to PiRpcCommand type
- Auto-send get_available_models after get_state in runPi.ts
- Cache model list and push to session metadata
- Register ListPiModels RPC handler with promise-based transport query

P0-3: Hub-side routing
- Add listPiModelsForSession to rpcGateway and syncEngine
- Add REST endpoint GET /sessions/:id/pi-models (pi sessions only)

P0-4: Web-side rendering
- Add PiModelSummary type to shared apiTypes
- Add usePiModels hook (TanStack Query, stale 60s)
- Add getSessionPiModels to API client
- Add sessionPiModels query key
- Wire piModelOptions into SessionChat availableModelOptions
- Model dropdown renders discovered models or falls back to Default

* fix(pi): address code review findings + pre-existing test issue

Review fixes:
- Fix race condition in sendPiRpcAndWait: use incremental id as key
  instead of command type, preventing resolver overwrite on concurrent
  calls (e.g. auto-discovery + ListPiModels RPC)
- Extract parsePiModels() to eliminate duplicated model parsing logic
  between handleResponse and ListPiModels RPC handler (DRY)
- Add resolvePendingRpc() call in error response path to prevent
  promise leaks when Pi rejects an RPC with an id
- Add piModelsState.error guard to onModelChange in SessionChat,
  matching the pattern used by codex and cursor flavors

Pre-existing fix:
- Fix apiMachine.test.ts symlink assertion on macOS (/var vs
  /private/var) by applying realpathSync to the expected path

* feat(pi): P1 — session rename sync, thinking level UI, skills/commands

P1-1: Session Rename → Pi notification
- Add set_session_name to PiRpcCommand
- Register RenamePiSession RPC handler in CLI
- Hub syncEngine.renameSession now forwards to Pi CLI for active sessions
- Hub rpcGateway + REST endpoint added

P1-2: Thinking Level support
- Add Pi thinking level constants to shared/src/piThinkingLevel.ts
  (off/minimal/low/medium/high/xhigh)
- Add ThinkingLevel capability to Pi flavor in flavors.ts
- sessionConfigRpc now supports effortMode for Pi thinking level
- runPi captures thinkingLevel from get_state and forwards via
  set_thinking_level
- Hub effort endpoint accepts pi sessions (was claude-only)
- Web: piThinkingLevelOptions.ts + HappyComposer renders Pi options
  when flavor=pi

P1-3: Skills/Commands discovery
- Add get_commands to PiRpcCommand, auto-discover after get_state
- Register ListPiCommands + ListSlashCommands RPC handlers in CLI
  (maps Pi commands to HAPI SlashCommand format)
- Hub: listPiCommandsForSession + REST GET /sessions/:id/pi-commands
- Web: usePiCommands hook + api client + query keys

Also fixes:
- Pre-existing ZodError.errors → ZodError.issues in hub/socket/server.ts
- Updated test expectation for effort endpoint error message

* feat(pi): implement P2 features — steer, queue modes, history, native images

P2-1: Steer/Follow-up
- Track piIsStreaming state from agent_start/turn_start/turn_end/agent_end
- When streaming, onUserMessage sends steer instead of prompt
- Added PiSteer/PiFollowUp RPC methods + hub routing + REST endpoints

P2-2: Queue modes
- Added set_steering_mode/set_follow_up_mode to PiRpcCommand
- CLI RPC handlers with mode state tracking
- Hub routing + REST POST endpoints
- Web API client methods

P2-3: History replay
- Added get_messages to PiRpcCommand
- CLI handler converts Pi AgentMessage to PiMessageEntry format
- Hub RPC routing + REST GET /sessions/:id/pi-messages
- Web usePiMessages hook + query key

P2-4: Native image passing
- Added PiImageContent type for base64 image data
- extractPiImages() helper reads attachment files as base64
- prompt/steer commands now include images field
- Falls back to @path text reference for non-image/unreadable files

* feat(pi): implement P3 advanced features — compact, fork, clone, switch, stats, export

P3 features for Pi agent integration:

- Compact: compact RPC with custom instructions, set_auto_compaction toggle
- Fork: fork at entry ID, get_fork_messages for fork context
- Clone: clone current Pi session
- Switch Session: switch Pi to a different session by path
- Session Stats: get token counts, message counts, cost
- HTML Export: export session as HTML file

All features follow existing P2 pattern:
- CLI: RPC handlers in runPi.ts with sendPiRpcAndWait
- Hub: rpcGateway + syncEngine routing + REST endpoints
- Web: API client methods + query keys + type exports + hooks (stats, fork messages)

Total: 8 new REST endpoints, 9 RPC handlers, 6 web API methods
Typecheck: all 3 packages pass (cli+hub+web)
Tests: 1155 pass (263 hub + 803 web + 89 shared), 0 failures

* refactor(pi): clean up runPi.ts imports and readability

- Replace require('fs') with top-level import { readFileSync } from 'fs'
- Extract handleGetState() as standalone function from handleResponse
  switch case (get_state case: 35 lines → 4 lines dispatch)

Typecheck: all 3 packages pass
Tests: 1066 pass (263 hub + 803 web), 0 failures

* fix(pi): remove native image passing, fix version pollution

- Remove extractPiImages helper and PiImageContent type: all
  attachments now use @path text references via
  formatMessageWithAttachments, consistent with every other agent
- Remove images field from prompt/steer/follow_up RPC commands
- Remove unused readFileSync import
- Restore cli/package.json version from test pollution
  (0.0.0-integration-test-should-be-auto-cleaned-up-51369 → 0.20.0)

Typecheck: all 3 packages pass
Tests: 1286 pass, 0 failures

* refactor(pi): extract hub helper, unify web hooks, fix import style

- Hub: extract withPiSession helper eliminating boilerplate across 15
  Pi REST endpoints (~400 lines → ~150 lines)
- Web: unify usePiForkMessages and usePiSessionStats to return
  destructured typed fields matching usePiModels/usePiCommands pattern
- Web: move 15 Pi response types from inline import() to top-level
  named imports in api/client.ts
- CLI: remove duplicate PiCommandSummary/PiCommandsResponse from
  types.ts, re-export from @hapi/protocol/apiTypes

Typecheck: all 3 packages pass
Tests: 1286 pass, 0 failures

* chore: untrack .agents/skills and .pi, fix .xyz-harness in gitignore

* refactor: remove unused text message id from converter layer, update gitignore

* fix: update tests for pi resume support and text id removal

* fix: restore cursor resume branch in buildCliArgs

* refactor: remove pi-specific rename from syncEngine, align with other agents

* refactor: remove effort field from sessionConfigRpc, Pi self-handles RPC

Pi agent now self-handles SetSessionConfig RPC (like Claude) using
the existing  field, instead of adding a parallel
field to the shared sessionConfigRpc helper which only knows about
.

- Remove effort/effortMode from sessionConfigRpc types and logic
- runPi.ts: self-register RPC handler with PiThinkingLevel validation
- Reuse resolveSessionConfigPermissionMode from sessionConfigRpc

* refactor: consolidate Pi RPC layer from 36 methods to 3 generics

rpcGateway: 12 methods → callPiRpc<T>
syncEngine: 12 passthroughs → callPiRpc<T> delegate
web client: 12 methods → callPiEndpoint<T>
routes: use engine.callPiRpc with RPC_METHODS constants
hooks: use callPiEndpoint, add missing type imports

* chore: revert unrelated apiMachine test change

* refactor: remove unused ThinkingLevel capability from flavors

Pi's thinking level is an effort variant, not a separate capability.
The ThinkingLevel constant and supportsThinkingLevel() had zero callers
— the frontend uses flavor-based branching for effort option rendering.

* refactor: drop Pi prefix from generic RPC method names

* refactor: remove 13 Pi RPC methods with no UI consumers

Steer: already handled by onUserMessage auto-routing
Follow-up: redundant with HAPI message queue
ListPiCommands/GetMessages/ForkMessages/SessionStats: no UI
Compact/SetAutoCompaction/Fork/Clone/SwitchSession/ExportHtml: no UI
SetSteeringMode/SetFollowUpMode: no UI

Kept: ListPiModels (has UI), SetSessionConfig, ListSlashCommands, Abort, Switch
Deleted: 4 web hooks, 13 RPC handlers, 12 REST routes, 13 rpcMethods entries
Net: -730 lines

* refactor: extract session.ts and loop.ts from runPi.ts

Restructure Pi agent following Codex pattern (without Local/Remote
splitting since Pi only has remote mode):

- session.ts: PiSession class managing state + hub communication
- loop.ts: response parsing, RPC resolver, transport event wiring
- runPi.ts: thin entry (bootstrap, RPC handlers, lifecycle)

Changes from review:
- Encapsulate RPC resolver in PiRpcResolver class (session-scoped,
  not module-level singleton)
- Remove unused extractTextFromPiMessage export
- Fix inline import('./types') → top-level import

* refactor: normalize Pi file naming and improve test coverage

- Rename PiTransport.ts → piTransport.ts, PiEventConverter.ts →
  piEventConverter.ts, PiMessageAccumulator.ts → piMessageAccumulator.ts
  (match project-wide camelCase convention)
- Delete handleResponse.test.ts (tested stale copy of inline function)
- Add loop.test.ts with 20 tests covering parsePiModels,
  parsePiCommands, wireTransportEvents integration, and sendPiRpcAndWait
- Total Pi tests: 73 (was 53)

* test: add E2E harness with 4 core helpers and integration specs

Helper functions in e2e/harness.ts capture the four non-obvious
interactions discovered during the 2026-06-09 retest:
- longPress: SessionActionMenu is triggered by 500ms press, not click
- mockOffline: useOnlineStatus hook listens to navigator.onLine +
  window offline event, not CDP Network.emulateNetworkConditions
- pollForText: thinking indicator flickers in <1s, 3s polling misses
- isVisible: element.offsetParent returns null for position:fixed
  dialogs even when visible; use getBoundingClientRect

Plus Chrome lifecycle (startChrome/stopChrome, never pkill chrome)
and hub API helpers (loginWithToken, listSessions).

5 integration specs (e2e/integration/) cover:
- yolo-permission: toggle + localStorage persistence (4 cases)
- codex-dialog: pre-flight check + dialog render (3 cases)
- stress: 10 concurrent + invalid JWT + malformed + unknown
  endpoint (5 cases, all PASS)

All 12 integration cases pass. Full E2E results in
.xzy-harness/2026-06-09-full-e2e-retest/ (67 cases, 0 functional
bugs found).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix: resolve Pi model selection and thinking level issues

- Fix PiModelPanel: use provider+modelId composite for selection check
  and React key, preventing duplicate highlights for same-name models
  across different providers
- Fix PiThinkingLevelPanel: unify thinkingLevelMap filtering logic by
  extracting shared isThinkingLevelSupported utility
- Fix HappyComposer: auto-reset effort to highest supported level when
  switching models, update label to reflect effective level

* refactor: remove 29 dead exports from feat-pi-support

Remove unused types, methods, and re-exports identified by dead code audit:

shared/src/apiTypes.ts (19):
- SessionModelIdentifier, ListPiCommandsResponse
- PiSteeringMode, PiFollowUpMode, PiSteerResponse, PiFollowUpResponse
- PiQueueModeResponse, PiMessageEntry, PiMessagesResponse
- PiCompactResponse, PiSetAutoCompactionResponse
- PiForkResponse, PiForkMessageEntry, PiForkMessagesResponse
- PiCloneResponse, PiSwitchSessionResponse
- PiSessionStats, PiSessionStatsResponse, PiExportHtmlResponse

cli/src/pi/types.ts (6):
- PiSessionStats, PiCompactionResult, PiForkMessageEntry (dead local duplicates)
- PiCommandsResponse, PI_THINKING_LEVELS, PI_THINKING_LEVEL_LABELS (dead re-exports)

cli/src/pi/piMessageAccumulator.ts (1):
- flushIfActive() method (comment claimed runPi calls it, but it doesn't)

cli/src/pi/piTransport.ts (1):
- isRunning() method (never called in production code)

web/ (2):
- ProviderGroup, PiThinkingLevelOption (unnecessary exports, made local)

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

* fix: resolve 7 PR review issues in Pi support

#3 Remove duplicated PI_THINKING_LEVELS in schemas.ts, import from @hapi/protocol
#2 Add piAvailableModels field to MetadataSchema (schema-runtime consistency)
#6 Replace hardcoded flavor names with supportsEffort() in effort route
#1 Move PiRpcResolver from module-level singleton to PiSession instance
#4 Add piCachedModels fallback in piModelOptions useMemo
#7 Merge message_update dead branch into unified not-converted case
#10 Fix misleading Pi model list comments in modelOptions.ts

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

* fix: normalize Pi model object to string in hub sessionCache (#5), remove extra blank line in rpcGateway (#8)

#5: applySessionConfig now extracts modelId from { provider, modelId }
    before passing to setSessionModel / session.model, preventing
    [object Object] from being stored in SQLite when Pi switches models.

#8: Remove double blank line before RpcGateway class declaration.

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

* fix(pi): preserve piAvailableModels on resume, document SetSessionConfig divergence

- sessionFactory: preserve piAvailableModels in pickExistingSessionMetadata
  so web shows cached models on inactive-session view without RPC round-trip
- sessionConfigRpc: extend resolveNullableSessionModel to accept
  {provider, modelId} object form for schema consistency
- runPi: document why Pi manually registers SetSessionConfig instead of
  reusing registerSessionConfigRpc (wire protocol needs separate fields)
- package.json: restore version to 0.20.0

* refactor: remove unused Pi types, extract JsonLineParser, clean up review findings

- Remove 13 unused PiRpcCommand variants and PiStreamingBehavior type (YAGNI)
- Remove unnecessary exports on 3 internal Zod schemas in pi/schemas.ts
- Extract JsonLineParser base class to utils/, shared by PiTransport,
  CodexAppServerClient, and AcpStdioTransport (eliminates 3x duplicate
  handleStdout buffer logic)
- Remove DEV-only duplicate session ID detection from SessionList.tsx
  (debug code unrelated to Pi support scope)
- Add comments explaining key prefix rationale in SessionChat.tsx

* chore: remove unrelated E2E test harness from Pi support PR

E2E harness (codex-dialog, stress, yolo-permission, scratchlist specs)
was introduced in this branch but tests generic HAPI behavior unrelated
to Pi agent support. Should live in a separate PR.

* fix: wrap cursor model change handler for union type compatibility

* fix: apply startup --model to Pi and remove duplicate lockfile entry

1. --model startup bug:
   - Add initialModel to PiSession to preserve startup model
   - handleGetState preserves initialModel instead of overwriting with Pi default
   - get_available_models handler resolves provider from cached models and sends set_model

2. bun.lock duplicate key:
   - Remove duplicate @twsxtd/hapi-win32-x64@0.20.0 entry
   - Fixes CI lockfile regeneration that caused hono type errors

* fix: update test expectation for effort endpoint error message

* fix(pi): resolve 8 link-review defects + abort session termination

- W1C-D-1: hasSameAgentSessionIds missing piSessionId/kimiSessionId
  + extractAgentSessionId also needs piSessionId recognition
- D-1: dispatchLocalResume pi branch missing effort param
- W1B-1-01: buildCliArgs only passes --effort for claude, not pi
- W2B-D-2: effort=null does not send set_thinking_level to Pi
- D-3: turn_start does not consume pendingLocalIds
- D-7: keep_alive falls into default case in convertPiEvent
- D-9: finally overwrites sessionEndReason set by Switch/Abort
- W2B-D-3: ListPiModels RPC does not update metadata
- Abort handler: remove cleanupAndExit, only cancel current turn

Also: Switch handler returns { success: true } for consistency

Test coverage: 13 new test cases across 5 files

* fix: restore cli version from integration test placeholder

* fix(pi): send restored thinking level to Pi subprocess on startup

opts.effort was stored in piSession.currentThinkingLevel but never
forwarded via set_thinking_level during the startup sequence, causing
runner-spawned and resumed sessions to show the restored effort in
HAPI while Pi kept its default.

* fix: restore cli package version from integration test residue

* fix(pi): switch-to-remote handler preserves session instead of terminating

Replace lifecycle.cleanupAndExit() with createModeChangeHandler + keepAlive
in the Switch RPC handler. Pi runs as a single long-lived subprocess
without BaseLocalLauncher's restart loop, so cleanupAndExit() permanently
destroyed the session on mode switch. The web handoff button now correctly
changes control mode while keeping Pi alive.

* fix(pi): remove permission mode selector (Pi RPC has no runtime switching)

Pi's --mode rpc is non-interactive and auto-approves all tool execution;
there is no set_permission_mode command in the protocol. The selector
reported success without changing Pi's behavior, misleading users.

Remove the concept across all four packages:
- shared: getPermissionModesForFlavor('pi') returns [] (cascades to
  hub 400 + web UI auto-hide via length===0 guards); drop
  PI_PERMISSION_MODES / PiPermissionMode
- cli: strip permissionMode from PiSession/runPi/pi command/resume;
  drop the no-op SetSessionConfig permission branch that stored state
  without forwarding to the subprocess
- web: delete PiPermissionPanel.tsx; remove panel block + imports
  from HappyComposer

* fix(cli): realpath workspace root in apiMachine test assertion

The handler realpaths the cwd as a symlink-escape guard, so on macOS
/var/folders/... resolves to /private/var/folders/... The test compared
against the un-resolved path and failed on macOS. Use realpathSync on
the expected value for cross-platform consistency (no-op on Linux where
/tmp has no symlink prefix).

* fix(pi): keepalive reads current mode instead of constructor-time startingMode

The Switch handler updated controlledByUser but PiSession.pushKeepAlive()
still emitted the readonly startingMode every 2s, so a runner-started
session switched to local would flip back to remote on the next keepalive.

Replace readonly startingMode with a mutable mode field; add setMode()
that updates it and re-pushes keepAlive immediately. The Switch RPC
handler now calls setMode() before handleModeChange.

* fix(pi): runner no longer passes permission flags to Pi subprocess

After removing the Pi permission selector, the Pi command parser rejects
--permission-mode and ignores --yolo. But the shared buildCliArgs tail in
the runner still appended these flags for Pi sessions, making runner-
spawned Pi children exit before registering a session.

Guard the permission/yolo append with agent !== 'pi'.

* fix(pi): preserve provider identity when persisting selected Pi model

The hub's applySessionConfig normalized Pi's { provider, modelId } object
down to a plain modelId string for the shared session.model field, losing
the provider. On reload or next render, web's selectedPiModel lookup
matched by modelId alone — if two providers share a modelId, the wrong
one was highlighted, and subsequent model/thinking-level changes sent the
wrong provider to the Pi subprocess.

Add a provider-qualified piSelectedModel field to session metadata
(schema + persistPiSelectedModel mirroring persistPreferredPermissionMode).
Web's selectedPiModel now prefers the provider-qualified match and only
falls back to modelId-only matching when absent.

* fix(pi): model picker checkmark follows provider-qualified selection

selectedPiModel already resolves via provider+modelId, but the model
panel's currentPiModel still matched by modelId alone — so with two
providers sharing a modelId the checkmark pointed at the wrong row.
Reuse selectedPiModel directly.

* fix(pi): steer messages consumed immediately, not queued in pendingLocalIds

onUserMessage unconditionally pushed localId into pendingLocalIds, but a
steer (sent while piIsStreaming) does not start a new turn — so the
steer's localId was never drained by turn_start. The next normal prompt's
turn_start would consume the stale steer localId instead, leaving the
new prompt's bubble stuck in the queued bar.

Only queue localId for the prompt path. Steer path emits
messages-consumed immediately.

* fix(pi): clear stale thinking level when switching to non-reasoning model

The model-change effect early-returned when selectedPiModel.reasoning ===
false, leaving the previously-set effort (e.g. 'high') persisted on the
session. The UI hid the thinking picker for the non-reasoning model, but
the hub still forwarded the stale effort as set_thinking_level — with no
visible control to clear it.

Call onEffortChange(null) for non-reasoning models.

* fix(pi): return provider-qualified model in SetSessionConfig applied

The CLI handler returned only currentModel (bare string), so the hub's
applySessionConfig saw a non-object model and cleared
metadata.piSelectedModel via persistPiSelectedModel(session, null) —
undoing the provider that was just stored on the inbound config.

Return { provider, modelId } when both are known so the hub keeps the
provider-qualified metadata intact across active model changes.

* fix(pi): preserve piSelectedModel in bootstrapExistingSession metadata

The metadata whitelist rebuild kept piAvailableModels but omitted
piSelectedModel, so the first resume/local-handoff update dropped the
provider identity — after which web fell back to modelId-only matching
and could select the wrong provider for duplicate modelIds.

* fix(pi): await Pi confirmation before reporting model/effort applied

SetSessionConfig was fire-and-forget — transport.send wrote JSONL to
stdin and returned immediately. If Pi rejected an invalid provider/model
or thinking level, the hub still persisted the new value and the UI
reported success while Pi kept the old runtime state.

Use sendPiRpcAndWait so a failed set_model/set_thinking_level rejects
the web request and leaves the session config unchanged.

* fix(pi): resolve set_model RPC so awaited model switch does not time out

SetSessionConfig awaits sendPiRpcAndWait(set_model) before reporting the
model applied, but handleResponse's set_model branch updated state and
fell through without calling resolvePendingRpc. The pending RPC promise
then waited the full 10s timeout and rejected, making /sessions/:id/model
return 409 even though Pi accepted the change. Mirror every other branch
by resolving the pending RPC after updating currentModel/currentProvider.

* fix(pi): drain pending localId on turn_start only; throw when set_model suppressed

- loop.ts: split agent_start/turn_start branches. Pi emits both per prompt;
  draining on both popped the FIFO twice and shipped an undefined localId to
  the hub. agent_start now only sets thinking state; turn_start drains.
- runPi.ts: when set_model is suppressed (provider unknown), throw instead of
  silently returning applied, so the hub returns 409 rather than persisting a
  piSelectedModel Pi never received.
- loop.test.ts: assert agent_start does not drain; add regression test that a
  single turn drains exactly one real localId.

* fix(pi): exclude Pi from generic Ctrl/Cmd+M model cycler

SessionChat fed piModelOptions into HappyComposer.availableModelOptions,
so the global Ctrl/Cmd+M shortcut ran getNextModelForFlavor over the Pi
list and called onModelChange with a bare modelId string. Pi needs
{ provider, modelId } to disambiguate duplicate model IDs across
providers; a bare string made runPi fall back to the first cached
provider match (wrong provider) or throw when the provider was unknown.

Drop the piModelOptions useMemo and pass undefined for Pi, mirroring
modelOptions.ts where the Pi branch already returns the current model
unchanged (no-op) when no custom options are supplied. Pi model changes
now go only through the dedicated provider-qualified picker (piModels).

* fix(pi): commit PiSession config only after Pi confirms the RPC

SetSessionConfig previously mutated piSession.currentModel /
currentProvider / currentThinkingLevel BEFORE awaiting
sendPiRpcAndWait(set_model / set_thinking_level). When Pi rejected the
value or the RPC timed out, the handler threw and the route returned
409, but PiSession kept the unconfirmed values; the 2s keepalive then
reported them back to the hub, where handleSessionAlive persisted a
model/effort Pi never accepted.

Resolve the requested model/effort into locals first, send the RPCs,
and only commit to PiSession after each await resolves. The null
(clear-model) path needs no RPC so it still commits immediately; the
unknown-provider path still throws without committing.

* fix(pi): apply startup model only after Pi confirms set_model

Two startup paths persisted the requested --model before Pi confirmed it:

1. handleGetState set session.currentModel = session.initialModel as soon
   as get_state returned, using the unconfirmed startup model instead of
   Pi's actual default. If the model was unavailable or rejected, the 2s
   keepAlive reported it to the hub, which persisted/showed a model Pi
   never accepted.

2. get_available_models then sent set_model fire-and-forget, so a Pi
   rejection was never observed and currentModel stayed on the bad value.

Fix: handleGetState now reports Pi's real current model (newModel) while
a startup model is merely requested. get_available_models resolves the
provider from the cached list, awaits set_model, and commits
currentModel/currentProvider only on success — on rejection it logs and
keeps Pi's default. The await is fired detached so the
get_available_models RPC itself still resolves for ListPiModels.

* fix(pi): do not persist startup model before Pi confirms set_model

The startup --model still reached the hub unconfirmed via two paths the
previous Fix #13 left open:

1. bootstrapSession({ model: opts.model }) seeded the hub session model
   at creation time, and SessionCache.handleSessionAlive persists every
   non-undefined keepAlive model — so an unavailable/rejected model was
   stored and shown before get_available_models/set_model ran.
2. PiSession constructor set this.currentModel = opts.model, so the very
   first keepAlive (sent by startKeepAlive before any RPC confirms the
   model) reported the unconfirmed value.

Pass model: undefined to bootstrapSession and start PiSession.currentModel
at null; opts.model is still captured as initialModel and applied/committed
only after get_available_models confirms it exists and set_model succeeds
(Fix #13). The hub now sees Pi's real current model from the first
get_state keepAlive and switches to the requested model only once accepted.

Also add sendPiRpcAndWait contract tests pinning the await<->resolve
symmetry (Fix #10): set_model/set_thinking_level/get_available_models must
resolve before timeout on a success response, and reject on a Pi error.

* fix(pi): apply startup effort only after Pi confirms set_thinking_level

runPi restored opts.effort straight into piSession.currentThinkingLevel
before startKeepAlive ran, and pushKeepAlive persists effort — so a
resumed/runner-spawned session could store/show a thinking level Pi
rejected or ignored. This is the effort analog of the startup-model
confirmation contract (Fix #13/#14).

Capture the requested effort into a local startupThinkingLevel instead of
mutating currentThinkingLevel up front. After transport.start() and the
get_state/get_available_models/get_commands sends, await set_thinking_level
and commit currentThinkingLevel + push a keepAlive only on success; on
rejection keep Pi's default (already reported by get_state). The await is
detached so the run loop is not blocked, and get_state is sent before the
set so its authoritative baseline lands first and cannot clobber the
confirmed value.

* fix(pi): omit unknown runtime config from keepalive, don't clear persisted state

Fix #14 changed PiSession.currentModel to start at null so the startup
--model was not leaked before confirmation. But the hub treats keepAlive
model:null as an explicit clear (sessionCache.ts only skips when the
field is undefined), so the first heartbeat (startKeepAlive runs before
get_state) now erased a resumed Pi session's persisted model/effort
before Pi reported its real state.

Distinguish "unknown" from "clear": currentModel/currentThinkingLevel
start undefined and keepAlive omits undefined fields (via
getKeepAliveRuntime), so the hub leaves persisted values alone until Pi
confirms. null remains an explicit clear and is still forwarded. Once
get_state/set_model/set_thinking_level confirm a value it is set and
reported normally.

* fix(pi): disable Ctrl/Cmd+M model cycler for Pi entirely

Fix #11 removed piModelOptions from availableModelOptions, assuming
getNextModelForFlavor('pi', model, undefined) was a no-op. It is not:
the Pi branch returns normalizeCurrentModel(model), i.e. the current
modelId as a bare string, so the shortcut still called onModelChange with
a bare modelId. That loses the provider and can pick the wrong cached
match, clear the model when session.model is empty, or hit 'provider is
not yet known'. Short-circuit the handler for Pi so model changes go only
through the dedicated provider-qualified PiModelPanel.

* fix(pi): persist piSelectedModel from get_state and startup set_model paths

Pi stores session.model as the bare modelId and relies on
metadata.piSelectedModel ({ provider, modelId }) to disambiguate
duplicate modelId values across providers in the web picker and
thinking-level filtering. But piSelectedModel was only written by the web
/sessions/:id/model path (hub persistPiSelectedModel). The runtime paths
that set currentModel/currentProvider — get_state, the startup
get_available_models set_model, and the set_model response — only
keepAlive'd the bare modelId, so a Pi session on Pi's default model,
resumed from CLI, or started with --model had no provider identity in
metadata and could render/filter against the wrong provider.

Add persistSelectedPiModel(session) (no-op unless both fields are known)
and call it after get_state, after a successful startup set_model, and
after the set_model response updates the fields. This mirrors what the
web picker already does.

* fix(pi): default startingMode to remote — Pi has no local TUI path

A terminal `hapi pi` launch defaulted to startingMode 'local' and marked
the session controlledByUser, but Pi only runs as `pi --mode rpc` with
piped stdio — there is no local terminal/TUI input path like Claude/Codex
have. The terminal user could not drive the session and the web treated
it as local-controlled, so the first terminal Pi session was stuck until
manually switched from the web.

Default to 'remote' so the session is immediately drivable from the web.
An explicit opts.startingMode (runner path) still takes precedence.

* fix(pi): resume with remote startingMode — no local TUI path

The previous Fix #19 changed the `hapi pi` default to remote, but
`hapi resume` still passed startingMode: 'local' into runPi for Pi
sessions, re-introducing the same unsupported local-control state on the
resume path: setControlledByUser publishes controlledByUser while Pi has
no terminal/TUI input, hiding/rejecting remote-only controls until a web
switch. Pass 'remote' here too and update the resume test accordingly.

* fix: restore e2e/scratchlist.spec.ts deleted from main by mistake

The earlier "remove unrelated E2E harness" commit (d1e5b4c) deleted the
whole e2e/ directory this branch had added, but scratchlist.spec.ts is a
main-branch Playwright spec (the only file under playwright testDir
./e2e). Its removal left `bun run test:e2e` with no tests to run while
the script and playwright.config.ts still point at that directory.

Restore scratchlist.spec.ts from main; the unrelated harness files
(HARNESS.md, harness.*, integration/*.mts) that were genuinely
branch-only additions stay removed.

---------

Co-authored-by: pi <pi@local>
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-18 10:01:10 +08:00
55d1bbb7bd feat(cursor): invisible sync-on-open migrator from legacy stream-json to ACP (#844)
* feat(cursor): invisible sync-on-open migrator from legacy stream-json to ACP

Closes #824

When the operator reopens a legacy stream-json Cursor session in HAPI,
the hub now transparently transplants its `~/.cursor/chats/<wsh>/<uuid>/store.db`
into `~/.cursor/acp-sessions/<uuid>/`, verifies it loads via `agent acp`,
flips `metadata.cursorSessionProtocol = 'acp'`, and removes the legacy
source - all before `resumeSession` returns. Subsequent opens are pure ACP.

The primary justification is safety, not feature parity. #784 (`cursor-agent`
fabricates `Questions skipped by the user` responses in legacy stream-json
mode) still fires regularly in dogfood despite #801's mitigation: the agent
ships destructive side effects against fabricated consent. Migration to ACP
closes the protocol-level door because the `AskQuestion` tool does not exist
on the ACP side, so there is nothing to fabricate.

working. That tradeoff was reasonable at the time. The accumulated #784
evidence makes legacy sessions actively unsafe; this PR makes the upgrade
path invisible enough that users stop avoiding it.

A pre-PR spike established that legacy and ACP `store.db` files use the
identical SQLite schema; only the directory layout differs. The migrator
therefore:

1. Sanity-checks the source store and pre-flips state (`session.active`,
   `lifecycleState`, on-disk presence, target collision)
2. Optionally archives a stale-running row (`forceArchiveRunning: true` is
   the default for the auto-migrate path because the caller already
   verified `session.active === false`)
3. Atomically creates `~/.cursor/acp-sessions/<uuid>/` with mode `0o700`
4. Copies `store.db` and chmods to `0o600` (multi-user-host hardening)
5. Writes a minimal `meta.json` sidecar (`schemaVersion`, `cwd`, optional
   `title`) with mode `0o600`
6. Spawns `agent acp` under HAPI_HOME isolation and verifies the session
   loads via `session/load`. On long histories the verify also drives a
   trivial single-turn prompt; on short ones load-only is enough
7. Flips `cursorSessionProtocol = 'acp'` AND clears the
   `cursorMigrationState` banner flag in a SINGLE metadata write
8. Removes the legacy source store (only after verify succeeded and the
   protocol flip committed). The legacy `~/.cursor/chats` parent dir is
   left as-is

Every failure leaves the legacy state intact. No `rm` fires without a
verify success AND a committed protocol flip.

The transplant takes 15-20s on long histories (copy a multi-hundred-MB
store, spawn `agent acp`, replay thousands of notifications, tear down
the probe). Without a progress indicator the wait reads as "broken" to a
fresh reviewer. A minimal banner ships alongside the migrator:

- Hub sets `metadata.cursorMigrationState = 'in_progress'` BEFORE the
  long-running transplant. The session-cache refresh emits the existing
  `session-updated` SSE event (no new event type), so the web client
  picks it up in milliseconds. No client-side polling needed.
- Hub clears the flag in the SAME metadata write that flips
  `cursorSessionProtocol` to `'acp'` on success, so the banner disappears
  in the same render tick the chat re-renders as ACP - no flicker window.
- Hub clears the flag explicitly in the auto-migrate helper's `finally`
  on failure/exception, so the banner never gets stuck if migration
  falls back to the legacy launcher.
- Web renders an accessible (role=status, aria-live=polite) banner with
  an indeterminate spinner. Deliberately no fake percentage - we do not
  have phase data and a fake progress bar would lie.

This PR is intentionally sequenced AFTER swear01's three ACP mop-up PRs
(merged today as ad038bbf, 8094b500, fa363c2f), all of which are
prerequisite for safe concurrent ACP launches.

The verify probe spawns `agent acp` directly via `AcpVerifyProbe` under
HAPI_HOME isolation (the migrator overrides `HOME` to a temp dir for the
verify pass), so it never touches `<real-HAPI_HOME>/locks/agent-acp-active/`
at all. Per swear01's #835 design note, the post-flip ACP launcher claims
the lock through the standard `registerActiveAcpTransport` entry and
behaves like any other concurrent ACP start. The migrator itself never
writes `pid` or `count` files directly.

The auto-migrate path is gated by `HAPI_CURSOR_LEGACY_AUTO_MIGRATE`. Set
to `0`, `false`, `no`, or `off` to suppress it entirely (legacy sessions
keep running through the existing stream-json launcher). Default is on.

A REST endpoint at `POST /api/sessions/:id/migrate-to-acp` allows
explicit migration of a single session outside the sync-on-open path
(e.g. for a specific cold archived session a user wants to re-engage).
Bulk migration surfaces (CLI subcommand, web button, bulk REST endpoint,
candidate-listing API) were deliberately stripped per reviewer feedback;
per-session sync-on-open + this escape hatch are the only two paths.

- 343 hub unit tests (4 new for the migration banner flag transitions,
  53 for the migrator core, 32 for the verify probe, 15 for the auto-
  migrate helper guard matrix, plus existing suites)
- 3 integration tests against a real `agent acp` (skipped by default
  unless `HAPI_CURSOR_LEGACY_MIGRATOR_INTEGRATION=1`)
- 10 web unit tests for the banner component (visibility paths + a11y)
- Typecheck clean across cli, web, hub

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

* fix(cursor): address upstream Codex review findings on #844 (2 majors)

Finding 1 (Major) — verify probe `agentLookupHome` ignored `metadata.homeDir`
in service-account hub deployments. `migrateOne` resolved the legacy store
under `metadata.homeDir` (the recorded session-owner home) but the default
createProbe factory still set `agentLookupHome` from `this.deps.homeDir()`
(the hub user's home). On a service-account hub, the store lookup
succeeded but `agent acp` discovery fell back to the hub user's
`~/.local/bin`, so verify silently failed and sync-on-open quietly fell
back to legacy.

Fix:
- Widen `CursorLegacyMigratorDeps.createProbe` signature from
  `(env) => AcpVerifyProbe` to `(env, agentLookupHome) => AcpVerifyProbe`
- Default factory uses the passed `agentLookupHome`
- `verifyInTempHome` threads `opts.sourceHome` (already the resolved
  session-owner home) through as the 2nd arg

2 new regression tests pin the contract:
- service-account case (metadata.homeDir != deps.homeDir()): captured
  agentLookupHome MUST equal metadata.homeDir
- legacy session record (no metadata.homeDir): falls back to
  deps.homeDir() correctly

Finding 2 (Major) — `bun.lock` win32-x64 pinned to 0.20.0 while
`cli/package.json` required 0.20.1 (rebase artifact from the v0.20.0 →
v0.20.1 release commit landing in upstream/main between the original
spike and the rebase). Frozen-install Windows users would either get
the wrong native binary or have the lock rejected.

Fix: regenerated bun.lock so the entry resolves
`@twsxtd/hapi-win32-x64@0.20.1`. `bun install --frozen-lockfile` now
passes clean.

Test budget:
- 391 hub unit tests pass (2 new for createProbe agentLookupHome
  contract, +0 regressions)
- Typecheck clean across cli + web + hub
- `bun install --frozen-lockfile` clean

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 13:57:55 +08:00
f086949a8a feat(web,hub): export session conversation (#808)
* test: reproduce issue #793

* fix: add session conversation export (closes #793)

* fix(hub): sort session export by display time for invoked scheduled messages

Export now uses COALESCE(invoked_at, created_at) ordering so JSON/Markdown
exports match the visible chat chronology after scheduled messages are invoked.

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

* fix(web): escape newlines in session export YAML front matter

Prevent session metadata containing newlines or quotes from breaking
Markdown export front matter.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 21:40:59 +08:00
f9ef3a4489 feat(codex): import local Codex sessions into Hapi (#796)
* local: add Codex Desktop session sync controls

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

---------

Co-authored-by: Codex Local <codex-local@example.invalid>
2026-06-04 17:53:12 +08:00
SSU-WEI HUANGandGitHub 1d03f186d6 feat(cursor): support model selection (#684) 2026-05-26 16:13:38 +08:00
weishu e88a9075df refactor: share core REST payload types 2026-05-21 14:35:47 +08:00
weishu 9704227ce2 refactor: share machine runner schemas 2026-05-21 14:29:42 +08:00
weishu 41f6b37d69 Structure SSE update patches 2026-05-21 10:44:46 +08:00
weishu 2e1e2d39db Share slash command definitions 2026-05-21 10:36:25 +08:00
weishu d6f97065c1 Share REST and RPC response types 2026-05-21 10:31:50 +08:00
weishu 6759cf4657 Remove old protocol compatibility layers 2026-05-20 17:45:28 +08:00
lekoandGitHub ce2e76a42e Add Windows remote terminal support (#642) 2026-05-19 07:54:12 +08:00
weishu 089ddad476 feat: support Codex goal slash command 2026-05-15 22:15:17 +08:00
junesandGitHub 0006d04f9e feat: support multiple workspace roots (#584) 2026-05-07 08:30:52 +08:00
Junmo KimandGitHub 9ee014098a feat(opencode): support model selection and mid-session model change (#558)
* refactor(opencode): declare ModelChange capability and add model field to OpencodeMode

Mark opencode flavor as supporting model change by adding Capabilities.ModelChange
to FLAVOR_CAPS.opencode. Add optional `model` field to OpencodeMode so the
set-session-config handler can carry a model alongside the existing permissionMode.

Pure structural change: no behavior change yet. The mid-session model change RPC
and UI wiring follow in subsequent commits, gated by this capability.

* feat(acp): branch setModel by flavor, capture session models metadata, expose getSessionModelsMetadata on AgentBackend interface

Adds an optional `flavor` argument to `AcpSdkBackend.setModel` so it can
dispatch the right `session/*` RPC for each agent flavor without changing
the call site Gemini already uses. Both Gemini and OpenCode wire to
`session/set_model`; the OpenCode response only carries `_meta.opencode`,
so the backend updates the cached `currentModelId` optimistically while
preserving the previously captured `availableModels`.

Captures `availableModels` and `currentModelId` from `session/new` /
`session/load` / `session/set_model` responses into per-session metadata,
exposed as `getSessionModelsMetadata(sessionId)` on the `AgentBackend`
interface so the hub can forward the snapshot to the web client.

* feat(opencode): accept model in set-session-config RPC and forward to launcher

Mirror the Gemini set-session-config handler so the web UI can change the
OpenCode model mid-session. Validates incoming model strings, persists null
("Default") for keepalive metadata, and pushes a keepAlive immediately so
the hub UI reflects the change without waiting for the next 2s tick.

Forward the model through opencodeLoop and into queued OpencodeMode entries
so the launcher can detect a per-batch model change. Add a setModel helper
on OpencodeSession to store the chosen model on the shared session base.

Wire --model up the runner path: parse --model <value> in commands/opencode.ts
and stop excluding opencode in buildCliArgs so the runner spawns OpenCode
with the user-selected initial model.

* feat(opencode): switch model mid-session via ACP RPC

Mirror the Gemini pattern from PR #543: when a user picks a different model
between turns, call backend.setModel with flavor='opencode' so the ACP backend
sends session/set_session_config_option (configId='model') to the running
OpenCode CLI. The next turn then runs against the new model.

The first batch on a fresh session seeds currentBackendModel without firing the
RPC — the OpenCode CLI was launched with that model via --model and there is
nothing to switch yet. If the running build does not implement the RPC we learn
that from the first method-not-found response, latch inline switching off, and
notify the user once. Other errors fall back to the previous model and surface
a one-line failure message.

* feat(hub): expose model selection and discovery for OpenCode sessions

Generalize the /sessions/:id/model guard via supportsModelChange so any flavor
that advertises the ModelChange capability becomes accepted automatically. This
piggybacks on the capability SSOT introduced in PR #400 and turns OpenCode on
without listing flavors inline.

Add a /sessions/:id/opencode-models endpoint that mirrors the existing
codex-models pattern. The endpoint forwards a per-session listOpencodeModels
RPC to the running OpenCode launcher, which returns the availableModels and
currentModelId metadata captured from the ACP session/new and
session/set_session_config_option responses. The web UI consumes this to
render the model dropdown without round-tripping ACP itself.

* feat(web): render OpenCode model dropdown in the chat composer

Mirror the Codex pattern in SessionChat: query /sessions/:id/opencode-models
via a new useOpencodeModels hook and feed the result into the composer's
availableModelOptions. The AssistantChat model dropdown now lists the user's
ollama / mlx / OpenCode Zen models with the same provider/model label that the
ACP server reports, so the picker matches the OpenCode TUI.

Stop falling back to the Claude composer model list when the flavor is
opencode and no custom options are supplied — that fallback briefly surfaced
unrelated Claude models in OpenCode sessions before the RPC response landed.
The NewSession flow keeps an empty MODEL_OPTIONS.opencode for now: model
discovery requires an active OpenCode ACP session, so the dropdown becomes
available once the session boots and stays empty (and hidden) at creation
time.

* feat(cli,hub): add cwd-based OpenCode model discovery RPC

Adds a short-lived `opencode acp` probe that runs `initialize` +
`session/new` against a target cwd to read the `availableModels` /
`currentModelId` snapshot, then tears the subprocess down. Results are
cached for 60s per cwd and concurrent probes coalesce into a single
spawn.

Exposes the probe through:
- `listOpencodeModelsForCwd` JSON-RPC handler on the CLI
- `RpcGateway.listOpencodeModelsForCwd` / `SyncEngine.listOpencodeModelsForCwd`
- `GET /api/machines/:id/opencode-models?cwd=...` on the hub

This lets the web NewSession form discover OpenCode models for a chosen
directory before any session is spawned.

* feat(web): add OpenCode model selector to NewSession with loading and default highlight

Adds a `OpencodeModelSelector` panel that the NewSession form swaps in
when the OpenCode flavor is selected. The panel:

- queries the new `GET /api/machines/:id/opencode-models?cwd=...` endpoint
  via `useOpencodeModelsForCwd` (TanStack Query, 60s staleTime, no retry),
- shows a labelled spinner + skeleton rows while discovering,
- renders an inline error with a Retry button when probing fails,
- renders an empty-state message when the directory yields no models,
- highlights the OpenCode-reported `currentModelId` with a "Default" badge
  and auto-selects it (or the first option) so the form has a sensible
  value if the user hits Enter without scrolling.

Selection is reset whenever the agent / machine / directory changes so a
new probe can establish a fresh default. Directory input is wrapped in
`useDeferredValue` so per-keystroke edits do not spawn a fresh
`opencode acp` probe. The chosen model is forwarded on session spawn
via the existing OpenCode `model` parameter.

Adds en + zh-CN locale strings for the loading / failure / empty / retry
/ default-badge labels.

* fix(cli): guard /machines/:id/opencode-models handler with workspace root check

The machine-scoped `listOpencodeModelsForCwd` RPC handler was registered by
`registerCommonHandlers` without any workspace-root check, so a web client
could pass an arbitrary `cwd` and have the runner spawn an `opencode acp`
subprocess plus a `session/new` against that path. That broke runner
isolation, since peer machine-scoped handlers (`list-directory`,
`spawn-happy-session`) already enforce the configured workspace root.

Re-register the handler in `ApiMachineClient` so it reuses the existing
`resolveForWorkspaceCheck` (realpath-based, with missing-tail walking) and
`isWithinWorkspaceRoot` helpers before delegating to the lower-level probe.
The resolved cwd is forwarded down so symlinked-but-contained paths still
work, while traversal attempts are rejected with the same error shape the
peer handlers use. Added unit tests around the new dispatch path. Addresses
HAPI Bot review on PR #558.

* fix(web): gate opencode model discovery on cwd existence

The new-session form previously enabled `useOpencodeModelsForCwd` as
soon as the OpenCode agent, machine, and any non-empty directory string
were present. Because that hook calls `/machines/:id/opencode-models`
and the CLI handler starts an `opencode acp` probe for that cwd, normal
typing through partial paths could launch expensive 30s OpenCode
subprocesses for non-existent directories before the path-existence
result had validated the final cwd.

Reorder NewSession so `useMachinePathsExists` runs before
`useOpencodeModelsForCwd`, then gate the discovery hook on the
directory having been positively confirmed to exist
(`pathExistence[deferredDirectory] === true`). The decision is
factored into a small pure helper `shouldEnableOpencodeModelDiscovery`
so the contract can be unit-tested without provider scaffolding.

* fix(web): keep current opencode model on shortcut without dynamic options

`getNextModelForFlavor` is invoked by the global Ctrl/Cmd+M shortcut in
`HappyComposer`, which is now active for OpenCode sessions because the
agent backend declares the `ModelChange` capability. When the dynamic
OpenCode model list has not yet been loaded — e.g. the user presses the
shortcut before `/opencode-models` returns — the function received an
`undefined`/empty `customOptions` and fell through to the Claude preset
cycler, which would emit `sonnet`/`opus` for an OpenCode session. The
following turn then attempted `session/set_model` with a Claude model id
that no OpenCode provider can serve.

Add an `opencode` branch that returns the (normalized) current model
unchanged when no dynamic options are available, mirroring the existing
empty-list policy of `getModelOptionsForFlavor`. Unit tests cover the
undefined / empty / null-current-model variants and lock the
no-Claude-fallback contract. Addresses HAPI Bot review on PR #558.
2026-05-03 12:50:22 +08:00
Junmo KimandGitHub 7d55bc1456 feat(web): float queued messages above composer until invocation (#542)
* refactor: add invoked_at column and propagate via messages-consumed

- Bump hub schema to V8: add `invoked_at INTEGER` to messages table
- Add `migrateFromV7ToV8` (idempotent ALTER TABLE ADD COLUMN)
- Add migration chain entries for V4/V5/V6/V7 → V8
- Expose `StoredMessage.invokedAt: number | null` and `markMessagesInvoked`
- Record server-side `Date.now()` in hub on `messages-consumed` socket event
- Propagate `invokedAt` through SSE (`messages-consumed` payload)
- Update `markMessagesConsumed` in web store to accept and store `invokedAt`
- Preserve optimistic `invokedAt` in `mergeMessages` (server echo path)
- Add migration unit tests (fresh V8, V7→V8 ALTER, markMessagesInvoked)

* feat(web): float queued messages above composer until invocation

Show queued (uninvoked) user messages in a dedicated floating bar above
the composer instead of inline in the thread timeline. Once the CLI acks
the batch via messages-consumed, the bar disappears and the messages
appear in the thread at their invocation position (invokedAt ordering).

- Add QueuedMessagesBar component: subscribes to message-window-store,
  filters user messages with invokedAt==null, shows clock icon + text
  preview; disappears when all messages are invoked
- Filter queued messages from thread (visibleMessages), sort by
  invokedAt ?? createdAt so invoked messages land at the right position
- Extend markMessagesConsumed to update server-loaded messages (status
  undefined) in addition to optimistic (status 'queued'), enabling
  multi-device and post-refresh scenarios
- Remove opacity-60 from UserMessage: queued messages no longer appear
  in the thread so the dimming branch is unreachable
- Include invokedAt in getMessagesPage/getMessagesAfter API responses
  so the web client can restore floating-bar state after page refresh
- Add invokedAt field to DecryptedMessageSchema for shared protocol type

* fix(hub,web): make sort use invokedAt and V8 backfill idempotent

- compareMessages: prioritize invokedAt/createdAt over seq so invoked
  messages land at their invocation position rather than their
  send-time seq position
- migrateFromV7ToV8: move backfill outside the ALTER guard so it
  re-runs if a previous attempt crashed between ALTER and UPDATE
  before the user_version bump (idempotent WHERE invoked_at IS NULL)

* fix(hub,web): cover localId-less messages and live-ack invokedAt

- addMessage: messages without a localId have no ack path
  (markMessagesInvoked matches by localId). Treat them as
  already-invoked at insert time so they land in the thread instead of
  sitting in the queued floating bar forever.
- markMessagesConsumed: apply the ack even when the message is already
  'sent' optimistically, so the live window receives invokedAt instead
  of waiting until a full refetch.

* fix(hub): propagate invokedAt in live message-received SSE payload

The SSE `message-received` event omitted `invokedAt` while REST
pagination included it, so localId-less CLI/local user messages arrived
on the live wire as queued (`invokedAt == null`) and stayed in the
floating bar until a full refetch replaced them with the stored row.

* fix(hub): propagate invokedAt in CLI socket message-received handler

The CLI socket 'message' handler fans out to web via a separate
`onWebappEvent` publisher; the previous fix only touched the
`MessageService` publisher. Aligns the live SSE payload shape with
the REST/page-load shape so localId-less CLI/local user messages with
`invokedAt = createdAt` (set in addMessage) reach web filters with the
field already populated, instead of being misclassified as queued
until a full refetch.

* fix(hub,web): add byPosition pagination to fix long-session queued message loss

Pagination used seq-based windows, so queued messages with low seq but late
invokedAt fell outside the visible window on refresh. Fix by adding a V8
byPosition mode that orders by COALESCE(invoked_at, created_at) DESC, seq DESC
with a composite cursor, while keeping the V7 seq path fully intact for
backward compatibility.

- hub/store/index: add idx_messages_session_position (createSchema + V7→V8 migration)
- hub/store/messages: add getMessagesByPosition with composite cursor SQL
- hub/store/messageStore: delegate getMessagesByPosition
- hub/sync/messageService: add getMessagesPageByPosition with nextBeforeAt response
- hub/sync/syncEngine: expose getMessagesPageByPosition
- hub/web/routes/messages: byPosition=1 query param dispatches to V8 path
- web/types/api: MessagesResponse.page gains optional nextBeforeAt
- web/api/client: getMessages gains byPosition + beforeAt options
- web/lib/message-window-store: fetchLatestMessages/fetchOlderMessages use V8
  composite cursor; fallback to seq cursor when hub returns no nextBeforeAt
- hub/store/migration-v8.test: 7 new tests covering position sort, composite
  cursor pagination, long-session scenario, V7 compat, and index existence

* fix(hub,web): re-sort on consume and use position cursor for next fetch

- markMessagesConsumed: re-merge with empty list to re-sort by position
  key after invokedAt is set. A queued user message becomes visible
  with the consume event; without re-sort it stays at its send-time
  array slot until the next fetch overwrites it.
- getMessagesPageByPosition: pick the cursor from stored[0] (oldest in
  position order) instead of scanning for minimum seq. With the page
  already in ascending position order, scanning for min seq could land
  on a low-seq, late-invoked row that is actually the newest in the
  page, causing the next older fetch to overlap.

* fix(web): trust invokedAt as the only invocation signal and pin cursor pair

- visibleMessages predicate (SessionChat + QueuedMessagesBar): drop the
  status === 'sent' check. status='sent' only means the REST write
  returned, not that the CLI consumed the message; an optimistic 'sent'
  with no invokedAt is still queued. invokedAt is the single source of
  truth for invocation.
- byPosition cursor: track oldestPositionSeq alongside oldestPositionAt
  so the server's cursor pair travels through the next older fetch
  unchanged. Recomputing beforeSeq from the local window's minimum seq
  could combine it with a server beforeAt that referred to a different
  row, causing the SQL cursor to skip or overlap.

* fix(hub): include uninvoked local messages in latest page

Long sessions can push a queued user message (invokedAt = null, sort key
= createdAt) outside the latest position-ordered page once the agent
emits more than `limit` later rows. A refresh or secondary client then
never receives the row, the floating bar stays empty, and the later
`messages-consumed` event only carries localIds — there is no way to
materialize the missing row at invocation time.

Pin uninvoked local user messages to every latest-page response
out-of-band. The pagination cursor still anchors to the position-ordered
page rows, so older-page fetches are unaffected.

* fix(web): preserve queued messages across trimVisible

The visible-window trim drops the oldest entries beyond
VISIBLE_WINDOW_SIZE, but a queued user message (invokedAt = null) sorts
by send time and is the oldest item. Once a long agent stream pushes
it past the window the row is gone from the client store, and the
`messages-consumed` SSE carries only localIds — there is no way to
restore or reposition the dropped row without a full refetch.

Pull queued rows out before slicing the regular budget, then merge
them back in. Queued rows are bounded by composer/CLI queue depth and
do not meaningfully grow the window.

* fix(web): use strict null for queued check and fall back invokedAt

- Optimistic message sets invokedAt: null explicitly so the strict-null
  queued check matches the local opt-in. Pre-V8 hub responses that
  omit the field (`undefined`) are treated as already-invoked and
  stay in the thread instead of being misclassified as queued.
- markMessagesConsumed: when the consume SyncEvent omits invokedAt
  (older hub) fall back to client time, otherwise a message that
  receives an ack with no server timestamp stays queued forever under
  the new strict-null filter. The persisted server value is still
  authoritative on next fetch.

* fix: comprehensive invokedAt propagation hardening (review feedback batch)

Bot review surfaced 11 propagation bugs incrementally; this batch fixes
9 additional adjacent issues found by hostile-review to break the
incremental discovery cycle:

- legacy DB (user_version=0 with HAPI tables): step ladder runs V1→V8
  before createSchema so pre-existing tables get all later columns/indexes
- step ladder includes V1/V2/V3 entries; previously V1-V3 DBs threw
- mergeSessionMessages collision branch forces invoked_at = created_at
  so unmergeable rows can't strand in the floating bar
- session-end auto-invokes still-queued user messages and broadcasts
  messages-consumed; the floating bar no longer pins ghost rows after
  the CLI is gone
- trimPending preserves queued rows symmetrically with trimVisible
- markMessagesInvoked is first-write-wins; duplicate acks are no-ops
  rather than re-stamping invoked_at and reordering the thread
- markMessagesConsumed migrates just-acked pending entries into the
  visible thread so non-at-bottom users see their own messages without
  scrolling
- mergeMessages dedup window compares by position key (invokedAt ?? createdAt)
  instead of createdAt only, so late-invoked optimistic copies don't
  duplicate the server echo
- isQueuedForInvocation centralized in lib/messages.ts (single
  predicate used by SessionChat, QueuedMessagesBar, and the store)

* fix(web): mirror hub's first-write-wins on markMessagesConsumed

The hub's markMessagesInvoked is first-write-wins, but the web store
was still overwriting any non-null invokedAt with the latest
messages-consumed timestamp. A duplicate ack (CLI re-emit) would leave
the SQLite row at the original timestamp while live clients moved
the message to the duplicate ack time, diverging until refetch.
Mirror the guard: only set invokedAt when it is null.

* fix: in-scope hostile-review polish

Web:
- fetchLatestMessages: persist the V8 composite cursor pair on the
  non-at-bottom branch too. Without this, a refresh while scrolled
  up dropped the cursor and the next loadMore fell back to V7 seq
  mode against a V8 hub — same asymmetric class of bug commit
  30df6b2 fixed for the at-bottom path.
- markMessagesConsumed: tighten the loose-null check on invokedAt
  to strict null, consistent with isQueuedForInvocation and the
  rest of the file. The idSet filter already shields V7-stamped
  rows from this path, but the strict-null contract should not
  vary by call site.
- messages: drop the upsertMessagesInCache export. It has no
  callers (verified with grep) and is the only user of the
  InfiniteData / MessagesResponse imports, so the imports go
  with it.

Hub tests:
- migration-v8.test.ts: add a session-end auto-invoke test
  (getUninvokedLocalMessages + markMessagesInvoked clears every
  queued row and stamps them all with the same invokedAt) and
  two byPosition union tests covering (1) a low-position queued
  row pushed out of the latest page is still surfaced via the
  uninvoked set, and (2) pageRows[0] is the oldest row in the
  page so the web client can safely anchor the next-older
  cursor on it.

* fix(hub,web): bot-13 polish — atomic SSE on DB success and attachment chip text

- sessionHandlers messages-consumed: emit messages-consumed only after
  markMessagesInvoked succeeds. Otherwise a transient SQLite failure
  would broadcast an invokedAt that was never persisted; live clients
  would hide the queued rows while a refresh / secondary client would
  see them as queued again, diverging the state.
- QueuedMessagesBar: fall back to attachment filenames when the
  message text is empty. The composer / POST /messages allow
  attachment-only sends; without the fallback those queued messages
  rendered as blank chips until invocation.
2026-04-29 17:23:01 +08:00
010dc41369 feat: workspace browser with --workspace-root opt-in scoping (#526)
* feat(web): add workspace browser for multi-directory navigation

Add /browse route with a folder browser that lets users navigate
filesystem directories on connected machines and launch sessions
from any folder. Supports saved workspace paths and direct path
input. The "Start Session" action pre-fills the NewSession form.

- CLI: register machine-level `list-directory` RPC handler
- Hub: add POST /machines/:id/list-directory route
- Web: add WorkspaceBrowser component with git repo detection
- Web: add /browse route with navigation from sessions sidebar
- Web: support initialDirectory/initialMachineId in NewSession

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add --workspace-root opt-in scoping for /browse and session spawn

Adds a single new flag, \`--workspace-root <path>\` (with \`~\` / \`~/foo\`
expansion), on \`hapi runner start\` and \`hapi runner start-sync\`.

When set:
- The runner reports the path in machine metadata.
- The list-directory and spawn-session RPC handlers reject paths outside
  the root, so the web UI can't escape the configured tree even if
  someone crafts a request manually.
- The /browse page in the web UI auto-opens that root, restricts the
  breadcrumb / go-up to its subtree, and shows directory entries with
  git-repo annotations.
- The /sessions/new form keeps its existing free-text directory input
  plus autocomplete + recent-paths chips, and gains a small "Browse"
  button (next to the input) that opens /browse for picking a folder.
- Reconnect-time metadata sync ensures stale records get the field
  filled in (or cleared when the flag is dropped on a later restart),
  so the hub state matches the CLI's intent.

When unset:
- Runner behaves like the legacy hapi (no scoping, no browse feature).
- /browse renders an informative state pointing at the flag instead of
  blocking the user.
- The /sessions/new form looks identical to the pre-change behavior;
  the "Browse" button is hidden.

Includes a startup banner so \`runner start-sync\` no longer looks like
it hung, and surfaces the workspace-root sync result on stdout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(hub): preserve workspaceRoot when rehydrating machines from store

MachineCache.refreshMachine() rebuilt the metadata object from an
explicit field allowlist, so any field not in the list (including the
new workspaceRoot) was silently dropped on every read — even though it
was correctly written to the store.

Add workspaceRoot to the zod schema, the Machine interface, and the
hand-rolled projection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(web): friendlier empty state on /sessions

When there are zero sessions the page used to be a vast blank
rectangle with just the "0 sessions in 0 projects" caption. Render a
centered empty state instead: a calendar/agenda icon, a short heading
and hint, and two buttons — "Start a session" (→ /sessions/new) and
"Browse workspace" (→ /browse).

SessionList gains an optional onBrowse prop. Router wires it on the
sessions page so the secondary button resolves; other callers can leave
it unset to hide that button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: document --workspace-root flag in cli/README and root README

Add a short paragraph under "Runner management" in cli/README.md
explaining what \`--workspace-root\` enables (scoped /browse tree,
list/spawn enforcement, tilde expansion) and that omitting it keeps
the legacy behavior. Mention the workspace browser in the top-level
README's Features list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address PR #526 review feedback

Three findings from the review bot:

1. [Major] Workspace-scope check was lexical only. With workspaceRoot
   = /safe, a symlink such as /safe/out -> /etc would pass the relative-
   path test and let list-directory / spawn-happy-session reach paths
   outside the configured root. realpath the workspaceRoot at construction
   time, and resolve every incoming path through realpath (walking up to
   the nearest existing parent for spawn targets that haven't been
   created yet) before the containment check.

2. [Minor] \`hapi runner start --workspace-root\` with no value used to
   drop the flag silently and start the runner unscoped. Now treats a
   missing or flag-shaped next argument as an error.

3. [Minor] /sessions/new's "Browse" button always opened /browse using
   localStorage's last-used machine, ignoring the user's current
   selection. NewSession already passes machineId in its callback;
   forward it through the /browse search params and seed
   WorkspaceBrowser with it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): gate list-directory RPC behind --workspace-root opt-in

Without a configured workspaceRoot, isWithinWorkspaceRoot() returns
true unconditionally, leaving the new list-directory RPC able to
enumerate any path on the runner. The Web UI already hides Browse
for these machines, but the backend should enforce the opt-in too.

Refuse the RPC up front when no workspace root is configured.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 09:59:18 +08:00
weishu 97be34e21c Add Codex model selection 2026-04-25 10:48:12 +08:00
Junmo KimandGitHub 32755f9056 feat(web): show queued status for messages pending inference (#492) 2026-04-20 19:49:26 +08:00
weishu 16829b7c78 Add support for codex plan mode 2026-03-16 20:48:39 +08:00
weishu 329d28a93c remove , using instead 2026-03-16 18:29:09 +08:00
4716d315b7 feat: improve spawn error handling and reporting across full stack (#249)
* feat: improve spawn error handling and reporting across full stack

- Return error result instead of throwing in apiMachine spawn handler
- Add lastSpawnError field to RunnerState for persistent error tracking
- Add error awaiter system for early process exit/error detection before webhook
- Build detailed webhook failure messages with exit code, signal, and stderr tail
- Report spawn outcomes to hub via runner state updates
- Handle more spawn result types in rpcGateway with better error messages
- Display runner last spawn error in web UI (NewSession & SpawnSession)
- Extract shared formatRunnerSpawnError utility to avoid duplication

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

* fix(cli): narrow spawnResult type check to fix TS2339 error

Use `type === 'error'` instead of `type !== 'success'` to properly
narrow the discriminated union, allowing TypeScript to infer errorMessage.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:55:50 +08:00
06b71dbe98 feat: Add Claude Code Agent Teams support (#258)
* feat: Add Claude Code Agent Teams support

- Add TeamState schemas and types for team collaboration
- Extract team state from TeamCreate, SendMessage, Task tools
- Add database migration V3→V4 for team_state storage
- Add TeamPanel component to display team members, tasks, messages
- Add team tool icons and presentation rules
- Support vite proxy configuration via VITE_HUB_PROXY env var

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

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

* fix: Add timestamp protection for team_state updates

Prevent old messages from overwriting newer team state by checking
team_state_updated_at before updating.

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

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

* fix: Extract team tasks from Task/TaskCreate/TaskUpdate tools

- Enhance processTaskToolWithTeam to also generate task entries from
  the Task tool's description field when spawning teammates
- Add processTaskCreate handler for TaskCreate tool calls
- Add processTaskUpdate handler for TaskUpdate tool calls
- Register both new tools in the extraction switch statement

This fixes the gap where the Tasks section in TeamPanel could never
populate because team task data was not being extracted from the
message stream.

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

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

* fix: Skip orphan TaskUpdate without title to prevent schema validation failure

When TaskUpdate arrives before TaskCreate (message ordering), skip inserting
incomplete tasks that lack required title field, preventing entire teamState
from being dropped by schema validation.

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

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

* test: Add unit tests for orphan TaskUpdate handling

Verify that applyTeamStateDelta correctly skips inserting tasks without
title field (orphan TaskUpdate) while still allowing normal task creation
and updates to existing tasks.

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

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

---------

Co-authored-by: tfq <tfq@gmail.com>
Co-authored-by: HAPI <noreply@hapi.run>
2026-03-08 11:26:11 +08:00
JlovecandGitHub ef3328f48f fix(cli): support project slash command completion (#245)
Add project-level slash command discovery with recursive nested command scanning, pass workingDirectory through slash-command handlers, and align hub/web source unions to include project commands.
2026-03-05 12:50:59 +08:00
Fdcode6andGitHub 2080ee8c7f feat(cli): support slash commands from installed plugins (#155)
* feat(cli): support slash commands from installed plugins

Allows HAPI to automatically scan and load slash commands from installed Claude plugins (e.g. superpowers). Commands are indexed with their plugin namespace prefix.

* fix(web): add 'plugin' to SlashCommand source type

This fixes the typecheck error identified by the PR review bot.

* fix(web): update Suggestion type to include 'plugin' source

* fix(slashCommands): 修复 scoped 插件名称解析及多版本安装选择逻辑

- 使用 lastIndexOf('@') 替代 split('@')[0] 以正确解析带 scope 的插件名
- 按 lastUpdated 降序排序选择最新的安装实例
- 增加 installations 空数组边界检查
2026-02-08 10:37:04 +08:00
weishu fb9abc18d4 feat: Add directory tree tab to session files 2026-02-06 20:18:46 +08:00
Trustin LeeandGitHub 7cad11ca27 Add About section to settings page with version info and tests (#119)
* feat(web): add About section to settings page

- Add website link to hapi.run
- Display app version from CLI package
- Display protocol version from shared module
- Add Vitest testing setup with settings page tests

🤖 Generated with Claude Code

* test(web): add tests for website link and i18n key usage

Address residual risks mentioned in PR review:
- Test website link URL and security attributes (target, rel)
- Verify correct i18n keys are used for About section via spy

Simplify test setup by using real I18nProvider and en locale.

🤖 Generated with Claude Code
2026-01-30 09:48:49 +08:00
weishu 6f8f44a314 refactor: move attachment metadata schema to shared protocol package 2026-01-23 16:04:37 +08:00
freespace8andGitHub 2cb8727874 feat: expose skills and $ autocomplete (#92) 2026-01-21 16:28:15 +08:00
weishu 53e4de6684 feat: add file upload support 2026-01-17 19:38:12 +08:00
weishu 803c2f65a9 feat: add notification optimization with visibility tracking and toast messages
Implement a visibility-aware notification system that delivers toast messages via SSE to visible browser tabs instead of sending push notifications, reducing unnecessary push requests. Includes VisibilityTracker for monitoring connection visibility, toast UI components, and enhanced SSEManager with toast delivery capability.
2026-01-07 18:58:31 +08:00
weishu 21a64dfb49 refactor: extract session summary utilities to shared module 2026-01-05 13:00:48 +08:00
Ruihang XiaandGitHub d1c532301e feat: sync thinking state to session list view (#32)
Signed-off-by: Ruihang Xia <waynestxia@gmail.com>
2026-01-04 20:48:21 +08:00
weishu e2028b6914 Unify protocol types and modes 2026-01-03 14:43:59 +08:00
weishu 2de3a3d635 fix: expand codex custom prompt 2026-01-02 21:45:29 +08:00
weishu 43519621f3 feat: add web push notifications support
Implement push notification system with VAPID keys, client service worker integration, and push subscription management. Includes server-side PushService for sending notifications and PushNotifier for reactive event handling, plus client-side usePushNotifications hook and service worker support.
2026-01-02 19:19:40 +08:00
weishu e821458af8 feat: add namespace-based multi-user isolation
Implement namespace support across sessions, machines, and users for multi-user server deployments. Add access control with specific error reasons (namespace-missing, access-denied, not-found) and database schema updates with namespace columns and indexes.
2025-12-31 21:56:01 +08:00