design phase 9 w7 native sessions - sinrimin/harness-nexus GitHub Wiki

Design: Phase 9 W7 โ€” native agent sessions (list + resume, no platform session store)

Status: SHIPPED 2026-09 (branch feat/p9-w7-native-sessions). Post-ship ground-truth corrections folded into ยงGround truth: dsh's session header entry carries its fields at the TOP level (no data wrapper), 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 live dsh --profile acp child 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's request/header pins the model, our W3 settings region is the catalog). Post-ship addendum โ€” dsh LIVE token streaming (branch feat/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).

Problem

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:

  1. 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).
  2. "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.
  3. 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.

Ground truth (researched on the rig, 2026-09-10)

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 transcript format (the no-replay workaround)

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):
    • session header {id, createdAt, cwd, delegationDepth} โ€” root sessions are delegationDepth === 0 with no parentSession.
    • 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; their ids distinguish genuine turns from the synthesized runtime-context user/message entries.
    • assistant/message {turn, step, message:{content blocks}} โ€” one COMPLETE message per step: reasoning / text blocks (full text) and tool-call blocks (superseded by the two below).
    • tool/call {callId, name, arguments(JSON string)} and tool/result {message:{source:{callId}, content:[{content:[{text}]}]}} โ€” the tool lifecycle pair.
    • turn/start / turn/end {reason:{kind}} โ€” turn boundaries.

Model change โ€” the platform stops persisting sessions

  • AcSession is deleted end-to-end: core domain type + port + UnitOfWork.acSessions, sqlite + memory repos, the shared acSessionViewSchema, and migration 0014 DROP 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.
  • ChatService becomes purely in-memory live-channel state: routing, gating order, permission watchdogs, and channel caps are unchanged. The boot sweep / orphaned-row closes / machine-deleted row settles all disappear (no rows).
  • "Close" is re-framed as "disconnect": the chat:session.close event 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.

Session list โ€” native, daemon-routed

  • REST: GET /api/agent-instances/:id/sessions is redefined to {agent, supported, sessions: NativeSessionView[]} with NativeSessionView = {sessionId, cwd, title?, updatedAt?}. Still owner-or-admin with 404-hiding (chat itself stays owner-only).
  • /ctl: server โ†’ daemon sessions:list {requestId, target}; daemon โ†’ server sessions:list:result {requestId, sessions?, supported?, error?}. New capability sessions; daemon version 0.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 โ€” an npx adapter 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/createdAt from the header frame, title from the session/title entry (bounded read โ€” stop after the title or ~64 KiB), updatedAt from stat mtime, ids currently live in this daemon excluded. zstd unavailable (Node < 22.15) โ†’ supported: true, sessions: [] plus an error note 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).

Resume

  • 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 native cwd through verbatim (dsh enforces its own match โ€” a mismatch surfaces as a channel error, the rail re-lists).
  • chat:session.start gains resume?: {sessionId, cwd}. The daemon spawns the adapter at that cwd and picks the method from the advertised capabilities (dialect-free, future-proof): prefer session/load (replay for free), fall back to session/resume. Neither โ†’ channel error ("adapter supports no resume").

History โ€” one wire shape, three producers

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.

  1. claude-code / codex (session/load replay): the daemon attaches its notification handler in capture mode before calling session/load (claude's adapter replays BEFORE the load response resolves), maps captured session/updates with the existing mapAcpUpdate โ€” except user_message_chunk, which becomes a user item instead of being dropped โ€” and appends a synthetic turn_result if none arrived.
  2. dsh (session/resume + transcript parse): the daemon reads session.jsonl.zstd for that id and maps: spliced-inserted user inputs โ†’ user items; assistant/message blocks โ†’ thought_delta/message_delta events (full text); tool/call+tool/result โ†’ two tool_call events (parsed rawInput, 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).
  3. Live rejoin (refresh): the daemon keeps a bounded per-channel ring (synthesized user items for prompts it forwarded + the mapped stream events it emitted, last ~1000). chat:session.resync {sessionId} (server โ†’ daemon, sent when chat:session.open re-joins a live channel) re-emits chat: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.

Web

  • 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).

Privacy

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.

