design phase 9 w7 native sessions - sinrimin/harness-nexus GitHub Wiki
Status: SHIPPED 2026-09 (branch
feat/p9-w7-native-sessions). Post-ship ground-truth corrections folded into ยงGround truth: dsh'ssessionheader entry carries its fields at the TOP level (nodatawrapper), and Node's zstd APIs (sync AND stream) stop after the first frame of a multi-frame file โ both caught by the smoke run. Post-ship fixes (fix/p9w7-resume-leak-stale-model, rig-found): a failed establishment now KILLS the adapter in the catch (every failed resume used to leak a livedsh --profile acpchild of the daemon); the dsh listing flags stale-model sessions (staleReason: 'model-missing'โ dsh validates the session's pinned route against the live provider catalog at resume, so a provider-config change orphans old sessions; the transcript'srequest/headerpins the model, our W3 settings region is the catalog). Post-ship addendum โ dsh LIVE token streaming (branchfeat/p9-w7-dsh-streaming-tail): see ยง"dsh live streaming" at the end. User directive (verbatim intent): ไผ่ฏไธ่ฝๅฐๅนณๅฐ โโ ไผ่ฏๅ่กจไนไธ้่ฆ๏ผๆไปฅไน ไธๅญๅจโๅ ณ้ญโไธ่ฏด๏ผๅฐฑไฝฟ็จๅๅฎถ่ชๅธฆ็ resume ไปฅๅไผ่ฏๅ่กจใ Supersedes the C5 "no resume" boundary (design-phase-8-c5.mdยง lifecycle) and the W6 session-list source (AcSession rows). References:design-phase-8-c5.md(chat transport โ unchanged),design-phase-9-portal-ui.md(W6 session page โ the surface W7 rewire),AGENTS.mdยง T1 (dsh ACP dialect).
W6's session model is platform-centric: every channel writes an AcSession
audit row (cwd, derived title), the session rail lists those rows, and
"close session" is a first-class action with terminal semantics. That model is
wrong on three counts:
- The conversation belongs to the agent, not the platform. Claude Code, Codex, and dsh all persist their own sessions on the user's machine and can resume them. The platform storing a parallel copy (even metadata) creates a second source of truth that drifts (sessions the user runs in their own terminal never appear; platform rows outlive the real session).
- "Close" is a fiction. Killing the channel kills a subprocess, not the agent's session. With native resume, there is nothing platform-side to close.
- No resume means half a product. Refreshing the page or reconnecting the daemon strands a conversation the agent could perfectly well continue.
W7 flips the model: the platform is a live channel + renderer; the session list, the transcript, and the resume mechanics are the agent's own, read through the daemon on demand. Nothing session-shaped persists server-side.
The ACP spec (as bundled with dsh's SDK) standardizes the surface:
agentCapabilities.sessionCapabilities = {list?, load?, resume?, close?, fork?}
and session/list โ {sessions: SessionInfo[], nextCursor?} with
SessionInfo = {sessionId, cwd, title?, updatedAt?}.
session/load = load with replay (the agent re-emits prior turns as
session/updates); session/resume = resume without replay. Both take the
session's original cwd.
| target | adapter (rig-pinned) | caps | list returns | resume |
|---|---|---|---|---|
| claude-code |
@zed-industries/claude-agent-acp 0.23.1 |
fork,list,resume,close + legacy loadSession:true
|
{sessionId, cwd, title, updatedAt} (title = SDK summary; reads ~/.claude/projects) |
session/load replays full history as session/update (incl. user_message_chunk) |
| codex |
@zed-industries/codex-acp 0.16.0 (Rust) |
list,resume,close + loadSession:true (auth-gated until codex auth exists) |
SessionInfo (adapter-owned storage) |
session/load (replay per spec) |
| deepseek |
dsh-acp 0.1.2-rc.1 (native dsh --profile acp) |
close,list,resume โ no load
|
{sessionId, cwd} ONLY (sorted newest-first, root sessions only, active excluded) |
session/resume {sessionId, cwd} โ cwd must match (physical-directory check), rejects subagent/non-root/active; restores the log without replaying old updates
|
| hermes | acp_adapter |
unverified | โ | W7 ships unsupported (graceful empty list) |
dsh persists every session at
~/.dsh/sessions/<cwd-slug>/<uuid>/session.jsonl.zstd:
-
Multi-frame zstd โ one frame per write batch. Node's one-shot AND stream
zstd decoders stop after the first frame; the reader must scan the frame
magic (
28 B5 2F FD), slice, and decode each frame separately (zlib.zstdDecompressSync, Node โฅ 22.15 โ machines running dsh already require โฅ 22.15). - Entry types that matter (all others skipped):
-
sessionheader{id, createdAt, cwd, delegationDepth}โ root sessions aredelegationDepth === 0with noparentSession. -
session/title {title}โ dsh's own derived title (usually early in the log). -
agent/inbox/spliced {inserted: [{content, role, id, source:{kind:'user'}}]}โ the real user inputs; theirids distinguish genuine turns from the synthesized runtime-contextuser/messageentries. -
assistant/message {turn, step, message:{content blocks}}โ one COMPLETE message per step:reasoning/textblocks (full text) andtool-callblocks (superseded by the two below). -
tool/call {callId, name, arguments(JSON string)}andtool/result {message:{source:{callId}, content:[{content:[{text}]}]}}โ the tool lifecycle pair. -
turn/start/turn/end {reason:{kind}}โ turn boundaries.
-
-
AcSessionis deleted end-to-end: core domain type + port +UnitOfWork.acSessions, sqlite + memory repos, the sharedacSessionViewSchema, and migration 0014DROP TABLE ac_sessions(destructive by design โ the table was audit-only metadata; the directive says sessions don't land on the platform). W1's detected-instance sync, the chat gates, and everything else that reads agent instances is untouched. -
ChatServicebecomes purely in-memory live-channel state: routing, gating order, permission watchdogs, and channel caps are unchanged. The boot sweep / orphaned-row closes /machine-deletedrow settles all disappear (no rows). -
"Close" is re-framed as "disconnect": the
chat:session.closeevent keeps its name (wire compat) but means drop this channel + subprocess โ never any session finality. UI copy follows (ๆญๅผ, with a hint that the conversation stays with the agent). Machine deletion / daemon disconnect still tear channels down the same way.
-
REST:
GET /api/agent-instances/:id/sessionsis redefined to{agent, supported, sessions: NativeSessionView[]}withNativeSessionView = {sessionId, cwd, title?, updatedAt?}. Still owner-or-admin with 404-hiding (chat itself stays owner-only). -
/ctl: server โ daemon
sessions:list {requestId, target}; daemon โ serversessions:list:result {requestId, sessions?, supported?, error?}. New capabilitysessions; daemon version0.10.0-p9w7. Gates mirror the W6 workspace route exactly (error-arm BEFORE timeout): offline โ409 MACHINE_OFFLINE, missing capability โ409 DAEMON_NO_SESSIONS, daemon error โ502 DAEMON_SESSIONS_FAILED, silence โ504 SESSIONS_TIMEOUT(SESSIONS_TIMEOUT_MS, default 30s โ annpxadapter spawn is slow cold). -
Daemon strategy per target:
- claude-code / codex โ spawn a short-lived adapter (the same command row as
chat),
initialize,session/list, kill. The adapter IS the vendor's session list; no storage-format coupling. - deepseek โ pure file scan of
~/.dsh/sessions(no adapter spawn, no auth): root sessions only,cwd/createdAtfrom the header frame,titlefrom thesession/titleentry (bounded read โ stop after the title or ~64 KiB),updatedAtfrom stat mtime, ids currently live in this daemon excluded. zstd unavailable (Node < 22.15) โsupported: true, sessions: []plus anerrornote surfaced as a rail hint. - hermes / zcode / generic โ
supported: false(the rail explains; hermes support is a follow-up once its adapter's surface is verified).
- claude-code / codex โ spawn a short-lived adapter (the same command row as
chat),
- Browser clicks a native session row โ
chat:session.open {agentInstanceId, resume: {sessionId, cwd}}(the values came from the daemon's own listing โ ground truth, not client input to validate against the workspace root). - Server: the SAME gating order as a new session (owner โ remote-chat โ online
โ chat capability โ channel cap). The baseWorkspace containment check
applies ONLY to the
directory(new-session) arm; a resume passes the nativecwdthrough verbatim (dsh enforces its own match โ a mismatch surfaces as a channel error, the rail re-lists). -
chat:session.startgainsresume?: {sessionId, cwd}. The daemon spawns the adapter at that cwd and picks the method from the advertised capabilities (dialect-free, future-proof): prefersession/load(replay for free), fall back tosession/resume. Neither โ channel error ("adapter supports no resume").
New daemonโserver push, relayed to the channel room exactly like chat:event:
chat:history {sessionId, items: HistoryItem[]} // โค 2000 items, daemon-side bound
HistoryItem = {type:'user', blocks: PromptBlock[]} | {type:'event', event: ChatStreamEvent}
Expressing history as user blocks + ordinary stream events (not a parallel row model) means the browser folds it through the EXISTING reducer โ one rendering path for live and history.
-
claude-code / codex (
session/loadreplay): the daemon attaches its notification handler in capture mode before callingsession/load(claude's adapter replays BEFORE the load response resolves), maps capturedsession/updates with the existingmapAcpUpdateโ exceptuser_message_chunk, which becomes auseritem instead of being dropped โ and appends a syntheticturn_resultif none arrived. -
dsh (
session/resume+ transcript parse): the daemon readssession.jsonl.zstdfor that id and maps: spliced-inserted user inputs โuseritems;assistant/messageblocks โthought_delta/message_deltaevents (full text);tool/call+tool/resultโ twotool_callevents (parsedrawInput, output text, terminal status);turn/endโturn_result; usage chunks โusage. zstd unavailable โ empty history + a rail/stream note (the session still resumes and continues). -
Live rejoin (refresh): the daemon keeps a bounded per-channel ring
(synthesized
useritems for prompts it forwarded + the mapped stream events it emitted, last ~1000).chat:session.resync {sessionId}(server โ daemon, sent whenchat:session.openre-joins a live channel) re-emitschat:history. This also fixes the W6 wart where a page refresh wiped the visible transcript.
chat:session.ready gains nativeSessionId (the agent's own session id) โ
the server remembers it on the live channel (re-push on rejoin) and the
browser uses it to highlight the rail row backing the open channel.
- The left rail lists native sessions (same cwd grouping, newest-first,
relative time) and re-lists after every channel open/disconnect. Row click =
resume. The live channel of this browser is highlighted via
nativeSessionId; rows carry no open/closed state anymore (that concept is gone). Unsupported / offline / old-daemon each get a muted rail hint. - New-session flow (DirPicker + baseWorkspace) is unchanged; resume bypasses the picker (the native cwd is authoritative).
- The "ๅ
ณ้ญไผ่ฏ" button becomes "ๆญๅผ" (
chat.disconnect) โ channel-only semantics, sessions never disappear from the rail because they were closed. - The fold gains
{type:'history', items}: rebuild from a fresh state โ user items append user rows (no live-turn side effects), events fold sequentially through the existing event switch. Idempotent (a resync re-ingest resets first).
Transcripts still never persist platform-side (stronger than before: there is no table to write them to). History transits the same authenticated owner-only channel that live events already use, produced on demand from the user's own machine.
- shared: history-item + native-session schema bounds.
-
cli: dsh transcriptโhistory mapping (injectable frame decoder; a real
zstd roundtrip test
skipIfNode < 22.15); fixture agent (test/fixtures/acp-agent.mjs) gainssession/list+session/load-with- replay arms; resume flow (load vs resume pick from advertised caps), resync ring, sessions:list per-target dispatch. -
server: sessions route gates (fake-daemon pattern from
test/workspace.test.ts), open-with-resume passthrough + containment skip,chat:historyrelay,nativeSessionIdon ready/rejoin. -
smoke
[9 W7]: fixture-adapter list โ open new โ turn โ disconnect โ list shows the native session โ resume โ history present โ continue turn. - Rig: real claude-code list/resume (auth permitting), dsh list + resume with the zstd history (the rig's node is 22.23).
- Surfacing
session/delete/session/fork. - Cross-agent session import/export; editing or annotating history.
- Multi-viewer history fanout beyond the rejoin resync (single re-syncing viewer per channel).
- Pagination (
nextCursor) โ first page per cwd group is enough for the rail.
The gap. dsh's official ACP adapter (@deepseek-ai/dsh-acp) surfaces only
COMMITTED updates: a whole reasoning/text block arrives as ONE
agent_message_chunk when the turn ends; during generation the protocol
carries nothing (rig-verified with a timing probe on 0.1.2-rc.1, and by
pulling 0.1.5-rc.1 โ same design). Every external ACP client inherits this;
dsh's own in-process bridges stream by subscribing the session event bus,
which an external client cannot reach. The ONE externally visible surface
that carries token batches live is the transcript file.
Ground truth (verified against dsh 0.1.2-rc.1 source โ the exact rig version โ via a reference source package of the engine loop, the JSONL persistence codec, and dsh's own bridge mapper):
- The engine appends one
assistant/chunksession event PER LLM chunk (data = { turn, step, chunk }; chunk typesblock-start | text-delta | reasoning-delta | tool-call-delta | block-end | usage | finish); a step ends with a committedassistant/message({ turn, step, message, usage? }). - Persistence coalesces via a bounded write-behind window (~100โ300ms
cadence) into one zstd frame per batch (append + fsync). A run of โฅ3
(
MIN_RUN) seq-contiguous same-block deltas packs into ONE row โtext-chunks/reasoning-chunks(data = { turn, step, index, dt, texts }, texts NOT joined โ token boundaries are data) /tool-call-chunks; shorter or split runs stay VERBATIMassistant/chunkevents. A reader must map both shapes โ skipping either silently drops content. - dsh's own bridge maps these to ACP with a stateful
stepsWithDeltasset keyed"turn:step": committed messages for steps whose deltas already went out carry ONLY usage (re-sending the block would double-render); steps with no streamed deltas fall back to the complete blocks.
The implementation (all daemon-side, packages/cli):
-
TranscriptTail(daemon/dsh-sessions.ts) polls the session'ssession.jsonl.zstdfor appended bytes (default 250ms), splits new zstd frames, buffers a torn trailing frame until its remaining bytes land, and maps entries throughcreateDshLiveMapper()โ our mirror of the reference semantics: both delta shapes โmessage_delta/thought_delta; theturn:stepcommitted dedup with full-block fallback; tools fromtool/call/tool/resultrows; usage from the commit (inputTokens/outputTokensโ the wire's ctx-occupancyusage_updatekeeps flowing and the fold field-merges the two). -
Wire suppression: while the tail is live, the adapter's committed
agent_message_chunk/agent_thought_chunkare dropped โ the tail streams the same content AND its fallback covers commits, so the wire copy is always redundant; tools/usage still flow (idempotent bytoolCallId/ field-merged). A BOUNDED frame that fails to decode is mid-file corruption: the tail stops (onFatal), suppression lifts, and the committed path takes over (a partially streamed message may render once more โ rare, never silent loss). -
Attach lifecycle:
ensureTailruns at session ready AND at each prompt (a NEW session's file materializes lazily โ it appears when the first prompt's user event flushes). A late attach may miss the turn's first batches, so the FIRST attach for a new session (never wire-rendered text โwireTextEmitted/tailReplayEligibleflags) replays from BYTE 0, recovering them; a resumed session's file pre-exists with rendered history, so its tail skips to EOF. Concurrent attaches dedupe per native session id. -
Turn-end ordering: the wire settles when the agent idles, but the final
write-behind batch (last deltas + commit +
turn/endrow) can land a beat LATER โ a delta arriving afterturn_resultwould open a NEW fold step (post-turn bubble).runPrompttherefore drains, then waits (โค600ms, 50ms steps) for the transcript'sturn/endrow before emittingturn_result. Rig-measured: the final batch lands ~34ms after the wire settles, so the common cost is one extra poll tick. - Degradation: no zstd binding (Node < 22.15) or no file โ no tail โ committed-only streaming (the pre-addendum behavior) โ suppression is keyed off a LIVE tail, never a pending attach, so it can never be active without the tail.
Verification. Unit: mapper semantics (both shapes, dedup, fallback,
ignores), tail (partial-frame recovery, settle signal, fatal, byte-0 replay
via the fixture seam). Rig (real dsh 0.1.2-rc.1 + deepseek-v4-flash):
progressive deltas precede turn_result with no duplicated text; tool turns
keep ONE stable callId across the dual sources; resume shows history and its
new turn streams without replaying pre-resume content; zero leaked
dsh --profile acp processes after repeated open/close. Daemon
0.10.1-p9w7.
Upstream note. dsh-acp could stream natively by mapping the same bus events its own bridges consume (the reference implementation is the proof of concept); until then the file tail is the only external streaming surface and keeps working regardless โ it depends only on the documented persistence format, not the adapter.
Rig symptom: opening the codex agent's session rail always ended in
504 SESSIONS_TIMEOUT ("Daemon did not answer the listing in time") โ while
claude-code and deepseek listed fine, and a manual initialize +
session/list probe against the same codex-acp answered in ~1.8s.
Chain (instrumented with a wrapper adapter command): the daemon DID list in
~1.7s and killed its short-lived adapter โ but the server's
sessions:list:result listener safeParsed the payload against
nativeSessionViewSchema, whose updatedAt was a strict z.string().datetime()
(Z-suffix only), and codex-acp (Rust chrono) emits RFC3339 with a
+00:00 offset. One field failed โ the WHOLE event was rejected
(proto:invalid ack) โ the waiter resolved nothing โ the route timed out
exactly at SESSIONS_TIMEOUT_MS. claude's wrapper (JS) and the dsh file scan
emit ISO-Z, so only codex tripped it.
Fix (branch fix/p9-w7-codex-timestamp-dialect):
-
shared/realtime.tsโupdatedAt: z.string().datetime({ offset: true })(accept RFC3339 offsets). This alone unblocks OLD daemons (the rig runs published 0.1.0-alpha.2) against a fixed server. -
daemon/sessions.tslistViaAdapterโ normalizeupdatedAtviaDate.parseto ISO-Z when parseable, DROP it when not (a malformed timestamp must never fail the listing); new daemons emit one dialect. - Regression:
shared/test/realtime.test.ts(dialect matrix) +server/test/sessions-route.test.ts(fake daemon replying+00:00โ 200).
Rig after deploy: codex 1.8s ยท claude 2.3s ยท deepseek 0.02s, all 200.
Symptom (rig E2E, claude-code): after switching away and back (history
replay), a turn's reply rendered ABOVE its question โ the question row was
followed immediately by the NEXT question. The ring itself was verified
correct via a socket driver (U:Q1 โ thought_delta โ message_delta โ U:Q2 โ โฆ โ turn_result), so the reorder was web-side.
Root cause: the fold's history action appended a user row without closing
the current assistant step โ and turn_result (which the claude replay
producer only appends ONCE, at the end of the whole batch) is the sole event
that closes a step. Turn N's deltas therefore patchSteped turn Nโ1's
still-open step, which renders above turn N's user row. The live path was
never affected (user_message sweeps and opens a fresh step).
Fix (fold.ts): a user history item closes the open step
(currentStepIdx: -1, stepClosed: true) โ so the following deltas open a
NEW step below the user row, mirroring the live action's semantics. The
boundary applies even when the item renders no blocks (empty text): the turn
break is real regardless. Daemon side deliberately unchanged โ the
fold-level boundary covers all three history producers uniformly, instead of
reconstructing per-turn turn_results on the wire.
Verification: apps/web gained vitest (the "test" placeholder is now
real); src/components/chat/fold.test.ts replays the exact dumped sequence
plus empty-block, with-markers, and resync-idempotence cases โ and was
confirmed to fail against the pre-fix fold.
Status: SHIPPED on
feat/46-dsh-0.2. User directive (verbatim intent): dsh ่ฟญไปฃๅพๅฟซ๏ผไธๅๅ ผๅฎน๏ผๅชๆๆๆฐ็ๆฅ๏ผไธๅจๅ ผๅฎน่ๅดๅ ็ๅฎขๆท็ซฏไธ่ฟๆฅ๏ผๆ็คบๅ็บง๏ผใ Ground truth is now dsh 0.2.0-rc.2 (npm latest at the time), replacing 0.1.2-rc.1 everywhere in this doc.
What moved upstream (source-diffed 0.1.2-rc.1 โ 0.2.0-rc.2; the first release after our old ground truth, 0.1.3-alpha, already carried all of it):
- The session log is generation-versioned:
session.jsonl.zstd(v0) โ current v4session.v4.jsonl.zstd. dsh keeps historical generations byte-identical and publishes the successor alongside; its own reader picks the numerically highest generation. Since v3 every physical row is ONE durable event ({type, seq, time, data}envelope unchanged) โ the v0 packedtext-chunks/reasoning-chunksrows and per-tokenassistant/chunkevents are GONE (their decoders live only in the frozen v0/v1 readers). - Token deltas left the session event bus: they ride agent-scoped
agent/assistant-streamframes (AssistantStreamFramestart/chunk/end; see the W7.1 addendum). Settlement rows (assistant/message/assistant/attempt) now embed the exact timedstream[];assistant/attemptis a settlement-only row for failed / retried / cancelled attempts that committed no surface message. -
tool/result.data.messageflattened:role:'tool', flatcontent: ContentBlock[](text/reasoning/image/tool-call/โฆ),toolCallId/isErroron the message.source.callIdsurvives (kept as the id fallback). -
turn/endreasoncancelledโaborted. - Unchanged: the header row (
type:'session'+cwd/createdAt/delegationDepth/parentSession, now alsoversion/id/isSeeded),agent/inbox/spliced(user turns),session/title(data.title),request/header(pinned model atheader.config.model), the~/.dsh/sessions/<slug>/<id>/layout, the zstd frame-per-write-batch physics, Node engines.
Decision โ no back-compat: the daemon reads ONLY the current generation
(SUPPORTED_SESSION_GENERATION = 4). A pre-v4-only session directory reads
as ABSENT (never a partial/guessed parse). This is safe because out-of-range
dsh clients are refused at connect time anyway (see
design-dsh-client-version-gate.md โ the floor is exactly 0.2.0-rc.2), so a
daemon can only ever meet logs from a client it admitted.
Reader changes (cli/src/daemon/dsh-sessions.ts):
-
currentSessionLog(dir, readdir)โ generation-aware discovery: highest canonicalsession.vN.jsonl.zstdwins; below the floor โ null; a session dir with no log yet returns the expected CURRENT name (a lazily materialized new session's tail can poll for the file's arrival).findTranscriptand the resume-history reader both ride it. -
createDshLiveMapper()is now an OBJECT with two arms:.event(row)(settlement/tool rows โassistant/messageemits usage always and the complete blocks UNLESS the tap's frame arm already streamed thatturn:step;assistant/attemptmaps to nothing) and.frame(frame)(startrecords attemptId โ turn:step;chunktext/reasoning deltas map and mark the step streamed; everything else maps to nothing). Theturn:stepdedup key moved from row data to the frame's start record. -
toolResultEventreads the flat v4 message (with thesource.callIdfallback); history items rideassistant/message.data.usagefor usage and mapabortedโturn_result{stopReason:'cancelled'}. - The transcript TAIL is now settlement-granularity by nature (token deltas are process-local frames that never reach the file) โ the tap is the only token-granular source, exactly as W7.1 intended.
Verification: cli suite 297/297 (rewritten v4 fixtures in
dsh-sessions.test.ts โ generation discovery, flat tool results, frame
mapper, abort; the two W7 chat integration tests rewritten to v4 rows); repo
pnpm -r typecheck + all four package suites green; pnpm format.
Rig consequence: the demo rig's dsh was pinned to 0.1.2-rc.1 via a
harness pin job โ BELOW the new floor, so chat there is refused until an
upgrade job lands @deepseek-ai/dsh@latest (the gate's hint names exactly
that). docs/dev/test-rig.md carries the local note.