Skip to content

chore(release): v0.5.2 - #108

Merged
linletian merged 62 commits into
mainfrom
chore/v0.5.2-release-prep
Oct 9, 2026
Merged

linletian merged 62 commits into
mainfrom
chore/v0.5.2-release-prep

Conversation

@linletian

Copy link
Copy Markdown
Owner

Content delta (main..develop since v0.5.1)

Merged PRs

Docs

  • discussion draft on terminal instance survival and restart reattach (docs/TERMINAL_DETACH_REATTACH_DISCUSSION.md)

Fixed issues

Highlights

Prep summary

  • CHANGELOG: Unreleased → v0.5.2 (2026-10-09) + summary line; entry added for the discussion draft (the five PRs already carried entries)
  • README.md / README.zh-CN.md: version sweep v0.5.1 → v0.5.2 (download URLs, tarball names, -v pin example, recommended-release wording)
  • scripts/install.sh: -v example versions bumped to v0.5.2

No code changes in the prep commit; release prep only.

linletian and others added 30 commits June 21, 2026 10:20
Lay out the design for embedding opencode's official web UI in
myworktree as a new "opencode-web" instance kind, replacing the
current PTY + xterm.js path that suffers from OSC/DA echo, high
CPU on TUI repaints, and fragile keyboard round-trips.

The investigation in FEASIBILITY.md covers the opencode app/cli
architecture (server is multi-tenant by directory, web UI is a
separate SolidJS app) and explains why the embedded web UI is too
rich for per-worktree use and why the /agent endpoint hangs with
oh-my-opencode loaded.

PLAN.md picks approach A (one opencode serve process per
myworktree instance) with command, --hostname and --port hard-
coded in Go for security, while OPENCODE_SERVER_PASSWORD is
always generated and injected by myworktree. Non-security env
vars from tag.Env are merged through BuildEnv.

