* fix(cli): drop unknown SDK message types instead of passing them through
The SDK-to-log converter's switch had a fail-open default that stamped any
unrecognized SDK message with transcript base fields (parentUuid/sessionId/
userType) and forwarded it. Claude Code emits a tool_progress heartbeat every
30s for long-running tools, so a single slow Bash call flooded the chat: the
web normalizer matches no known shape for those records and falls back to
rendering the raw envelope as message text.
Gate the switch on an explicit allowlist instead, bailing before the uuid is
allocated so a dropped event cannot advance sidechain/parent tracking -- the
heartbeats share one parent_tool_use_id and were overwriting the pointer a
subagent's next real message parents to. This matches the local launcher,
which already enforces the same allowlist via RawJSONLinesSchema.safeParse.
The default branch stays as a fail-closed guard so adding a type to the
allowlist without a matching case drops the message rather than leaking it.
* fix(cli): re-check reassembled text for internal event JSON at flush boundary
isInternalEventJson was only applied per incoming chunk. In delta mode
(OpenCode) every chunk is a fragment, so none of them parses as JSON on its
own and the filter never fires; the pieces accumulate and flushText emits the
reassembled envelope verbatim. The dedupe path has the same hole whenever two
chunks share no overlap.
Check again in flushText, which is the first point the complete text exists,
and tolerate surrounding whitespace so an envelope preceded by a newline is
not waved through by the leading-'{' fast path.
Genuine assistant output that happens to be JSON is unaffected: the matcher
still requires the specific { type: 'output', data: { parentUuid, sessionId,
userType } } envelope shape.
* fix(cli): fail closed on unrecognized agent message in converter
convertAgentMessage's exhaustiveness default returned the message object
itself at runtime. The never binding makes the branch unreachable today, but
every caller forwards a non-null result straight into the chat stream, so the
failure mode if it were ever reached is a raw object on screen. Keep the
compile-time check, return null at runtime.
* test(cli): cover command_lifecycle, a second unknown type seen leaking
Observed in the same session after tool_progress. The allowlist already
covered it with no code change, which is the argument for gating on known
types rather than adding a case per offender.
* 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>
Hub already passes access.sessionId on resume (#1088); Cursor CLI still ignored
it (Codex-only). Parse/pass the flag for cursor and lock in reuse-without-ready-wait tests.
Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): buffer Pi prompts until RPC startup ready
A prompt POSTed immediately after spawn (a supported handoff pattern used
by hapi-ping-peer and intake scripts) could reach `pi --mode rpc` before
its `new_session`/`get_state` startup finished, wedging the turn:
`agent_start` then silence, no tool calls. The socket goes `active` (spawn
success) well before Pi's session is initialized, so `active` is not a
safe ready signal for Pi.
Gate outbound prompt/steer sends behind a startup ready gate on PiSession:
`runWhenReady()` delivers immediately once ready, else buffers FIFO;
`markReady()` fires on the first `get_state` response (the signal that
persists `metadata.piSessionId`, which working callers already wait for)
and drains the buffer in order. A 30s unref'd fallback timer force-drains
if `get_state` never lands, degrading to prior send-anyway behaviour
rather than swallowing the message forever.
Fixes#1143
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): honor cancel-queued-message for buffered Pi prompts
Addresses the MAJOR review finding on the startup ready-buffer: while a
prompt is held behind runWhenReady, the hub can send cancel-queued-message
for its localId. Pi registered no onCancelQueuedMessage handler, so
ApiSessionClient acked removed:false, the hub marked the row invoked, yet
the buffered closure still drained on get_state and fired the cancelled
prompt.
Carry the localId with each buffered send and add
PiSession.cancelBufferedMessage, then register apiSession.onCancelQueuedMessage
so a cancel drops the still-buffered prompt (returns true) instead of
sending it. Once drained to Pi it cannot be recalled — returns false,
matching the other agents' queue.cancelByLocalId best-effort semantics.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Cursor ACP (and other remotes) flagged the glued-on SKILL_LOOKUP_INSTRUCTION
as prompt injection. Keep discovery on the skill_lookup MCP tool description
and on system prompts (OpenCode/Grok); do not taint user messages.
Fixes#1095
Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cli): cap and back off consecutive remote launch failures
claudeRemoteLauncher's respawn loop retried claudeRemote() immediately
on every throw with no backoff or limit. A deterministic launch
failure (bad auth, invalid model/args, spawn failure) respawned in a
tight loop instead of giving up, hammering the same failure forever.
Track whether onReady() fired at least once per attempt to tell an
immediate/deterministic failure apart from a failure after real
progress, back off between immediate-failure retries, and after 3
consecutive immediate failures drop the message that keeps triggering
them and reset the streak, instead of respawning forever. The session
keeps running so a later, unrelated message still gets its own budget.
The streak reset on a non-throwing attempt is itself gated on having
reached onReady, not applied unconditionally -- otherwise a message
that keeps getting parked and re-picked-up on alternating attempts
(e.g. an isolated command hitting the same deterministic failure)
would reset the streak every other attempt and the cap would never
fire.
* fix(cli): restore queued message when remote launch fails before delivery
MessageQueue2.collectBatch() acks a message (fires onBatchConsumed,
which the hub uses to mark it consumed) at dequeue time, before the
message ever reaches the SDK. If claudeRemote() then throws before
onReady -- e.g. the process dies right after picking up the message --
the catch block only logged and retried, so the message vanished: the
hub already thinks it was delivered, but the CLI never acted on it.
Track the message returned from nextMessage() (whether freshly
dequeued or held in `pending` across a mode change) as in-flight until
the next onReady confirms it was handled, and restore it to the front
of the queue (preserving isolation via unshiftIsolated when needed) if
the attempt throws and will be retried. Restoring happens even if the
throw races with a user-initiated switch/exit, so a message is not
silently dropped by that unrelated shutdown either.
When the immediate-failure cap from the previous commit is reached,
the in-flight message is dropped instead of restored: unshifting it
back would just feed it into another immediate failure on the very
next attempt, storming again. This mirrors
cursorLegacyRemoteLauncher's existing drop-and-reset policy on its own
consecutive-failure cap.
* fix(cli): preserve localId when restoring a failed message batch
MessageQueue2.collectBatch() already collects each queue item's
localId (it fires onBatchConsumed with the full list to ack them), but
only exposed the joined `message` string to callers, discarding the
per-item localIds and their original boundaries in the process.
When claudeRemoteLauncher restores a dequeued-but-undelivered batch
after a launch failure, it re-added the joined string as a single new
queue item with no localId, orphaning the retried prompt from the hub
row(s) it originated from (and from cancel-by-localId).
Expose the pre-join `items` breakdown (message + localId per item)
alongside the existing joined `message` field on
collectBatch()/waitForMessagesAndGetAsString() -- purely additive, so
the other callers of waitForMessagesAndGetAsString() (grok, kimi,
opencode, cursor, codex, runAgentSession) are unaffected. On restore,
unshift each original item individually in reverse order, so the
localId and relative order of a multi-message batch are both
preserved instead of just the first item's.
* fix(cli): reset immediate-failure streak on a delivered non-onReady success
claudeRemote.ts's /clear handling delivers the queued message to the
SDK, then calls onSessionReset()/onCompletionEvent() and returns
successfully without ever calling onReady(). The success-path streak
reset only cleared on reachedReadyThisAttempt, so a successful /clear
between two unrelated immediate launch failures did not reset the
streak: an unrelated message's very next failure could hit the
3-in-a-row cap after just 1 failure, and the resulting banner would
misreport "3 times in a row".
Track whether nextMessage() actually handed a message to the SDK this
attempt (deliveredMessageThisAttempt), separately from whether the
attempt reached onReady, and reset the streak on either signal. The
livelock-prone case this guards against (a message parked into
`pending` and the attempt returning without ever delivering anything)
leaves both flags false, so it still does not reset the streak.
* feat(shared,cli): whitelist away_summary so auto recap reaches the hub
Claude Code's local TUI writes an automatic away-summary recap to the
session transcript on window blur/focus (5min+ idle), but
VISIBLE_CLAUDE_SYSTEM_SUBTYPES dropped it before it ever reached the
hub. Add it to the whitelist so the local launcher forwards it like
the other system subtypes, and cover the forwarding + Zod passthrough
of the recap `content` field with tests.
* feat(web): render Claude Code's automatic away recap in the chat
Once away_summary reaches the hub (previous commit), the web chat
still dropped it silently: normalizeAgent had no branch for the
subtype, so it fell through to `return null`. Add a `recap` AgentEvent,
a normalizeAgent branch mirroring the existing turn_duration/compact
subtype branches, and a presentation entry that prefixes the text with
`recap:` so it reads distinctly from the manual /recap assistant
bubble (which already renders as a normal message). No new render
component needed: it flows through the existing generic system-event
row (SystemMessage.tsx + getEventPresentation) that every other system
subtype already uses.
* fix(web): drop inaccurate manual-/recap comparison from recap comments
* perf(claude): scan transcripts incrementally
The claude session scanner re-read the entire transcript JSONL on every
scan, so the cost of each poll grew with the length of the conversation.
Track a byte offset per file instead and parse only the bytes appended
since the previous scan.
A trailing partial line — a write still in progress — is held back until
its newline arrives. A file that shrank resets the cursor to 0; the base
scanner's uuid dedup absorbs the re-sent events. A read that fails
returns no events and leaves the cursor where it was, so a transient
error is retried on the next scan rather than skipping content.
The codex scanner received this in #1031; this extends the same
improvement to the claude scanner. readSessionLog is exported for tests,
mirroring readTranscriptRange there.
* fix(claude): forward a complete final record with no trailing newline
The incremental reader consumed only through the last newline, so a final
JSONL record flushed without its terminating newline — at shutdown or on
import — was held back as if it were a partial write and never forwarded
until a later append supplied the newline. The previous whole-file reader
parsed such a record.
Consume a trailing segment when it already parses as a complete JSON value,
and keep holding back a genuinely partial line (which parses as incomplete).
* fix(opencode): stop treating empty tool input as final args
OpenCode emits input/rawInput as {} on tool start (and sometimes again
during permission), then fills real arguments on running/completed.
Treat empty objects as unusable so ACP and local hooks keep waiting for
real args, never clobber them, and ignore non-tool parts as fake results.
* fix(web): add exec timing fields to ToolCard test fixture
ChatToolCall now requires execStartedAt/execCompletedAt; update the
fixture so typecheck passes.
* fix(opencode): recover late tool-call after empty execute.before
Skip empty before under name-only queue pairing, emit tool-call on after when
still missing, and reject content JSON {} on ACP initial tool_call.
* fix(claude): consume the one-time --resume flag only once it is used or discarded
Reopening a remote Claude session and hitting a relaunch trigger (e.g.
/compact, or a mode/model/effort change) before the first turn is ever
processed loses the original --resume session id and starts a brand new
Claude session with no prior context. With /compact this also shows up as
the compaction ending immediately with "Not enough messages to compact.",
because the session it lands in is empty.
claudeRemoteLauncher called session.consumeOneTimeFlags() right after every
claudeRemote() call, including calls that returned before spawning Claude
(nextMessage() resolves null when the relaunch trigger arrives before any
turn was handled, so the message is parked as pending). That retired the
one-time --resume flag before the SDK ever had a chance to use it, so the
next launch started fresh instead of resuming.
Tie the flag's lifetime to the invariant it needs: retire it only once it
has been used, or once the context it points at has been explicitly
discarded. It is now consumed from onSessionFound (Claude reported a
session id back) and from onSessionReset (/clear dropped the context, and
/clear likewise returns before spawning Claude, so without this the flag
would outlive the reset and the next launch would resume the very session
the user just cleared). Attempts that reach neither outcome never touched
the anchor, so the flag survives for the next launch.
* fix(claude): report the actual /compact outcome instead of always success
When Claude cannot compact a session it says so on a system/status message
carrying compact_result: "failed" and a compact_error reason (for example
"Not enough messages to compact."), which arrives shortly before the result
message. The completion event was emitted from the result message alone, so
a compaction that did not happen was still surfaced to the user as
"Compaction completed".
Record the reported outcome when the status message arrives and use it when
the result message is handled, mirroring the wording the Codex launcher
already uses for the same situation ("Compaction failed: <reason>").
Only an explicitly reported failure is recorded: a status shape without
compact_result, or one reporting anything other than "failed", leaves the
existing success path untouched, so an unrecognised or unseen status can
never invent a failure.
hapi kimi local mode spawned the kimi TUI with no transcript sync, so
terminal conversations never reached the hub and the web UI stayed empty.
After the kimi-code rewrite (data moved from ~/.kimi to ~/.kimi-code),
model resolution also broke: hapi read the gone ~/.kimi/config.toml and
fell back to the invalid hardcoded default kimi-k2, and the KIMI_MODEL /
KIMI_PROJECT_DIR env vars it set no longer exist upstream.
Local sync (mirrors the codex transcript scanner):
- kimiWireLocator: derive the kimi-code workspace id
(wd_<slug>_<sha256(cwd).12>, ported verbatim from upstream workdir-slug),
poll for the session dir created by the just-spawned process, and watch
its agents/main/wire.jsonl. Pre-existing sessions are snapshotted and
excluded (awaited before spawn) so a retry cannot bind to a stale
session; multiple fresh candidates are refused as ambiguous.
- kimiWireScanner: incrementally read wire.jsonl and convert events into
hapi messages (user prompts/steers, assistant text/thinking, tool
call/result incl. is_error, step.end usage with cached input summed
into inputTokens).
- kimiLocalLauncher: attach locator+scanner, report kimiSessionId on
discovery (enables web resume and local<->remote handoff).
Model handling:
- config.ts: read <KIMI_CODE_HOME|~/.kimi-code>/config.toml (legacy
~/.kimi fallback); drop the hardcoded kimi-k2 default and the dead
KIMI_MODEL env source - when nothing is configured, omit --model so
kimi-code uses its own default_model.
- kimiBackend/kimiLocal: stop setting KIMI_MODEL and KIMI_PROJECT_DIR
(both unused by new kimi-code).
- kimiRemoteLauncher: apply the resolved model over ACP after session
creation (session/set_model, falling back to the advertised model
config option), and display the agent-reported current model instead
of the env guess.
Verified against live kimi-code 0.26.0: ACP initialize/session-new/
prompt probes, locator discovery of a running session, and converter
robustness over a real 800-line wire.jsonl.
* test: reproduce issue #786
* fix: load extra headers from settings (closes#786)
* test: cover extra header precedence and redaction
* fix: redact persisted extra headers in diagnostics
* test: cover runner extra header identity
* fix: restart runner when extra headers change
Legacy rows and inserts left sessions.active_at NULL while SessionSchema
required a number, so CLI GET /cli/sessions/:id failed Zod and resume
surfaced HTTP 500. Persist active_at on insert, harden hub read coerce,
and nullish-transform activeAt in SessionSchema (output stays number).
Fixes#1025
Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>