Test & verification plan

  • shared: history-item + native-session schema bounds.
  • cli: dsh transcriptโ†’history mapping (injectable frame decoder; a real zstd roundtrip test skipIf Node < 22.15); fixture agent (test/fixtures/acp-agent.mjs) gains session/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:history relay, nativeSessionId on 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).

Out of scope

  • 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.

dsh live streaming (post-ship addendum โ€” feat/p9-w7-dsh-streaming-tail)

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/chunk session event PER LLM chunk (data = { turn, step, chunk }; chunk types block-start | text-delta | reasoning-delta | tool-call-delta | block-end | usage | finish); a step ends with a committed assistant/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 VERBATIM assistant/chunk events. A reader must map both shapes โ€” skipping either silently drops content.
  • dsh's own bridge maps these to ACP with a stateful stepsWithDeltas set 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's session.jsonl.zstd for appended bytes (default 250ms), splits new zstd frames, buffers a torn trailing frame until its remaining bytes land, and maps entries through createDshLiveMapper() โ€” our mirror of the reference semantics: both delta shapes โ†’ message_delta/thought_delta; the turn:step committed dedup with full-block fallback; tools from tool/call/tool/result rows; usage from the commit (inputTokens/ outputTokens โ€” the wire's ctx-occupancy usage_update keeps flowing and the fold field-merges the two).
  • Wire suppression: while the tail is live, the adapter's committed agent_message_chunk/agent_thought_chunk are dropped โ€” the tail streams the same content AND its fallback covers commits, so the wire copy is always redundant; tools/usage still flow (idempotent by toolCallId / 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: ensureTail runs 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/tailReplayEligible flags) 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/end row) can land a beat LATER โ€” a delta arriving after turn_result would open a NEW fold step (post-turn bubble). runPrompt therefore drains, then waits (โ‰ค600ms, 50ms steps) for the transcript's turn/end row before emitting turn_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.

Post-ship fix โ€” codex listing "timeout" was a timestamp dialect (2026-09-14)

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.ts listViaAdapter โ€” normalize updatedAt via Date.parse to 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.

Post-ship fix (#8, 2026-09-22): a user history item is a turn boundary

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.

#46 addendum (2026-10-13): dsh 0.2.x โ€” session-log v4, NO back-compat

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):

  1. The session log is generation-versioned: session.jsonl.zstd (v0) โ†’ current v4 session.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 packed text-chunks/reasoning-chunks rows and per-token assistant/chunk events are GONE (their decoders live only in the frozen v0/v1 readers).
  2. Token deltas left the session event bus: they ride agent-scoped agent/assistant-stream frames (AssistantStreamFrame start/chunk/end; see the W7.1 addendum). Settlement rows (assistant/message / assistant/attempt) now embed the exact timed stream[]; assistant/attempt is a settlement-only row for failed / retried / cancelled attempts that committed no surface message.
  3. tool/result.data.message flattened: role:'tool', flat content: ContentBlock[] (text/reasoning/image/tool-call/โ€ฆ), toolCallId/isError on the message. source.callId survives (kept as the id fallback).
  4. turn/end reason cancelled โ†’ aborted.
  5. Unchanged: the header row (type:'session' + cwd/createdAt/ delegationDepth/parentSession, now also version/id/isSeeded), agent/inbox/spliced (user turns), session/title (data.title), request/header (pinned model at header.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 canonical session.vN.jsonl.zstd wins; 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). findTranscript and the resume-history reader both ride it.
  • createDshLiveMapper() is now an OBJECT with two arms: .event(row) (settlement/tool rows โ€” assistant/message emits usage always and the complete blocks UNLESS the tap's frame arm already streamed that turn:step; assistant/attempt maps to nothing) and .frame(frame) (start records attemptId โ†’ turn:step; chunk text/reasoning deltas map and mark the step streamed; everything else maps to nothing). The turn:step dedup key moved from row data to the frame's start record.
  • toolResultEvent reads the flat v4 message (with the source.callId fallback); history items ride assistant/message.data.usage for usage and map aborted โ†’ 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.

โš ๏ธ **GitHub.com Fallback** โš ๏ธ