Default peer CLI and MCP messages to auto, steering eligible Codex turns and queueing otherwise. Preserve explicit delivery modes and uncertain-send safety, and clarify steer guidance for important time-sensitive findings.
Persist and backfill the last user-message timestamp, and use it for deterministic session ordering across web and native clients while preserving manual pins.
Remove the fixed-order setting and guard ordering against transcript echoes, replayed messages, and stale REST/SSE updates. Add regression coverage and regenerate the SSE fixture.
* feat(android): anchor the Material type scale on the shared reading tokens
MaterialTheme was built with only a colorScheme, leaving HapiTypography's
body/code/caption tokens an island that never influenced Material
typography. Derive the app-wide Material type scale from those tokens so
Material widgets and direct token consumers share one type system: the
body token drives bodyLarge (the chat reading size) and the caption token
drives bodySmall (the metadata size), while every other slot keeps its
Material platform default so existing MaterialTheme.typography call sites
stay pixel-identical. Only size and leading come from the tokens; weight,
tracking and family stay Material's. HapiTheme now accepts a typography
argument (defaulting to the shared scale) and passes it through, and the
monospace code token stays a direct-consumer token because Material has
no code slot.
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
* feat(android): render settings pickers as expressive choice dialogs
The theme and language pickers were the last bare AlertDialog + radio-row
surfaces in the app: plain stacked radio buttons with no visual anchor for
what each option means, and two focus targets per row (the row and the
RadioButton each carried their own click handler). Give small exclusive
pickers a shared expressive form: each option renders as a full-width
choice card whose selected state is a secondaryContainer fill plus a
trailing check, and options can carry a fixed preview swatch.
The theme modes show two-tone palette chips — follow-system spans the
light and dark palettes, OLED is outlined pure black, dark uses the dark
canvas rather than black so the two stay distinguishable at a glance.
Swatch colors are fixed values depicting the mode they name, not derived
from the active scheme, mirroring how the web color-theme cards hardcode
their previews: a "light" chip must stay light even inside a dark dialog.
Rows keep radio semantics through Modifier.selectable with Role.RadioButton
inside a selectableGroup, so TalkBack still announces the standard
"selected, n of m" picker phrasing while each option becomes a single
focus target. Tapping an option still applies it immediately. Language
options skip the swatch slot; the component stays generic over the value.
Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>
---------
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
Pin the build toolchain to Bun 1.4.4 so CI includes the darwin-arm64 signing fix required by macOS 27.
Reject stale optional platform packages before launching, document clean reinstall recovery, and add launcher regression coverage.
Whether a session's agent is thinking lived only in the session cache,
so once a turn ended nothing could say how long the agent had worked,
and a hub restart forgot even the turn in progress. Tools that track
time (Weft, for one) had no way to see when agents on other machines
were busy.
Schema v28 adds session_turns: a row opens when a session starts
thinking (a prompt queued through the hub, the agent reporting it,
or a patch) and closes when it stops, the session ends, or its
keepalives expire, at the last keepalive. Rows are only written on
those transitions, never per keepalive. Turns move with a session
when it is merged away.
At startup the hub closes turns left open by the previous process at
the session's newest message. If the agent carries on thinking within
10 minutes of the restart, the same turn continues, so the work between
that message and the restart isn't lost. The deadline counts from the
restart, and a keepalive or patch reporting idle, the session ending,
or a new prompt ends the chance: those start a new turn.
GET /api/activity returns, for the caller's namespace, each session's
turns in a window (default 24 h, at most 31 days) with what identifies
it (flavor, agent session id, machine, path, title) and the permission
prompts and questions it waited on, taken from agent_state.
`hapi usage [--since] [--until] [--json]` prints a summary or a
hapi.activity.v1 report for other tools, with distinct exit codes for
bad arguments (2), missing or refused credentials (3) and a hub too
old to answer (4). The summary counts only the part of each turn
inside the window and subtracts overlapping waits once. It reuses the
peer commands' token exchange.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Abort sends session/cancel, and ACP answers the pending soft-steer
session/prompt with stopReason "cancelled". The completion handler
ignored the stopReason and acknowledged the row as steered, so a
message Cursor may never have run was shown as delivered.
Surface the stopReason from beginSoftSteerPrompt. On "cancelled" the
row is held as indeterminate (the same path a transport failure takes),
because Cursor may or may not have folded it into the cancelled turn:
the user resolves it with Retry or Cancel instead of a silent claim or
an automatic re-run after Stop.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
On iOS 27, SwiftUI re-runs a representable's update only when an
environment value read during it changes; even @Environment(\.self) is
tracked per key. AnchoredTranscriptList copied context.environment
without reading keys, and compared metrics later in a drain that is
often deferred. A theme switch or a system text size change therefore
never reached the hosted rows: text kept its old size and colors.
updateUIViewController now reads every runtime-changing value the rows
follow (Dynamic Type, bold text, typography, locale, direction, color
scheme, theme, link action, enabled state). Rows still receive the
complete environment snapshot.
The tests drive Dynamic Type through UIKit traits, as the system does.
On iOS 27 a SwiftUI dynamicTypeSize override does not reach a UIKit
representable's environment, and the app never sets one above the
list. A new test checks that theme-only and text-size-only changes
reach visible rows without reconfiguring cells; it fails without the
fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The iPhone Duo layout uses UIHingeInteraction, which only exists in
the iOS 27.1 SDK. CI ran Xcode 16.4 (iOS 18.5 SDK) on macos-15, so the
app build failed: #available is a runtime check and cannot hide a
symbol the SDK lacks. ChatView's iOS 26/27 modifiers would have failed
next.
Both jobs now run on the xcode-27 runner image (Xcode 27.1 by default),
the toolchain releases are built with. The READMEs state Xcode 27.1 as
the minimum.
Swift 6.4 also timed out type-checking a long `+` chain in
MultipartEncoderTests, so `swift test` failed to build; the expected
body is now joined from an array.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hub's fork, rewind and export routes had no iOS UI. The chat's
menu now offers:
- Fork Conversation: when the agent advertises forkCurrent, the
session is live and no Codex turn is running (the web gate). The new
session opens in place of this chat.
- Rewind or Fork…: a sheet listing loaded user messages that have a
native point in metadata.conversationHistoryPoints. Each row offers a
confirmed Fork from Here or Rewind to Here, gated like web: forks
need an idle turn except on Codex, and rewinds always need one. The
actions live in a sheet, not on the transcript bubbles, because those
keep text selection and cached row heights. After a rewind the tail
resyncs with a history reset. An ambiguous native boundary (409
ambiguous_native_boundary_fork_safe) offers a fork from the same
point instead.
- Export Conversation…: saves the hub's lossless JSON payload through
the Files save panel, using the web download's filename. A large
export asks before retrying with force; a 413 explains the limit.
Markdown export stays web-only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iOS showed scheduled messages but could not create them. A clock
button now sits next to the composer's attachment button. It offers
the web presets (in 5 min, 30 min, 1 h, 4 h) or a specific time up to 7
days ahead. A chip above the input shows the pending time; its × sends
normally again.
Presets resolve when the user taps Send, so "in 5 minutes" counts from
the send. Validation follows the web: a 30 s grace period for past
times and a 7-day maximum.
A scheduled send is text-only and queued. The hub rejects scheduledAt
with attachments or steer, so the clock is disabled while attachments
are present and the steer menu is hidden. The schedule clears once the
send dispatches. Sending reuses the existing optimistic path, and a
retry keeps the row's scheduledAt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Typing / in the iOS composer offered nothing; web and Android show the
session's commands. While the input is a lone /token, a list above the
card shows matching commands. A tap inserts "/name " so the user can
add arguments.
As on Android, the sources are metadata.slashCommands plus the
/slash-commands RPC. The RPC is fetched once, when the input first
enters slash mode, and its entries override bare names. A failed fetch
retries on the next /. There is no copy of the shared built-in table,
so the CLI stays the source of truth. Ranking follows web: exact,
prefix, contains, then near-miss typos. The list is hidden in
Scratchlist mode.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The iOS session list could only filter by machine. Web also has text
search, an unread-only filter and Mark All as Read.
- Search: a .searchable field ranks matches with the web relevance
rules, ported from sessionListSearch.ts via Android as
SessionListSearch. It searches title, worktree, summary, agent,
machine, path and id; every word must match, with */? wildcards.
Results keep the global/project pin sections, so the Pinned header
still applies.
- Filters menu:
- It now holds an Unread Only toggle that keeps the iPad's selected
chat listed.
- The machine section appears only with two or more machines, as
before; the menu itself shows whenever the hub has sessions.
- Mark All as Read advances the local last-seen watermarks after a
confirmation, as on web.
- Empty list: an empty search or filter result gets its own empty state
instead of "No sessions yet".
getSessionTitle moves into HapiKit so search and the rows share it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The settings sheet only had model pickers for Claude and Codex; other
agents could not change models after creation. Live sessions now load
/sessions/:id/<flavor>-models. Agy uses its machine catalog, since it
has no session route. As on web, grok and copilot skip
terminal-controlled sessions.
Rows follow web getModelOptionsForFlavor:
- Copilot, dsh, grok and kimi offer a Default or Auto row that clears
the model.
- Cursor's Auto is the explicit 'auto' wire id.
- Opencode, agy and pi only switch between listed models.
- An unlisted current model stays visible.
Pi resolves provider/modelId from metadata.piSelectedModel and switches
with a {provider, modelId} reference.
Efforts:
- Grok and OpenCode options come from their *-reasoning-effort-options
routes and reload after a model switch. OpenCode options that still
describe the previous model stay hidden.
- Pi shows the current model's thinking levels.
The sheet's load-failure flag now covers every catalog, not just Codex.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The create form only offered models for Claude and Codex, so every
other agent spawned on its default model. The form now loads the
catalogs web uses. Agy, cursor, dsh and pi read
/machines/:id/<flavor>-models. Copilot, grok, kimi and opencode probe
the same route with cwd once the directory exists; the view refetches
when the typed path turns out to exist.
Option rows follow the web builders:
- Copilot and cursor label the default row "Auto".
- Kimi and pi prefix the provider; pi values are provider/modelId.
- OpenCode marks the agent's own default.
- Cursor shows a flat list of ACP ids, without the variant drill-down.
Grok efforts and pi thinking levels follow the selected model, and an
OpenCode variant becomes the reasoning effort. Grok's auto permission
mode is hidden when the probe reports it unsupported. A restored
selection the catalog no longer offers falls back to Default, and
Create waits while a non-default selection is still being validated.
The spawn body sends model for all of these agents, and cursor always
sends it, with 'auto' as the default. Grok and pi send effort; opencode
sends modelReasoningEffort.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Push payloads do not name a hub, so a tap always opened the session
against the active hub. With several paired hubs, a session from
another hub then failed to load until the user switched hubs by hand.
When the active hub's list does not know the session, the tap now
looks it up with GET /api/sessions/:id. Hubs are tried in the same
active-first order, and 404/403 have the same meaning, as for the
notification actions. If another hub owns the session, the app
switches to it first; the new home screen then opens the chat. A miss
or an unreachable hub opens against the active hub, as before.
Single-hub installs skip the lookup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API wrappers existed but iOS had no UI for these actions, while
web and Android did. The session row's context menu and the chat's …
menu now offer Rename for every session. Active sessions keep Archive.
Inactive sessions get Reopen and a confirmed Delete instead of
Archive, which the hub rejects with 409 for them.
The store applies renames and deletes optimistically. A failed rename
refetches server state; a failed delete restores the row and the
cached detail. Reopen marks the returned session active right away.
When reopen returns a superseding id, the chat seeds the new window,
moves the draft and navigates to it, as send-resume already does. A
reopen from the list also moves the draft and opens the session. A
422 shows the hub's message with the missing fields.
On iPhone, an authoritative removal (delete, or archive elsewhere) now
pops that chat, matching the iPad split, which already clears the
selection.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the single-column settings screen with a category directory
whose rows come from a declarative catalog (SettingsHubCatalog: one
entry = route + title + summary + icon + visibility lambda), so adding
a category later (Display, Chat, Voice, Machines) is a list element,
not layout surgery. Mirrors the web settings IA at the category level.
- General subpage (settings/general) inherits the uncategorized
sections: Appearance (theme + Material You), Language, About
(versions + hub health). Zero behavior change per item.
- The owner-only Insights section dissolves into hub-level rows:
Usage/Storage render only for the owner (same ns=default gate) and
route to their existing dashboards, matching the web's owner-only
nav categories (SettingsNav OWNER_ONLY_CATEGORIES).
- Flat settings/... route pattern with per-hub ViewModelHolder, same
as the existing usage/storage precedents; back from a subpage lands
on the hub. The hub's holder passes probeHealth=false so opening the
hub itself fires no /health request; the General holder keeps it.
- Previews: hub owner/non-owner gating + General body. JVM tests pin
catalog order, the owner gate, route uniqueness, and row summaries.
- Strings: settings_category_general(+_summary) in en/ru/zh; relocate
three zh keys (chat_tool_resolved, chat_plan_implement,
chat_plan_continue) that had drifted to the file tail and broke the
three-language key order (KEYSYNC) — pure moves, values untouched.
via [HAPI](https://hapi.run)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
* ci: merge owner-approved PRs automatically after checks pass
Approval by the repo owner on a PR should be the only manual step before
merge. The auto-merge workflow squash-merges open PRs the owner approved
at their current head once every required check's latest run on that
head has passed (test, integration, windows-codex-mcp).
Fork PR review events receive no secrets, so a no-op signal workflow
runs on pull_request_review and its completion triggers the privileged
workflow, which runs default-branch code. Merges use AUTO_MERGE_TOKEN, a
user token, so the resulting pushes to main trigger push workflows; the
workflow refuses to merge if that secret is missing.
* ci: load the auto-merge script from main on every trigger
workflow_dispatch checks out the ref chosen by the caller, so a manual
run from a topic branch would execute unmerged merge logic with the
owner's token. Pin the checkout to main.
New Session treated a disabled availability query (empty agents, not
loading) as "this machine has no CLIs." That painted the same string as
a real empty probe, next to "No machines available."
Co-authored-by: Cursor <cursoragent@cursor.com>
A session's keepalive is emitted over a volatile socket channel, so any
transport outage longer than the 30s expiry window flips the session
offline even though the CLI process is alive mid-turn and its outbound
messages are buffered for delivery on reconnect — the session comes back
the instant the socket reconnects, which surfaces to clients as a
spurious "offline during thinking" state.
Give sessions with thinking=true a 5-minute expiry grace instead. The
grace is bounded, resets to the plain 30s timeout as soon as a keepalive
reports thinking=false, and genuinely dead processes still expire on the
extended timeout.
via [HAPI](https://hapi.run)
Co-authored-by: HAPI <noreply@hapi.run>
* feat(cli): add diff-shape tool input canonicalization helper
* fix(cli): canonicalize ACP diff-shaped tool inputs for the Edit/Write views
* fix(opencode): normalize local-mode edit/write tool parts to the diff view shapes
* fix(opencode): preserve replaceAll and fix id-less hook pairing
- canonicalizeDiffToolInput now carries OpenCode's optional replaceAll through
to the canonical Edit input as replace_all, so persisted history keeps
whether an edit replaced one occurrence or all.
- opencodeLocalLauncher computes the execute-hook pairing signature from the
raw name/input BEFORE canonicalizing, so an id-less 'before' with empty or
partial args shares a queue key with the full 'after'.
* fix(opencode): preserve snake_case replace_all in edit canonicalization
canonicalizeDiffToolInput accepts already-canonical snake_case edit inputs
(file_path/old_string/new_string), but only read camelCase replaceAll. Read
replace_all too so the execution argument survives every call site.
* fix(opencode): upgrade partial local edit/write calls when full args arrive
A partial native before ({filePath}) was emitted as-is and recorded as sent,
so the later canonical full input was deduped and the web Edit/Write view never
received the complete arguments. Track the emitted input per callId and, when
a partial native edit/write call is superseded by a canonical full input, emit
a replacement tool-call (same callId) before the result. Applies to both the
message.part.updated and tool.execute.* local event paths.
* fix(opencode): release completed tool inputs from upgrade tracking
emittedToolInputs retained the full emitted Write content and Edit old/new
strings for the whole launcher lifetime. Once a call is completed
(sentToolResults), later upgrades are already blocked, so drop the stored
payload in both result paths. Add a lifecycle test that a stale part for a
completed callId neither re-emits nor emits a duplicate result.
* style(opencode): restore 4-space indentation on local launcher tracking decls
The per-session tracking declarations (sentTextParts, sentToolCalls,
sentToolResults, emittedToolInputs) were flush-left inside opencodeLocalLauncher,
violating the repo's 4-space indentation rule.
* fix(opencode): gate diff canonicalization on edit/write semantics
canonicalizeDiffToolInput now takes the tool's semantic name/kind and only
normalizes known edit/write aliases (edit, write, write_file, replace, modify,
file_edit). The Edit shape (path + both old/new) is unambiguous on its own, but
Write is just {path, content} — a shape many non-edit MCP tools also accept —
so without the gate those tools were renamed to Write and their extra args
dropped. OpenCode maps both edit and write to kind 'edit', which stays in the
allow-list, so the native-shape cases keep working. ACP callers pass the update
kind (falling back to the derived name); local callers pass the tool name.
* fix(opencode): re-emit canonical edit/write when the input changes
shouldEmit only handled the null->canonical transition, so a shape-complete
Write/Edit that already canonicalizes (e.g. {content: ""}) was suppressed
when the final content arrived, leaving the web diff with stale input. Emit on
first usable input and re-emit whenever the canonical input value changes,
across both message.part.updated and tool.execute.* paths.
* test(opencode): cover changed-input re-emit on the message.part.updated path
The execute-hook path already had a regression for canonical inputs changing
from empty to final content; add the matching case for message.part.updated so
both local emit paths are covered.
* fix(web): render opencode native read output as a file content code block
OpenCode's lowercase read tool fell through to the generic markdown
result view, collapsing the whole file into a soft-wrapped paragraph
and dropping the <path>/<type>/<content> markup. Register the read
alias, unwrap the opencode payload (including the trailing
"(End of file ...)" trailer), parse its 1-indexed line numbering into
the code block gutter, and resolve the camelCase filePath key in the
card title.
* fix(web): only unwrap complete opencode read envelopes
Anchor the envelope match to the full <path>/<type>file/<content>
structure so plain Read results whose file text contains a literal
<content> element are left untouched, capture the body greedily so an
embedded </content> survives, and stop trimming real trailing
whitespace.
A failed `result` envelope still carries the turn's answer in `response` —
agy decodes that field from the complete buffer regardless of status. The
driver used it as the failure text, so a failed turn delivered the whole
answer a second time as an error event, right under the streamed copy.
Read the reason from `error` instead, which is what agy actually reports
there (e.g. "timeout waiting for response"), and keep the existing
`agy turn failed: <status>` fallback when the envelope carries none.
* feat(web): open a session in a separate window
* test(web): cover session sidebar restore control
* fix(web): preserve hidden sidebar across session navigation
* fix(web): retain hidden sidebar on session routes
* feat(hub): generate fallback titles after the first completed turn
* fix(hub): recognize batched first-turn title prompts
* fix(hub): track heartbeat-only first turns for title fallback
* fix(hub): preserve title turn identity across restarts
* feat(cli): report claude compact completion with token delta
Replace the plain 'Compaction completed' line with a pi-style
'📦 Compacted (<before> → <after> tokens)' fallback built from the
system/compact_boundary metadata, and prefix the start notice with 📦.
The SDK stream carries no summary body (measured), so the delta line is
the terminal state of the completion event.
* fix(cli): hide local-command tag user messages from chat
Claude Code echoes slash-command bookkeeping back over the SDK as plain
user-role messages wrapped in <local-command-*> tags. Flag them isMeta so
the existing downstream filters drop them instead of leaking raw stdout.
* feat(web): collapse compact summary card by default
Keep the header (title, token delta, timestamp) always visible and hide
the summary body behind a chevron toggle. Toggle state is component-local
and non-persistent.
* feat(cli): promote claude compact summary from the local transcript
* fix(cli): don't double-render the manual /compact boundary as a status line
* fix(cli): detect later-turn /compact and ignore stale transcript summaries
* fix(cli): establish the compact transcript baseline before the result can race it
* fix(cli): narrow the local-command meta filter to the compact stdout echo
* fix(cli): range-read only the transcript tail when polling for the compact summary
* fix(cli): complete the transcript tail read before parsing the compact summary
* fix(cli): refresh the context bar with post-compaction tokens after /compact
* fix(cli): scope the Compacted stdout suppression to the active compact turn
* fix(cli): make compact lifecycle ownership race-safe
* fix(cli): publish compact outcome before accepting the next turn
* fix(cli): cancel compact completion with its failed stream attempt
* fix(cli): propagate deferred compact completion failures
* refactor(cli): coordinate compact completion in the response loop
* fix(cli): correlate compact completion with its stream signal
* fix(cli): commit compact ownership when its result arrives
* refactor(cli): name the manual compact stream suppression predicates
* fix(cli): suppress the compact echo even when hook output is appended
* fix(cli): carry the resolved context window into the compact usage refresh
* feat(cli): list local OpenCode sessions over machine RPC
* feat(hub): import OpenCode sessions into HAPI
* feat(web): import OpenCode history from new-session picker
* fix(cli): derive OpenCode imported message ids from part ids
Random ids made every scan differ, so delta classification flagged
unchanged transcripts as diverged on re-import.
* fix(cli): surface unreadable OpenCode db as rpc error
A locked or unopenable opencode.db now fails the listing instead of
reporting an empty success; summaries normalize timestamps and the web
import toast counts only created or actually-appended sessions.
* fix(hub): fail closed on opencode re-import past live HAPI continuation
Imported entries must remain a strict prefix of the native transcript.
Insertions ahead of the imported boundary or new native messages after
the session continued in HAPI now fail closed instead of appending
duplicated or reordered history.
* fix(web,hub): apply selected launch config when importing OpenCode history
Pass the model, reasoning effort, and permission mode chosen in the
new-session picker through the import request and persist them on the
imported HAPI session via applySessionConfig, so reopen launches with
the visible selection instead of stale or default values.
* fix(hub,cli): preserve explicit launch resets and skip streaming parts on import
- Launch config keys are applied by presence, so explicit null/default
selections from the picker must reach applySessionConfig to clear
stale model, effort, or permission values on re-import. Requests that
omit the fields entirely still leave persisted config untouched.
- Assistant text parts are mutable while a response streams. Mirror the
live scanner gate and import only finished (time.end set), synthetic,
or user text so an in-progress row can never be stored and later
finalized as transcript_diverged.
* test(hub): fix launch-config reset assertions to satisfy strict types
* fix(cli): fail opencode transcript reads on query errors instead of partial snapshots
Swallowed SQLite errors could return a truncated message list that the
hub later rejects as transcript_diverged with no repair path. Full
transcript reads now propagate query failures; summary lastUserMessage
extraction stays resilient per message.
* test(cli): cover unreadable-part-table transcript read stays non-partial
* fix(hub): scope launch-config failures to the per-session import result
A thrown applySessionConfig call used to escape the bulk handler as an
unstructured 500 even though transcripts were already persisted. Catch
it per session and report it as a config_failed result so remaining
sessions in a bulk request still import.
Also cover the mid-transcript part-query failure with an injected
opener that rejects instead of returning a holey transcript.
* fix(cli): read opencode transcripts inside a deferred snapshot transaction
Messages and their parts were read in separate statements, so a
concurrent OpenCode write could leave a hole in the imported sequence
that positional re-import checks then reject forever. Wrap each full
transcript read in BEGIN DEFERRED/COMMIT with rollback on failure.
* fix(cli): follow machine path policy for OpenCode imports
* fix(web): use unified permission mode for OpenCode imports
* fix(opencode): harden session import consistency
* fix(hub): preserve metadata on rejected OpenCode imports
* fix(hub): refresh OpenCode import state under lock
* fix(hub): resolve OpenCode imports after locking
* fix(opencode): bind imported resume to source machine