No code changes in this commit; documents only for review.
PR 1 of opencode-native-ui implementation plan. Solidifies the
spec for the new opencode-web instance kind across all project
docs: PRD §7 gets opencode-web entry, API.md documents the new
GET /api/instances/<id>/opencode endpoint and /__opencode/<id>/*
reverse proxy, ARCHITECTURE.md adds §8 with diagram and design
constraints, CHANGELOG.md records the spec entry.

TASK.md at docs/plans/opencode-native-ui/ maps out 5 PRs with
file lists and acceptance criteria.
PR 2 of opencode-native-ui. Zero behavior change — PTY instances unchanged.

- state.go: ManagedInstance gains Kind + Extra (backward-compat via omitempty)
- opencode.go: Command(), GeneratePassword(), BuildEnv(),
  ExtractListeningAddress(), IsAPIPath()
- tag.go: opencode-web default tag added to built-in list
- Tests: backward compat for old state.json without Kind/Extra,
  round-trip with new fields, all opencode helpers covered
PR 3 of opencode-native-ui. Branch Manager.Start() on Kind field:

"pty" (default): existing PTY path unchanged.
"opencode-web": hardcoded opencode serve invocation, crypto/rand
password generation, BuildEnv for env merge with forced overrides,
io.Pipe stdout → pumpAndWatch goroutine (ring buffer + listening
address scan + status transition), health check goroutine polling
/global/health every 5s with 3 consecutive failure → failed.

Graceful tag fallback: if opencode-web tag is missing from
tags.json, Start uses built-in defaults rather than failing.

Add Get() and UpdateExtra() for proxy access. Mock binary
integration test verifies end-to-end: spawn mock, detect listening
port in stdout, transition to "running", health endpoint works.
PR 4 of opencode-native-ui.

- POST /api/instances now accepts optional "kind" field (default "pty").
- GET /api/instances/<id>/opencode returns iframe_src, api_base,
  password_set, worktree_path, host, port for frontend iframe src.
- /__opencode/<id>/* reverse proxy to opencode server:
  injects Basic auth, adds ?directory=<worktree> to GET/HEAD API
  requests, SSE flush enabled, 502 on upstream unreachable.
- Proxy mounted on main mux under existing withAuth middleware.
PR 5 of opencode-native-ui. Frontend changes:

- Add #opencode-panel div with sandboxed iframe next to terminal-container.
- Add .badge-oc CSS (accent-colored compact label).
- renderTabs() shows "OC" badge for opencode-web instances.
- selectInstance() branches on inst.kind:
  "opencode-web" → fetch /api/instances/opencode?id=, set iframe.src,
  hide terminal, show opencode-panel, disconnect PTY session.
  "pty" → restore terminal-container visibility, hide opencode-panel,
  existing PTY connect logic unchanged.
Refactor instance management to framework-based architecture:
- internal/framework/: pluggable kind registry (pty, opencode-web)
- internal/instance/opencode_web/: driver, proxy, CSP shim
- internal/instance/pty/: extracted PTY kind from old manager

opencode-web integration:
- Reverse proxy at /__opencode/<id>/* with Basic auth injection
- Injects ?directory=<worktree> for GET/HEAD API paths
- HTML rewriting: <base> tag, absolute path rewrite (src/href)
- Three-layer shim injected into SPA HTML:
  1. history.replaceState strips proxy prefix for SPA Router
  2. localStorage override sets defaultServerUrl to proxy URL
  3. fetch/EventSource/XHR URL rewrite (defense-in-depth)
- CSP hash injection for inline shim script
- iframe sandbox: allow-scripts allow-same-origin allow-forms allow-popups
- Debug report: docs/plans/opencode-native-ui/DEBUG.md

UI:
- Kind badge (OC) on instance tabs
- iframe panel + poll-based ready detection (500ms/60s)
- Debug bar showing upstream/proxy/worktree info
… + UI guard)

Replace the deep session link (which kept blanking the SPA) with full-page
embed at /__opencode/<id>/, and add proxy-side directory monitoring plus an
injected UI guard so the embedded opencode web UI can no longer silently
drift into a different worktree's project / session / server list.

Implementation:
- Reverse-proxy directory monitor records out-of-scope requests in an
  in-memory ScopeTracker, exposed via /api/instances/opencode/scope?id=
- Injected script hides cross-worktree switch entries (project switch /
  add-project / open-project) and normalizes localStorage server list to
  the single embedded server
- opencode --version gate (advisory, 1.18.x) + DOM-anchor / visibility
  checks reported via postMessage
- Persistent in-panel warning bar (out-of-scope + hiding-not-effective
  states)

Design / decision record: docs/plans/opencode-native-ui/WORKTREE-ISOLATION.md
Threat model / review checklist: docs/ARCHITECTURE.md §8
…er rewrite + project preseed)

Follow-up to b66f25b. The full-page embed (/__opencode/<id>/) on the
home route surfaced cross-worktree switch entries that the previous
guard did not cover, and the new-session button silently no-op'd
because the persisted project list was empty. Patch the injected
script:

- Hide the home page project list by hiding the <aside> wrapping
  [data-slot='home-projects-scroll'] (covers the project list,
  add-project button, and per-project menus in one go).
- Hide [data-action='project-switch'] on the session page so the
  click-to-switch entry is gone alongside the filtered project items.
- Preseed localStorage opencode.global.dat:server with
  sd.list=[proxyUrl] AND sd.projects[proxyUrl]=[{worktree, expanded:true}]
  so the new-session button has a project to open (was empty
  → home page's create button silently no-op'd).
- Extend r() to also rewrite root-relative paths (opencode's promise
  client builds 'new URL(path, baseUrl)' and was hitting the myworktree
  origin instead of the proxy → 404).
- Extend ri() to rewrite URL objects (their .href) in addition to
  Request objects (.url).
- Intercept the Worker constructor so Vite-bundled
  ?worker&url assets (Shiki highlighter, markdown worker) are routed
  through the proxy too.
- Extend the L2 structural-anchor check with home-session-search and
  home-add-project so the L2 poll resolves on both the session page
  and the home page (was timing out on home).
… jank

Replace the MutationObserver + querySelectorAll walk that hid
[data-slot="home-projects-scroll"] (home project list) and
[data-action="project-switch"] (project switch button) with a one-shot
<style> injection, so the SSE-driven session stream no longer re-traverses
the whole DOM on every mutation. The JS path still hides per-element
foreign-worktree projects and the "Open project" button.

DEBUG.md gains a debugging write-up for the full-page embed hardening
round (issues 11-18, lessons learned).

CHANGELOG entry added under Unreleased.
…e buffer cleanup

Closes the code-review packet for feature/opencode-native-ui HEAD (e4158fa)
plus one regression not surfaced by the review.

Part 1 — REVIEW-e4158fa findings (9 files, +370/-54):

* pty: per-instance subscriber scoping (multi-tab cross-talk fix).
  The kind-level subs map was global; every PTY tab received every
  other tab's output. Now keyed by instance id with an explicit
  empty-id rejection so a buggy caller can't subscribe to "everyone".
  TestSubscribeOutput_ScopedPerInstance + TestBroadcast_Parallel.

* framework: spawn-time ring buffer ownership. PTY used to allocate
  its own buffer, which bypassed Manager.totalBufBytes / dropBuffer
  and silently broke the 25%-of-RAM budget. The framework now
  pre-allocates the per-instance buffer via Manager.AllocateBuffer
  and threads it through SpawnParams.Buffer; PTY panics if nil
  (framework contract, not user error).

* opencode-web: pumpAndWatch now drains remaining stdout to EOF after
  parsing the listening line. Without this, the child filled the OS
  pipe (~64 KiB on Linux) and blocked on write(2) — long-run UI
  "stuck". TestPumpAndWatch_DrainsAfterListening.

* opencode-web: Handle.publisher is now atomic.Pointer; readers in
  pumpAndWatch / healthLoop / wait go through loadPublisher().
  Eliminates the -race hit on the bare-field read in hot paths.
  SetPublishers is now exactly-once (panics on second call).

* opencode-web: Stop(graceSeconds) actually uses the parameter
  (was hardcoded 5s); 0/negative falls back to package default.

* opencode-web: per-request proxy log moved from os.Stderr to
  Manager.Logger (default io.Discard), so SSE-driven traffic
  doesn't spam stderr and can't be poisoned by client-controlled
  dir/scope values.

* Cleanup: dropped the var _ = log.Printf / var (_ sync.Mutex)
  placeholders that were keeping dead imports alive.

* Documentation: REVIEW-e4158fa.md added for the review packet.

Part 2 — buffer map leak in Manager.runLifecycle (1 file + tests):

The review fixed PTY's buffer reaching the budget counter but
missed the converse: runLifecycle's defer only decremented
totalBufBytes without calling dropBuffer, so m.buffers[id] held a
stale *RingBuffer for every instance that exited via lifecycle
(Stop / kind-reported terminal status / ready-timeout). Long-running
daemons accumulated dead buffers proportional to total stop/start
cycles (~4-32 MiB each for PTY).

Fix: replace
    defer m.totalBufBytes.Add(-capBytes)
with
    defer m.dropBuffer(inst.ID)

dropBuffer is idempotent (returns early when id absent) and is the
contract specified in docs/ARCHITECTURE.md §"Buffer lifecycle" —
"Stop / wait / Restart / Delete all funnel into dropBuffer".
This realigns runLifecycle with that contract and lets the capBytes
parameter be removed from the runLifecycle signature.

Tests: TestRunLifecycle_DropsBufferOnTerminalStatus,
TestRunLifecycle_DropsBufferOnReadyTimeout, TestStop_DropsBuffer.

Verification:
- go build ./...                                              clean
- go vet ./...                                                clean
- go test -race -count=1 ./internal/framework/...             ok
- go test -race -count=1 ./internal/instance/pty/             ok
- go test -race -count=1 -short ./internal/app/               ok
- go test -race -count=1 ./internal/instance/opencode_web/    ok
  (driver_test only; TestStartOpencodeWebEndToEnd fails on the
   sandbox env identically with and without these changes —
   TempDir cleanup race, unrelated.)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Mirror the reasonix ensureWebFrame pattern so switching tabs no longer
resets the embedded opencode page to about:blank and no longer
unconditionally re-assigns iframe.src on every poll tick.

- activate() now hides the panel and only resets the navigation cache
  when the instance is stopped; for a running instance whose iframe
  already shows the right src, visibility is restored and the loaded
  page (chat draft, scroll, app state) survives.
- The poll only navigates when dataset.instance/dataset.src actually
  change, so repeated polls for the same running instance are no-ops.
- Added ui_test regression TestOpencodeWebRendererKeepsFrameAlive
  that asserts the dataset guard is present and the unconditional
  navigation/reset lines are gone.
…ce tab switches

Extend 49b18a1's single-iframe keep-alive to a per-instance iframe pool,
so switching between two running opencode-web tabs only hides/shows their
iframes instead of re-navigating the shared one (which would reload the
embedded opencode SPA on every cross-instance switch and lose chat draft,
scroll, app state).

- Each opencode-web instance gets its own iframe cached in a Map keyed
  by instance id; activate() builds one lazily and shows only the active
  frame, leaving the others hidden but DOM-resident.
- _pruneStaleFrames() drops cached iframes whose instance is gone, with
  an active-id guard so a transient state update can never force the
  currently-running instance to rebuild + reload.
- A stopped instance no longer keeps its iframe around: activate() takes
  an early-return path that calls destroyFrame(session.id), and
  stopInstance()/deleteInstance() in index.html call destroyFrame
  immediately so dead iframes don't linger eating memory.
- Cross-instance hidden-report cross-talk is fixed by filtering
  postMessage on event.source === this._currentFrame.contentWindow
  inside the scope listener, so only the active frame drives the
  warning.
- ui_test assertions extended with destroyFrame, prune active guard,
  postMessage source filter, and _currentFrame cleanup hooks.

Per-instance long-run memory cost is left as a follow-up: the user will
profile tab-count vs. JS heap before deciding whether to add an LRU cap
on _frames.
The injected CSS hid every [data-action="project-switch"] element, but the
current worktree's project-switch entry doubles as the sidebar
expand/collapse toggle (WORKTREE-ISOLATION.md §2.4 entry 2 — "只保留当前
worktree 项,其余隐藏/禁用"). Hiding it broke the toggle.

- Scope the hide rule to :not([data-project="<current>"]) so the current
  worktree's entry stays visible while every other worktree's entry
  remains hidden.
- The JS-side filter() already used the correct logic via foreign()
  (compare data-project === wt); only the CSS rule was over-broad.
  The two paths now agree.
- TestBuildInjectScript extended with a positive guard on the new
  selector prefix to prevent the over-broad form from regressing.
… inject server displayName

Per OPENCODE-WORKDIR-DEBUG-2026-08-13.md §D.5: the proxy only monitors
client-supplied directories (no rewriting) so opencode keeps its
cross-directory capability; cross-worktree switch entries in the
embedded UI are now disabled (visible but inert) instead of hidden, so
the user still sees opencode's other projects as a reminder while
pointer events are blocked and the entries are greyed out (aria-disabled).

Server list injection now writes {type:"http", http:{url:pu}, displayName:<worktree basename>}
instead of just the proxy URL, so the search bar shows a project name
instead of the raw URL (WORKTREE-ISOLATION.md §0.3定位: 防误操作而非禁止).

L3 effectiveness report renamed from hide-failed → disable-failed and
the frontend warning text updated accordingly. Proxy now strictly
observes client directory (no rewriting) and only injects worktree as
the default-directory hint for directory-less GET/HEAD on API paths,
matching the original contract.

Tests:
- TestBuildInjectScriptSyntax uses node --check to catch CSP-hashed
  inject-script syntax errors that would white-screen the embedded UI.
- newProxyTestUpstream captures every forwarded request; new assertions
  verify out-of-scope directories are forwarded as-is and that GET
  without a directory still gets worktree injected as default.
…arting + reusable loading overlay

Per OPENCODE-WORKDIR-DEBUG-2026-08-13.md §E.7.4:

- deactivate() now hides _currentFrame (visibility toggle, not teardown)
  so cross-instance tab switches no longer leave the previous
  instance's page visible on an empty panel.
- activate() no longer bails on status !== 'running'; starting status
  keeps polling and the iframe auto-navigates once the server is ready,
  so the user no longer needs to manually re-click the tab to recover.
- New #opencode-loading overlay (spinner + instance info lines) shown
  during starting, stopped, and timeout states; helpers
  _ensureLoading/_showLoading/_hideLoading are designed for reuse by
  future web-ui kinds.

Front-end warning copy updated to match the proxy's hide→disable rename.
Captures the investigation, decision, and first-observation log for the
opencode-web workdir phenomena reported on 2026-08-13:

- §2 four-phenomena unified root-cause analysis (initial HOME-ization
  hypothesis, later revised in 附录 D after DB/runtime verification).
- 附录 D final verdict: real cause is the stale worktree field on
  project a6e112c1ae... in opencode.db (the dir was a real checkout on
  2026-06-19, cleared later but the row stayed); propagation is
  sync().project?.worktree → x-opencode-directory header → server
  defaultDirectory (header > proxy-injected query > cwd).
- §D.5 chosen fix: proxy only monitors (no directory rewriting); UI
  cross-worktree switch entries disabled (not hidden); opencode data
  untouched (ghost sessions preserved).
- 附录 E TUI vs web UI behavior split (same opencode server, different
  directory sources), and the E.7.4 cross-instance tab-switch
 串台 fix that the previous two commits implement.
… (2026-08-14)

Appends to OPENCODE-WORKDIR-DEBUG-2026-08-13.md:

- E.7.5 single-server preseed (方案 A): bare-origin server entry so
  resolveServerList merges canonical + stored into one server and the
  home page renders like a native launch; defaultServerUrl + project
  preseed scoped under the canonical 'local' key.
- Cross-instance localStorage clash (review finding) and fix: redirect
  the shared 'opencode.global.dat:server' key to a per-instance key
  (opencode.global.dat:server/__opencode/<id>) via getItem/setItem/
  removeItem hijack, so each instance keeps its own preseed while still
  presenting a single merged server.
- Review follow-ups: sendBeacon/WebSocket rewrite coverage + known
  dynamic-element limit, orphan-data rationale (no auto-cleanup), dn
  unified on jsQuote, pu0 -> origin0 rename, U+2028/U+2029 accepted.
…isolation + sendBeacon/WebSocket rewrite

Per OPENCODE-WORKDIR-DEBUG-2026-08-13.md E.7.5:

- Persist the server entry with the bare origin (same key as
  entry.tsx's canonical server) so resolveServerList merges them into
  one server and the home page renders single-server mode; set
  defaultServerUrl to the origin and preseed the project under the
  canonical 'local' scope.
- Isolate the shared 'opencode.global.dat:server' key per instance
  (opencode.global.dat:server/__opencode/<id>) by hijacking
  getItem/setItem/removeItem, so two instances on one origin no longer
  clobber each other's preseed (review finding: cross-instance clash).
- Extend r() rewrite to ws://wss:// URLs and hijack sendBeacon +
  WebSocket so every SDK channel reaches the proxy prefix.
- Harden interpolated values with jsQuote (strconv.Quote + \u003c) so a
  worktree path can never break out of the <script> element.

Tests: TestBuildInjectScriptSingleServer (dual-instance sim with
resolveServerList merge mirror), TestInjectURLRewriteBehavior
(behavioral rewrite coverage across fetch/XHR/EventSource/sendBeacon/
WebSocket), TestBuildInjectScriptEscapes (script-tag breakout guard).
…ng) + hide terminal controls + drop debug bar

- proxy.go / opencode_web.js: rewrite URL prefix at new Request() construction time
  instead of rebuilding in fetch(). Pre-SDK-every-request goes through
  new Request(url, init), so intercepting the constructor prefixes the URL at
  source — fetch() receives an already-prefixed Request and passes it through
  unchanged. Zero rebuild, zero body/duplex round-trip. This matches the
  pre-single-server path (worked on every browser, including Safari's stricter
  ReadableStream handling). ri() rebuild stays as a fallback for bare-origin
  strings. Covers string / URL.href / Request copy (u.url) shapes.
- proxy_test.go: tests for Request constructor interception, Request copy
  construction (must keep prefix + method + body), and the streaming-upload
  invariant (duplex:half + body stream + content all survive the rewrite).
  Reads back the rebuilt body via getReader() so a missing body lands in the
  call log instead of silently passing.
- opencode_web.js: remove the bottom debug bar (the embedded page owns its own
  status UI now). Stopped-state path no longer polls back into the bar.
- index.html: hide #terminal-controls when active instance is opencode-web
  (refresh / scroll-to-bottom shortcuts overlay the embedded page and are
  meaningless there). Class toggle, not inline style, so CSS keeps layout
  control.
- docs: add FOLLOWUPS.md (review follow-ups: extract inject script, Playwright
  smoke test, old-data cleanup) + update README to point at it. Trim the
  long debug doc to final conclusion only (process & wrong hypotheses removed).
…uthority for session maps

deactivate() no longer disconnects the WebSocket, so re-activating a hidden
terminal shows up-to-date buffer content instead of a stale 64KB-capped log
replay (matches pre-renderer behavior on main/develop).

reconcileTerminalSessions / reconnectRunningTerminalSessions /
renderTerminalSessions now iterate the PtyRenderer as the single authority
for live sessions, falling back to the legacy map only when the renderer is
absent (unit tests / embeds without pty.js). Fixes the silent no-op on
stopped-session cleanup and reconnect for renderer sessions. renderTerminal
Sessions also guards against container-less sessions on future async teardown.
Follow-up notes recorded in FOLLOWUPS.md.
Preserves the released reasonix feature by porting it onto the
opencode-native-ui kind architecture:

- internal/instance/manager.go taken as deleted (branch refactor wins);
  its reasonix logic moved to a third framework kind
  (internal/instance/reasonix/kind.go wrapping Driver.Start) with
  Stop/Cleanup, Reattach (RestartSurvivor), and Status parity.
- framework: RestartSurvivor re-attach in ReconcileRunningOnStartup,
  StopAllKind for shutdown, ResourceCleaner for Delete/Restart,
  persistNewInstance version-conflict retry, SetInstancePID via
  Publisher.
- Restored tag semantics for all kinds: tag.Env / preStart / Command /
  Cwd resolve at Start (pty sends initial command, opencode-web and
  reasonix run preStart with kind-consistent env; reasonix strips
  REASONIX_HOME/REASONIX_STATE_HOME from preStart and serve alike).
- app: independent loopback /rx/ listener + web_url (issue #44),
  same-origin fallback, instanceView, dedicated kind registry.
- Frontend: third Reasonix modal tab, kinds/reasonix.js renderer with
  per-instance iframe keep-alive (issues #53/#54/#55), rx badge.
- Tests ported to framework APIs; ui tests updated for the
  renderer-single-authority reconcile refactor; gofmt clean for CI.
- Docs: API.md sections 5.10-5.13 merged/renumbered, ARCHITECTURE and
  PRD updated for the kind architecture and restored tag semantics.
The Reasonix tab now only asks for a name: the serve command is fixed
and the UI sends an empty tag_id. Backend support is intentionally
kept — tag env / preStart / cwd still apply when a tag_id is passed
via the API or CLI (HTTP_PROXY, REASONIX_HOME opt-out, pre-serve
setup), so no v0.4.0 capability is lost.

- internal/ui/static/index.html: remove tagSelectRx select + its
  renderModals population + promptStartInstance reset; doStartInstance
  sends tag_id:'' for the reasonix tab.
- internal/ui/ui_test.go: TestReasonixTabIsFixedCommandNoTemplate pins
  the fixed-command shape and the absence of the template picker.
- docs/PRD.md: Reasonix tab description updated (name only; env/
  preStart via API/CLI tag_id).
…sting tags.json

Two startup regressions from the merge, both verified by tests:

1. Reasonix web UI never rendered and the terminal looped on
   'kind "reasonix" does not support output subscription': the
   <script> tag for /static/kinds/reasonix.js was missing from
   index.html, so window.KindRenderers['reasonix'] never registered
   and selectInstance fell back to the PTY path (TTY WS →
   SubscribeOutput error → retry). Added the script tag; the
   regression is pinned in TestReasonixTabIsFixedCommandNoTemplate.

2. Opencode-Web startup alerted "unknown tag id: opencode-web":
   with tag semantics restored, the tab's tag_id must resolve, but
   the built-in opencode-web tag was only written when tags.json was
   first created — pre-existing configs lacked it. tag.Manager.
   LoadMerged now merges the built-in default tags as the base layer
   at load time (user file entries still override), so the
   command-less opencode-web reference tag always resolves.
   Pinned by TestLoadMergedDefaultsSurviveExistingFile.
Three frontend issues reported after the merge, all resolved at the UI
layer (backend verified healthy: real-reasonix two-instance scratch run
— Start <1s each, /rx/ proxy 200 in ~30ms for both):

1. Stacked half/half iframes: switching between opencode-web and
   reasonix tabs left BOTH panels visible because deactivate() only
   hid the iframe. selectInstance now hides both web panels before
   dispatching, and each renderer's deactivate() hides its panel.

2. Second reasonix instance white: the per-instance iframe cache kept
   several reasonix SPA pages alive simultaneously under one origin.
   Rebuilt ReasonixRenderer on the proven v0.4.0 model: ONE shared
   iframe, re-navigating on instance switch; hide-not-destroy
   keep-alive across non-reasonix tabs (issue #53); invalidate-on-stop
   (same-src fallback regression); issues #54/#55 xterm cleanups kept.

3. Startup stall: the renderer now navigates while the record is
   running OR starting (Driver.Start already waited for the listen
   port, and the proxy accepts starting), closing the async
   status-flip race that left a blank panel after Start.

Tests: TestReasonixRendererKeepsFrameAlive updated to pin the
single-frame model (and reject a per-instance cache); full suite +
vet green.
…heal poll

Per user decision: cross-instance switches must not reload and lose
drafts. Restores the per-instance iframe cache (each reasonix instance
keeps its own frame; switching hides/shows instead of re-navigating,
so instance A's draft survives A→B→A — beyond main's single shared
frame, which reloaded), plus the fixes gathered while diagnosing the
white screen:

- navigation only while the record is running/starting (serve is
  already listening; proxy accepts starting) — closes the async
  status-flip race that left a blank panel;
- a 2s self-healing poll re-navigates on web_url port changes (daemon
  restart), invalidates on stop/failure, and retries fresh frames —
  restoring main's per-render-tick re-check;
- panel mutual exclusion (selectInstance hides both web panels before
  dispatch; deactivate hides its own panel) — no stacked half layout;
- [reasonix-renderer] console diagnostics so any remaining white
  screen can be reported with the actual activation/navigation log.

Tests: TestReasonixRendererKeepsFrameAlive pins the per-instance cache
and the poll/guard hooks. Full suite + vet green.
linletian and others added 28 commits August 14, 2026 21:25
Reorganize the intro sections of both READMEs to lead with a punchy
tagline (an ORCA-like lightweight agents team orchestrator), a curated
'Features' / '核心能力' block using ORCA-style wording for the common
capabilities, and a separate 'What makes myworktree different' /
'myworktree 的特色' block for the distinctive ones.

- Tagline reframes myworktree as 'ORCA-like' without explicit comparison.
- 'Bring your own agents' now leads with OpenCode + Reasonix (native
  web UI embedded in the sidebar); Tag templates stay as the path for
  everything else.
- 'Output replay' bullet reworded: the PTY ring buffer is in-memory
  only, so 'survives restarts' was an overstatement — replaced with
  'without disk writes'.
- 'Fan one prompt across many worktrees' replaced: myworktree does
  not auto-fan-out a prompt; users create worktrees and start
  instances themselves. New bullet is 'One worktree per task, kept
  apart by git'.
Resolve the post-v0.4.0 fork between the release line (main: v0.4.2
opencode-web snapshot + one-line installer) and the dev line (develop:
PR #60 opencode-native-ui with follow-up fixes):

- opencode-web / framework / reasonix / pty Go code: keep develop side
  (PR #60 state, strictly newer than the v0.4.2 snapshot)
- CHANGELOG: merge v0.4.2 entry into Unreleased
- README / README.zh-CN: keep ORCA-style positioning (local commit
  5b3bf54) + adopt main's Quick start / one-line install section
- adopt main-only assets: scripts/install.sh, ci release.yml,
  docs/REVIEW-pr61.md, docs/plans/opencode-native-ui DEBUG doc
- .gitignore: ignore .gocache/ (already ignored on feature branches)
* docs(dsh-native-ui): add DeepSeek Harness web UI native-instance feasibility report

* docs(dsh-native-ui): add missing-binary handling — interactive npx/install choice

* docs(dsh-native-ui): switch workspace restriction to opencode-style — disable entries, monitor only, no interception

* docs(dsh-native-ui): add workspace bootstrap — inject worktree via workspace.create after ready

* docs(dsh-native-ui): record port-conflict semantics — no fallback on bind failure, pin --port 0

* docs(dsh-native-ui): add per-worktree data isolation — redirect storages/sessions roots via overlay

* docs(dsh-native-ui): session-plane tradeoff — shared sessions vs full isolation, pending decision

* docs(dsh-native-ui): reorganize as final-decisions document; drop rejected options, keep pitfalls

* docs(dsh-native-ui): FEASIBILITY 实施修订 + 跨进程会话边界

- 记真机验证(dsh 0.1.0-rc.6)后的四项实施修正:
  * restrict overlay 停 directory-picker 自动组合器 + 裸挂
    directory-picker-browse host 后端行(保留 api-gateway 依赖的
    directoryPicker 服务、不挂 client 表面 → Add workspace 入口
    不渲染;直接禁用整行会触发 '1 entry did not activate')
  * dsh web 启动命令 flag 顺序必须 launcher 优先:
    'web --patch <overlay> --host 127.0.0.1 --port 0'
    (web subcommand 遇到第一个未知 option 即 pass-through;--patch
    放 --host 之后会被透传给 app 层报 unknown option)
  * RPC 信封 endpoint 从 URL 路径派生:POST /api/<method>,body
    'method' 字段必须与 endpoint 一致(打裸 /api 会 404)
- 跨进程会话边界限定为'可见、可打开、可读快照':
  * 会话实时事件只在写入者进程内广播(无 fs.watch/轮询)
  * 第二进程打开活跃会话的构造器无锁追加 session/end-seed
    (seq = log length) 撞 seq → 日志损坏(corrupt session log:
    seq gap in committed region);详见 CROSS-PROCESS-SESSION.md
- §2.2 '终端裸跑 dsh 与 web 实例会话互通' 同限定为快照级

* docs(PRD): add dsh-web instance type entry, status 已实现

Per review (REVIEW-2026-08-15.md #10 / REVIEW-2026-08-15-r2.md 必改 #1):
the dsh-web PRD entry was missing in HEAD and the '规划中' status
written in the working tree was stale — the implementation lives in
internal/instance/dsh_web/, CHANGELOG has 'feat(instance): dsh-web
kind implemented' in Unreleased, and README feature list already names
dsh-web.

Add the full entry mirroring the opencode-web paragraph above it:
hardcoded spawn (dsh web --patch <restrict.yml> --host 127.0.0.1 --port 0,
launcher flags first — PLAN §踩坑 11), shared DSH_HOME with per-worktree
storages (sessions shared = snapshot-only across processes, upstream
dsh issue — see CROSS-PROCESS-SESSION.md), per-instance dedicated
loopback origin (SPA API base hardcoded to location.origin + '/api'),
workspace bootstrap via workspace.create, restrict overlay (disable
directory-picker composer + bare -browse host backend, client-hmr —
PLAN §踩坑 12), proxy-side scope record-only with persistent warning
bar, hard version gate (< 0.1.0 fail-fast) + advisory [0.1.0, 0.2.0)
+ L2 --dump-config overlay verification (overlay_verified), three-way
missing-dependency dialog (npx pin / install / cancel), remote mode
(non-loopback main listener or TLS) with mandatory myworktree token
gate on the per-instance reverse proxy.

* docs(dsh-native-ui): implementation plan, task checklist, cross-process session analysis

Implementation record for the dsh-web integration (PR 1 of 5 — docs-first).
FEASIBILITY.md and the PRD entry (the public doc) already landed in earlier
commits; this commit is the in-team working docs.

- PLAN.md — 5-PR implementation plan in dependency order (docs → core →
  proxy/app → frontend → remote), with security posture, key decisions
  (per-instance dedicated loopback origin, shared DSH_HOME + per-worktree
  storages, record-only scope monitoring, npx/install launch modes, version
  gate + L2 overlay verification), and the 11–15 实施踩坑 (launcher flag
  order, directory-picker composer not to be outright disabled, RPC wire
  endpoint, --dump-config needs writable DSH_HOME, cross-process session
  log corruption upstream dsh bug).
- TASK.md — PR-level checkbox mirror of PLAN §实施步骤.
- CROSS-PROCESS-SESSION.md — forensic record of the upstream dsh session
  bug (session/end-seed appended without locking by the second reader →
  seq collision → log corruption), byte-level evidence from the real
  ~/.dsh/sessions/session-d95142a8-…/session.jsonl.zstd, source citations
  in dsh-session/lib/index.js and dsh-host-apiproxy, full timeline tying
  the 3080 terminal process's writes to the 34463 embedded instance's
  open, reproduction steps for upstream reporting. Root cause + design
  boundary separated (现象 A = single-writer broadcast model, 现象 B =
  implementation bug); myworktree side keeps 可见/可打开/可读快照
  posture and does not implement cross-process sync.

* feat(instance): dsh-web kind (DSH_HOME share + per-worktree storages + per-instance loopback proxy)

internal/instance/dsh_web/ — a self-contained framework.Kind package that
embeds the DeepSeek Harness web UI (dsh web) as a managed instance.
Mirrors the opencode_web pattern; intentionally independent of app/,
pty/, reasonix/, or opencode_web/.

Driver (driver.go)
  - framework.Kind 'dsh-web' (Interactive:false). Spawn pipeline:
    launch-mode resolve → dsh LookPath preflight → hard version gate
    (core < 0.1.0 fail-fast, reasonix #45 pattern) → write restrict.yml
    → L2 --dump-config verification (overlay_verified) → exec with
    Setpgid for npx mode → pumpAndWatch scans ANSI-stripped stdout for
    'dsh web: http://<host>:<port>' ready line → starts per-instance
    reverse proxy via ProxyStarter seam → ready. Stop signals the whole
    process group for npx mode (kill(-pid, SIGTERM) → grace → SIGKILL).
    Health loop probes GET / at 5s/3-strike.
  - tag preStart runs under zsh -lc with redact.Text scrubbing.

Restrict overlay (overlay.go)
  - storage-json.root → <dataDir>/dsh/<gitx.HashPath(worktree)>/storages
    (per-worktree registry isolation; sessions stay shared in ~/.dsh/).
  - directory-picker.disabled + insert @deepseek-ai/dsh-host-directory-
    picker-browse bare host backend row (preserves the directoryPicker
    service that api-gateway hard-depends on while suppressing the
    'Add workspace…' client surface — PLAN §踩坑 12, fixed in -rc.6).
  - client-hmr.disabled (production hygiene).
  - File mode 0o600 (carries worktree paths).
  - verifyOverlayDump checks the 4 expected rows in --dump-config
    output, block-scoped (a 'disabled: true' on a different row cannot
    satisfy the directory-picker check).

Per-instance reverse proxy (proxy.go)
  - Loopback bind by default; binds the main listener's host with a
    mandatory token gate when the main listener is non-loopback or TLS.
  - Director: Host→upstream loopback, Origin deleted, authq.StripToken
    (token never reaches the dsh subprocess, neither via query nor via
    header), Accept-Encoding passthrough, FlushInterval=-1 for SSE/WS.
  - WS Upgrade passed through (events.mux / events.host).
  - Token gate: ?token= or mw_token cookie; on query-token success the
    proxy HttpOnly-sets the cookie on ITS origin and 302-redirects to
    the token-free URL, so the embedded document never retains the
    myworktree token in its own location.search (ARCHITECTURE §9
    checklist item 9).
  - observeBody reads POST /api bodies (capped at 16 MiB) to record
    workspace.create {path} / session.create {cwd|workspaceId} into
    ScopeTracker. Record-only, never blocks or rewrites. Bodies are
    re-attached for forwarding; oversized bodies are passed through
    untouched via MultiReader.
  - Unexpected listener death (TLS reload failure etc.) fails loud:
    logs + MarkFailed + iframe_url cleared + handle teardown; the
    health loop probes only the upstream, so this is the only way the
    dead proxy becomes visible. Pinned by TestProxyListenerDeathFailsLoud.

Scope classification (scope.go)
  - normalizeDir canonicalizes a path (clean + EvalSymlinks + trim sep)
    so symlink / .. / trailing-slash differences do not false-positive.
  - classifyRPC: dir vs worktree (cwd) — workspaceId vs worktree's own
    id (from bootstrap); when bootstrap hasn't landed yet and only
    workspaceId is supplied, returns ok=false (no observation) so the
    worktree's own workspace isn't flagged as out-of-scope.

Workspace bootstrap (bootstrap.go)
  - After the ready line, Go http.Client posts POST /api/workspace.create
    DIRECTLY to the upstream loopback (no Origin → passes the /api trust
    fence). Wire contract: endpoint derived from URL path, body method
    must equal endpoint — verified against the real binary (PLAN §踩坑 13).
  - 3×2s retry on transient failure, warn-only on give-up.
  - Records worktree's own workspaceId in blob for scope classification.
  - bootstrapCreateSession = true preseeds a blank session.

Launch modes (launch.go)
  - path / npx / install persisted PER WORKTREE at
    <dataDir>/dsh/<worktreeHash>/launch.json (per-worktree because
    framework Restart allocates a fresh instance id and wipes the
    per-instance state dir — a per-instance launch file would be
    orphaned on the failed → choose npx → Restart path).
  - npx mode runs the pinned command: npx --yes @deepseek-ai/dsh@<NpxPin>
    web ...; Setpgid, kill the whole group on Stop. The pin is the
    exact version the integration was verified against.
  - install mode records the absolute bin path resolved by
    'npm prefix -g' (no daemon restart needed).
  - webArgs: launcher flags MUST come first (--patch <overlay>
    --host 127.0.0.1 --port 0). PLAN §踩坑 11: the web subcommand
    switches to pass-through at the first unknown option, so
    --patch after --host is forwarded to the app layer and
    rejected as 'unknown option'. dumpArgs has the same order for
    the L2 --dump-config verification.

Version gate (version.go)
  - minVersion = 0.1.0, supportedMaxVersion = 0.2.0 (exclusive),
    NpxPin = 0.1.0-rc.6 (the version the launcher flag order, the
    directory-picker composer behavior, and the RPC wire were all
    verified against).
  - parseVersion tolerates -rc.N suffixes; 'dev' is logged and allowed.
  - verMemo memoizes successful probes per binary path (reasonix #45).

Tests (driver_test / overlay_test / version_test / launch_test /
scope_test / bootstrap_test / proxy_test / integration_test)
  - mock upstream at testdata/dsh-mock.go.
  - End-to-end via framework.Manager: Spawn → ready → health → Stop,
    proxy + upstream dual port released, npx process group has no
    residual after Stop.
  - Token gate matrix: no / wrong / query / cookie / query→cookie
    redirect, token never leaked upstream.
  - WS passthrough: real socket round-trip through a real listener.
  - L2 dump-config with the composed overlay rows.
  - 11-case scope classification matrix.
  - Hard version gate fail-fast on 0.0.9 mock.
  - ErrDshNotFound surfaced on missing binary.
  - Proxy listener death → MarkFailed + blob iframe_url cleared.
  - Bootstrap envelope + retry + warn-only on give-up + workspaceId
    persisted in blob.

* feat(app): dsh-web kind registration + 4 API endpoints + shutdown

Wires the dsh_web package (from the previous commit) into the main
myworktree daemon.

- internal/store/state.go: KindDsh = "dsh-web" alongside the existing
  KindPTY / KindReasonix / KindOpenCodeWeb constants.
- internal/tag/tag.go: defaultTags appends {ID: "dsh-web"} — like
  opencode-web, the tag is a label / env / preStart source only; the
  hardcoded dsh web invocation lives in the driver.
- internal/app/app.go:
    * Server struct gains dshScope (*dsh_web.ScopeTracker) and dshDrv
      (*dsh_web.Driver) plus dshInstallMu (sync.Mutex, serializes the
      npm install -g flow so two tabs clicking install now don't run
      two concurrent global installs).
    * New() registers the dsh_web driver via the per-server Registry
      (NOT framework.Default — see comment for the test-rerun reason).
      The driver's Tracker / Proxy / ProxyStarter are filled here from
      the main listener settings.
    * dshProxyConfig derives the per-instance reverse-proxy settings
      from the main listener: loopback bind with no token locally; when
      the main listener is non-loopback or TLS, binds the main
      listener's host with a MANDATORY token gate (dsh has no
      authentication layer — the proxy is the only boundary between
      the LAN and the unauthenticated upstream). TLS mirrors the main
      certificate so an https page never embeds a mixed-content iframe.
    * 4 new routes:
        GET  /api/instances/dsh            — handleInstanceDshInfo
        GET  /api/instances/dsh/scope      — handleInstanceDshScope
        POST /api/instances/dsh/launch     — handleInstanceDshLaunch
        POST /api/instances/dsh/install    — handleInstanceDshInstall
      All four enforce method + id + dsh kind guards; non-dsh instances
      get 404 before any dsh driver touch.
    * handleInstanceDshInfo reports iframe_src (built from the caller's
      r.Host when the proxy binds 0.0.0.0 — non-loopback mode), proxy
      host/port, worktree path, version, version_supported, overlay_
      verified, and recomputed missing_dsh (when the instance is
      failed with a structured ErrDshNotFound). npm-available +
      suggested pin are surfaced so the frontend can render the three-
      way dialog.
    * handleInstanceDshInstall runs npm install -g @deepseek-ai/dsh
      under dshInstallMu (5 min timeout), then npm prefix -g to resolve
      the absolute bin path, and persists it via Driver.SetInstallBin
      (no daemon restart). npm output is truncated to 300 chars after
      redact.Text scrubbing (private-registry URLs, lock dumps, etc.).
    * Shutdown() adds StopAllKind(store.KindDsh) so SIGTERM cleans up
      running dsh-web instances alongside the existing PTY / opencode-
      web / reasonix cleanup.
- internal/app/app_dsh_test.go:
    * newIsolatedTestServerWithDsh mirrors app.New for tests.
    * Dsh kind guard 404 on non-dsh instances.
    * Remote host mapping: 0.0.0.0-bound proxy gets the caller's host.
    * Scope endpoint reads from the in-memory tracker.
    * Launch endpoint rejects bogus modes + non-dsh instances.
    * Install endpoint kind-guard test (the real npm run is exercised
      manually — it mutates the global npm prefix).

* feat(ui): dsh-web renderer (iframe keep-alive, scope warning bar, missing-dep dialog)

- internal/ui/static/kinds/dsh_web.js (new, DshWebRenderer):
    * activate() hides the PTY terminal container, shows #dsh-panel,
      prunes stale iframes, and either reuses the existing frame for
      the instance or shows a "starting…" placeholder.
    * _startPolling polls /api/instances/dsh?id=... every 500ms up
      to 60s for the iframe src. On success: cache the tokenized
      src on the frame's dataset, mark the frame's instance, and
      unhide. On missing-dsh: show the three-way dialog but keep
      polling (a cancel only dismisses the dialog until the next
      recovery/restart outcome — never stops the poll, so an install
      or PATH fix clears the dialog automatically).
    * _tokenizeIframeSrc appends ?token= only when this page is
      served remotely (non-loopback); the proxy validates the first
      navigation, HttpOnly-sets mw_token on its own origin, and
      302-redirects to a token-free URL, so the embedded document
      never keeps the credential in its own location.search. Locally
      the iframe stays token-free.
    * _startScopeMonitoring polls /api/instances/dsh/scope?id=...
      every 1.5s and drives a three-state warning bar above the
      iframe (record-only, never blocks): restriction ineffective
      (version out of supported range OR L2 --dump-config did not
      confirm the overlay rows) → danger bar; out-of-scope session
      → orange bar with the directory; normal → hidden.
    * Missing-dependency dialog: dsh 未安装 + three buttons (npx /
      install / cancel). The install button is a two-step confirm
      (the install rewrites the global npm prefix — make the user
      click twice; the second click sends POST /api/instances/dsh/
      install and then auto-restarts the instance). npm_available
      from the info endpoint hides the install button when npm is
      not on PATH.
    * Cleanup destroys all per-instance iframes (stops the keep-alive
      cache) and clears scope-monitoring state.
- internal/ui/static/index.html:
    * CSS for #dsh-panel / #dsh-frames / .dsh-frame / #dsh-loading /
      .dsh-loading-{spinner,title,info} / #dsh-scope-warning /
      .dsh-warning-danger / .badge-dsh.
    * #dsh-panel DOM (scope-warning + frames).
    * Dsh-Web start-instance tab (modal-tab-panel) with name input.
    * #modal-dsh-missing dialog (titlebar, body, three actions,
      progress + error paragraphs).
    * Web-ui kind lists collapsed into one WEB_UI_KINDS constant
      (panel mutual exclusion, controls.oc-hidden toggle, pty tags
      filter, stop-instance destroyFrame, delete-instance loop).
    * The DS badge next to instance names of kind dsh-web.
    * <script src="/static/kinds/dsh_web.js"></script> after
      kinds/reasonix.js.
- .gitignore: ignore .gocache/ (sandbox GOCACHE build artifact).

* docs(dsh-native-ui): API + architecture + changelog + readme sync

Public-facing doc sync for the dsh-web integration. The PRD entry landed
in a993ec3; this commit is everything else.

- docs/API.md §5.14–5.17: dsh instance endpoints
    * GET  /api/instances/dsh?id=...            — iframe_src,
      proxy_host/port, host/port, worktree_path, version,
      version_supported, overlay_verified, optional missing_dsh;
      404 on non-dsh instances.
    * GET  /api/instances/dsh/scope?id=...      — last observed
      scope (in-scope | out-of-scope) + directory + unix at;
      404 on non-dsh instances.
    * POST /api/instances/dsh/launch            — body {id, mode}
      where mode ∈ {path, npx, install}; 400 on unknown mode.
    * POST /api/instances/dsh/install           — body {id}; runs
      npm install -g @deepseek-ai/dsh (5 min timeout, mutex
      serialized, output truncated 300 chars after redact.Text);
      returns {status, resolved_bin}.

- docs/ARCHITECTURE.md §9 (new): dsh-web integration
    * ASCII architecture diagram (myworktree main UI ↔ per-instance
      reverse proxy ↔ upstream dsh web, with the trust boundary
      and the data plane).
    * Threat model: loopback isolation (local), token gate (remote),
      credential hygiene (?token= strip, cookie HttpOnly+Secure,
      token never reaches the dsh subprocess), cross-worktree
      drift (record-only, matches opencode/reasonix philosophy),
      dsh sandbox (OS-level, the real write boundary for the
      embedded UI).
    * Review checklist (dsh-specific, 9 items): token gate cannot
      be disabled, token scrubbing, Origin deletion, WS upgrade
      passthrough, process-tree cleanup (npx Setpgid), row-id drift
      (L1 advisory + L2 --dump-config), bootstrap scope leak
      (worktree path only, never client-supplied), body parsing
      (record-only fallback), and the global §8 items 1–9 carry
      over (auth file 0o600, no token in logs/tests, cookie
      Secure/Domain/Path, iframe document never carries the token
      in its own location.search).
    * Cross-references to FEASIBILITY.md / PLAN.md / TASK.md /
      CROSS-PROCESS-SESSION.md and the §9 implementation files.

- CHANGELOG.md (Unreleased): add the dsh-web kind implemented entry
  (full implementation summary) plus the 实施修订 / fix entries
  (restrict overlay must not disable directory-picker outright,
  bootstrap RPC wire is POST /api/<method>, cross-process session
  boundary record) and the review-fix batch (302 redirect, loopback
  bypass, listener death fail-loud, install two-step + mutex +
  truncate, restrict.yml 0o600, npx pin → 0.1.0-rc.6, WEB_UI_KINDS
  constant, status wording sync).

- README.md / README.zh-CN.md: Feature list adds dsh-web to the
  per-worktree instance roster; new section "dsh-web sessions are
  snapshot-only across processes (upstream dsh issue)" with the
  non-technical summary + pointer to CROSS-PROCESS-SESSION.md.

* docs(review): dsh-web integration code-review audit trail

Three review documents, one per review pass, kept together so the
audit trail survives even when a reviewer is reading the branch
years later. Each file is a point-in-time assessment (REVIEW-<DATE>.md
+ a -2 / -r2 suffix for re-reviews); filename convention documented
in the first file's header.

- REVIEW-2026-08-15.md: first pass. 13 findings ranked by risk,
  from the install endpoint (no audit / mutex / truncate) through
  the proxy listener death silent-swallow to the cosmetic WEB_UI_KINDS
  refactor suggestion. All findings tied to specific file:line
  ranges and to the opencode-web precedent where relevant.
- REVIEW-2026-08-15-2.md: author's own re-review with a re-framed
  11-item risk list, fix-tracking table, and a process note that
  the original 13-finding review is the source of truth.
- REVIEW-2026-08-15-r2.md: follow-up review verifying the fix
  batch (302 redirect, listener fail-loud, install two-step +
  mutex + truncate, polling continuation on missing_dsh, restrict.yml
  0o600, WEB_UI_KINDS constant, NpxPin = 0.1.0-rc.6). 6/13 修或
  部分修; 2 条接受不修(#2 跨进程会话嗅探 / #5 r.Host,作者论据
  合理); 5 条 follow-up. Single remaining 必改 = docs/PRD.md:75
  stale '规划中' status, fixed in a993ec3 (which landed BEFORE the
  re-review but is referenced as the post-fix state in the doc).

Read in order: 1 → 2 → -r2. The two 必改 items that the re-review
identified (PRD status line, CHANGELOG fix batch) both landed
before this commit, so the re-review's "必改 before commit" wording
reflects the snapshot at review time, not the current HEAD.

No code changes in this commit. The review documents are an audit
trail for the four preceding commits, not part of the implementation.

* feat(dsh-web): follow-up issues #62-#67 (REVIEW-2026-08-15-2)

Review follow-ups for the dsh-web feature on feature/dsh-native-ui,
implementing the six follow-up issues filed at
docs/plans/dsh-native-ui/REVIEW-2026-08-15-2.md.

- #63 foreign-session activity advisory (record-only mitigation of
  the dsh single-writer log-corruption boundary, see
  CROSS-PROCESS-SESSION.md): a daemon-level SessionWatch
  (sessionwatch.go) scans $DSH_HOME/sessions every 3s, treats
  session.jsonl.zstd modified within 90s as active, and subtracts
  sessions this daemon itself drives. Own-attribution sources:
    * write-driving session.* RPC bodies observed through the
      per-instance proxy (session.prompt / .cancel / .fork /
      .rename / .selectModel / .attachment / .updateQueue;
      subagent.prompt uses both parentSessionId and childSessionId);
      read-only methods (session.history / .models / .list /
      .search) are deliberately excluded — opening a session
      does not write, and marking it would wrongly suppress the
      warning for a session a terminal dsh is actively writing;
    * session.create response (id lives in the value, not the
      body) — teed via a sessionCreateBody wrapper that parses
      the envelope once the stream ends. The real upstream value
      shape is {sessionId: …}; the mock testdata was wrong and is
      fixed in the same change;
    * workspace-bootstrap preseed (createSession in bootstrap.go).
  Remaining active sessions surface as foreign_active_sessions
  on /api/instances/dsh/scope, and the frontend renders a
  persistent warning bar telling the user to wait for the
  session to idle. Record-only — no iframe DOM changes.

- #64 upstream mid-body failure abort: ErrorHandler cannot emit
  a 502 once the response headers are on the wire, so the
  previous code truncated the body silently. A new
  headerTrackRW wrapper records whether headers were written
  (WriteHeader, or the implicit 200 from the first Write) and
  ErrorHandler panics http.ErrAbortHandler in that case so the
  browser fails loud. Unwrap preserves http.ResponseController
  Hijack/Flush paths (WebSocket upgrade still works).

- #65 validate r.Host-derived iframe URL: r.Host is client-
  controlled and is the only source of a routable host when
  the dsh proxy binds 0.0.0.0/::. A new validPublicHost only
  allows plain DNS names, IPv4 literals, or bracketed IPv6; a
  numericPort guard requires the host:port split to actually
  have a numeric port before the host part is trusted. Any
  failure path yields an empty iframe_src — the frontend
  stays on the 'starting' placeholder instead of a dead URL.

- #66 remote iframe auth hint: when a remote page has neither
  mw_token cookie nor ?token= in the address bar, the proxy's
  mandatory token gate will 401 the first iframe navigation.
  The renderer now detects this case and shows a 'remote
  access needs a token' warning bar before the guaranteed 401,
  in front of the existing restriction-effectiveness /
  foreign-active / out-of-scope priority chain.

- #67 dedupe test helper: managedTestInstance(id, kind, …) in
  internal/app/testhelper_test.go is the shared per-kind
  ManagedInstance stub; the dsh tests (app_dsh_test.go) drop
  the kind-specific dshTestInstance and call it instead. Future
  web-ui kinds reuse the same helper.

- #62 pr5-remote-e2e.sh: half-automated LAN+TLS end-to-end
  coverage (real myworktree daemon bound 0.0.0.0 with self-
  signed TLS, real dsh 0.1.0-rc.6, isolated DSH_HOME /
  worktree). Asserts: no token → 401 / first nav with
  ?token= → 302 Location=/ + Set-Cookie → 200 / cookie only →
  200 / loopback passthrough → 200 / WS Upgrade → 101 Switching
  Protocols / session-watch attribution (own preseed excluded,
  forged foreign session raises the warning, deletion clears
  it) / shutdown leaves no orphan processes. The full browser
  click-through (SPA render, interactive WS frames) remains
  manual per TASK PR5.

Docs: CHANGELOG §v0.4.3 (unreleased); API.md describes the new
foreign_active_sessions field; ARCHITECTURE.md §9 adds a
'Foreign-session activity advisory' paragraph and lists
sessionwatch.go in the file map; PLAN.md / TASK.md /
CROSS-PROCESS-SESSION.md get status markers and the 'mw
side' mitigation is now marked implemented (warn-only).

Tests: TestSessionWatchScanForeignActive /
TestSessionWatchOwnAttribution / TestSessionWatchMissingDir /
TestProxySessionOwnMarking /
TestProxySessionCreateResponseMarking /
TestProxyUpstreamMidBodyFailureAbortsConnection /
TestHandleInstanceDshInfoHostValidation /
TestHandleInstanceDshScopeForeignActiveSessions.

* docs(dsh-native-ui): record server-tokenized iframe_src fix (REVIEW-2026-08-15 #13 closure)

The original issue #13 (REVIEW-2026-08-15.md) documented that the
remote iframe needed ?token= on the first navigation. The previous fix
pushed the tokenization to the frontend (cookie → address-bar fallback),
which regressed in the portal/login flow: mw_token is HttpOnly (page JS
can never read it) and portal/login has no ?token= in the address bar,
so logged-in remote sessions got a false '远程访问需要认证' warning AND
a token-less cross-origin iframe URL that 401'd. The follow-up fix
moves tokenization back to the server (handleInstanceDshInfo), reading
Proxy.AuthToken (the field checkToken validates against).

CHANGELOG: new entry under ## Unreleased for the regression fix.
API.md / ARCHITECTURE.md §9: updated Remote access wording to reflect
that the server appends ?token= to iframe_src (page JS cannot read the
HttpOnly cookie).
REVIEW-2026-08-15.md: appended '附2: 服务端 token 化归档
(2026-08-16, issue #13 的回归修复)' — regression symptom, root cause,
fix contract, and verification (so the review file evolves from a
checklist into a checklist + fix archive).

* fix(dsh-web): server appends ?token= to iframe_src when proxy gate is on

Remote portal/login sessions were hitting a token-less cross-origin
iframe URL (the proxy's mandatory token gate 401'd) AND a false
'远程访问需要认证' warning. Root cause: the frontend tried to read
mw_token from document.cookie, but the cookie is HttpOnly — page JS
cannot read it — and portal/login flows carry no ?token= in the
address bar.

The server now owns the tokenization:

  - handleInstanceDshInfo (internal/app/app.go) appends
    ?token=<Proxy.AuthToken> to iframe_src when the proxy's token
    gate is on. Reads Proxy.AuthToken (not cfg.AuthToken) so the URL
    stays correct even if the main token and the proxy token are
    ever split. Sits behind withAuth (loopback bypass included), so
    only authenticated clients receive it; the proxy then validates,
    syncs its own HttpOnly cookie, and 302-redirects to the
    token-free URL — the embedded document never retains the token
    in its location.search (ARCHITECTURE §9).

  - dsh_web.js _tokenizeIframeSrc: dropped the document.cookie read
    (always failed) and rewrote as 'accept server-tokenized src /
    fall back to address-bar token / warn only on genuinely
    token-less remote srcs'. Local pages still never get a token.

  - TestHandleInstanceDshInfoTokenizesRemoteSrc now sets cfg and
    Proxy AuthToken to DIFFERENT values and asserts the iframe_src
    carries the Proxy value (not the cfg value) — pins the token
    source so a future regression to cfg.AuthToken is caught.

  - New docs/plans/dsh-native-ui/dsh_web.test.mjs (node --test,
    4 cases) covers the three branches: server-tokenized / address-
    bar fallback / warn. Lives under docs/plans/ (not ui/static/)
    to avoid being embedded as a static asset by //go:embed static/*.

  - pr5-remote-e2e.sh: step 0 now runs the JS unit test; the new
    server-tokenized-src check parses iframe_src with python3 +
    urlparse + parse_qs and asserts the token query field equals
    the expected value exactly (substring grep would have missed
    ?token=…&other=… regressions).

Verification: go build ./... / go vet / go test ./internal/app/
-clean; node --test dsh_web.test.mjs — 4/4 pass; pr5-remote-e2e.sh
full matrix green.

* fix(dsh-web): UX guard for remote access (issue #72)

dsh-web's session/workspace sync uses WebSocket, which the user's
remote network path silently drops (server-side, curl/node, and
raw-socket replays all return 101; only the browser's WS upgrade
dies on remote networks — see
docs/plans/dsh-native-ui/REMOTE-WS-BREAKAGE.md for the full
investigation). Result on remote pages: a user could Start a
dsh-web instance, the iframe would render, and the sidebar would
just say "暂无会话" with no indication that the network, not the
code, is the problem.

This is a UX mitigation, not a fix. Three layers:

  1. Start Instance modal: disable the Start button on the
     dsh-web tab when isRemoteAccess() and show a red
     .form-warning hint. New CSS uses --danger-bg / --danger-
     border variables (light + dark themes) shared with the
     pre-existing #dsh-scope-warning.dsh-warning-danger.

  2. doStartInstance defence in depth: alert + return if the
     active tab is dsh-web and isRemoteAccess(), so re-enabling
     the disabled button via DevTools does not bypass the gate.

  3. Running-instance view: new _updateRemoteMask() in
     kinds/dsh_web.js puts a full-screen gray mask with a
     centred card over #dsh-frames, and _refreshWarning gets a
     new highest-priority "Remote access" branch (case 0) that
     surfaces the same message in the existing #dsh-scope-
     warning bar. Closes the gap where a user with a locally-
     started dsh-web instance opens the iframe remotely and
     sees an empty sidebar with no explanation.

Centralization: window.isRemoteAccess() in framework.js is the
single source of truth (loopback = localhost / 127.0.0.1 / ::1 /
[::1]). Replaces four inline copies in index.html (Remote
badge, renderSidebar, renderTagsAndTemplateEditBtn,
updateLLMSettingsButtonVisibility) and the partial-IPv6 check
in dsh_web.js#_tokenizeIframeSrc — that one accepted "::1" but
not "[::1]", so an IPv6 loopback user would have been wrongly
treated as remote for token handling; now consistent everywhere.

Test stub (docs/plans/dsh-native-ui/dsh_web.test.mjs) mirrors
the new isRemoteAccess contract so existing _tokenizeIframeSrc
cases run against the same helper the production code uses.

The actual fix — a WS→SSE bridge in the proxy plus a shim
injection for the SPA's WebSocket constructor — is plan A in
REMOTE-WS-BREAKAGE.md §8 and is left for a follow-up. It
conflicts with the existing "dsh does not inject into the
iframe" decision and needs its own design review; the report's
§10 决策留痕 table tracks the open items.

* fix(ui): follow-up issues #68-#73 (REVIEW-2026-08-16)

Start Instance modal + instance-tab UI consistency batch,
filed as issues #68-#71/#73 at
docs/plans/dsh-native-ui/REVIEW-2026-08-16.md. #72 was
replied-to and closed (its UX guard already landed in
ec88389); no code change here.

- #68 tab-bar wrap: .modal-tabs gets flex-wrap: nowrap +
  overflow-x: auto (thin scrollbar), .modal-tab gets
  white-space: nowrap + flex: 0 0 auto — narrow windows
  degrade to a horizontal scrollbar instead of wrapping the
  tab text into tall blocks.
- #69 tab naming (plan A/C): the four tabs are now
  Terminal / OpenCode-Web / Reasonix-Web / DSH-Web.
  data-tab values and the backend kind mapping are
  unchanged. The dormant Manifest().Label fields are synced
  to the same scheme (OpenCode-Web / Reasonix-Web /
  DSH-Web) so a future UI consumer never shows an old name.
- #70 unified kind badges: .badge-oc / .rx-badge /
  .badge-dsh collapse into one .kind-badge base (10px,
  line-height 1, weight 600, 2px 4px padding, 4px radius,
  system font) with per-kind color-only classes; dsh colors
  move to new --badge-dsh-bg / --badge-dsh-text CSS
  variables (light #0969da / dark #1f6feb) so both themes
  work. Legacy class names removed from the tab template.
- #71 Terminal tab form-info: explains it opens a PTY shell
  in the worktree (no web service, no iframe) and that
  Template / Custom Command still apply.
- #73 English-only UI, enforced across the whole served UI,
  not just the modal: dsh missing-dependency dialog, and
  every user-visible string in dsh_web.js / opencode_web.js
  (warning bars, loading titles, timeout/error copy) were
  translated; the last Chinese code comments were removed,
  so served UI sources are zero-CJK. TestUICopyIsEnglishOnly
  asserts zero CJK characters on /, all three web-kind
  renderers and framework.js, plus positive English-copy
  checks per file — a stray Chinese string fails loudly.

Docs: REVIEW-2026-08-16.md (batch record + second-round
review-response table, all four findings addressed);
TASK.md gains the PR6 entry and a Status line update;
CHANGELOG.md gains two Unreleased entries and drops the
stale "(uncommitted, pending review)" marker from the
already-committed #62-#67 entry.

Tests: TestStartInstanceModalTabLabelsAndWrap /
TestStartInstanceModalFormInfosAreEnglish /
TestKindBadgesUnified / TestUICopyIsEnglishOnly, plus the
fetchStaticPath helper and ReadAll error checks in
fetchIndexHTML / fetchStaticPath (review nit2).

* fix(ui): Start Instance modal width follows content + badge polish

- #68 rework: drop the modal-tabs horizontal-scrollbar band-aid
  (overflow-x: auto / scrollbar-width: thin) in favour of sizing the
  dialog to its content — #modal-start-instance is now
  width: max-content with min-width: 400px (no narrower than the
  other dialogs) and max-width: min(90vw, 640px) (long form-info
  paragraphs cannot blow the dialog up). All four tabs fit on one
  line at any normal window size; tabs keep flex: 0 0 auto +
  white-space: nowrap. Test pins the scrollbar-free .modal-tabs
  block and the three width caps.
- reasonix instance-tab badge text rx -> RX (consistent with the
  OC / DS siblings) and .kind-badge font-size 10px -> 9px.

* fix(review): address PR #74 review findings (REVIEW-2026-08-16 #3 round)

- fix(security): strip mw_token cookie in dsh proxy Director before
  forwarding upstream — checkToken syncs it onto the proxy origin, and
  the unauthenticated dsh subprocess must never see the main token
  (review #3 Major); pinned by TestProxyStripsMwTokenCookieUpstream
- fix(pty): close ptmx master fd when the log pump exits — every PTY
  start/stop leaked a fd until exhaustion (review #2 HIGH-1)
- fix(framework): skip the 16-256 MB ring-buffer pre-allocation for
  web-UI kinds — only PTY captures output (new BufferConsumer
  interface; opencode-web / reasonix / dsh-web return false)
  (review #2 HIGH-2)
- fix(ui): escapeHtml user-controlled values in innerHTML templates —
  instance displayName/rename, diverge badges branch+error, Start-modal
  branch names and tag command/cwd/preStart/env (review #2 HIGH-3)
- fix(dsh-web): persist https scheme in IframeURL when the main
  listener is TLS (review #1-1)

Verified: go build, go vet, go test ./... all green (incl. -race on
dsh_web/pty).

* fix(review): CSRF guard for dsh install/launch + proxy hardening (MED-6/8)

- fix(security): dshWriteOriginGuard rejects cross-origin writes to
  /api/instances/dsh/install and /dsh/launch even on loopback — withAuth
  skips Origin checks for loopback clients, so a hostile webpage could
  drive a no-cors fetch into `npm install -g` (arbitrary code exec).
  Origin-less requests (curl/CLI) stay allowed. Pinned by
  TestDshWriteOriginGuard (unknown-instance id: guard-passing requests
  stop at 404, never reach npm).
- fix(dsh-web proxy): ReadHeaderTimeout/ReadTimeout/IdleTimeout on the
  per-instance http.Server (slowloris guard) and constant-time token
  comparison (crypto/subtle).

---------

Co-authored-by: linletian <linletian@users.noreply.github.com>
…(issue #75) (#76)

opencode embeds the Bun runtime, whose fetch honors HTTP(S)_PROXY but
does not match CIDR ranges in no_proxy (e.g. the common 127.0.0.0/8).
With a LAN proxy configured, in-process plugin SDK calls to the
loopback server were routed through the proxy and failed with 502,
surfacing in oh-my-openagent as 'messages.map is not a function' and a
100% task-tool failure in web/serve mode (TUI is unaffected because
plugins use in-process fetch there).

buildEnv now merges NO_PROXY/no_proxy (both cases) and appends the
literal hosts localhost, 127.0.0.1, ::1 so every no_proxy matcher
bypasses loopback. No-op without a configured proxy or with a '*'
wildcard; existing entries (CIDR included) are preserved; remote LLM
provider traffic still goes through the proxy.

Verified live: opencode serve + Bun probe returns 502 with CIDR-only
no_proxy, and 401/200 (direct) after the fix. Unit tests cover append,
merge/dedupe across cases, wildcard, and no-proxy no-op.

Co-authored-by: linletian <linletian@users.noreply.github.com>
Codifies the v0.4.2/v0.4.3 release path: prep branch from develop,
squash-merged release PR to main, annotated tag triggers the release
workflow, verification (assets + /releases/latest redirect), and the
back-merge into develop. Includes the flaky-test re-run note, tag
immutability rules, and an unexercised hotfix flow.
…y (issue #72) (#78)

* feat(dsh-web): add 0.1.5 remote-capability floor and bump npx pin to 0.1.5-rc.1

* feat(ws): add hand-rolled WebSocket client Dial with client-side frame masking

* feat(dsh-web): capture upstream launch token, expose remote_capable, tolerate 401 health probes

* feat(dsh-web): add upstream browser-session cookie relay (launch-token exchange)

* feat(dsh-web): relay upstream browser-session auth through the instance proxy

* fix(dsh-web): carry relay cookie in workspace bootstrap RPCs

* feat(dsh-web): WS-to-SSE bridge with native-first shim for remote access

* feat(dsh-web): pre-start remote capability probe endpoint

* feat(dsh-web): gate remote Start on dsh version instead of blanket block

* feat(dsh-web): make the remote mask version-conditional

* test(dsh-web): cover auth relay and bridge through the full instance path

* docs(dsh-web): record remote-support decisions and refresh limitations

* fix(dsh-web): sanitize exchange errors so the launch token never reaches logs; F2 review findings

* fix(dsh-web): PR #78 Go review — strip Referer upstream, parse the unredacted ready line, validate Sec-WebSocket-Accept in Dial, 413 on oversized uplink, SSE write deadline, client-side frame reassembly, fresh capability probes, invalidate throttle, gating/injection tests

* fix(dsh-web): PR #78 shim review — gate bridge-open on the server frame, anti-wedge send queue, terminal SSE error teardown, close-code mapping, shim unit tests; honest Retry affordance; CI race step

* test(dsh-web): PR #78 round-2 follow-ups — raise async-signal budgets for -race CI, drop the inert SSE retry preamble, cover wedged-queue close + probeVersion exec failure + npx capability

* fix(dsh-web): PR #78 round-4 — distinguish probe failure from version-too-low in the Start defense; document loopback token-exchange premise, verMemo tradeoff, and shim binaryType fidelity

---------

Co-authored-by: linletian <linletian@users.noreply.github.com>
# Conflicts:
#	CHANGELOG.md
…#85)

* fix(terminal): serve log replay from the ring-buffer tail (issue #81)

Re-selecting an idle terminal instance (or pressing Refresh) replayed
the oldest 64 KB held in the ring buffer instead of the newest, so the
UI showed stale session-start output; only a window resize (SIGWINCH ->
TUI repaint) recovered the live screen.

The kind refactor had rewired Manager.Tail from RingBuffer.Tail to
ReadLogs(handle, 0, n), and ReadSince(0, n) clamps to the oldest live
byte -- orphaning the RingBuffer.Tail implementation that already did
the right thing.

Kind.ReadLogs now has an explicit tail contract: a negative since means
'newest maxBytes, cursor set to the current end offset'. The pty driver
serves it from RingBuffer.Tail; Manager.Tail requests it and clamps a
non-conforming kind cursor to 0. Manager.ReadSince clamps a negative
since to 0, so tail reads live behind the single Tail entry point, and
the log stream handler routes an omitted/negative since to Tail instead
of defaulting to offset 0. Bare GET /api/instances/log responses now
carry X-Log-Offset (the end offset), so the frontend cursor is a real
byte offset rather than a chunk length.

Frontend: loadLog drops since=0 and validates the cursor with
Number.isFinite, falling back to 0 (never replay) when the header is
absent; the dead loadLogSince helper is removed.

Docs synced to the behaviour (docs/API.md log + stream endpoints, incl.
stopped/unknown instance response shapes, docs/ARCHITECTURE.md 4.1
cursor semantics, docs/PRD.md buffer path, CHANGELOG Unreleased).
Pinned by TestDriver_ReadLogs_TailReturnsNewestBytes,
TestManager_TailRequestsNewestBytes,
TestManager_ReadSinceClampsNegativeSince,
TestHandleInstanceLog_TailReturnsNewestBytes,
TestHandleInstanceLogStream_StartsFromTail and
TestHandleInstanceLogStream_NonTailKindDoesNotReplay.

Closes #81

* fix(terminal): keep previous log cursor when X-Log-Offset is unavailable (issue #81)

Resetting the cursor to 0 on a missing/unparseable X-Log-Offset header
made startSSE connect with since=0, replaying the oldest 64KB on top of
the tail just written. Only advance the cursor from a valid header, and
drop the now-redundant pre-load reset (loadLog always overwrites the
cursor from the header on success; the previous cursor is a strictly
safer fallback on failure).

* fix(terminal): close review findings on #81 tail-replay fix

Follow-up to 23a6c58 / 844c261, addressing both PR #85 review reports.
No behavior regression in the #81 fix itself; these close the residual
paths and the contract/documentation gaps both reviews found.

Frontend cursor sentinel (reviews #1-1 and #2-1, same defect):
- A new session started at logCursor 0. If loadLog failed or was
  aborted, startSSE still sent since=0 and the server replayed the
  OLDEST 64KB — the #81 symptom on the very path this PR fixed.
  startSSE now omits `since` unless a real positive cursor exists, so
  an unknown cursor falls through to the server's tail default. The
  initial value is -1 ("cursor unknown"), which is the sentinel the
  server already defines. startSSE's cursor update switches from a
  truthy check to Number.isFinite(next) && next >= 0, matching loadLog.

Kind contract (review #1-2, #1-3):
- kind.go now states the rule for kinds without log capture
  (MUST return ("", 0, nil) for a negative since) instead of leaving
  it only in ARCHITECTURE.md, and its route reference is corrected to
  the real shape (/api/instances/log?id=<id>).
- Manager.Tail additionally clamps a cursor that would end before the
  bytes it just returned, so a non-conforming kind cannot make the
  follow-up ReadSince re-deliver them.

Docs (review #1-4, #2-2):
- API.md no longer claims stopped/unknown streams always open with
  next=0 — an incremental read echoes the requested since.
- API.md documents instance_log_tail's `n` and window direction; the
  MCP tool's public return window changed from oldest to newest.

Tests:
- TestHandleMCPCallInstanceLogTail_ReturnsNewestBytes pins the MCP
  path (mutation-checked: reverting Manager.Tail to ReadLogs(0, n)
  makes it fail).
- TestRestartCreatesNewID's inst2 wait loop now reports the timeout
  instead of silently falling through, matching the loop above it.

Refs: #81, #82, #83, #86, #87

* fix(framework): correct ReadLogs no-capture contract wording, test the cursor clamp

Closes findings N1 and N2 from the re-review of 30e28da. Both concern
the contract text and its test coverage, not the #81 behavior.

N1 — the kind.go contract contradicted the code it describes. The new
clause said kinds without log capture MUST return ("", 0, nil) for a
negative since, but all three of them (reasonix, opencode-web, dsh-web)
return ("", since, nil), i.e. ("", -1, nil) for a tail request. That
also contradicted ARCHITECTURE.md §4.1 ("the manager clamps the cursor
to 0") and manager.go's own comment. A kind implementer reading only
the interface would have been told to change working code. Softened to
describe the lenient strategy that is actually implemented, with the
manager normalization stated explicitly.

N2 — the cursor lower bound added in 30e28da had no test. No existing
kind or test double can trigger it: pty's Tail returns off = head >=
len(body), the no-capture kinds return an empty body, and the doubles
report ("newest-bytes", 4242), ("tail-body", 42) and ("", since, nil).
Deleting the clamp produced zero failures. TestManager_TailClampsShort-
KindCursor adds a double reporting 10 bytes with cursor 4 and pins
off >= len(body); verified non-vacuous by re-removing the clamp.

Also qualified the clamp comment: the len(body) lower bound holds for
zero-based offset spaces, which is all that exists today.

Refs: #81

* test(ui): pin cursor bootstrap in both session factories; fix stale #86/PR text

Third-round review round: one real miss on my side (a second cursor
bootstrap path I had never looked at), plus documentation drift.

The miss: kinds/pty.js builds its own session with its own logCursor and
its own bootstrap in activate(). All four kinds register a renderer, so
the index.html path I changed in 30e28da is the legacy no-renderer
fallback and only runs if a renderer script fails to load — my earlier
logCursor grep was scoped to index.html and never saw it.

Reviewed whether the pty line should switch to the -1 sentinel and
concluded no. activate() calls resetTerminalForSwitch first, which clears
the screen; if loadLog then fails, retaining an old cursor makes startSSE
resume with since=<oldOffset> and repaint only the post-offset delta onto
an empty terminal. Zeroing the cursor makes startSSE omit `since`, so the
server's tail default repaints the full screen — the current behavior is
the correct one. Documented it in place so the next reader does not
"fix" it into the regression, and scoped the index.html comments to say
which factory they describe.

Coverage, which is why this slipped through: internal/ui had zero
logCursor assertions.
- TestLogCursorBootstrapNeverRequestsOldestBytes pins the -1 sentinel,
  the `logCursor > 0` guard and both Number.isFinite acceptances, and
  fails if an unconditional since= URL or a since=0 fetch returns.
- TestPtyRendererResetsCursorAfterTerminalReset pins that the pty
  renderer zeroes the cursor after clearing the terminal.
Both verified non-vacuous by reverting the guarded code (MUT: drop the
> 0 guard; drop `s.logCursor = 0`) — each makes its test fail.

Housekeeping and doc drift:
- startRecordingInstance's `k` parameter was dead (zero uses in the
  body); removed, so the new test no longer passes &k.recordingLogsKind
  to satisfy a parameter nobody reads.
- Issue #86's Gap was written against an earlier state of this PR and all
  three of its steps were now false. Rewritten: the failure mode is no
  longer a replay of the oldest 64KB but a duplicate rendering of the
  tail when X-Log-Offset is stripped on a fresh session, on both the SSE
  and WS paths, with the two unaffected paths spelled out.
- PR description: dropped the deleted `("", 0, nil)` MUST and added the
  two tests added since.

Refs: #81, #86, #87

* fix(ui): reset the log cursor after every screen reset, in both factories

Fourth-round review round. Two reviewers converged on one behavior bug
and one documentation bug; the reviewers disagreed on the remedy, and the
evidence favors changing the code rather than documenting the divergence.

The bug: index.html's legacy no-renderer fallback cleared the terminal and
then *retained* the previous cursor (that retention came from 30e28da).
On a revisited running instance, if loadLog then failed, startSSE sent
since=<oldOffset> and the server returned only the post-offset delta,
which got painted onto the screen that had just been cleared. The user
sees a blank or partial terminal. maybeDestroyInactiveStoppedSession
returns early for running instances, so the session — and its positive
cursor — survives a tab switch, which makes that state reachable.

Both factories now reset the cursor to the -1 "unknown" sentinel right
after resetTerminalForSwitch, so startSSE omits `since` and the server's
tail default repaints the whole screen. pty.js already did this; it is
now the same convention in the same words in both files, which also
removes the two comments that told readers to do opposite things.

This trades one failure mode for a less severe one. Previously the
fallback retained the cursor, so a stripped X-Log-Offset header produced
no second tail; now it does. A duplicated 64 KB is strictly better than a
blank screen, and the duplicate case was already tracked by #86.

Coverage, which is what let this survive four rounds: the pty renderer
had an ordering assertion and index.html did not, so the asymmetry in
coverage mirrored the asymmetry in behavior.
- TestLogCursorBootstrapNeverRequestsOldestBytes now mirrors the ordering
  assertion: the cursor reset must come after resetTerminalForSwitch.
Verified non-vacuous three ways: removing the reset, moving it before the
clear, and reverting pty.js to 0 each make a test fail.

Documentation:
- pty.js: the old comment justified "Zero" as the load-bearing choice,
  but 0 and -1 are behaviorally identical under startSSE's `> 0` guard.
  Rewritten to say the value is not load-bearing and the shared
  convention is the point. Session init also moves 0 -> -1 so there is
  exactly one "unknown" sentinel.
- ui_test.go: the bootstrap test's comment claimed it covered both
  factories; it only reads index.html. Reworded to point at its
  companion test.
- Issue #86: two scope-note claims were path-specific and one is now
  false everywhere, since every screen reset clears the cursor. Rewrote
  the section with the three-way table and stated that the
  header-stripped duplicate now applies to any session.
- PR description updated to match.

Refs: #81, #86

---------

Co-authored-by: linletian <linletian@users.noreply.github.com>
…e PTY (issue #80) (#88)

* fix(terminal): a new instance could show [Process Stopped] over a live PTY (issue #80)

A just-created terminal instance sometimes rendered the red [Process Stopped]
banner, greyed-out styling, and no visible input or output — while blind
typing still reached the process and produced correct results. Switching to
another tab and back, or pressing Refresh, "fixed" it.

Root cause: the frontend collapsed the server's seven-state status enum into
a `status === 'running'` boolean. Manager.Start persists a new record as
`starting` and flips it to `running` on the kind's ready signal in a separate
goroutine (a state.json version conflict costs 3 x 10ms of retries), so an
instance created moments earlier is routinely observed as `starting` at
selection time — and both activation paths treated that transient state as
terminal: they disconnected the transport, replayed the log with the banner,
and never reconnected.

Nothing could recover from it either. selectInstance() returns early when the
tab is already selected, and the 2s poll's renderTerminalSessions() only
toggles container visibility, so activate() runs exactly once per selection
change. The session stayed transport-less forever: input silently degraded to
the buffered HTTP fallback (which is why typing "worked"), while output —
published only to WS/SSE subscribers — never came back.

Fix:

- Classify the status into three buckets shared by both activation paths:
  live (running, unhealthy — the process is alive and takes I/O), pending
  (starting, stopping — transient) and terminal (stopped, failed, exited).
  Only the terminal bucket paints the banner, greys out the terminal, refuses
  input, or blocks the connect. `unhealthy` means a probe failed, not that the
  process died, so it now keeps its transport and its reconnect-on-close retry.
- Move re-activation from selection-driven to poll-driven.
  reconcileTerminalSessions() runs on every refresh() with fresh state and
  promotes the active session as soon as its status turns live. The promotion
  calls connectTTY only — no loadLog, since the WS handshake replays the tail
  itself and that duplication stays tracked as #87 — and is guarded against a
  socket, stream, state transition, pending reconnect timer or in-flight
  activation so the 2s poll can never flap an existing connection.
- Replay the log once on entry into the terminal bucket (tracked per session
  via lastKnownStatus), so a crashed or failed instance still gets its banner
  without every poll refetching and repainting it.

Docs-first: the bucket contract, the poll-driven promotion and the revised
recovery table are written up in docs/ARCHITECTURE.md §5.3 / §5.4 / §5.6 /
§5.8. docs/API.md claimed the Start endpoint returns status "running"; it
returns "starting", and that inaccuracy is what invited the bug.

Tests: 22 node --test cases in testdata/terminal_status.test.mjs slice the
shipped index.html / kinds/pty.js and run them against stubbed page state
(wired into `go test` via TestTerminalStatusHandling), plus the
source-contract TestTerminalStatusBucketsReplaceRunningEquality pinning that
both activation paths classify the status. 15 mutations were checked: 14 are
caught by the behavioural layer and all 15 by the source-contract layer; the
one that survives behaviourally is index.html's legacy no-renderer fallback
branch, which only runs when a renderer script fails to load.

* fix(terminal): address review feedback on the issue #80 fix

All findings were checked against the code first; this commit fixes the
accurate ones and keeps the rest out (see the PR reply for the reasoning).

Activation now shares the promotion's in-flight guard:

- `hasTerminalTransportInFlight(session)` is extracted as one function and
  used by both `ensureTerminalLiveTransport()` and `kinds/pty.js`
  `activate()`. Previously activate() only checked
  `hasLiveTTYConnection()`, which is false while a socket is still
  CONNECTING — so switching to a tab that the poll-driven promotion had
  already started to bring up cleared the screen and opened a second
  socket, replaying the ring-buffer tail twice (#87). The guard is now
  single-sourced so the two paths cannot drift.
- `ws.onclose` drops the session's socket reference. A CLOSED socket left
  behind read as "transport in flight" and blocked the promotion until
  something else cleared it. Fixed at the root rather than by loosening
  the guard.

Terminal replay no longer strands the tab on a failed fetch:

- `loadLog()` swallows its own errors, so the once-per-transition replay
  had no way to know it had failed: one transient 5xx left the tab with
  neither log nor `[Process Stopped]` banner until someone pressed Refresh
  by hand. `loadLog` now latches its own outcome in
  `session.lastLogLoadFailed` and `syncActiveTerminalStatus` retries while
  the last attempt failed.

The legacy no-renderer fallback no longer diverges from `kinds/pty.js`:

- An id that `state.instances` cannot resolve used to fall into the live
  branch and open a WebSocket against it, which the server accepts and
  then closes with 1013 — surfacing as a dropped connection rather than a
  bad request. The fallback now leaves the session alone, matching the
  renderer's `if (!inst) return`, and `connectTTY()` refuses an
  unresolvable id as a second layer for every other caller.

`unhealthy` and `stopping` labels follow the buckets too:

- The status-bar text for reasonix and dsh-web used `=== 'running'`, so a
  freshly created instance read "stopped" for seconds while
  `kinds/reasonix.js` kept its iframe navigating — the status bar
  contradicted the panel next to it. Both now go through
  `instanceStatusLabel()`.
- The source contract banned `!== 'running'` forms but no `=== 'running'`
  form, which is exactly the gap that let those two survive; both shapes
  are now forbidden.

Smaller items:

- `kinds/pty.js`'s inline fallback chain guarded `isInstancePendingStatus`
  and `isInstanceTerminalStatus` but called `window.isInstanceLiveStatus`
  unguarded, so the terminal branch threw when the page had not published
  it yet. All three are guarded the same way now.
- `docs/ARCHITECTURE.md` §5.3 described the pending bucket as "no
  transport"; the code deliberately keeps an existing one. §5.6's close
  row said "retry after 1s" against a 5s timer, and §5.3's promotion guard
  listed three conditions against the five now in
  `hasTerminalTransportInFlight()`.
- CHANGELOG said "17 node --test cases" (there were 22, now 27) and
  implied `unhealthy` is observable. `runLifecycle` persists only
  `running` and the terminal statuses, and a kind's health-probe result is
  discarded, so the `unhealthy` and `stopping` branches cannot be
  exercised against a live backend — stated in the entry now.
- `go-ci.yml` pins node 22 and runs the browser layer directly. The JS
  tests were reachable only through a Go wrapper that `t.Skip`s when node
  is absent, so a runner image without node would report green without
  ever running them.

Tests: 27 `node --test` cases (was 22), including the shared guard from
both call sites, a per-case table for its five conditions, the failed-replay
retry, `instanceStatusLabel` across all seven statuses, and a loop that
activates every status with the `window` helpers unpublished. The
`connectTTY` stub now performs the synchronous writes the real function does,
so the guard under test is actually exercised — which turned
"connects exactly once per session" into a test that means what it says.
12 mutations were checked: 8 caught by the behavioural layer and all 12 by
the source contract. The four that survive behaviourally are the legacy
fallback branch (no kind registers a renderer), two call-site pins
(`instanceStatusLabel`'s use in renderWorkspace, `connectTTY`'s null
refusal) and `ws.onclose`, none of which the sliced harnesses can reach.

* ci: pin setup-node to v7 to avoid the Node 20 deprecation warning

* fix(terminal): third review round on the issue #80 fix

- `hasTerminalTransportInFlight` was committed at column 0 — the only
  zero-indented declaration in the whole inline script — and the four-line
  comment above `ensureTerminalLiveTransport` still described five guards
  that had moved out of it, so an audit of the anti-flap logic read an
  explanation and found nothing to audit. Re-indented to 8 spaces, the
  surviving sentence folded into the helper's own doc, and
  `TestIndexHTMLInlineScriptIsIndented` now fails on any column-0
  declaration so the slip cannot recur.

- The in-flight early return in `activate()` was silent, unlike every other
  exit. `deactivate()` leaves the status bar on "idle", so re-selecting a
  tab whose reconnect was still queued looked inert for the whole 5s. It now
  reports "ws closed, retrying..." when a retry is queued and "connecting..."
  otherwise — and deliberately says nothing when an SSE stream is live,
  since that session is not connecting and its own message is the accurate
  one. The legacy no-renderer fallback mirrors the treatment.

- The terminal-bucket replay retry is bounded now. `loadLog` reports its
  failures into the terminal, so an unbounded 2s retry appended one error
  line per tick forever. Retries wait 2^n seconds capped at 60s, driven by
  the consecutive-failure count and the attempt timestamp that `loadLog`
  records; a success resets both. The ordinary stopped case never enters
  this path: `Manager.Tail` answers `("", 0, nil)` for an instance that is
  no longer running, so the replay succeeds.

- The replay latches are initialised in both session factories, next to the
  `lastKnownStatus` latch they now sit beside.

- The CHANGELOG's case count had been wrong twice in two rounds (17, then
  26, for a file holding 29), so it is no longer maintained by hand:
  `TestTerminalStatusChangelogCount` counts the cases in the test file and
  fails if the entry disagrees. It caught the stale number on this run.

- The CHANGELOG now records the visible label change — the status bar for
  reasonix and dsh-web instances reports `starting...` where it used to say
  `stopped` — since that ships under a `fix(terminal):` heading.

- The legacy branch edit in this round briefly inverted the in-flight test
  and dropped `resetTerminalForSwitch(session)`; the pre-existing ordering
  assertion in `TestIndexHTMLCoversReconcileLogic` caught it before push.

Tests: 29 `node --test` cases (was 27) — the backoff schedule and the three
in-flight status outcomes, plus `loadLog` asserting that it owns the
backoff inputs rather than the reconciler. A 9-mutation matrix over this
round's logic: 5 caught by the behavioural layer, 9 by the source contract.
The four that survive behaviourally are the legacy no-renderer branch (three)
and the replay latches in kinds/pty.js's session factory, none of which the
sliced harnesses can reach.

* fix(terminal): let an explicit tab re-selection take over a queued reconnect

Fourth review round. Three of the four findings were accurate and are fixed
here; the fourth is a product call and this takes option one.

**A queued reconnect no longer freezes an explicit re-selection.** The
in-flight guard was blocking activation on `ttyReconnectTimer`, so switching
to a tab and back inside the 5s reconnect window did nothing: no reset, no
replay, no connect. The tab sat on the pre-drop screen, and the handshake
replay then painted the last screenful a second time. `hasTerminal
TransportInFlight` now takes an options argument, and both activation paths
pass `{ ignoreQueuedRetry: true }` while the poll-driven promotion passes
nothing.

The asymmetry is deliberate and now documented in ARCHITECTURE.md §5.3: the
promotion is a bystander that would take over on every tick and flap the
connection, while the user is not — re-selecting a tab is an explicit
request, and waiting out a 5s timer to honour it is the freeze. The handover
is safe because `connectTTY()` → `disconnectTTY()` cancels the pending timer
before anything is opened, so it cannot leave two sockets. The other four
conditions still block, so an activation already inside `await loadLog()` is
never raced — which also removes the "brief flap" risk the review flagged.

**The over-indented comment line is gone**, and the guard now covers the
shape of the defect it missed: `TestIndexHTMLInlineScriptIsIndented` checks
that every line of a `//` block sits at the same depth, not only that no
declaration is at column 0. A moved block that strands its first line at a
different indent is now caught. The declaration check stays one-sided on
purpose — requiring exactly 8 spaces flags every nested `const`, and
deciding top level properly needs a parser; that is noted in the test.

The new test immediately found a drift of its own: the harness forwarded
only the first argument to the shared guard, silently disabling the handover
it was meant to exercise. It now forwards both.

**`TestUICopyIsEnglishOnly` got its doc comment back.** Inserting the
indentation test above its `func` had stranded its godoc on the new
function, so `go doc` would have rendered the CJK-enforcement paragraph as
the indentation test's.

**ARCHITECTURE.md §5.3 caught up with the code**, which is the point of this
PR: it existed because documentation drifted into a bug. §5.3's terminal
bullet still said the replay happens "only" on the transition — pre-backoff
semantics — so a reader would conclude a failed replay is never retried,
which is the round-2 bug. It now documents the bounded retry, that an
explicit user action bypasses the backoff, the `ignoreQueuedRetry` asymmetry
and why it is safe, and the three-way status on the in-flight early return
(`ws closed, retrying...` / `connecting...` / silence while SSE is live).

Tests: 32 `node --test` cases (was 29) — the takeover and the three shapes it
must still yield to, the promotion still standing aside, and the `connectTTY`
stub now clearing the timer like the real one does. A 5-mutation matrix over
this round: 3 caught by the behavioural layer, 5 by the source contract; the
two survivors are the indentation defect (not behavioural) and the legacy
no-renderer branch (unreachable).

* fix(terminal): cancel the queued retry at the takeover, not at connect time

Fifth review round. D1 was a real race in the handover this PR introduced,
and the sentence added to §5.3 in the previous round was false without
this.

**The race.** `ttyReconnectTimer` is cleared in exactly one place,
`disconnectTTY` (index.html:3893). `activate()` reaches `disconnectTTY` only
through `connectTTY`, which it calls from `loadLog().finally(...)` — that
is, *after* loadLog settles. So after an explicit re-selection took over
from a queued retry, the timer was still armed:

    T0        ws.onclose arms the 5s retry, nulls ttySocket, ttyState = IDLE
    T0+Δ      user re-selects; the guard passes; loadLog starts, timer still armed
    T0+5s     if Δ + loadLog-duration > 5s, the retry fires → connectTTY → WS#1
              opens and the handshake replays the tail
    later     loadLog resolves → connectTTY → disconnectTTY kills WS#1 → WS#2
              opens and the handshake replays the tail again

Two connects, one wasted socket, and the tail painted twice — the outcome
the whole guard family exists to prevent, reintroduced through the door
`ignoreQueuedRetry` opened. `loadLogController` could not help: it guards
activations, not timers. The window is narrow on localhost but Δ is the
user's reaction time to a visibly dropped connection, which is most of the
5s window, and a 64 KB log fetch over Tailscale or LAN is not instant.

Both activation paths now cancel the pending retry at the moment they take
over, before `loadLog()` starts. The reviewer's one-liner, taken as given.

**The vestigial status arm goes with it.** Once the takeover clears the
timer, a queued retry can no longer be the reason an activation returns
early — `ws.onclose` leaves the session IDLE with no socket, and a connect
clears the timer again — so the `ws closed, retrying...` arm described a
mapping the code no longer performs. The early return is now simply
`connecting...`, or silence over a live SSE stream, and §5.3 says so.

**The indentation guard no longer contradicts its own doc comment.** The
doc promised an exact-8-spaces declaration rule that the code deliberately
does not implement (it flags every nested `const`); the bullet is narrowed
to column 0 and the reason stays in the function. The mixed-indent check now
compares every line against the run's *minimum* indent instead of its first
line's, so a block whose odd line is in the middle is caught too — the
first-line comparison misses exactly that case, which a mutation confirms.

Tests: 33 `node --test` cases (was 32). The new one holds `loadLog` open in
the harness and asserts the retry is already cancelled while it is pending,
which is the only way to observe the window the race lived in; moving the
cancel after the load fails it. A 6-mutation matrix over this round: 3
caught by the behavioural layer, 6 by the source contract; the survivors are
the legacy no-renderer branch and a restored ternary arm, neither reachable
from the sliced harnesses.

---------

Co-authored-by: linletian <linletian@users.noreply.github.com>
…sue #84) (#89)

* feat(dsh-web): adapt the embedded DSH UI to dsh 0.2.x + --no-open (issue #84)

* feat(dsh-web): adapt the embedded DSH UI to dsh 0.2.x + --no-open (issue #84)

* feat(dsh-web): adapt the embedded DSH UI to dsh 0.2.x + --no-open (issue #84)

* feat(dsh-web): adapt the embedded DSH UI to dsh 0.2.x + --no-open (issue #84)

The dsh-web kind embeds the DeepSeek Harness browser UI (`dsh web`) as a
managed instance, iframe'd behind a per-instance reverse proxy. Two problems:
the integration spoke dsh 0.1.x, and every instance Start popped a stray tab
in the host's default browser (issue #84).

Everything below was verified by hand against the installed dsh 0.2.0-rc.2,
not inferred from release notes.

Issue #84 -- `dsh web` opens the default browser on every boot ("opening the
default browser; pass --no-open to disable"). webArgs() now appends
`--no-open`. It rides AFTER `--port 0`, grouped with the other web-app
options: the launcher parses with allowUnknownOption + passThroughOptions, so
the first option the web app does not know (`--patch`) flips it into
pass-through, and a launcher flag after that reaches the web app and kills
the boot. The ready line still prints (printUrl stays true), so pumpAndWatch
is unaffected. launch_test.go pins the order independently of the expected
slice, so reordering the expectation alongside the bug cannot pass.

dsh 0.2.x adaptation, 0.1.x dropped rather than dual-branched. Two breaking
upstream changes:

- Endpoint segments became `<namespace>/<method>`: `POST /api/workspace/create`,
  `/api/session/create`, `session/prompt`, and `subagents/prompt` -- the
  subagent namespace is PLURAL. The envelope's `method` must still equal the
  URL-path endpoint, and bare `/api` still 404s. The 0.1.x dotted names 404
  on 0.2.
- Every verb's single object argument nests at `payload.args.request`, so the
  bootstrap sends `{args:{request:{path:<worktree>}}}` and the proxy reads
  ids and scope targets from `payload.args.request`. The envelope and the
  `{ok, value}` answer shape are unchanged, and so are the value shapes
  (`{workspace:{workspaceId,...}, created}` / `{sessionId, agentPreset?}`).
  There is no fallback shape: the gateway rejects a flat payload outright
  ("Remote payload must contain exactly one plain-object args field").

bootstrap.go gained a requestPayload wrapper; proxy.go matches
`/api/session/create` for the session-id tee and routes
ownSessionWriteMethods / ownSessionIDsInBody / classifyRPCBody through a
shared decodeArgsRequest.

Version gates move to 0.2.x only: minVersion 0.2.0, supportedMaxVersion
0.3.0 (exclusive), minRemoteVersion 0.2.0, NpxPin 0.2.0-rc.2 (npm publishes
it as both `latest` and `next`, and it is the build every finding above was
verified against). probeAndGate's error now names the slash endpoints and
--no-open.

Verified unchanged on 0.2.0 and deliberately untouched: --patch and
--dump-config, the ready-line format, GET /?token= -> 303 + dsh-auth-* cookie
(the auth relay is untouched and dsh's browser-session auth is still the
proxy's sole credential to hold), the /api/remote.mux WS carrier, the
injected static/dsh-bridge.js, and the restrict overlay rows -- all four
still compose per --dump-config, and a live instance's pluginInventory/list
shows directory-picker-auto and dsh-client-hmr disabled with
dsh-host-directory-picker-browse active and NO dsh-client-ui-directory-picker-*
client surface loaded, so the "Add workspace..." entry still cannot render
(0.2's composer mounts backend and surface together, so disabling the
composer and inserting the bare backend is now even more correct).

Two review-driven fixes landed in the same round:

- isSupportedVersion/hardVersionOK compared the RAW `dsh --version` string,
  so a `-rc.N` suffix broke splitVersion's Atoi and versionLess answered
  false for BOTH bounds. npx mode is the only caller passing the raw pin, so
  every npx-launched instance reported version_supported:false and the UI
  painted the permanent red "restriction may not be effective" bar even with
  overlay_verified true. Both gates now core-parse via parseVersion, like
  isRemoteCapable always did. Pinned by prerelease cases in version_test.go,
  TestVersionGatesAgreeOnPrereleaseCore and TestSpawnNpxModeReportsVersionSupported
  (a real npx Spawn).
- Both testdata/dsh-mock*.go doubles ignored unknown argv, so a dsh build
  that dropped --no-open -- or one where --patch moved behind an app-level
  option -- would stay green in CI while every real Start died. They now
  validate the recognised web flag set and fail like commander
  (`unknown option '<flag>'`, exit 1); TestMockDshWebFlagContract pins both
  mocks, positive and negative.
- TestProxySessionOwnMarking now tables all eight ownSessionWriteMethods
  entries (was three) with distinct ids, an exact-set comparison and a
  bidirectional coverage guard, so a typo in any verb can no longer silently
  disable own-session attribution.

Docs: the stale /api/events.mux + /api/events.host carrier paths became
/api/remote.mux (dsh 0.2 removed them), the false "dsh has no
authentication surface" claim was corrected wherever it survived (it has had
browser-session auth since 0.1.2), the now-dead "use it from the local
127.0.0.1" fallback advice was dropped from the capability warnings (a
sub-0.2.0 dsh cannot start in ANY mode), API.md 5.14 shows the reachable
version with missing_dsh split into its own failed-instance example, and
PLAN.md gained a marked dsh-0.2.x amendment block declaring the 0.1.x text
history. pr5-remote-e2e.sh now drives the real /api/remote.mux handshake and
FAILS on a non-101.

Verification: gofmt / vet / build / go test ./... all clean, plus
`node --test docs/plans/dsh-native-ui/dsh_web.test.mjs`. pr5-remote-e2e.sh
run end-to-end against a real dsh 0.2.0-rc.2 with an isolated DSH_HOME: the
instance boots, the token gate matrix passes (401 / 302+cookie / 200 /
loopback bypass), the server-tokenised iframe_src loads, the WS upgrade on
/api/remote.mux returns 101 through the proxy, and the foreign-session watch
flags and clears a synthetic active session.

Review round 2 (two independent reports on the PR) — all findings adjudicated
against the installed dsh 0.2.0-rc.2 before acting:

Fixed:

- version.go's hard-gate bullet claimed `--port 0`, `--patch` and the
  browser-auth ready line "did not exist yet" below 0.2.0. All three existed
  throughout 0.1.x (ready line since 0.1.2 — this same file's remote-floor
  bullet says so), so the justification contradicted itself. It now names only
  the genuinely 0.2-only surface: the slash endpoints, the
  payload.args.request nesting, `--no-open` and the plural subagents
  namespace; the other facts moved to the advisory bullet, marked as
  "verified, but NOT floor justifications".
- decodeArgsRequest treated `request: null` as a successful decode into an
  all-zero struct ("null" is 4 bytes, so the emptiness check waved it
  through, and unmarshalling null into a struct is a documented silent
  no-op). Nothing mis-recorded today, but only via three INCIDENTAL guards
  downstream; a null request is now rejected structurally. Pinned by
  TestDecodeArgsRequestNullIsAbsent plus null rows in TestClassifyRPCBody
  and TestProxySessionOwnMarking.
- The read-only exclusion comment named session/history and session/models,
  neither of which exists on 0.2 (the session namespace has no `history` —
  only dsh-schedule has that — and the model-list verb is
  session/modelCatalog). Replaced with verbs that do exist.
- Both mocks now ROUTE the eight write verbs, so an upstream rename turns the
  end-to-end write-verb POSTs red instead of silently degrading own-session
  attribution; added negative cases for the dotted workspace.create and the
  singular subagent/prompt.
- TestStartDshWebEndToEnd now asserts blob.WorkspaceID like its two siblings
  did (it is the whole point of the workspace/create bootstrap);
  TestDumpArgs pins dumpArgs' --patch-first order the way TestWebArgs pins
  webArgs'; the mock headers no longer imply the mock enforces flag ORDER
  (it enforces the flag SET); the mock models --trusted-host as the variadic
  flag it is; the remote-incapable mask branches are documented as
  defence-in-depth rather than deleted; ARCHITECTURE.md's §9 checklist item 6
  regains the load-bearing half of the 0.2.0 re-verification (no
  dsh-client-ui-directory-picker-* client surface loaded at all, which is
  what actually proves the "Add workspace…" entry cannot render); the
  CHANGELOG names what a user still on dsh 0.1.x actually hits.

Rejected, with reasons:

- "index.html's cap._error branch still carries dead 'use the local
  127.0.0.1' advice" — wrong: cap._error is set only when the capability
  FETCH failed, so the installed dsh version is unknown; it may be a fine
  0.2.x and a local Start would work. The "terminology drift" between the
  cap.missing and !cap.remote_capable branches is also wrong — they state
  different facts and the differing wording is deliberate.
- parseVersion's unanchored first match, probeAndGate's memo downgrade
  window, and sessionCreateBody's uncapped buffering are pre-existing and
  untouched by this change; they are follow-ups, not this PR.

Review round 3 (two more reports on the PR). The round-2 fixes above never
actually reached the PR — `git commit --amend -F <file>` replaces only the
message and does NOT stage the working tree — so this amend carries both
rounds. A reviewer caught that; the message below now matches what is
committed.

All findings were adjudicated against the installed dsh 0.2.0-rc.2 before
acting.

Fixed:

- Three comments (both mocks, the end-to-end write-verb loop) claimed an
  automatic upstream-drift guard that does not exist: "if upstream renames
  one, it drops out of this table and the write-verb POSTs go red". The
  table is a hand-written Go switch, so an upstream rename changes nothing
  locally — the map, both mocks and the test loop go stale together and no
  test turns red. Reworded to say what is true: the table is a
  hand-maintained MIRROR of the upstream `@Remote('<verb>')` declarations
  that must move in the same change as ownSessionWriteMethods; what CI
  actually catches is internal drift between the copies; and the one
  automatic pin is TestProxySessionOwnMarking's bidirectional map<->table
  check.
- The eight verb names were hardcoded in four places. The end-to-end loop
  now derives them from ownSessionWriteMethods (same package), so it cannot
  become a second hand-maintained copy; the explicit literal set stays in
  TestProxySessionOwnMarking's table, which is where that pin belongs.
- The "trusted-host is variadic" subtest was renamed to what it actually
  pins (the accepted argv FORM). The accepted-argv set is provably
  identical under the old single-value map and the new variadic map — a
  bare token already fell through the generic positional branch — so no
  black-box test can discriminate them, and the comment now says so
  instead of implying coverage of the mechanism.
- The remote-incapable mask note's two justifications were optimistic: a
  pre-upgrade blob's instance is marked stopped by ReconcileRunningOnStartup
  and activate() returns early without polling it, and the blob is written
  once at ready/proxy time and never re-derived, so a later downgrade cannot
  rewrite it. Both were traced and are unreachable. The code is kept — the
  conclusion is right — but the justification is now the honest forward-
  looking one: the two floors are deliberately separate constants, so a
  future spawnable-but-bridge-less dsh is exactly when the branch returns.
- CHANGELOG said the loopback-fallback advice "was dropped from the dsh
  capability warnings". It was dropped from the two VERSION-GATED branches
  and deliberately kept in cap._error; narrowed to say exactly that.

Rejected, with reasons:

- "index.html's cap._error branches (:3081 / :3167) still carry dead
  loopback-fallback advice". cap._error is set only when the capability
  FETCH failed, so the installed dsh version is UNKNOWN — it may be a
  perfectly good 0.2.x, and a local Start would then succeed. That is
  exactly why the advice is actionable there rather than dead. (The
  reviewer now agrees the version is unknown; the disagreement is only
  whether a local Start helps, and it does when the version is unknown.)
  index.html unchanged.

Review round 4 (two more reports; both reviewers approve this delta). All
findings adjudicated before acting; one of them corrected an instruction I
had given a sub-agent, and one corrected a claim I had made myself.

Fixed:

- kinds/dsh_web.js: the polling comment claimed "a dsh upgraded mid-session
  flips this on the next poll". It does not: the poll re-reads the SAME
  persisted blob every tick and handleInstanceDshInfo only unmarshals and
  echoes it, so an in-place upgrade cannot flip the value until the instance
  is re-spawned. That directly contradicted the note this PR had just added
  100 lines away. Reworded, keeping the correct "zero value before readiness"
  sentence.
- integration_test.go: the comment claimed the mock's accepted-argv set was
  "provably identical" before and after the variadic change. It is NOT, and I
  verified both directions by building the mock either side of the change:
    --trusted-host --bogus   OLD: accepted, booted   NEW: unknown option,
  exit 1
  The old single-value i++ swallowed an option-like token as the flag's
  VALUE and never validated it, so a misspelled flag could hide behind
  --trusted-host. The new behaviour is commander-correct and closes a real
  (small) hole. The comment is corrected and the discriminating negative case
  is now pinned for both mocks. (My round-3 instruction to the sub-agent —
  "no black-box test can discriminate them" — was wrong; this counterexample
  is one.)
- testdata/dsh-mock-modern.go: its comment claimed a CI guard that never ran
  against that file. buildMockDsh builds only dsh-mock.go, and nothing POSTed
  any of the eight write verbs to the modern mock, so its case list was
  entirely unexercised — a typo there kept CI green. Fixed both halves: the
  loop is now mirrored into TestDshWebRemoteEndToEnd (same relay-cookie path
  the existing session/create POST there uses, asserting result.ok rather than
  HTTP 200), and the comment names the guard that actually covers this copy.
- version.go: "existed throughout 0.1.x (ready line since dsh 0.1.2)" was
  self-contradictory — 0.1.0/0.1.1 predate 0.1.2. Now "existed in 0.1.x".
- Three places disagreed on the read-only exemplar: proxy.go was corrected
  this PR to verbs that exist on 0.2, but proxy_test.go and ARCHITECTURE.md
  still used session/history, which does NOT exist in dsh 2's session
  namespace (the only @Remote('history') anywhere is dsh-schedule's). Both
  siblings now use session/list.
- CHANGELOG bullet 1 still called the loopback fallback "(now dead)",
  contradicting bullet 6 one line below. Aligned, and the PR description was
  corrected the same way.

Not changed: nothing in index.html. The round-2 finding about the cap._error
branches was retracted by the reviewer after they checked the shipped code
rather than only reasoning about it — the capability probe is gated on
onDsh && remote, the version is UNKNOWN there, and a local Start may well
succeed, so that advice is the only one that can help.

---------

Co-authored-by: linletian <linletian@users.noreply.github.com>
… (issue #87) (#90)

* fix(terminal): WS handshake replayed the 64KB tail on every reconnect (issue #87)

A network blip, a proxy idle timeout or a laptop sleep/wake duplicated or
garbled the terminal. ws.onclose reconnects after 5s without clearing the
screen, while completeHandshake unconditionally replayed Tail(id, 64*1024)
to EVERY connection, so the newest 64 KB landed under whatever the terminal
already showed.

Root cause: the WS handshake had no offset contract. The connect URL carried
only id/token, the handshake threw away Tail's end offset via `_`, and the
client's logCursor served only the SSE path -- so the client could not even
express a position, let alone have one honoured.

Fix: the handshake gains a cursor round-trip.

  GET /api/instances/tty/ws?id=<id>[&since=<offset>]

  since absent/unparseable -> -1 sentinel -> Tail, unchanged (first connect).
  since >= 0              -> catch-up loop, see below.

completeHandshake(since) loops ReadSince(id, cursor, 64*1024) until the body
comes back empty, retaining only the NEWEST chunk, so peak memory stays
~64KB regardless of the ring cap and the replay is the newest <=64KB
contiguous with head. A single capped ReadSince would instead publish a
cursor 64KB BEHIND head while the live subscription starts AT head: those
bytes land in neither path and, since a client cursor only moves forward,
they are never re-requested. Retaining the newest chunk (rather than a
rolling 64KB window) keeps the stronger property that every replayed byte is
new to the client.

After the replay the server emits a TEXT control frame:

  {"type":"sync","offset":N}

even when the replay was empty (0 is a legitimate cursor on a fresh
instance). The client stores it as `ttyOffset`, advances it by the wire
byte count of every binary frame received after that sync, preserves it
across ws.onclose, and sends it back as `since` on reconnect.
RingBuffer.ReadSince supplies the silent stale-cursor clamp for free.

Cursor invalidation is screen-content driven: callers that wipe the screen
(activate(), refreshCurrentInstance, resetTerminalForSwitch) reset BOTH
cursors to the -1 sentinel at the clear, and a successful loadLog re-pins
ttyOffset to logCursor immediately afterwards (one ring end offset, two
consumers). The SSE handler advances both cursors together so a later WS
reconnect cannot replay bytes SSE already painted.

Because the cursor counts wire bytes, the server must never emit a
non-ring binary frame: both handshake error paths now CLOSE the connection
with 1013 instead of writing the diagnostic as binary output, matching the
existing newTTYClientHandle precedent. The replay write error also aborts
the handshake rather than advertising a cursor over undelivered bytes.

Defensive: a first read that makes no progress consults Tail for the real
head, because ReadSince echoes the caller's own number once the cursor
reaches head. A cursor AHEAD of head (not reachable today -- Manager.Restart
mints a new instance id, so a given id's ring head is monotonic) degrades to
the first-connect tail instead of publishing a cursor the ring never held;
output written between that read and the consult is picked up by continuing
the loop rather than skipped.

Tests: internal/app/tty_ws_test.go gives the endpoint its first coverage --
8 TestHandleInstanceTTYWS_* cases driving a real ws.Dial against the real
handler (no-redelivery / tail / silent clamp / sync offset / over-64KB
contiguity / read-failure close / cursor-ahead-of-head / consult-sees-new-
bytes), plus the at-head no-op and the non-running instance. The pty driver
gains a since>=0 contract test so the app-test mirror cannot drift.
Frontend gains 6 cases in testdata/terminal_status.test.mjs (39 total).

Docs: docs/API.md (TTY WS section) and docs/ARCHITECTURE.md.

* fix(terminal): stream the WS replay and close the review-round holes (issue #87)

Follow-up to the first #87 fix, driven by two independent reviews of the
handshake cursor contract.

Server (internal/app/app.go):
- completeHandshake STREAMED the replay instead of buffering the newest
  chunk. `replay = body` advanced the published cursor across the whole
  delta while delivering only its newest <=64KB, so any delta larger than
  one read stranded the older part forever — measured on a 100100-byte
  delta: 34564 bytes delivered while publishing offset 100100. Each chunk
  is now written to the socket as it is read, at the same iteration count
  and the same ~64KB peak memory, and endOffset is taken from that write's
  own end offset, so the server never publishes ahead of the wire.
- ttyHandshakeReplayBudget (8MB) bounds one handshake's replay; the loop
  previously ran up to 4096 iterations against the live ring mutex while
  the client sat at "websocket live" with its handshake timer already
  cleared. Truncation DEFERS: the remainder sits ahead of the published
  cursor and the next reconnect re-requests exactly those bytes. Its cost
  (up to 32 reconnects at a 256MB ring, xterm scrollback churn) is now
  documented rather than left implicit.
- The !first empty-body exit no longer leaves endOffset at its zero
  value. On a closed ring ReadSince answers ("", head, nil) while
  since < head, so the handshake could publish offset 0 — a cursor the
  ring never held — and drop the client back into a full tail re-replay.

Client (index.html, kinds/pty.js):
- startSSE now takes the cursor that is actually ahead. logCursor alone is
  -1 on the ensureTerminalLiveTransport promote path, which connects the WS
  without loadLog, so the SSE fallback would have replayed the whole tail
  over a live screen. Each missing sibling degrades to the -1 "unknown"
  sentinel rather than poisoning the max with NaN.
- resetTerminalForSwitch invalidates both cursors itself; two comments
  claimed it already did, and it is exported on window for other kinds.
- loadLog pins both cursors only inside its session.term paint block.
- ws.onmessage ignores frames from a socket that no longer owns the
  session, so a replaced socket can never move the live cursor.
- The handshakeSynced comment now gives the real reason for the latch
  (a clamped stale cursor means the replay need not start where we asked)
  instead of an inverted one, and states the replay->sync death window.

Docs/tests: docs/API.md, docs/ARCHITECTURE.md and CHANGELOG.md now describe
the streamed replay, the budget and its real exceptions; stale exact-source
pins in internal/ui/ui_test.go were re-pinned to the new text without
weakening their intent. TestHandleInstanceTTYWS_LargeDeltaStreamsEveryByte
SinceCursor replaces the test that pinned the bug, plus new cases for the
budget boundary and the closed-ring cursor; four new node --test cases.

gofmt clean; go vet clean; go test ./... 21 packages ok; node --test 43/43.

---------

Co-authored-by: linletian <linletian@users.noreply.github.com>
…#82) (#91)

* fix(pty): an overflowing output subscriber was silently skipped (issue #82)

broadcast() did a non-blocking send per subscriber behind an EMPTY
`default:` arm. When a subscriber's 64-slot channel filled, the chunk was
discarded and the subscriber stayed registered -- unbounded, permanent
loss, with no counter, no signal to the client and no disconnect.

The chain that reached it: the WS writer in handleInstanceTTYWS drains
one chunk per select iteration and writes it straight to the socket, and
ws.Conn.writeFrame blocks inside rw.Flush() while Upgrade sets no write
deadline. A throttled or backgrounded tab stalls that write, the loop
stops draining, the queue fills, and from then on every chunk vanishes.
A missed clear-screen or cursor-move corrupts everything the client
renders afterwards, and neither side can notice it happened.

An overflowing subscriber is now DISCONNECTED instead of skipped: it is
removed from the registry and its channel is closed, so the handler sees
`<-outputChan` closing, answers with a 1013 close frame and returns. The
browser's ws.onclose logs the abnormal close and reconnects, and thanks
to the #87 offset contract it resumes incrementally from its own cursor
rather than re-appending the whole tail.

The close needs an exactly-once guarantee, because the channel now has
two closers: the caller's cancel and the overflow in broadcast. A bare
channel cannot express "already closed", and moving cancel's close()
inside the lock does NOT fix it -- cancel's delete would be a no-op
against an already-removed channel and the second close would panic. The
registry value is therefore a *subscriber holding the channel plus a
closed flag, with a closeLocked() helper, and both closers delete from
the map before closing, under the existing subsMu. Because every close
happens under that lock and every close is preceded by a delete, no
channel present in the map is ever closed, so broadcast's non-blocking
send can never hit a closed channel.

The 1013 frame is bounded and best-effort. This branch exists precisely
because the socket stalled, so an unbounded close write would only move
the stall from WriteBinary to WriteClose and leave the handler parked --
which would not have fixed anything. A 5 s write deadline is therefore
set on this failure path only; nothing writes on the conn afterwards.
Resync does not depend on the frame arriving: the browser reconnects on
any socket close and resumes from its cursor.

Capacity stays 64 on purpose. A bigger queue only postpones the
disconnect of a consumer that cannot keep up, which is the right outcome
anyway. The real threshold is ~64 KB of unread output (1024-byte
pumpLogs chunks x 64 slots), and recovery costs the client one reconnect
against the browser's hard-coded 5 s retry. A write deadline for HEALTHY
connections -- the gentler knob -- is issue #83 and is deliberately not
here.

SubscribeOutput's public signature and its empty-id rejection are
unchanged; the only production consumer is the tty WS handler.

Tests: internal/instance/pty/driver_test.go gains overflow-disconnects,
within-capacity-is-never-disconnected, cancel-after-overflow-does-not-
panic and cancel-races-overflow. internal/app/tty_ws_test.go gains an
end-to-end test driving the real handler over a hijacked WebSocket and
asserting the 1013, the socket teardown and the empty registry. Note the
end-to-end test exercises the producer-outruns-the-queue condition over a
real socket; it does NOT reproduce the TCP stall itself -- what it pins is
everything downstream of the close.

* fix(pty): bound the overflow teardown and make it observable (issue #82)

Follow-up to the PR #91 review round. Two independent reviews landed;
every finding was reproduced against the code before being acted on, and
all of them held.

1. The failure path was not actually bounded. Go delivers a closed
   channel's BUFFERED values with ok == true before it ever reports
   ok == false, so after broadcast closed the subscriber the WS handler
   had to WriteBinary every queued chunk -- up to 64 of them, ~64KB --
   onto the very socket that stalled in the first place, and only then
   reach the `!ok` branch that sets the 5s write deadline. Those writes
   were unbounded, and in the canonical stall the handler is already
   parked inside a live WriteBinary and never reaches the deadline at
   all. broadcast now drains the closed subscriber's queue at the source
   (after the close, so the loop cannot block), so the consumer observes
   `!ok` on its very next receive. Discarding is safe: pumpLogs writes
   every chunk to the ring buffer BEFORE broadcasting it, so each drained
   chunk is replayed from the client's #87 cursor after it reconnects --
   the whole premise of this fix. The 5s deadline is kept but its comment
   now says what it actually bounds: this close-frame write, on this
   teardown path. A handler already wedged inside a live write is
   reclaimed when that write returns; that is issue #83's write-deadline
   territory and stays out of scope.

2. The overflow had no server-side trace at all. An earlier round
   declined this on the premise that `internal/app` does no logging --
   which was my own grep error: I searched for the stdlib `log.Printf`
   instead of `s.logger.Printf`. Server has a `logger *log.Logger` field
   (app.go:65) with 16 call sites in this file and a TTY-specific one at
   app.go:522. This PR introduces a new user-visible disconnect while the
   client retries on a hard-coded 5s timer with no backoff, so a consumer
   that overflows repeatedly was a silent infinite reconnect+replay loop
   whose only trace was a browser console warning. Added a nil-guarded
   log line matching that precedent.

3. TestSubscribeOutput_CancelRacesOverflow did not prove what its comment
   claimed. `broadcast` holds subsMu across BOTH its map delete and its
   closeLocked, so once broadcast has deleted the entry, cancel cannot
   reach its own delete until broadcast releases the lock: the two
   closeLocked calls can never run concurrently regardless of the flag.
   The serialisation is the map delete, not lock discipline around the
   flag. Moving cancel's closeLocked outside subsMu still passes under
   -race, which is exactly why the old wording was unfalsifiable. Both
   that comment and closeLocked's doc now state the real division of
   labour, and the test says plainly what it does NOT prove. The `closed`
   flag remains load-bearing for the sequential cases (cancel after an
   overflow, cancel twice), pinned by TestSubscribeOutput_CancelAfterOverflowDoesNotPanic.

4. The CHANGELOG asserted that the overflow arm preserves the "key exists
   iff it has a live subscriber" invariant, but removing
   `delete(subs, id)` left the package green. Now pinned with
   registeredInstanceCount() delta assertions at the len(subs) level,
   plus a complement asserting the key STAYS while a healthy subscriber
   remains. Removing the delete now fails the test; removing the drain
   fails three.

5. docs/API.md gained the new wire-visible outcome this PR introduces:
   close code 1013 with reason "subscriber overflow: slow consumer", the
   ~64KB trigger, that the discarded backlog is replayable, that the frame
   is best-effort, and that the 5s deadline is teardown-only.

Behaviour change worth noting: draining flips three existing backlog-read
assertions to pin the new `!ok`-first semantics. That is the mandated fix,
not a weakened test -- no test was deleted and coverage strictly grew.

* fix(pty): drain outside the process-global lock, and make the drain honest (issue #82)

Second review round on PR #91. Four findings, all reproduced by the
orchestrator against the code before being acted on; all four held.

1. BLOCKING (mcode): the drain-at-source ran INSIDE the subsMu critical
   section. subsMu is one process-global mutex shared by every instance,
   and `for range` over a channel terminates only because that channel is
   already closed -- which is discipline, not a compiler-enforced
   guarantee. One future edit that drops or reorders the closeLocked()
   call, while keeping the load-bearing-looking `delete`, would park
   broadcast holding the global lock forever: no panic, no log, no
   timeout, every instance's PTY output silently frozen. Before the drain
   existed the same slip produced a loud, local `panic: send on closed
   channel`; the drain had traded a local failure for a global silence.
   broadcast now collects closed subscribers under the lock and drains
   them in its deferred func after subsMu.Unlock() -- unlock is the first
   statement, so the drain provably runs lock-free and touches nothing
   but the collected slice. closeLocked() now returns whether THIS call
   performed the close, so a subscriber is collected only when we provably
   closed it in this same critical section. Worst case of any future
   breakage degrades from "global hang" to "no drain".

2. The overflow log was never executed by any test: ttyWSTestServer
   mounted `&Server{instanceMgr: m}` with a nil logger, so deleting the
   s.logger.Printf line left the whole internal/app package green. The
   test server now injects a real logger through a mutex-guarded buffer,
   and the overflow test asserts the handler logged the id. `app.New`
   rejects a nil logger, so the guard is defensive against a state
   production cannot reach -- now noted where the guard lives.

3. The e2e test double did not mirror the drain, so the test exercised
   the pre-drain shape and reverting production's drain left internal/app
   green. The double now drains after releasing its own lock, same as
   production, and its comment says plainly that production's drain is
   pinned at the pty layer, not here.

4. "Nothing is lost" needed a qualifier. `Manager.dropBuffer` calls
   RingBuffer.Close (closed=true, data=nil, used=0, head NOT reset) and
   deletes the buffer; pumpLogs holds its own pointer and still broadcasts
   chunks that went nowhere. On a closed ring Tail returns ("", head) and
   ReadSince clamps to head, so a drained chunk is not replayable in that
   narrow window. API.md and the CHANGELOG now say "nothing is lost THAT
   IS STILL IN THE RING BUFFER" and name the exception. No code change --
   ARCHITECTURE 4.1 already declares post-swap chunk loss intentional.

Separately, and found while fixing (3): the exact binary-frame-count
assertion added last round is FLAKY -- measured at roughly 1 run in 10
under scheduling load, and missed entirely by clean-room runs. Its third
premise is false: it assumed the handler takes nothing after the drain
starts, but close() readies any receiver already parked on the channel,
and the handler IS parked in its select, so it competes for the same
buffered values and may win some. The count therefore varies with
scheduling and cannot discriminate drained from undrained at that layer,
so the assertion is removed rather than loosened -- a tolerance there
would be a fake pin. The 1013 close code, the reason naming the
overflow, and the no-second-sync check are unaffected and remain.

Every production and test comment that promised the drain deterministically
beats the consumer has been reworded to distinguish what IS guaranteed
(the queue is empty when broadcast returns, so no full ~64 KB queue reaches
a stalled socket) from what is merely typical (the next receive is the
close). One test comment that stated the drain runs inside subsMu -- the
exact opposite of the load-bearing post-unlock placement -- was corrected.

Verified: gofmt clean, go build ./... and go vet ./... clean, go test
-count=1 ./... all 22 packages green, go test -race -count=10
./internal/instance/pty/ and -race -count=5 on the TTY WS suite clean,
and the e2e suite run 150 times under concurrent load with 0 failures.

* docs(test): retract an overreaching claim about the dropped frame-count pin (issue #82)

Third review round. mcode approved with zero blocking items. The
independent review raised one actionable item, and it was aimed at a
sentence I wrote last round.

When I removed the exact binary-frame-count assertion, the reason I
recorded was too strong. I had measured only that the EXACT assertion
`binaries == published-17` flakes (about 1 run in 10 under scheduling
load, in two shapes), and I wrote from that: "No bound separates the
drained case from the undrained one, so widening it to a tolerance would
pin nothing" and that the frame count is "simply not an observable of the
drain at this layer". Neither followed. A third-round review measured the
discriminator directly and showed the count is in fact a clean
separator here -- 280 drained runs all inside [0, 2] against 60
drain-less runs all at 17, so a loose `frames > 8` bound would very
likely catch a revert.

Both sentences are now retracted explicitly, quoting what they said and
saying plainly that neither was measured. The justification that
replaces them claims only what the measurements support, and is sharper
than either argument alone: the reviewer's own re-measurement put
drained at <= 2, but the 1-in-2000 flake that motivated removing the
assertion was itself 17 frames WITH the drain in place -- inside the
undrained region. So no threshold can both catch a revert at `> 8` and
survive that run, and a bound here is load-sensitive for coverage that
is already pinned deterministically one layer down (the pty package,
where the test goroutine is the channel's only consumer and nobody races
the drain).

The decision to drop the assertion is unchanged; only the reason was
wrong. No bound is reintroduced -- the reviewer explicitly leans that way
and stability wins over coverage we already have.

Three further overreaches of the same kind were found in the same file
and corrected in the same pass: "nothing drains" (contradicted by the
race documented further down), "socket buffers never become the binding
constraint" (an unmeasured absolute), and "says nothing about whether
the drain exists" (same overreach as the first two).

Comment-only: the diff against the parent commit, filtered to non-comment
lines, is empty. Verified green: gofmt clean, go build ./... and go vet
./... clean, go test -count=1 ./... all 22 packages, -race -count=10 on
internal/instance/pty, and -count=30 on the overflow e2e test.

---------

Co-authored-by: linletian <linletian@users.noreply.github.com>
* fix(terminal): a half-open TTY WebSocket read as live (issue #83)

hasLiveTTYConnection checked four instantaneous conditions and nothing
else: session exists, a socket reference, readyState === OPEN, and a
client-side state string. A half-open TCP socket -- laptop sleep, a
NAT/proxy idle timeout, a silently swapped network path -- keeps
readyState OPEN and never fires onclose, so every one of those still
held and the UI reported the connection as healthy.

Consequences: selecting the instance showed the stale xterm buffer and
never reloaded; the 2s poll RE-AFFIRMED the dead socket on every tick,
because ensureTerminalLiveTransport gated on the same function; and
keystrokes were silently swallowed, since term.onData gates on
readyState === OPEN and ws.send() does not throw on a half-open socket,
so the HTTP input fallback was never reached. Server-side, nothing on
this path generated traffic or set a deadline, so ReadMessage blocked
forever and the handler goroutine, reader goroutine, fd and ttyClients
entry pinned the daemon for its entire lifetime.

Liveness now rides on heartbeat TRAFFIC, never on the absence of
program output -- a shell at a prompt, vim, less, top or an idle agent
TUI emit nothing for hours and must never be disconnected for it.

Server: internal/ws gains WritePing (the opcode and writeFrame
primitive already existed; only WriteText/WriteBinary/WritePong/
WriteClose were exposed, so a server ping could not be emitted at all).
handleInstanceTTYWS becomes a thin wrapper over
handleInstanceTTYWSLiveness(w, r, pingInterval, readDeadline) -- the
intervals are parameters, not mutable package state, so a test can
compress the windows to milliseconds without making a parallel test's
connection flap. The reader goroutine arms a read deadline before
every ReadMessage; on expiry it returns, msgChan closes, and the
EXISTING `!ok` branch unwinds the handler through its existing defers
(no second exit path). A ticker emits an RFC 6455 ping -- the browser's
network stack auto-Pongs, refreshing that deadline from outside the app
layer -- plus a TEXT {"type":"ping"} frame, because onmessage never
fires for control frames and that text frame is the only heartbeat page
JavaScript can observe. A client-sent {"type":"ping"} is answered
{"type":"pong"} in a branch placed BEFORE the SendInput fallthrough,
since a probe is transport traffic and forwarding it would type JSON
into the user's shell.

Client: parseTTYControlMessage whitelists ping/pong (anything it
returns null for falls through and is painted into the terminal as
literal text). lastDataAt is stamped as the first statement of
ws.onmessage for every frame. A 5s watchdog, armed on READY, sends the
client probe -- the only way to detect a socket whose server->client
direction is dead but whose client->server direction still looks
writable -- and treats the socket as dead after 30s of silence, forcing
a reconnect. isTTYHeartbeatStale is consulted by hasLiveTTYConnection,
hasTerminalTransportInFlight and ensureTerminalLiveTransport, which is
what breaks the poll's re-affirmation loop rather than just patching
activate(). Staleness requires READY, so a CONNECTING socket is still
in flight and no second socket can open.

Two ordering bugs found in review, both real:

- ensureTerminalLiveTransport's stale arm called connectTTY with the
  stale socket still attached. disconnectTTY only reaches IDLE when
  there is NO socket, so the session landed in DISCONNECTING and
  connectTTY burned ~1.05s in its 10x100ms wait loop. Since the 2s
  poll crosses the 30s threshold before the 5s watchdog, that was the
  path healing most half-open sockets.
- Both activate() paths hit the same spin, because #83's !stale gates
  in hasTerminalTransportInFlight newly released the stale-socket case
  into loadLog().finally(connectTTY).

Both are fixed by reconnectStaleTTY (disconnect -> force IDLE -> arm
the retry AFTER disconnect, which clears any prior timer), used by the
watchdog, the poll arm and the overlay recovery, plus a root fix in
connectTTY: disconnectTTY unconditionally closes and nulls the socket,
so a DISCONNECTING left behind describes a socket that no longer
exists. The wait loop is now unreachable for every caller and is kept
as labelled defence in depth. reconnectStaleTTY connects immediately
when there is no socket -- that is the connection-overlay recovery
path, where the user is waiting and there is nothing to back off from.

Numbers, with the margin documented at the constants: server pings
every 10s, read deadline 45s (4.5 ticks), client staleness 30s (3x the
ping, below the server deadline so the browser reaps first and keeps
its cursor). A background tab's setInterval is throttled to ~1/min
while WebSocket delivery is not, so inbound heartbeats keep lastDataAt
fresh there; a short server interval cannot cause background churn.

Scope, stated honestly in the docs: the TTY write path still carries
no write deadline, so a handler blocked inside WriteBinary is reclaimed
when that write fails or returns, not by the read deadline. And
non-browser clients get no automatic Pong, so any client of this
endpoint must answer RFC 6455 pings or send {"type":"ping"}.

Tests: internal/ws gains TestWritePingRoundtrip (0x9 on the wire, 0xA
echo, empty payload included). internal/app gains heartbeat-frame,
probe-not-typed-into-the-PTY, dead-peer-reaped, and
responsive-peer-survives-3x-the-deadline cases -- the last proving the
positive half of the invariant, that a peer emitting zero output with
zero program bytes stays alive and usable. Frontend gains 7 cases in
testdata/terminal_status.test.mjs (46 total), including the exact
loadLog().finally(connectTTY) activation wiring that regressed.

* fix(terminal): route input around a heartbeat-stale socket and correct the review's docs claims (issue #83)

* docs(terminal): one read-side scope note, both healers named, the input gate documented (issue #83)

Review round 2, prose and comments only -- zero executable lines
changed, no test touched, so the node suite stays at 58 cases and
CHANGELOG.md still says "Pinned by 58 `node --test` cases".

F1: fe9b305 shipped docs/ARCHITECTURE.md line 297 with the new §5.6
read-side scope note pasted TWICE on one line, the two copies
differing only in wording ("the deadline persisting" vs
"`SetReadDeadline` persisting"), which is why grep -c reported 1.
Kept the second copy: it names the API the reader goroutine actually
calls (app.go's conn.SetReadDeadline), and its closing clause names
the declined alternative precisely -- "a second deadline armed around
the send ... with no measured failure to justify it" -- where the
first copy said only "arming a deadline ... no measurement has ever
shown open". No distinct content to merge; every claim survives.
Line 297 goes 2683 -> 1613 bytes, and each of the five phrases now
occurs exactly once. The write-deadline scope note in the same
paragraph is byte-identical to fe9b305 (535 bytes).

F2: the #83 CHANGELOG entry showed the input gate as the BUG
(term.onData gating on readyState) and then tested the FIX (seven
FIX-A cases), but never said the gate changed. Added the sentence
that does: both factories' onData arms now gate on the heartbeat
stamp, pty.js through the new window.isTTYHeartbeatStale export, and
what that honestly buys -- not the 0-30s window, where the predicate
cannot tell half-open from healthy and the keystroke is still send()'d
into a socket nobody reads, but the sliver between the stamp crossing
the threshold and the reaper, plus the two factories no longer
answering "is this live?" two different ways.

F3: fe9b305 bounded that sliver by the watchdog alone ("at most
TTY_LIVENESS_CHECK_MS") in BOTH factories' comments, contradicting
ARCHITECTURE.md and API.md two documents away, which both name the 2s
poll as primary. Restated as the FASTER of the two: ~2s for the
active session, because refresh() -> reconcileTerminalSessions() ->
syncActiveTerminalStatus() -> ensureTerminalLiveTransport()'s stale
arm -> reconnectStaleTTY() is the chain that nulls the socket there,
and TTY_LIVENESS_CHECK_MS (5s) bounds only a session the poll does
not promote.

F4: §5.8 enumerated the stale rejection, the ping/pong whitelist, the
helper routing and the interval clears, but not the input gate -- the
one item whose removal silently reverts to readyState-only. Added to
the existing bullet.

F5: recorded, NOT fixed. A keystroke buffered in the sliver is wiped
by the teardown that follows -- but only on the legacy path:
disconnectTTY clears termSendTimer and resets termInputBuffer, while
pty.js buffers under _inputBuffer/_inputFlushTimer, which it touches
neither, so there the identical keystroke survives and its POST still
fires. Another drift of the two session factories on parallel field
names; noted in both factories and in the entry.

---------

Co-authored-by: linletian <linletian@users.noreply.github.com>
…86) (#93)

* fix(terminal): WS handshake replayed the 64KB tail on every reconnect (issue #87)

A network blip, a proxy idle timeout or a laptop sleep/wake duplicated or
garbled the terminal. ws.onclose reconnects after 5s without clearing the
screen, while completeHandshake unconditionally replayed Tail(id, 64*1024)
to EVERY connection, so the newest 64 KB landed under whatever the terminal
already showed.

Root cause: the WS handshake had no offset contract. The connect URL carried
only id/token, the handshake threw away Tail's end offset via `_`, and the
client's logCursor served only the SSE path -- so the client could not even
express a position, let alone have one honoured.

Fix: the handshake gains a cursor round-trip.

  GET /api/instances/tty/ws?id=<id>[&since=<offset>]

  since absent/unparseable -> -1 sentinel -> Tail, unchanged (first connect).
  since >= 0              -> catch-up loop, see below.

completeHandshake(since) loops ReadSince(id, cursor, 64*1024) until the body
comes back empty, retaining only the NEWEST chunk, so peak memory stays
~64KB regardless of the ring cap and the replay is the newest <=64KB
contiguous with head. A single capped ReadSince would instead publish a
cursor 64KB BEHIND head while the live subscription starts AT head: those
bytes land in neither path and, since a client cursor only moves forward,
they are never re-requested. Retaining the newest chunk (rather than a
rolling 64KB window) keeps the stronger property that every replayed byte is
new to the client.

After the replay the server emits a TEXT control frame:

  {"type":"sync","offset":N}

even when the replay was empty (0 is a legitimate cursor on a fresh
instance). The client stores it as `ttyOffset`, advances it by the wire
byte count of every binary frame received after that sync, preserves it
across ws.onclose, and sends it back as `since` on reconnect.
RingBuffer.ReadSince supplies the silent stale-cursor clamp for free.

Cursor invalidation is screen-content driven: callers that wipe the screen
(activate(), refreshCurrentInstance, resetTerminalForSwitch) reset BOTH
cursors to the -1 sentinel at the clear, and a successful loadLog re-pins
ttyOffset to logCursor immediately afterwards (one ring end offset, two
consumers). The SSE handler advances both cursors together so a later WS
reconnect cannot replay bytes SSE already painted.

Because the cursor counts wire bytes, the server must never emit a
non-ring binary frame: both handshake error paths now CLOSE the connection
with 1013 instead of writing the diagnostic as binary output, matching the
existing newTTYClientHandle precedent. The replay write error also aborts
the handshake rather than advertising a cursor over undelivered bytes.

Defensive: a first read that makes no progress consults Tail for the real
head, because ReadSince echoes the caller's own number once the cursor
reaches head. A cursor AHEAD of head (not reachable today -- Manager.Restart
mints a new instance id, so a given id's ring head is monotonic) degrades to
the first-connect tail instead of publishing a cursor the ring never held;
output written between that read and the consult is picked up by continuing
the loop rather than skipped.

Tests: internal/app/tty_ws_test.go gives the endpoint its first coverage --
8 TestHandleInstanceTTYWS_* cases driving a real ws.Dial against the real
handler (no-redelivery / tail / silent clamp / sync offset / over-64KB
contiguity / read-failure close / cursor-ahead-of-head / consult-sees-new-
bytes), plus the at-head no-op and the non-running instance. The pty driver
gains a since>=0 contract test so the app-test mirror cannot drift.
Frontend gains 6 cases in testdata/terminal_status.test.mjs (39 total).

Docs: docs/API.md (TTY WS section) and docs/ARCHITECTURE.md.

* fix(terminal): a stripped X-Log-Offset replayed the tail twice (issue #86)

GET /api/instances/log returns the tail and carries the real end offset
in the X-Log-Offset header; the browser keeps that byte offset as
session.logCursor. If a proxy in front of the daemon strips the header
the browser has no cursor, so startSSE omits `since` entirely, the
server falls into its tail default, and the tail is painted a second
time on top of what loadLog just drew. The WS handshake had the same
shape and duplicated the 64KB tail as binary frames.

Root cause: TWO different "no valid offset" states were collapsed into
one -1 sentinel.

  nothing painted   -> the client genuinely wants the tail
  painted, offset unknown (header stripped) -> the client wants NO
                        replay at all; it already has the tail

Fix: a "follow from live end" sentinel, sinceFollowLiveEnd = -2,
meaning send nothing, publish the current end offset, then stream only
newly produced data. It is branched on EXPLICITLY in both
handleInstanceLogStream and completeHandshake, ahead of their existing
`since < 0` tail branch -- parseInt64Default passes any negative through
untouched, so an unbranched -2 would behave exactly like an omitted
`since` and reproduce the duplication this value exists to prevent. On
SSE that is one empty frame `{"chunk":"","next":<head>}` (writeSSELogEvent
already always writes both fields, so no new frame format is needed);
on WS it is zero binary frames plus the existing `sync` control frame.

New Manager.EndOffset(id) returns the head without copying the buffer:
it is Tail(id, 0) in every respect that matters -- same lookup, same
kind delegation with a negative since, same clamps (a kind without log
capture echoes -1 and gets 0; a non-running instance reports 0, exactly
as Tail returns ("", 0, nil)) -- except that RingBuffer.Tail
short-circuits n <= 0 to ("", head). Tail(id, 0) itself cannot be
reused because Manager.Tail clamps n <= 0 to 4096. internal/framework/
kind.go now states the contract this relies on: a tail read must carry
its cursor even at maxBytes == 0.

Client: CURSOR_UNKNOWN (-1) and CURSOR_FOLLOW_LIVE_END (-2) are named
constants in index.html, mirrored (prefixed) in kinds/pty.js. loadLog
sets the sentinel only after bytes have actually reached the screen --
the gate is the byte count writeSanitizedTerminalOutput returns, not
the response length, because a tail of nothing but stripped escape
sequences sanitizes to "" and paints nothing. A failed or aborted fetch
leaves CURSOR_UNKNOWN, so an empty screen still asks for the tail.
Both transports send the sentinel explicitly; neither ever sends
since=0, which the server would read as the oldest live byte (issue
#81). GET /api/instances/log now 400s the sentinel rather than
silently serving the opposite of what it means.

Trade-off, stated honestly in the code, docs and changelog rather than
glossed: this trades duplication for a bounded loss. Bytes produced
between the head loadLog's tail read (H1) and the head the sentinel
publishes (H2) are in no painted body, no replay and no live frame,
and the published cursor already sits past them, so no reconnect
re-requests them. That is milliseconds normally; on the
WS-handshake-timeout -> SSE fallback it is 5s + 500ms of real lost
lines. It only applies behind a header-stripping proxy.

Also fixed here: a guard test that fails when two classic scripts
declare the same top-level name. kinds/pty.js and index.html's inline
block both declared `const CURSOR_UNKNOWN`, and classic scripts share
one global lexical environment, so index.html's entire application
script died at parse time with "Identifier 'CURSOR_UNKNOWN' has already
been declared" -- a total SPA outage. The existing node harness slices
each function with new Function(...) and so is structurally blind to
that constraint.

* fix(terminal): check both sibling cursors for the live-end sentinel (PR #93 review)

Two findings from the PR93 reviews, both verified accurate against the
code before fixing.

startSSE's sentinel fallback checked only session.logCursor while its
own comment says a real cursor outranks -2 "on either side". The
fallback now checks both siblings:

  else if (logCursor === CURSOR_FOLLOW_LIVE_END || ttyOffset === CURSOR_FOLLOW_LIVE_END)

Unreachable today -- the only -2 assignment site (loadLog) sets both
cursors together, confirmed by grep -- but a future single-sided
ttyOffset assignment would otherwise silently omit `since` and replay
the whole tail over the painted screen, the same shape as the #87
duplication. Behaviour is bit-identical in every state that exists
today. connectTTY is deliberately unchanged: it reads ttyOffset in
both branches, which is complete there because logCursor is only ever
written at paired sites while ttyOffset additionally advances alone
(WS sync frame, latched binary frames), making ttyOffset the superset
cursor. That reasoning is written into both comment blocks, and the
ui_test.go source anchor was updated to the new two-sided line.

Also narrows writeSanitizedTerminalOutput's return-semantics comment:
it returns the sanitized text's `.length` (UTF-16 code units, NOT
bytes), used only as a `> 0` boolean gate, and must not be used for
byte accounting. Aligned wording in loadLog's painted-gate comment,
the harness stubs, docs/API.md and docs/ARCHITECTURE.md.

Node suite gains a case driving the split state
{logCursor:-1, ttyOffset:-2} asserting `since=-2` still goes on the
wire, plus the mirror split, the both-unknown omit, and a real cursor
outranking -2 (67 total, CHANGELOG count re-derived).

---------

Co-authored-by: linletian <linletian@users.noreply.github.com>
- release-beta.yml: v*-beta.* tags build the platform matrix and publish
  a GitHub prerelease; release.yml jobs are guarded with
  !contains(github.ref_name, '-') so a beta tag never fires the stable
  pipeline (its v* pattern would otherwise match)
- install.sh: --beta / MYWORKTREE_BETA=1 resolves the latest prerelease
  via the releases atom feed (API fallback); -v already accepts beta
  tags; the existing-binary version regex now tolerates prerelease
  suffixes
- RELEASING.md: beta runbook (tag on develop tip, prerelease, no merge
  to main, no back-merge, /releases/latest stays on stable)
- README/README.zh-CN: one-line beta install instructions

First beta: v0.5.1-beta.1, cut from the tip of develop.
The documented 'NO_MODIFY_PATH=1' never took effect: the env value was
compared literally against "true", so only --no-modify-path (or
NO_MODIFY_PATH=true) actually skipped the rc-file edit. Normalize both
no_modify_path and want_beta to accept 1/true/yes after arg parsing.
… instance (issue #96)

selectInstance only calls prevRenderer.deactivate() when the previous
renderer is a DIFFERENT object, so switching A(running) -> B(stopped)
within one kind never hid A's keep-alive iframe; and each renderer's
stopped branch shows the stopped overlay and returns early without
hiding the other cached frames (only the running path's _showOnly
does). The two stacked half/half — overlay on top, A's live iframe
below. A cross-kind switch (terminal -> B) did not reproduce because
it DID deactivate.

All three web-ui renderers shared the defect in different shapes:
kinds/dsh_web.js and kinds/opencode_web.js in their status ===
'stopped' early-return branches, kinds/reasonix.js in _tick's
not-running branch (which hid only the active id's own frame). Each
stopped/not-running branch now hides EVERY cached frame via
this._showOnly(null) — nothing but the stopped overlay should be on
screen. Pinned by TestWebRenderersStoppedSwitchHidesAllFrames.
…ge root

dsh web --dump-config emits long scalars as >- folded block scalars,
wrapping the line AT the value's whitespace. Every macOS storages root
lives under ~/Library/Application Support/..., so the dump split the
root mid-path and verifyOverlayDump's literal substring match could
never hit: overlay_verified=false on EVERY macOS instance and the
frontend's 'version too new / overlay not effective' bar stayed up
even though the overlay was fully composed (the other three rows
matched on the same dump). Linux paths have no spaces, so the check
only ever passed contiguously there — the failure was invisible in
dev. Live-reported on v0.5.1-beta.1 with dsh 0.2.0-rc.2.

The storage-json check now falls back to blockContainsFolded, a
whitespace-stripped comparison over the row block: YAML folding only
breaks at whitespace, so stripping both sides is the same check with
the emitter's wrapping undone. Pinned by
TestVerifyOverlayDumpFoldedRoot.
…s-up

A process's comm is the basename of the path it was LAUNCHED through,
so a daemon started via the mw symlink reports as 'mw' and pgrep -x
myworktree alone never sees it — on a machine with six live daemons
the check matched zero and the 'restart the daemon to pick up the new
binary' warning never printed (found during the 0.5.0 -> 0.5.1
upgrade-path manual test). Detection now checks both the binary name
and the resolved alias name, deduplicated; the force-fallback pkill
hint lists the alias form too.
# Conflicts:
#	CHANGELOG.md
#	README.md
#	README.zh-CN.md
#	scripts/install.sh
…me visible (issue #101) (#103)

* fix(ui): hide web panels when switching to an instance-less worktree (issue #101)

* fix(ui): share worktree-switch leave cleanup, pin deactivate ordering in tests (issue #101 review)

- Extract deactivateInstanceRenderer helper: kind-agnostic unconditional
  deactivate, skips unresolvable ids instead of falling back to 'pty'
- selectWorktreeByID (create/import flows) now runs the same synchronous
  leave cleanup — deactivate previous renderer, clear activeInst, render —
  so the old web iframe no longer covers the new worktree during the
  refresh round-trip
- TestEmptyWorktreeSwitchHidesAllWebPanels asserts deactivate BEFORE
  state.activeWT assignment (position, not presence)
- sliceJSFunction derives the end-marker indent from the declaration
  line instead of hardcoding 8 spaces

* test(ui): pin deactivate-before-render ordering in selectWorktreeByID; changelog covers review follow-ups (issue #101 review round 2)

- swb assertions now pin deactivateInstanceRenderer BEFORE render();
  presence alone let a deactivate-after-render variant pass (Qwen nit)
- CHANGELOG Unreleased entry extended with the deactivateInstanceRenderer
  helper and selectWorktreeByID leave-cleanup behaviour change (mcode nit)

---------

Co-authored-by: linletian <linletian@users.noreply.github.com>
…nt caps opt-in (issue #98) (#104)

Co-authored-by: linletian <linletian@users.noreply.github.com>
…about gitignored files, and offers force (issue #102) (#105)

* fix(worktree): detailed delete refusal, force exit, gitignored-file warning (issue #102)

* chore: gofmt the issue #102 tests

* fix(worktree): review-round fixes — trailing --force, force skips the status probe, MCP 409 parity, ignored-loss surfacing, -uall counts (issue #102)

Two independent PR reviews (findings verified locally before fixing):
- CLI: myworktree worktree delete <id> --force silently dropped the flag
  (Go flag parsing stops at the first positional); scan for --force
  manually and reject extra positionals with a usage error.
- force no longer gates on git status: the probe only builds the refusal,
  and on a damaged worktree (corrupt index) it fails while git worktree
  remove --force still succeeds — force skips it entirely.
- MCP worktree_delete dirty refusal now answers the same 409 +
  worktree_dirty + structured breakdown as the HTTP API (was a flat 400).
- A clean-looking delete still destroys every gitignored file with the
  directory; the count is now returned and surfaced (API/MCP
  ignored_destroyed, dashboard post-delete notice, CLI stderr note).
- Both status probes run with -uall (untracked/ignored dirs count
  file-by-file) and -c core.quotePath=false (literal non-ASCII paths);
  the carried porcelain payload is capped at 200 lines.
- UI nits: failed force delete shows body.message instead of the raw
  error code; Esc closing the dialog resets its state via cancel.

* fix(worktree): second review round — omit ignored_destroyed on force, --force=<bool> and -- parsing, cap-comment nits (issue #102)

Both second-round reviews confirmed every first-round fix; remaining nits:
- ignored_destroyed is now present only when counted: a forced success
  omits the field instead of answering a 0 that read as 'none destroyed'
  while meaning 'not counted' (HTTP + MCP).
- The CLI's manual scan honors --force=<bool> and the -- terminator like
  the flag-based subcommands; a garbage --force value fails loudly.
- Stale maxDirtyFirstPaths comment (porcelain is capped now), singular
  'line' in the truncation marker, and a comment naming the ignored-count
  probe-timeout edge case.

* test(cli): resolve symlinks on temp paths — macOS CI failure (issue #102)

The CLI keys the project state dir off HashPath(git rev-parse
--show-toplevel), which returns the REAL path; on macOS t.TempDir() lives
under /var, a symlink to /private/var, so the test wrote state.json under
the unresolved path and the CLI looked it up under the resolved one —
'unknown worktree id' on the three delete tests that reach Manager.Delete.
Reproduced locally by pointing TMPDIR through a symlink (fails without
this fix, passes with it).

---------

Co-authored-by: linletian <linletian@users.noreply.github.com>
…t the first match (issue #95) (#106)

* test(ui): the node-test count guard now checks every claim occurrence, not the first (issue #95)

The FindSubmatch anchor was positional: entries are prepended in reverse
chronological order, so any future entry using the claim phrase above the
#80 entry would silently hijack the guard — a correct claim above plus a
corrupted real claim would still pass. Take the issue's stricter proposal:
EVERY 'Pinned by N node --test cases' occurrence must equal the derived
total, making the phrase position-independent (subset counts keep their
distinct phrasing). Verified by injection: a stale claim above the anchor
fails, and a correct claim above plus a corrupted #80 claim fails — the
exact silent-failure mode named in the issue.

* test(ui): review fixes — entry under ## Unreleased, located violations, documented convention (issue #95)

Both reviews flagged the new entry landing between the title and
## Unreleased (an orphan the release flow would strand above every
version section): moved into the section and given the house-style
'Pinned by TestName' ending. The guard's failure now reports the
offending line and lists every violation in one run (t.Errorf). And the
strict-rule convention is written down where entry authors will see it:
an HTML comment at the top of CHANGELOG.md plus a line in RELEASING.md's
changelog maintenance step.

---------

Co-authored-by: linletian <linletian@users.noreply.github.com>
…drops resume (issue #94) (#107)

* feat(terminal): publish the replay's real start offset so mid-replay drops resume (issue #94)

After #87 the streaming handshake replay widened the "painted but not yet
synced" death window from one <=64KB frame to the full 8MB budget: the
client's handshakeSynced latch blocks frame-by-frame cursor advancement
until the closing sync, because RingBuffer.ReadSince silently clamps a
stale since and accumulating from the request could publish a cursor over
bytes never delivered. A drop mid-replay therefore re-pulled everything
from the old cursor — which does not converge under sustained network
degradation.

The catch-up loop now brackets the replay with TWO sync frames:
{"type":"sync","offset":S,"start":true} ahead of the FIRST binary
chunk — S = first chunk's next - len(body), so S and the chunk come from
the same ReadSince call (the clamped oldest-live value whenever clamping
happened, never the raw request, no new Manager surface) — and the
unchanged closing sync after the last chunk.

The frame reuses the "sync" type instead of adding a frame type plus a
capability gate: any recipient provably parses "sync" already (caps=sync
or the inferred explicit-since opt-in, #98; every catch-up request
presents since by definition), and the shipped client's existing handler
— adopt the ABSOLUTE offset, lift the latch — is exactly the wanted
semantics, so the client needed zero code changes and even a
never-refreshed v0.5.1 page benefits from a new daemon. "start":true
exists for consumers that must know when the replay ends; the UI ignores
it. Tail / since=-2 / caught-up-at-head paths still send the closing sync
alone; completeHandshake stays single-shot per connection.

Server: internal/app/app.go (completeHandshake). Tests:
TestHandleInstanceTTYWS_ReplayStartOffsetPublishesClampedStart,
TestHandleInstanceTTYWS_MidReplayResumeContinuesFromRenderedBytes,
TestHandleInstanceTTYWS_StartSyncOnlyOnTheStreamingCatchUpPath, the
extended 8MB budget test, and a new node --test case driving start sync
-> replay frames -> mid-replay drop -> reconnect since. Harness:
dialHandshakeFrames stops only at the sync WITHOUT "start":true and
fails on a start frame arriving after any chunk. Contract documented in
docs/API.md (TTY WS) and docs/ARCHITECTURE.md.

* fix(terminal): address PR #107 review — start frame on the beyond-head degrade, doc contract sweep (issue #94)

Two independent reviews (Qwen3.8-Flash-Next, MiMo-V2.6-Flash); all
findings verified accurate before fixing except one wrong file cite.

R2#2 (main): the cursor>head degrade wrote its tail binary chunk from
inside the catch-up branch WITHOUT a start frame, contradicting the
documented "exceptions emit no streamed chunk" rule. Now emits
{"type":"sync","offset":head-len(tail),"start":true} ahead of the
tail, so EVERY replay addressed to a cursor-bearing client carries a
start frame; only the cursor-less first-connect tail goes without.
Pinned by the extended TestHandleInstanceTTYWS_CursorAheadOfHeadFallsBackToTail.

R1M1/R2#5: the start-frame emit is now an emitStartSync closure whose
comment states syncCap is PROVABLY true at both call sites (catch-up
requires an explicit since, already the inferred opt-in half) and the
guard stays as defensive depth against future inference changes.

R1M2: documented the defensive next<=cursor corner where S dips below
the client's own since (bounded, self-healing re-render of [S, since)).

R2#3: softened "S + sum(frames) always equals the closing offset" in
API.md and CHANGELOG — a full ring rollover mid-replay clamps a later
chunk above the running cursor and the closing sync jumps the gap
(truthfully). ARCHITECTURE.md carried no such claim (wrong cite).

R2#1: API.md's numbered handshake protocol, Message Types -> Sync and
the example flow diagram now describe the start/closing sync pair; two
singular phrasings ("with sync at the end", "The sync offset is")
now name the CLOSING sync.

R2#4: tty_ws_test.go's overflow-teardown comment no longer claims sync
is written exactly once (assertion — no sync on the LIVE stream —
unchanged).

R2#6: recorded the accepted UTF-8 split trade-off in the client's latch
comment (pre-existing on the live path, cosmetic, unrecoverable either
way).

* docs(terminal): second review round — stale step cross-reference, dangling referent, boundary-test rename (issue #94)

All three round-2 findings verified accurate:

- API.md's step 4 referenced "the closing sync in step 5" after the
  renumbering moved the closing sync to step 6 (step 5 is the binary
  replay) — a stale cross-reference in exactly the numbered-protocol
  doc that can least afford one.
- The client latch comment's "unrecoverable either way" had a
  dangling referent: read as "pre/post-#94" it contradicts the
  preceding clause ("the pre-#94 full re-pull redelivered it"). Now
  names the two paths that have already stepped the cursor past the
  lead bytes — the replay path post-#94 and the live path.
- The boundary test's name/comment still said "only STREAMING
  replays announce a start", but the contract since the R2#2 fix is
  "every replay chunk addressed to a cursor-bearing client" (the
  single-frame degrade included). Renamed to
  TestHandleInstanceTTYWS_StartSyncOnlyForChunkedReplaysWithACursor
  with the comment spelling out both sides and the tail-path failure
  message now naming the cursor-less reason. CHANGELOG reference
  updated.

---------

Co-authored-by: linletian <linletian@users.noreply.github.com>
…start reattach

Records a discussion (no decision, no code change) on what happens to pty terminal instances and their inner processes (e.g. opencode cli) when the myworktree daemon exits, and what it would take to keep them alive, traceable, and re-attachable after a restart.

Key facts captured with file:line references:
- The terminal dies with the daemon via PTY hangup, not an explicit kill: myworktree holds the pty master fd in the per-instance Handle (internal/instance/pty/driver.go) and Shutdown deliberately does not stop pty kinds (internal/app/app.go) — process exit closes the master, the kernel sends SIGHUP to the foreground process group and session leader. nohup/setsid-escaped processes become untracked orphans (no PDEATHSIG anywhere).
- The framework already has a RestartSurvivor/Reattach path, implemented by reasonix only (internal/framework/methods.go ReconcileRunningOnStartup); pty is explicitly excluded because its in-memory stdin/stdout bindings and ring buffer cannot resume.
- Converged requirement: survive a daemon restart and re-attach the session as-is, uninterrupted. The single necessary and sufficient condition: move PTY master fd ownership out of myworktree (tmux with an isolated socket is the mature form; a self-built supervisor is the alternative).
- Documents the honest boundaries of "uninterrupted", the long-term unreclaimed-risk list, and the open decision forks (scrollback depth, hard dependency vs fallback, Shutdown semantics, orphan reaping).

Also flags an adjacent pre-existing hole: dsh-web never implements Reattach, so a kill -9 daemon leaves dsh processes orphaned on their ports.

Co-authored-by: linletian <linletian@users.noreply.github.com>
CHANGELOG:
- Rename `## Unreleased` to `## v0.5.2 (2026-10-09)` with a release
  summary line; keep an empty `## Unreleased` placeholder at the top.
- Add the entry for the terminal-detach discussion draft (docs-only
  commit on develop, shipped without a CHANGELOG line). The five
  merged PRs since v0.5.1 (#103-#107) already carried entries.

README:
- Bump download URLs, example tarball names, pin-version example, and
  recommended-version wording from v0.5.1 to v0.5.2 in both README.md
  and README.zh-CN.md.

install.sh:
- Bump the `-v` pin examples from v0.5.1 to v0.5.2 (header comment,
  `--version` usage examples, and the Examples block).

No code changes; release prep only.
@linletian
linletian merged commit 6387438 into main Oct 9, 2026
4 checks passed
@linletian
linletian deleted the chore/v0.5.2-release-prep branch October 9, 2026 12:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant