Commit Graph
164 Commits
Author SHA1 Message Date
AnanovoandGitHub 5c5c8b3a9f feat(codex): support user-configured MCP servers in remote sessions (#1789)
* feat(codex): load user MCP servers in HAPI sessions

* feat(codex): proxy Windows MCP command shims

* fix(codex): address MCP review feedback

* fix(codex): use HAPI-owned MCP proxy

* fix(codex): preserve Windows MCP argv boundaries

* fix(codex): keep remote MCP placement unchanged
2026-09-09 09:26:43 +08:00
NightWatcher314andGitHub 97b1dd4c34 fix(codex): preserve context config in remote sessions (#1633) 2026-09-06 14:19:44 +08:00
AnanovoandGitHub aa0c1dc808 fix(codex): fail closed on ambiguous Web Rewind boundaries (#1707)
* fix(codex): fail closed on ambiguous Web Rewind boundaries

* fix(codex): reject malformed native rewind history

* fix(codex): reject duplicate rewind identifiers

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

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

* fix(codex): reject unresolved rewind boundaries safely

* chore: trigger PR checks

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

* fix(codex): require complete user ids for rewind fallback
2026-09-06 14:18:40 +08:00
f4553dd1ec fix(codex): ignore late app-server writes during disconnect (#1748)
* fix(codex): ignore late app-server writes during disconnect

* fix(codex): scope app-server responses to their process

---------

Co-authored-by: huxiang <huxiang@myai.tech>
2026-09-06 14:18:20 +08:00
weishu e5a8212f4a feat(session): validate agents and browse workspace directories 2026-08-25 16:12:29 +08:00
SSU-WEI HUANGandGitHub f0e5ba9c0f feat(codex): mid-turn Steer via app-server turn/steer (#888) (#1606)
* feat(shared): steer capability gates and live steered signal schemas

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(steer): persist indeterminate outcomes without replay

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

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

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

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

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

* fix(steer): recheck indeterminate cancels after ACK

* fix(steer): close retry and abort races

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

* fix(steer): distinguish live dispatching from unknown

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

* fix(steer): distinguish held cancel from removal

* fix(store): combine schema v24 migrations

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

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

* fix(steer): release explicitly cancelled unknown reservations

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

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

* fix(codex): terminate abandoned transport writes

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

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

* test(codex): mock abandoned transport callback

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

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

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

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

* fix(steer): serialize concurrent retry claims

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

* fix(native): serialize retry operations

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

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

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

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

* test(web): mock indeterminate queued recovery state

* fix(steer): recover consumed ACK tombstones

* fix(steer): expose consumed cancel tombstones
2026-08-19 20:07:39 +08:00
Shawn TianandGitHub 5c81ed69e2 fix(runner) preserve native instructions (#1557)
* fix(codex): preserve native instructions

Keep HAPI guidance in the developer layer so app-server retains Codex persistence rules. Align long-running goal states with the Codex 0.147 protocol.

* fix(codex): filter new goal statuses
2026-08-16 22:38:15 +08:00
AnanovoandGitHub effc505a07 fix(mcp): clarify display_image user-output semantics (#1568) 2026-08-15 11:15:27 +08:00
SSU-WEI HUANGandGitHub e6e229c068 fix(codex): preserve plan approval in yolo (#1420) 2026-08-10 10:44:41 +08:00
weishu c2cc73159c fix(codex): preserve transcript final answer ordering 2026-08-10 10:36:34 +08:00
weishu cde0507bad fix(codex): harden model discovery fallback 2026-08-09 09:04:28 +08:00
b7f52f58ca feat(media): add audio and file display (#1405)
* feat(cli): cross-flavor inline image display via MCP and ACP

Share display_image prompt across MCP-bridge flavors (Cursor, Gemini,
Kimi, Codex, Claude, OpenCode), auto-approve the tool in
buildHapiMcpBridge, handle ACP image content blocks, and harden
generated-image registration with content sniffing.

Closes tiann/hapi#956

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

* fix(web): render generated-image cards reliably in chat

Keep object URLs stable across refetch, upscale tiny inline images,
fetch generated-image bytes with cache no-store (avoid empty 304 bodies),
and load hapiMcpUrl from per-session API in hapi-display-image tooling.

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

* feat(cli+web): display_video MCP for inline mp4/webm (#956)

Add display_video alongside display_image, video MIME sniffing with avif
guard, web GeneratedImageCard video player, and hapi-display-image auto-routing.

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

* feat(cli+web): cross-flavor display_video parity with images (#956)

Share display_video prompts across MCP-bridge flavors, auto-approve the
tool, register mp4/webm via path sniffing, render inline video in web on
the existing generated-image RPC path, and restore robust media card fetch.

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

* fix(cli): ACP image ordering and inline media source provenance

Flush buffered assistant text before async generated_image emit from ACP
image blocks (PR #958 review Major). Add optional source metadata on
generated-image wire messages (ingress, flavor, toolCallId, toolName) for
MCP, ACP, and Codex tool-result paths. Seeds artifact-event follow-up #966.

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

* fix(cli+web): address PR #958 review Majors on media order and stale blobs

Queue ACP session updates and await async image registration before later
events; clear GeneratedImageCard blob state when imageId changes.

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

* fix(cli): await ACP queue after late-drain before turn_complete

Straggler session/update during drainLateBuffers can queue async image
registration; re-await sessionUpdateQueue so generated_image is not emitted
after turn_complete.

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

* fix(scripts): route AVIF ftyp brands to display_image in helper

Match server-side detectImageMimeType so .avif files are not sent to
display_video and rejected as unsupported video.

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

* feat(#956): agent inline-media doctor and discovery fixes

- hapi doctor inline-media: probe bridges, print per-session inline commands
- Expose hapiMcpUrl on session list summaries (stops false "no MCP" scans)
- Helper script: match cursorSessionId prefixes; HAPI_SESSION_ID path-only mode
- ACP bridge prompt: shell fallback + HAPI session id vs agent id rule

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

* fix(web): allow immutable cache for generated media blobs

Drop cache: no-store on generated-image fetch so browser can reuse hub
immutable responses; on 304 re-read via force-cache (#927, PR review).

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

* fix(#956): Cursor native MCP overlay; drop user-turn bridge prepend

Cursor ACP ignores session/new mcpServers. Write .cursor/mcp.json and
run agent mcp enable hapi instead. Remove HAPI_MCP_BRIDGE_PROMPT from
user turns on ACP remotes; enrich MCP tool descriptions for discovery.

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

* fix(cli): preserve non-HAPI mcp.json keys on Cursor overlay cleanup

Cleanup only removes or restores the hapi MCP entry instead of rewriting
the full pre-session snapshot, so concurrent edits to other servers survive.

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

* fix(cli): handle generated_image in Grok ACP launcher switch

Upstream Grok launcher exhaustiveness broke after AgentMessage gained
generated_image for cross-flavor inline media.

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

* fix(cli): rebase fallout for display_video + OpenCode skill lookup

Gate display_video in the STDIO bridge, restore OpenCode first-prompt
TITLE_INSTRUCTION (skill_lookup), and update tool-list test expectations.

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

* fix(cli): leave user-owned hapi MCP entry alone on overlay cleanup

Only undo mcpServers.hapi when it still matches the exact entry this
session installed; concurrent Cursor/user edits of that key survive.

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

* fix(cli): exact-match auto-approve for display_image and display_video

Move media tools off substring name/id hints onto the exact-name set so
forged lookalike tools are not approved in default permission mode.

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

* fix(cli): drop dead Cursor bridge prompt; put guidance in MCP descriptions

Cursor must not get a user-turn media prepend (prompt-taint). Remove unused
HAPI_MCP_BRIDGE_PROMPT_CURSOR and embed DISPLAY_*_PROMPT_CURSOR in the
display_image/display_video MCP tool descriptions instead.

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

* fix(cli): require user approval for display_image and display_video

Those tools read arbitrary local paths into chat; keep them on MCP
approval_mode prompt and out of default-mode auto-approve exact names.

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

* chore: re-trigger Codex PR review after provider 503

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

* fix(cli): ignore URI-only ACP image blocks that read local disk

Passive ACP agentMessageChunk handling must not load file:// or bare
paths; local media goes through prompt-gated display_image/display_video.

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

* fix(cli): allowlist MP4 ftyp brands for video sniffing

Reject HEIC/HEIF and other non-video ISO-BMFF containers instead of
treating every non-AVIF ftyp as video/mp4.

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

* fix(cli): keep Cursor ACP startup if MCP overlay fails

Wrap installCursorMcpOverlay so a malformed project .cursor/mcp.json
cannot abort the session; continue without inline media tools.

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

* fix(cli): fail doctor inline-media when checks fail

Exit non-zero whenever required checks fail, even if an active
hapiMcpUrl bridge is present.

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

* fix(cli): expect display_video when change_title is disabled

Native ACP title mode still exposes display_image and display_video;
update startHappyServer test after rebase onto 0.23.4.

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

* fix(cli): keep ACP title sync synchronous outside media queue

session_info_update title forwarding (#1028) must not wait on the
async message-handler queue used for inline media ordering.

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

* fix(cli): sniff media headers only; advertise OpenCode display_video

Read 16 bytes for detectMediaTool instead of the whole file, and include
hapi_display_video in OPENCODE_NATIVE_TOOL_INSTRUCTION for remote ACP.

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

* fix(cli): show Cursor generate_image inline in HAPI chat

cursor/generate_image only emitted a tool card; register filePath or
base64 imageData into generatedImages and emit generated_image so the
web chat card renders (issue #956 / swear01 report).

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

* fix(cli): ignore path-only Cursor generate_image reads

Path-only filePath registration bypassed permission-gated display_image /
display_video MCP tools. Keep base64 imageData only; local paths must go
through MCP approval.

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

* fix(cli): gate inline media base64 length before decode

Reject oversized ACP/Cursor base64 payloads by character count so the
CLI never allocates past the 25 MB generated-image cap.

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

* fix(cli): require EBML DocType webm for inline video sniff

Bare EBML magic matches Matroska/MKV too; only accept DocType webm.
Also restore annotated Playwright cursor in annotatedVideoUseOption.

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

* fix(scripts): read 128-byte header for WebM DocType sniff

detectMediaTool only loaded 16 bytes, so EBML DocType webm was often
missing and valid WebM files fell through to display_image.

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

* fix(web): compare generated-image source by value in reconcile

Wire normalization allocates a fresh source object each pass; reference
equality forced media-card recomputation on every reload/SSE refresh.

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

* fix(cli): inject Cursor MCP enable for overlay unit tests

installCursorMcpOverlay always spawned `agent mcp enable`; tests now pass
a noop so the suite never shells out to a real Cursor binary.

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

* fix(cli): shell-quote doctor inline-media helper command

Paths and session prefixes with spaces/metacharacters broke the copied
snippet; JSON.stringify each interpolated argument.

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

* test(cli): fix doctor inline-media quote path expectation

Repo root from scriptPath is three levels up (cli/), not the parent of cli.

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

* fix(cli/web): per-session Cursor MCP overlay id and bound tiny-image scale

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

* fix(cli): redact generate_image base64 from logs and fix doctor MCP ids

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

* fix(cli): harden inline-media doctor for packaged installs and hub headers

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

* fix(cli): lock Cursor mcp.json updates and bound ACP media filenames

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

* fix(cli): preserve mcp.json mode and token-scoped overlay locks

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

* fix(cli): publish Cursor MCP lock owners via link(2) and treat EPERM as alive

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

* fix(cli): fail closed on stale MCP locks; keep concurrent mcp.json top-level keys

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

* fix(cli): roll back Cursor MCP overlay when agent mcp enable fails

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

* fix(cli): await ACP session queue in suppressUpdatesDuring tests

#958 queues handleUpdate for media registration; upstream compact tests
assumed sync delivery after restore.

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

* fix(cli): capture ACP handler at enqueue; keep display_video manual

Close two Major review findings on #958: suppress queue leak after
restore, and Claude --allowedTools auto-approving local-path video.

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

* fix: write through symlinked mcp.json; lazy-load inline video

Preserve user Cursor MCP symlinks on atomic overlay writes, and require
explicit Load video before fetching large generated-video blobs.

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

* fix(web): use valid TerminalToolDisplayMode in media card test

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

* fix(scripts): require unique session prefix in display helper

Reject ambiguous prefix matches so images/videos cannot land in the
wrong HAPI chat when multiple agent session ids share a prefix.

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

* fix(cli): always cleanup Cursor MCP overlay on teardown

Run overlay cleanup in finally so cancelAll/disconnect failures cannot
leave a dead hapi-<sessionId> entry in .cursor/mcp.json.

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

* fix(cli): handle Copilot generated_image; refuse MCP symlinks

Unblock typecheck after Antigravity/Copilot merge, and fail closed when
.cursor/mcp.json or .cursor is a project-controlled symlink.

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

* fix: abort restore deliveryMode; prune dead Cursor MCP overlays

Unblock web typecheck after steer merge, and recover orphaned hapi-*
mcp.json entries via HAPI_MCP_OVERLAY_PID ownership stamps.

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

* fix(cli): recover dead-PID Cursor MCP overlay locks

Token-matched unlock so a crash mid-lock no longer permanently disables
inline media; keep live-owner waits identity-safe.

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

* fix(cli): serialize Cursor MCP stale-lock recovery

Acquire an exclusive recovery lock before token-matched unlink so two
recoverers cannot remove a successor's live mcp.json lock.

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

* fix(cli): fail closed on stale Cursor MCP overlay locks

Withdraw racy auto-recovery: pathname check-then-unlink/rename can steal
a successor lock. Stale locks throw with an explicit rm hint instead.

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

* fix(web): drop duplicate deliveryMode on abort restore

Merge left both steer and queue; keep queue so retries after abort
do not re-bind to a later Pi turn.

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

* fix(cli): bound inline media reads on open fd

Close TOCTOU between pathname size check and readFile for display_image /
display_video and registerGeneratedImageFromPath. Also preserve non-PID
env edits on Cursor MCP overlay cleanup.

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

* fix(cli): repair happyMcpStdioBridge test syntax after merge

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

* fix(cli): write Cursor MCP overlay to ~/.cursor, not the project

Keep ephemeral hapi-<sessionId> bridges out of the checked-out tree so
agents cannot git-add a live loopback URL. Tests inject mcpConfigDir.

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

* fix(cli): point Cursor MCP diagnostics at ~/.cursor/mcp.json

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

* feat(media): add audio and file display

---------

Co-authored-by: HeavyGee <133152184+heavygee@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
2026-08-08 13:46:59 +08:00
weishu e3ed80676a fix(codex): restore transcript messages from Codex 0.147 2026-08-08 13:45:51 +08:00
988f8f183f feat: settings toggle for AGENT_NOTIFY_SUMMARY contract injection (#1376)
* feat: settings toggle for AGENT_NOTIFY_SUMMARY contract injection

Add a hub-persisted, default-off Settings control so operators can opt agents into emitting the trailing AGENT_NOTIFY_SUMMARY line. Propagate the resolved flag on CLI session bootstrap and inject at call time for Claude, Codex, OpenCode, and Grok (Cursor still unsupported).

Closes #1375

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

* fix: lock hub settings RMW and restore abort deliveryMode

Serialize settings.json updates with the shared .lock protocol and unique
temp files so the new hub toggle cannot clobber CLI/relay fields under
concurrency. Also supply deliveryMode on abort send-error restore so web
typecheck (and CI) pass.

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

* fix(web): wrap SettingsGeneralPage tests with QueryClientProvider

The hub-settings toggle uses TanStack Query; the settings page suite
was rendering without a QueryClient and blew up CI.

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

* fix(web): defer summary-contract toggle until settings load

Avoid rendering an interactive false switch while the hub GET is still
in flight, which could overwrite an enabled preference on early click.

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

* fix(web): clarify Grok coverage for summary-contract toggle

Local Grok has no instruction inject path; settings copy now matches
remote-only Grok support (and still excludes Cursor).

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 18:51:35 +08:00
KorenKritaandGitHub 256ad98ece fix: normalize cache token usage semantics (#1390)
Normalize usage input at parse time, mark inclusive producers, rebuild derived usage indexes, and preserve valid primary usage when cache partitions are malformed.

Fixes #1389
2026-08-06 18:50:49 +08:00
weishu 97c7412434 fix(codex): forward lifecycle hooks without allow decision 2026-08-05 23:04:10 +08:00
c0b30bf916 feat(cli): MCP list_peers + runner hub auth for peer discovery (#1372)
* feat(cli): MCP list_peers + runner hub auth inheritance

Runner-spawned agents could not discover same-hub peers without
sitting on the hub host or pasting a session id. Add MCP list_peers
(in-process credentials), export HAPI_API_URL/CLI_API_TOKEN after
auth init for shell fallbacks, and clearer auth failure hints.

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

* fix(cli): do not export default hub URL into HAPI_API_URL

exportHapiHubAuthEnv was writing the implicit localhost default into
process.env, which made maybeAutoStartServer skip starting the bundled
hub. Only export HAPI_API_URL when the URL came from env or settings;
always still export CLI_API_TOKEN. Also fill missing deliveryMode on
abort restore so web typecheck matches RawSendError (main tip unblock).

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

* fix(cli): widen initializeApiUrl mock return type in test

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

* fix(cli): never export CLI_API_TOKEN; exclude self from list_peers

Keep settings/prompt-backed hub secrets out of wrapped agent env so
shell JWT+curl cannot bypass peer-tool approval. Fresh hapi re-reads
settings; env-backed tokens already inherit. list_peers omits the
calling session from the shortlist.

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

* fix(cli): resolve peer labels via summary/path like web titles

list_peers was showing (unnamed) for ordinary sessions because titles
live in metadata.summary.text. Match web getSessionTitle and collapse
whitespace so each peer stays one agent-readable line.

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

* fix(cli,hub): emit full peer ids and honor GET /sessions?limit

Short 8-char prefixes collide across UUID namespaces; print full ids so
resolveSessionByPrefix stays unambiguous. Honor optional limit after sort
so listPeerSessions stops loading the whole namespace for scheduled counts.

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

* fix(hub): type sessions limit test mock as Map<string, number>

CI tsc rejected Map<string, null> for getNextScheduledAtBySessionIds.

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

* fix(cli,hub): unbounded ping resolve; peer list order=updatedAt

Keep GET /sessions?limit only for discovery callers. ping/inspect omit
limit so full UUIDs outside the first 500 stay resolvable. Peer lists
pass order=updatedAt so truncation matches newest-first. Basename
fallback splits Windows paths.

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

* fix(cli): auto-approve ACP title List Peer Sessions

Permission derivation prefers request.title; match the MCP tool title
form so default-mode ACP sessions do not prompt on discovery.

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

* fix(cli): pad list_peers fetch; split hub URL vs token hints

Fetch limit+2 when excluding the caller so overflow still surfaces at
limit=100. Clarify that auth login only saves the token, not HAPI_API_URL.

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

* fix(cli): use boolean overflow for ping-peer --list

Match MCP list_peers: fetch limit+1 and mark hasMore instead of claiming
an exact omitted count from a 200-row sample.

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

* fix(hub): tolerate mocked machineCache without expireInactive

CI flake: 5s inactivity tick hit test doubles that only stubbed
getOnlineMachinesByNamespace. Optional-call + stub the method.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:16:04 +08:00
c7e38e872c feat(a2a): steer session citations toward inspect_peer (#1373)
* feat(a2a): steer session citations toward inspect_peer

Copy-reference prose and markdown /sessions/<id> links both parse to hub
ids; MCP/CLI descriptions and flavor prompts forbid treating them as local
FS paths so agents call inspect_peer first (tiann/hapi#1370).

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

* fix(a2a): fail closed on ambiguous session citations

Codex #1373: do not silently pick ids[0] when a paste contains multiple
/sessions/ links (shared by inspect_peer and ping_peer). Also strip
trailing prose punctuation from bare citation ids.

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

* fix(a2a): prefer Copy-reference path over title /sessions/

Codex #1373 MINOR: titles containing /sessions/<other> must not make
normalizeSessionIdPrefix fail closed on an otherwise valid paste.

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

* fix(a2a): do not short-circuit multi-citation Copy-reference pastes

Codex #1373 MAJOR: only treat parenthesized Copy-reference as canonical
when the paste is that citation alone (plus optional steer suffix).

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 15:05:38 +08:00
00e8fc3a47 fix(codex): recover ready after stale terminal event (#997)
* fix codex stale terminal recovery

* fix(codex): ignore stale retry failures

* fix(codex): ignore stale retry terminal failures

Only task completion may bypass stale-turn duplicate handling during same-thread recovery, preventing delayed failed events from finalizing the active retry.

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

* fix(codex): separate stale turn recovery guard

Limit matching-thread status events to missing turn IDs so delayed status failures cannot affect an active retry turn.

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

* fix(codex): scope stale completion recovery turn

Accept a stale completion only for the immediately finalized turn, so older retries cannot finalize the active turn.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 10:58:49 +08:00
1761b696f7 feat: add cache-aware token usage dashboard (#1338)
* feat: add cache-aware token usage dashboard

Track normalized Claude, Codex, and ACP usage with incremental SQLite backfill. Exclude imported transcript history, rebuild usage after history rewrites, and expose an owner-only dashboard with cache-aware totals and breakdowns.

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

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

* fix: preserve usage model and local dates

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

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

* fix: normalize cached usage and timezone buckets

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

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

---------

Co-authored-by: HAPI <noreply@hapi.run>
2026-08-03 18:02:26 +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
SSU-WEI HUANGandGitHub 2556729ba8 fix(codex): use app-server native skills (#1289)
* fix(codex): use native skill catalog

* fix(codex): refresh changed skill inventory

* fix(codex): preserve skill fallback on discovery error

* fix(web): refresh skills when completion opens
2026-08-02 08:49:35 +08:00
bbe99f5c4c feat(codex): /personality slash + in-session app-server params (#1265)
* feat(codex): support /personality via in-session override

Intercept /personality in the Codex slash layer, keep the value in CLI
memory only, and forward it on thread/turn start when set. Unset means
omit the field so Codex config/thread defaults apply—no Hub DB or web UI.

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

* fix(codex): refuse fake /personality clear (sticky thread setting)

turn/start.personality sticks for later turns; omitting the field does not
restore config.toml. Drop default|auto|clear success paths and require an
explicit friendly|pragmatic|none instead.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 23:26:32 +08:00
bc543dc494 feat: rich composer session @-mentions + inspect_peer (#1228)
* feat(web): feature-flagged rich composer for inline session @ mentions

Custom segmented contenteditable (not TipTap) inserts caret-local session
atoms from the existing @ picker and serializes to markdown links on send.
Textarea path remains default until flag parity dogfood.

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

* fix(web): rich composer mention boundary + #1215 refs

Treat U+FFFC mirror atoms as word boundaries so @ after a session
token still opens autocomplete. Point comments at Fixes #1215.

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

* test(web): peer-stack e2e for rich composer session @ mentions (#1215)

Smoke: flag on, @ picker inserts inline session atom chip (not prose dump).
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): preserve newlines in rich composer Enter-newline mode

Chromium splits contenteditable on Enter into block divs; serialize those
as \\n and insert <br> when parent leaves Enter unhandled (Shift+Enter /
enter-inserts-newline).

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

* fix(web): show @ badge when rich composer mentions flag is on

Dogfood was invisible: flag-off looks like a normal textarea, and flag-on
had no chrome. Surface a small @ badge when enabled.

* fix(web): rich session composer on by default (not a user setting)

The plan dual-path was an engineering kill-switch, not an opt-in. Default
to the segmented composer; only richMentions=0 disables. Drop the flag
badge and record a peer-stack motion proof covering chips + baseline UX.

* fix(web): make rich composer Shift+Enter create a visible newline

Trailing <br>+empty text node was a silent no-op at EOL. Use
insertLineBreak (ZWSP pad fallback), assert real \\n in peer e2e.

* feat(web): hover tooltips on rich composer session chips

Show full title, status, short id, and path on chip hover via a portal
bubble fed by live useSessions lookup (drafts fall back to title + id).

* fix(web): dismiss rich composer chip tooltips on mouse leave

contenteditable pointerout/relatedTarget was flaky so tips stuck after
leaving the chip. Hit-test on pointermove, clear on prose/input/leave.

* fix(web): address cold-review Blocker/Majors on rich composer

Exclude peer e2e from default Playwright; force plain-text paste; restore
newline hard-stop in findActiveWord; fix root-anchored selection mapping
and nested-block serialize; cover with unit tests.

* chore: drop accidental .cursor files from rich-composer tip

* fix(web): close remaining cold-review gaps on rich composer

Drop absolute peer e2e tooling imports, prove chip→markdown send, and
harden paste/EOL/focus/tooltip/Enter edges before Meta rematerialize.

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

* chore: absorb soup playwright.config union for clean remat

Keep fork peer-stack timeouts/annotated-video wiring and add testIgnore
for e2e/peer so the next driver rematerialize does not conflict.

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

* revert: drop fork playwright tooling from upstreamable tip

Peer-stack annotated-video + HAPI_PEER wiring stay on fork main / soup.
Product tip only needs testIgnore for e2e/peer (see docs/tooling/peer-stack.md).

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

* fix(web): fix rich composer Shift+Enter double newline and paste space

Prefer manual newline+pad over execCommand insertLineBreak, and stop
applying autocomplete trailing-space on paste/drop paths.

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

* fix(web): pad EOL Shift+Enter after Range.insertNode split

insertNode always leaves an empty text sibling, so !nextSibling never
saw EOL; detect meaningful trailing content and cover with jsdom tests.

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

* fix(web): drop custom onDrop from rich composer

Intercepting drop without caretRangeFromPoint landed text at EOF or
no-oped in-editor moves. Native CE drop is enough for #1215; paste
still forces plain text.

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

* feat(web): sidebar-parity tooltips on rich composer session chips

Reuse SessionRowSummary (flavor, thinking/attention, schedule, todos,
relative ago, path) for chip hover so the tip matches the session list.

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

* chore: keep peer-stack e2e off the upstreamable tip

Peer specs and playwright.peer.config stay on fork main per
docs/tooling/peer-stack.md; default config still testIgnore's e2e/peer.

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

* feat: cite sessions with UUID wire + inspect_peer for agent/overseer

Rich composer chips already serialize to [title](/sessions/<id>); flush
before send so the agent prompt never gets title-only chip text. Add
inspect_peer (MCP + hapi inspect-peer) as the read twin of ping_peer so
that same id is immediately usable for overseer/agent peer lookup, with
system-prompt glue from citations to inspect/ping.

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

* fix(cli): gate inspect_peer behind permission approval

Cross-session history reads need the same prompt path as ping_peer:
keep inspect_peer off Claude --allowedTools and treat it as sensitive
in ACP/OpenCode read-only mode so prompt injection cannot silently
enumerate peer transcripts.

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

* chore: clarify playwright peer testIgnore is upstream-safe

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

* fix(web): keep session UUIDs on rich composer copy/cut/paste

Copy/cut write wire markdown so chips do not collapse to @title-only
clipboard text; paste reparses session links back into atoms.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 19:42:56 +08:00
weishu 92958890de fix(codex): preserve transcript sync across mode switches 2026-07-28 12:20:54 +08:00
weishu f0c7d0b7e7 fix(codex): preserve reasoning effort across mode switches 2026-07-28 12:20:54 +08:00
e2631a553a feat(cli): ping-peer CLI + MCP ping_peer for peer messaging (#1195)
* feat(cli): add ping-peer CLI and MCP ping_peer for peer messaging

Promote resume-if-inactive + wait-active + POST message into a first-class
CLI command and session MCP tool so agents stop reinventing JWT+curl.

Fixes #1194

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

* fix(cli): do not auto-approve MCP ping_peer

Cross-session messaging can resume a peer and inject a prompt, so keep
permission-mode gating (Codex PR review Major on #1195).

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

* fix(cli): require approval for ping_peer in read-only mode

Read-only auto-approve treated non-write names as safe; ping_peer can still
resume a peer and inject prompts, so gate it like a write tool.

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

* fix(cli): keep ping_peer out of Claude --allowedTools

toolNames still registers the MCP tool, but Claude auto-allow must not
pre-approve cross-session resume+inject without a permission prompt.

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

* fix(cli): re-check session active before ping-peer send

List/get can race; POST /messages still 409s if the target flips inactive
before send. Resume+wait again (and re-gate pi) immediately before POST.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 08:36:56 +08:00
SSU-WEI HUANGandGitHub 73bc45c1ae fix(codex): clear completed safety review prompt (#1156) 2026-07-26 15:07:24 +08:00
SSU-WEI HUANGandGitHub bd5e87898a feat(codex): support proactive /agent mode (#1172) 2026-07-26 15:01:53 +08:00
NightWatcher314andGitHub 5300e1ee39 feat(codex): support file mentions (#774)
* feat(codex): support file mentions

* fix(codex): quote file mention paths with spaces

* fix(codex): keep punctuation outside file mentions

* fix(codex): avoid parsing literal at-mentions

* fix(codex): scope file mention autocomplete
2026-07-24 11:00:48 +08:00
SSU-WEI HUANGandGitHub 40c1789357 fix(acp): sync native agent session titles (#1028)
* fix(acp): sync native session titles

* fix(acp): keep title refresh off turn path

* fix: reconcile native ACP titles with skill lookup

* fix: preserve OpenCode image tool instruction
2026-07-24 10:59:47 +08:00
af8d160364 feat(cli): export HAPI_SESSION_ID into wrapped agent env (self-targeting) (#1121)
* feat(cli): export HAPI_SESSION_ID into wrapped agent env

Publish the hub session id into process.env at session bootstrap so every
downstream agent spawn inherits it. HAPI runs one hub session per CLI process
(the runner forks a fresh hapi child per session; local is 1:1) and every
flavor's agent spawn derives its child env from process.env, so a single seam
covers claude / codex / cursor / gemini / opencode / kimi / grok / pi -
runner-spawned and local - plus future flavors, without touching each launcher.

Agents can read HAPI_SESSION_ID to self-target their own hub session over REST
or shell helpers without listing /api/sessions. Prefer the MCP display_image
tool for inline media when available; HAPI_SESSION_ID is the deterministic
fallback for non-MCP tooling.

Closes #1119

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

* feat(scripts): self-target hapi-display-image via HAPI_SESSION_ID

Teach the in-tree shell helper to use $HAPI_SESSION_ID for path-only /
self invocations: GET /api/sessions/:id directly instead of listing
/api/sessions. Explicit session prefixes keep the previous list path.

Gives #1119 a tangible now benefit - the tool that forced the wasteful
list-and-reverse-lookup dance no longer needs it inside a wrapped session.

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

* fix(cli): defer HAPI_SESSION_ID export until lazy Codex materializes

The provisional lazy-session id was exported at bootstrap before the hub
row existed, so path-only self-targeting (GET /api/sessions/:id) could
404 while materialization was still pending. Export on onMaterialized
instead, and await materialize in buildHapiMcpBridge before starting the
MCP server / spawning Codex so the agent inherits an id the hub can
resolve (and so hapiMcpUrl is persisted, not only local pending state).

Addresses Codex review Major on #1121.

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

---------

Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 10:56:59 +08:00
Fuyan YuanandGitHub fee853766a fix(codex): normalize resume args on local handoff (#1137) 2026-07-24 10:52:40 +08:00
SSU-WEI HUANGandGitHub aa5beb3af2 feat(codex): preserve native exploration actions (#1139) 2026-07-24 10:52:24 +08:00
weishu 74ad25ec57 feat(codex): refine tool activity display 2026-07-24 09:39:22 +08:00
weishu 3208f139b5 fix(codex): ignore subagents in transcript fallback 2026-07-22 09:20:00 +08:00
weishu 3dd425b15c fix(codex): restore transcript tool calls 2026-07-22 09:02:06 +08:00
bfd8c7e3fd fix(codex): use supported safe-yolo approval policy (#1079)
Co-authored-by: NPUlrk <21106497+NPUlrk@users.noreply.github.com>
2026-07-19 14:16:03 +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 6f5ecde2c4 fix(codex): expose HAPI threads to Codex Desktop (#1022)
* test(codex): reproduce desktop thread classification gap

* fix(codex): classify HAPI threads as user sessions

* test(codex): cover thread source creation paths
2026-07-16 12:35:04 +08:00
SSU-WEI HUANGandGitHub f457156bd1 feat(cli): add skill_lookup MCP for non-native agents (#1035)
* test: reproduce issue #752

* fix: expose skill lookup MCP tool (closes #752)

* test: cover ACP skill lookup instructions

* fix: inject ACP skill lookup instruction

* test: narrow skill lookup auto-approval

* fix: restrict skill lookup auto-approval

* test: cover exact skill lookup tool names
2026-07-16 12:31:36 +08:00
SSU-WEI HUANGandGitHub 553b3492f1 fix(codex): wait for manual compaction to finish (#1038)
* test: reproduce issue #982

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

* test: cover Codex compaction turn completion
2026-07-16 12:30:04 +08:00
87f5c78ab2 fix(codex): scan transcripts incrementally (#1031)
Co-authored-by: zj1123581321 <zj1123581321@users.noreply.github.com>
2026-07-13 15:51:40 +08:00
SSU-WEI HUANGandGitHub d97b270ba8 fix(codex): bridge MCP elicitation through user input (#1008)
* fix(codex): bridge MCP elicitation through user input

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

* fix(codex): preserve MCP form semantics

* fix(codex): accept implicit MCP form mode

* fix(codex): harden MCP elicitation prompts

* fix(codex): require valid MCP choice answers

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

* fix(web): require explicit MCP URL confirmation

* fix(codex): preserve MCP array item types

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

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

* fix(codex): route MCP tool approvals through permissions
2026-07-12 18:42:55 +08:00
SSU-WEI HUANGandGitHub d9ea9db507 fix(codex): preserve native safety behavior (#1024)
* test: reproduce issue #1020

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

* test: cover Codex safety buffering dismissal

* test: cover Codex safety retry edge cases

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

* fix(web): prevent service worker edge caching

* ci: retrigger stuck Actions run

* fix(codex): accept dynamic reasoning effort values

* fix(web): restore reasoning effort on model switch failure
2026-07-12 18:38:56 +08:00
weishu 8782b8a110 fix(codex): align local transcript message projection
Use semantic events as the visible text source and preserve completed plans as ordered plan proposal cards.
2026-07-12 14:33:37 +08:00
weishu 5a377e38b9 fix(codex): defer session persistence until user activity 2026-07-12 11:00:08 +08:00
2ab3b39887 fix(hub,cli): four hub-restart-cascade cleanup bugs (#913 #914 #916 #919) (#923)
* fix(hub,cli): four hub-restart-cascade cleanup bugs (#913 #914 #916 #919)

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

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

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

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

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

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

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

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

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

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

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

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

Refs tiann/hapi#914.

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

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

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

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

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

Refs tiann/hapi#916.

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

* revert(cli): drop HAPI_DEFAULT_ARCHIVE_REASON env override

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

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

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

Refs tiann/hapi#914.

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

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

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

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

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

Refs tiann/hapi#914.

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

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

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

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

Refs tiann/hapi#916.

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

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

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

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

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

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

Refs tiann/hapi#914.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 17:37:32 +08:00
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