Note: This document covers the HTTP API. Terminal-related client-side settings (e.g., scrollback buffer size, font size, theme) are handled by the Web UI and are not part of the backend API.
Base URL: printed when starting myworktree or mw, e.g. http://127.0.0.1:50053/.
mw opens the browser automatically by default; myworktree prints the URL unless you pass -open=true.
Auth:
- If
--auth <token>is set, sendAuthorization: Bearer <token>. - Alternatively, pass
?token=<token>for simple clients. - For Portal dashboard access, the
mw_tokenHttpOnly Cookie is used as the third token source (automatically sent by browser after/api/authlogin). - Token priority:
Authorizationheader →?token=query →mw_tokenCookie. - Prefer the
Authorizationheader when possible so tokens do not end up in browser history or shell history. - Auto-generate token: When
--authis not provided and no token exists inauth.json, the CLI automatically generates a random 32-char hex token, persists it to~/.config/myworktree/auth.json, and uses it as the instance auth token. This ensures instances always have auth enabled by default.
Common response header:
X-Myworktree-Server-Rev: <rev>is returned by API and UI responses.- Clients can treat this value as a backend revision fingerprint; if it changes after reconnect, reload the page to align frontend assets/runtime with the upgraded backend.
Version commands:
myworktree --versionmyworktree versionmw --versionmw version
GET /api/main
Returns the main (host) git repository name and its currently checked-out branch. Useful for quickly identifying which project a myworktree tab belongs to.
Response:
{
"name": "myproject",
"branch": "feature/ui-update",
"github_url": "https://github.com/owner/myproject"
}name: basename of the git root directory.branch: currently checked-out branch (viagit rev-parse --abbrev-ref HEAD). Returns empty string on detached HEAD (e.g., CI shallow clones).github_url: when the main repo has a git remote pointing atgithub.com, returns the canonicalhttps://github.com/<owner>/<repo>URL; otherwise returns an empty string. Resolution order:git remote get-url origin(preferred).- If
originis missing or not parseable, fall back to iteratinggit remoteand trying each remote in declared order. - Supported URL formats:
<user>@github.com:owner/repo.git(SCP-style; the user segment is arbitrary —gitis the conventional default, but~/.ssh/configaliases and CI bots commonly use other usernames),https://github.com/owner/repo.git,ssh://[user@]github.com/owner/repo.git(no explicit port —ssh://git@github.com:22/...is not recognized). The.gitsuffix and a trailing/are stripped. - Only host
github.com(case-insensitive) is recognized. GitHub Enterprise (*.ghe.com, self-hosted) and any non-GitHub host (GitLab, Bitbucket, local paths,file://) yield an empty string. - The field is always present in the JSON response (an empty string means "no GitHub link to show").
GET /api/worktrees
Response:
{ "worktrees": [ {"id":"...","name":"...","path":"...","branch":"..."} ] }branch: live — queried on every call viagit rev-parse --abbrev-ref HEADfrom the worktree path. Reflects the currently checked-out branch, not the branch name used at creation time.
POST /api/worktrees
Body:
{ "task_description": "fix login", "base_ref": "", "adopt_if_exists": false, "branch_name": "" }branch_name: optional. If provided, it is used directly as the branch name (no automatic prefix added). If omitted, LLM is used to generate a branch name if configured, otherwise slugified task description is used.- If
adopt_if_existsis true and the target branch already exists, the server will attempt to import/adopt an existing git worktree for that branch; if no existing worktree is found, it falls back to creating a new worktree with a numeric suffix.
Response (201):
{ "id":"...","name":"fix-login","path":"...","branch":"fix-login","created_at":"..." }POST /api/worktrees/import
Body:
{ "name": "foo" }name: "foo"maps to branchfoo.- You can also pass a full spec like
"feature/foo". - For backward compatibility,
"foo"also matches existing branches withmwt/fooprefix.
Response (201): same as create.
POST /api/worktrees/delete
Body:
{ "id": "<worktreeId>", "force": false }force(optional, defaultfalse, issue #102): delete even with uncommitted or untracked changes. The refusal response lists everything force would destroy — including gitignored files the dirty check never blocks on — so a client that resends withforce: truehas seen the cost. Force delete is unrecoverable: tracked changes AND every gitignored file vanish with the directory.
Response:
{ "status": "ok", "ignored_destroyed": 0 }ignored_destroyed counts the gitignored entries the delete destroyed with
the directory (issue #102 review): a worktree whose ONLY at-risk contents are
gitignored files deletes without a refusal — the blocking check never sees
them — so the success response carries the count and the dashboard surfaces
it after the delete. The field is present only when counted: the force
path skips the status probe (a damaged worktree can fail git status while
git worktree remove --force still succeeds — force must not gate on the
probe), so a forced success OMITS the field rather than answering a 0 that
would read as "none destroyed" while really meaning "not counted" (second
review round). Counting itself is best-effort: a probe failure (e.g. the
10 s timeout on a pathologically large ignored tree) never blocks a clean
delete and leaves the count at 0.
Refusal (409, dirty worktree and no force): the error code
"worktree_dirty" is part of the API contract — the dashboard matches on it
to open the dirty-details dialog instead of a generic alert. message
carries the one-line summary for text-only clients; dirty carries the full
breakdown (issue #102: the old flat "delete is refused" message made a
beforehand-damaged workspace indistinguishable from a leftover scratch file):
{
"error": "worktree_dirty",
"message": "worktree has uncommitted or untracked changes; delete is refused: 3 entries (1 deleted, 0 modified, 2 untracked); first paths: .gitignore, README.md, scratch.txt; additionally 1 gitignored entry present, which a force delete would destroy unrecoverably; retry with force to delete anyway",
"dirty": {
"entries": 3,
"deleted": 1,
"modified": 0,
"added": 0,
"renamed": 0,
"untracked": 2,
"ignored": 1,
"first_paths": [".gitignore", "README.md", "scratch.txt"],
"porcelain": "?? .gitignore\n D README.md\n?? scratch.txt"
}
}ignored counts git status --porcelain --ignored "!!" entries — gitignored
files/dirs that do NOT block the delete (the blocking check runs without
--ignored) but a force delete destroys unrecoverably. Both status probes
run with -uall (untracked/ignored directories count file-by-file instead of
collapsing to one entry) and -c core.quotePath=false (non-ASCII paths stay
literal); the carried porcelain is capped at 200 lines with a truncation
marker, while counts always reflect the full output. The MCP
worktree_delete verb takes the same force flag and answers a dirty
refusal with the SAME 409 + worktree_dirty + dirty breakdown shape
(issue #102 review — it previously returned a flat 400 MCP clients could not
recognize programmatically); its success result carries ignored_destroyed
under the same present-only-when-counted rule (omitted on force). The CLI
spelling is myworktree worktree delete [--force] <id> — --force may
appear before or after the id (issue #102 review: Go's flag parsing would
otherwise silently drop a trailing flag), the --force=<bool> spelling and
the -- terminator work as in the flag-based subcommands, and a clean
delete prints the destroyed gitignored count to stderr.
POST /api/worktrees/open-terminal
Opens the selected worktree path in the host machine's Terminal app. The UI should only expose this action for local browser sessions (localhost / 127.0.0.1), because it affects the machine running myworktree, not the client device.
Body:
{ "id": "<worktreeId>" }idcan be a managed worktree ID, or"__main__"to target the main repo root.- Uses
open -a Terminal <path>on macOS. - The backend enforces a loopback-only boundary using the request remote address; non-loopback callers receive HTTP 403 even if they know the endpoint.
Response:
{ "status": "ok" }- Returns HTTP 404 if the worktree ID is unknown or the resolved path no longer exists.
- Returns HTTP 403 if the request does not originate from a loopback client.
- Returns HTTP 500 if launching Terminal fails.
POST /api/worktrees/open-finder
Opens the selected worktree path in the host machine's Finder. As with open-terminal, this is a host-local side effect and should only be presented in the UI for local browser sessions.
Body:
{ "id": "<worktreeId>" }idcan be a managed worktree ID, or"__main__"to target the main repo root.- Uses AppleScript (
osascript) to tell Finder to open the path and activate the app, so the Finder window is brought to the foreground more reliably than plainopen <path>. - The backend enforces a loopback-only boundary using the request remote address; non-loopback callers receive HTTP 403 even if they know the endpoint.
Response:
{ "status": "ok" }- Returns HTTP 404 if the worktree ID is unknown or the resolved path no longer exists.
- Returns HTTP 403 if the request does not originate from a loopback client.
- Returns HTTP 500 if launching Finder fails.
GET /api/worktree/status?id=<worktreeId>
Returns the list of changed files, split into staged and unstaged changes, for the specified worktree.
id: worktree ID ("__main__"for the main repo) or a managed worktree ID fromGET /api/worktrees.- Uses two concurrent git commands, each with a 2-second timeout:
git diff --cached --numstat— staged changes (index vs HEAD)git diff --numstat— unstaged changes (working tree vs index)
- Returns empty lists if there are no changes.
- Returns HTTP 500 if both git commands fail.
- On partial failure (one command fails), the failing section includes an
errorfield describing the failure, while the successful section returns its results normally. HTTP 200 is returned in this case.
Response:
{
"staged": {
"changes": [
{ "path": "foo.go", "additions": 10, "deletions": 3 }
],
"total": { "additions": 10, "deletions": 3 }
},
"unstaged": {
"changes": [
{ "path": "bar.go", "additions": 5, "deletions": 2 }
],
"total": { "additions": 5, "deletions": 2 }
}
}Partial failure example (staged succeeded, unstaged failed):
{
"staged": {
"changes": [
{ "path": "foo.go", "additions": 10, "deletions": 3 }
],
"total": { "additions": 10, "deletions": 3 }
},
"unstaged": {
"changes": [],
"total": { "additions": 0, "deletions": 0 },
"error": "git diff failed: context deadline exceeded"
}
}- Returns HTTP 400 if
idis missing or unknown.
GET /api/worktree/file/diff?id=<worktreeId>&path=<filePath>&staged=true|false
Returns the unified diff (git diff [--cached] -- <path>) for a single file in a worktree.
id: worktree ID ("__main__"for the main repo) or a managed worktree ID.path: file path relative to the worktree root.staged: optional, defaults tofalse. Set totruefor staged (index) diff.- Uses a 2-second timeout.
- Returns plain text (
Content-Type: text/plain; charset=utf-8). - Returns HTTP 400 if
idorpathis missing. - Returns HTTP 403 if the path escapes the worktree root or targets a sensitive file.
- Returns HTTP 413 if the diff output exceeds 500 KB.
- Returns HTTP 500 on git failure.
Synthetic diff for untracked files: when a file is untracked (not in git's index), git diff produces no output. In this case the endpoint synthesizes a standard unified diff header with the full file content as an all-addition hunk, mimicking git diff /dev/null <path>. The synthetic diff includes \ No newline at end of file when the source file lacks a trailing newline, matching native git behaviour.
Clients that parse this output should be prepared for both real and synthetic diffs.
GET /api/worktree/file/content?id=<worktreeId>&path=<filePath>
Returns the raw content of a single file in a worktree.
id: worktree ID ("__main__"for the main repo) or a managed worktree ID.path: file path relative to the worktree root.- Returns plain text (
Content-Type: text/plain; charset=utf-8). - Response headers:
X-File-Type: hint —text/markdown,text/x-code, ortext/plain.X-File-FullPath: absolute filesystem path of the requested file.
- 1 MB file size limit.
- Returns HTTP 400 if
idorpathis missing. - Returns HTTP 403 if the path escapes the worktree root or targets a sensitive file.
- Returns HTTP 404 if the file does not exist.
- Returns HTTP 413 if the file exceeds 1 MB.
- Returns HTTP 415 if the file is binary (detected via null bytes).
GET /api/worktrees/diverged
Returns divergence information for all worktrees: whether each worktree branch is behind the main branch or develop branch.
- Called on page load and every 60 seconds.
- For each worktree whose branch is the main branch itself, returns an empty object
{}(no divergence check needed). - For each worktree whose branch is develop, checks only main.
- For all other worktrees, checks both main and develop (if develop exists locally).
- Uses the local vs remote effective head that is more ahead (
git rev-list --left-right --count). - If the worktree's current branch cannot be determined (e.g., detached HEAD), the worktree entry contains only an
errorfield.
Response:
{
"items": {
"wt_abc123": {
"mainBranch": {"diverged": true, "ahead": 3},
"develop": {"diverged": false}
},
"wt_def456": {
"mainBranch": {"diverged": true, "ahead": 1},
"develop": {"diverged": true, "ahead": 2}
},
"wt_detached": {
"mainBranch": {"error": "cannot determine branch: git HEAD is detached or malformed"}
},
"__main__": {}
}
}diverged:truemeans the upstream branch has commits not yet contained in the worktree branch HEAD.ahead: number of commits the upstream effective head is ahead of the worktree HEAD. Only present whendivergedistrue.error: optional string describing why the check failed (e.g., git command timeout). When present,divergedisfalseandaheadis absent.mainBranch/develop: each key may be absent if the check is not applicable (e.g., develop does not exist locally).
GET /api/worktree/diverged?id=<worktreeId>
Returns divergence information for a single worktree. Same response structure as above, but only contains the requested worktree entry.
- Called immediately when the user selects a worktree in the sidebar to refresh divergence labels.
idcan be a managed worktree ID, or"__main__"for the main repo.
Response:
{
"items": {
"wt_abc123": {
"mainBranch": {"diverged": true, "ahead": 3}
}
}
}GET /api/branches
Returns local branches with default branch always first, then branches sorted by last commit time (newest → oldest), max 10.
Response:
{ "default": "main", "branches": [ {"name":"main","commit_unix":1700000000} ] }GET /api/tags
Response:
{ "tags": [ {"id":"...","command":"..."} ] }GET /api/instances
Response:
{ "instances": [ {"id":"...","worktree_id":"...","worktree_name":"...","tag_id":"...","name":"build-server","pid":123,"status":"running"} ], "version": 7 }version: monotonically increasing state version. IncrementingSaveWithVersioncalls cause this to grow. Clients should track it and send it back on operations that modify state (e.g., reorder) to detect concurrent modifications.
POST /api/instances
Body:
{ "worktree_id": "<worktreeId>", "tag_id": "optional", "command": "optional", "name": "optional", "kind": "optional" }worktree_idcan be a regular worktree ID, or"__main__"to run an instance in the main (host) git repository. For"__main__", the instance starts in the main repo root directory.kind:""/"pty"(default) starts a PTY terminal instance;"opencode-web"starts anopencode servesubprocess embedded via/__opencode/<id>/(see section 5.11);"reasonix"starts areasonix servesubprocess in the worktree and exposes its web chat UI under/rx/<id>/(see section 5.10). For"reasonix"the tag'scommandis ignored; tagenv/preStartare applied.
If both tag_id and command are empty, the server starts an interactive shell instance in the worktree.
If command is provided, it is sent to the shell as the initial command and the shell remains available for further input.
Example (ad-hoc command without tags):
{ "worktree_id": "<worktreeId>", "command": "echo hello && ls" }Response (201):
{ "id":"...","pid":123,"status":"starting","created_at":"...","kind":"pty" }status is starting, not running. Start returns as soon as the process is spawned; the record is persisted as starting and flipped to running on the kind's ready signal by a separate goroutine, so the 201 body — and any GET /api/instances issued immediately afterwards — legitimately reports starting. The full status vocabulary is starting / running / unhealthy / stopping / stopped / failed / exited (internal/framework/status.go); see docs/ARCHITECTURE.md §5.3 for how the UI buckets them. Clients MUST NOT treat "not running" as "stopped": a starting instance is alive and accepts I/O.
Error: log buffer budget exceeded (503 Service Unavailable)
Returned when starting the new instance would push the per-process log-buffer total past the global budget (default: 25% of system RAM — see docs/ARCHITECTURE.md §4.1 Instance log buffer). The error code log_buffer_budget_exceeded is part of the stable API contract; the UI matches on it to surface a dedicated modal.
Headers:
Content-Type: application/jsonRetry-After: 0(will not auto-resolve; user must close other instances or raiselog_buffer_bytesinauth.json)
Body:
{
"error": "log_buffer_budget_exceeded",
"message": "Insufficient memory to start new instance: used 100.00 MB, limit 64.00 MB.",
"used_bytes": 104857600,
"limit_bytes": 67108864,
"system_bytes": 268435456,
"hint": "Close other instances, raise LogBufferBytes in auth.json, or reduce concurrent tabs."
}The same shape is returned by the MCP instance_start tool when the budget is exceeded.
PATCH /api/instances
Updates mutable metadata of an existing instance. Currently only name is supported.
Body:
{ "id": "<instanceId>", "name": "build-server" }name: new display name (required). Empty/whitespace-only names are rejected.- Returns the full updated instance as JSON.
- Returns HTTP 404 if instance not found, HTTP 400 if name is empty.
Response (200):
{ "id":"...","worktree_id":"...","worktree_name":"...","tag_id":"...","name":"build-server","pid":123,"status":"running","created_at":"..." }PATCH /api/instances/reorder
Sets the tab display order for a specific worktree. The order persists across page reloads and server restarts.
Body:
{ "worktree_id": "<worktreeId>", "order": ["id1", "id2", "id3"], "version": 7 }worktree_id: the worktree whose tab order is being set (can be"__main__"for the main repo)order: ordered list of ALL instance IDs belonging to that worktree. All instances must be included.version: the state version observed by the client (fromGET /api/instances). Used for optimistic locking — if the state has changed since the client fetched it, the server returns HTTP 409 Conflict.
Response (200):
{ "status": "ok" }- Returns HTTP 400 if the order list is missing an instance or contains an ID that does not belong to the worktree.
- Returns HTTP 409 Conflict if the state version has changed (concurrent modification). The response body includes the current version so the client can refresh and retry:
{ "error": "state changed, please refresh", "version": 8 }- The instance order is also stored as the array order in
state.json, soGET /api/instancesreflects the new order immediately.
POST /api/instances/stop
Body:
{ "id": "<instanceId>" }POST /api/instances/restart
Body:
{ "id": "<instanceId>" }- Creates a new instance with the same worktree + tag/command.
- If the old instance is not running, it will be deleted automatically.
- The old instance record is linked to the new one via
restarted_to/restarted_from.
POST /api/instances/input
Body:
{ "id": "<instanceId>", "input": "ls -la\n" }GET /api/instances/tty/ws?id=<instanceId>[&since=<offset>][&caps=<list>]
Bi-directional stream for terminal output/input with PTY support.
since is an optional byte cursor (issue #87), with three states (issue #86):
- omitted / not a valid integer → the client has painted NOTHING, so the
server replays its newest 64KB tail, as before (the first-connect path).
This is the browser's
CURSOR_UNKNOWN(-1). since=-2→ follow from the live end: the client's screen already holds the tail and only its end offset is unknown (a reverse proxy strippedX-Log-OffsetoffGET /api/instances/log). The handshake replays NOTHING — no binary frame at all — and answers with{"type":"sync","offset":<currentHead>}(since=-2is itself an explicitsince, so any client in this mode receives the echo) and then live output only. Sending the tail here would paint it a second time under the screen that already shows it, which was the bug. This is the browser'sCURSOR_FOLLOW_LIVE_END(-2), mirrored bysinceFollowLiveEndininternal/app/app.go; it is branched on explicitly, ahead of the negative tail branch, so it is never confused with an omittedsince.since=0→ accepted, but it is NOT a "tail" mode. OnGET /api/instances/loga single read starting at 0 starts at the OLDEST live byte (the #81 symptom); on this WS endpoint the catch-up loop reads forward from 0 and streams every chunk it reads straight to the socket, so the effect there is a replay of everything still live in the ring — up to the 8 MB handshake budget — with the closingsyncat the end of the last chunk actually written (the replay is bracketed by the start/closing sync pair described below). The shipped UI never sends0; it omitssinceentirely when its cursor is unknown (CURSOR_UNKNOWN) and sends-2when the screen is painted but the offset is not.since>0→ the server replays the bytes at or after that offset, in full, so a reconnecting client that still holds its rendered screen does not receive the tail a second time.
caps is an optional, comma-separated capability list (issue #98), and the
parameter may be repeated (?caps=pong&caps=ping — every value is
scanned, not just the first). Matching is by whole, case-sensitive token:
caps=pinger and caps=PING do NOT opt in. Two capabilities are defined:
ping— the client whitelists the TEXT{"type":"ping"}heartbeat (stamps its liveness clock, never renders it), so the server may send that frame. A client that does NOT opt in — a page loaded before the whitelist shipped, any non-browser client that did not ask — is never sent the TEXT heartbeat, because it would render it as terminal output every 10 s. The RFC 6455 ping on the same tick is unaffected: control frames never reach page JavaScript, so it is sent to every client.sync— the client whitelists the{"type":"sync"}offset echo and drives its reconnect cursor off it (issue #87), so the handshake sends it. The echo is also sent to any client that presents an explicitsinceparameter — the inferred opt-in: the cursor parameter and the sync whitelist shipped in the same fix (#87, v0.5.1), and v0.5.0 never sendssinceon this endpoint at all, so a client presenting one provably parses the other. This keeps pre-caps v0.5.1 pages on the cursor contract: without the echo their per-connection cursor latch never sets, the cursor freezes, and every reconnect would re-replay from it. A client with NEITHER — v0.5.0, a fresh non-browser probe — is never sent the frame, because it would paint it into the terminal once per connect. Residual: a pre-caps page whose cursor is still the unknown/zero sentinel sends nosinceeither, so it gets no echo and its reconnects re-replay the ≤64KB tail until a loadLog re-pin or a refresh lands a cursor — the pre-#87 behaviour that page shipped with. Insince=-2mode the echo is the ONLY cursor handoff (the replay is empty by design) — which is exactly why the explicit-sinceinference matters: a pre-caps page in that mode still receives it. The rule is deliberately opt-IN rather than version-gated: the server can never again leak a future visible control frame to a client that did not declare it. Older servers ignore unknown query parameters, so a new client against an old daemon behaves exactly as before (the old daemon sends the TEXT ping unconditionally, and the new client whitelists it).
Each replay read is capped at 64KB. When the delta since since exceeds
64KB, the server loops reads until it is caught up and streams each chunk
to the socket as it reads it instead of retaining one, so the whole delta
is delivered and peak memory stays ~64KB (one chunk) regardless of the ring
cap. The closing sync's offset is the end of the last chunk actually
written to
this socket — never an offset over bytes the client did not receive and can
still receive, because a client cursor only moves forward and would never ask
for them again. The two deliberate exceptions both publish the ring's real
head, and neither re-delivers anything a healthy client still had coming: a
path that writes nothing has no last chunk to name, and a ring whose process
has exited and whose buffer has been dropped publishes head precisely because
the bytes behind it are permanently undeliverable — publishing 0 there would
instead throw the client into a full tail replay. One handshake replays at
most 8 MB
(ttyHandshakeReplayBudget in internal/app/app.go), counted in bytes
already written and checked before the next read: once the budget is spent
the loop stops, and the undelivered remainder sits ahead of the published
cursor, so the next reconnect re-requests exactly those bytes — truncation is
deferred to the next reconnect, never a skip. The deferral has a cost:
ring caps are 16–256 MB, so a badly lagged client can need up to 32 reconnects
to converge, and until it reconnects the deferred bytes stay off a screen whose
socket still looks healthy (the replay lands in xterm.js scrollback: 10000,
so it is the older replayed content that scrolls out, never the live output
that follows it). There is deliberately no write deadline on this path: the
handshake is not yet subscribed to the output stream, so a blocked write does
not stall the pump, and a wall-clock deadline would livelock a slow-but-
progressing consumer where a byte budget simply resumes next reconnect. Output produced between the
last chunk written and the live subscription sits behind
the published cursor and is re-requested by the next reconnect (self-healing
contiguity, not absolute coverage). A since older than the oldest byte
still in the ring buffer is silently clamped to the oldest live byte; a
since at or beyond head replays nothing (at head) or falls back to the
tail (beyond head, defensive) — the client's own number is never echoed back
as authoritative.
On that streaming catch-up path the replay is bracketed by TWO sync frames
(issue #94). Ahead of the FIRST binary chunk the server sends
{"type":"sync","offset":<S>,"start":true}, where S is the replay's real
start offset: the first chunk's end offset minus its length, so S and the
first chunk come from the same read — the requested since when no clamp
happened, the clamped oldest live byte when it did, never the raw request
value. The closing sync after the last chunk is unchanged. A client that
adopts S on arrival may advance its cursor by every replay frame's wire
length from chunk 1 on, so a connection that dies mid-replay resumes from
the bytes it already rendered instead of re-pulling the whole (up to 8 MB)
replay from its old cursor — under sustained network degradation the pre-#94
behaviour does not converge. The frame REUSES the "sync" type rather than
inventing a new one: every client that receives it already provably parses
"sync" (the caps token, or the inferred explicit-since opt-in above —
any catch-up request presents since by definition), its existing handler —
adopt the absolute offset, lift the per-connection latch — is exactly the
wanted semantics, and a new frame type would have to fight the stale-page
leak class (issue #98) with a new capability gate. The "start":true field
exists for consumers that must know WHEN the replay ends (test harnesses,
third-party clients); the shipped UI deliberately ignores it and treats both
sync frames alike. S + Σ(replay frame bytes) equals the closing offset
UNLESS the ring rolls a full cycle mid-replay and evicts past the loop's
cursor between two reads — a later chunk then clamps to the new oldest live
byte, the sum lands short, and the closing sync jumps the gap (truthfully:
those bytes are undeliverable anyway, the same rule the read→subscribe
window already lives by). The beyond-head tail degrade (since past head —
defensive, unreachable through normal flows) announces its tail's start the
same way, S = head − len(tail): the client presented a cursor, so EVERY
replay addressed to one carries a start frame. The closing sync arrives
ALONE only where there is no cursor to advance: the cursor-less
first-connect tail replay (a single ≤64KB frame whose death window is the
pre-#87 one — and a cursor-less client may not parse sync at all, which is
why THAT path's syncCap is a genuine gate, not an inference), the
since=-2 handshake (zero binary frames by design) and the caught-up-at-head
exit (nothing to announce).
In since=-2 mode the published offset comes from Manager.EndOffset —
the head read with a zero-length body, because there is no replay to attach
it to. Two windows surround that read and they behave differently, so do not
conflate them:
- After it (head →
SubscribeOutput): bytes produced in that gap are not delivered live, but the client's cursor still points at the published head, so its next reconnect asks for[head, …)and takes them out of the ring. Self-healing, exactly like the read→subscribe window of every other mode. - Before it (the client's painted tail → this read): the client reached
-2by paintingGET /api/instances/log's tail, which ended at some headH1. This read reportsH2 >= H1and the client adoptsH2, so its cursor runs AHEAD of its own screen.[H1, H2)is in no replay (there is none), no painted body and no live frame — and because the cursor is already past it, no reconnect ever re-requests it: those bytes are permanently gone from that client's screen. Normally the hole is theloadLog()→connectTTYhop plus the upgrade (milliseconds); if the WS handshake times out and the client falls back to SSE it is 5s + 500ms of output, i.e. real lost lines. That is the accepted price of a proxy strippingX-Log-Offset: duplicate the tail, or lose a window. Closing it would need the client to knowH1, which is precisely the number the stripped header took away.
Handshake Protocol:
- Server sends
{"type":"ready"}immediately after connection - Client should wait for this message before sending resize
- Client sends
{"type":"resize","cols":80,"rows":24}to start data flow - Server sends
{"type":"sync","offset":<S>,"start":true}(text frame, issue #94) announcing the replay's real start offset S — the requestedsince, or the clamped oldest live byte whensincepredated it — ahead of the FIRST binary frame. Sent on every replay addressed to a cursor-bearing client (the streamed catch-up AND the beyond-head tail degrade); skipped only by the cursor-less first-connect tail and by empty replays. Same opt-in as the closing sync in step 6 — every client that receives it provably parses"sync", and a client that ignores thestartmarker is merely back to the pre-#94 behaviour of waiting for the closing sync - Server sends the initial log (the tail, or only the bytes after
since) as binary frames — and insince=-2mode sends NO binary frame at all - Server sends
{"type":"sync","offset":<int64>}(text frame, only to clients that opted in viacaps=syncor presented an explicitsince— issue #98) — the CLOSING sync: the end offset of that replay (insince=-2mode, the live head it refused to replay); the client stores it and sends it back assinceon its next reconnect. On the streaming catch-up path this is the second sync frame — the start announcement of step 4 preceded the replay; everywhere else it is the only one - Real-time output continues as binary frames
- Client receives first data and triggers second resize (50ms delay) for TUI redraw
- If the client stops draining the live stream, the server closes the
connection with
1013/ reasonsubscriber overflow: slow consumer(issue #82) instead of silently dropping output — see Close codes below; the client reconnects with thesincecursor it already holds, exactly as after any abnormal close
Frontend session model:
- The current UI keeps transport state per running instance rather than sharing a single terminal across tabs.
- Switching tabs may leave other running instances connected in the background; hidden instances are not rendered, but their PTY attachment can remain alive.
- Stopped instances still use the log replay endpoints as their primary display source.
Message Types:
Client → Server:
- Input: text/binary frames (raw bytes)
- Resize:
{"type":"resize","cols":<number>,"rows":<number>} - Liveness probe:
{"type":"ping"}(text frame, issue #83) — answered with{"type":"pong"}and not treated as input: the server consumes it before the input fallthrough, so the probe JSON is never typed into the instance's PTY. (A user literally typing that exact JSON is swallowed the same way{"type":"resize",...}already is.)
Server → Client:
- Ready:
{"type":"ready"}(text frame) - Output: binary frames (terminal output chunks)
- Sync:
{"type":"sync","offset":<int64>}(text frame, opt-in viacaps=sync— or inferred from an explicitsinceparameter — since issue #98), the cursor handoff of the handshake, sent even when the replay was empty. Since issue #94 a replay addressed to a cursor-bearing client is bracketed by TWO of these:{"type":"sync","offset":<S>,"start":true}ahead of the FIRST replay chunk carries the replay's real (possibly clamped) start offset, and the closing sync after the last chunk carries its end offset — every other path (the cursor-less tail,since=-2, an empty replay) sends the closing sync alone. The client latches per connection: a replay frame advances the cursor by its wire byte count only once SOME sync has arrived — against a post-#94 server that is the start sync, so frames count from chunk 1 on (S and chunk 1 come from the same server-side read, so the base is truthful); against a pre-#94 server the latch lifts only at the closing sync, which publishes the end of the whole replay in one step. Either way the closing sync is the absolute authority — whenever the summed frames and it drift (a ring rollover mid-replay can evict unsent bytes, landing the sum short), it wins. Counting binary frames is sound at all because every one of them is ring-buffer output: the server closes the connection rather than writing diagnostics as binary. The"start":truemarker exists for consumers that must know WHEN the replay ends; a client that ignores it and treats both syncs as absolute offsets to adopt implements the full contract) - Heartbeat:
{"type":"ping"}(text frame, every 10 s, issue #83; opt-in viacaps=pingsince issue #98) — the application-level mirror of the RFC 6455 ping the server sends on the same tick. Browsers answer the protocol ping automatically from their network stack (which refreshes the server's 45 s read deadline); this text frame is the heartbeat browser JavaScript can observe, sinceonmessagenever fires for control frames. Clients MUST whitelistping(andpong) as control types and MUST NOT render them as terminal output — and the server only sends this frame to clients that declaredpingin the handshakecapslist, so a client without the whitelist never receives it.
Liveness (issue #83): half-open TCP sockets (laptop sleep, NAT/proxy idle timeout) keep readyState === OPEN without ever firing onclose, so liveness rides on heartbeat traffic in both directions. Server: pings every 10 s; arms a 45 s read deadline before every read and reaps a peer that has sent nothing for that long (the browser's automatic Pong refreshes it). Non-browser clients get no automatic Pong — internal/ws's own client returns opPing as an ordinary message and installs no responder — so any Go or embedded client of this endpoint MUST answer protocol pings with Pong and/or send {"type":"ping"} periodically, or it will be reaped at the 45 s deadline. Client: stamps the arrival of every frame, probes {"type":"ping"} every 5 s once READY, and reconnects after 30 s without heartbeat traffic (3 × the ping interval, under the server's 45 s backstop). The web UI's primary detector is the 2 s poll — ensureTerminalLiveTransport() notices the stale stamp on the active session at ~30 s and queues the reconnect (returning false: a queued reconnect is not yet a live transport); the 5 s watchdog is the fallback that also covers sessions the poll does not promote. Liveness is never inferred from the absence of program output — a prompt, vim or top emit zero bytes for hours and stay connected. A write deadline was deliberately not part of issue #83: normal-traffic writes on this socket carry none (the only bounded write here is the 5 s deadline on #82's overflow close-frame), so a handler blocked mid-write is reclaimed when that write fails or returns, not by the read deadline.
Server → Client close codes:
1013with reasonsubscriber overflow: slow consumer(issue #82) — the live output subscription was torn down server-side because this client stopped draining: its 64-slot output queue filled (the PTY pump reads in 1024-byte chunks, so ≈64 KB of unread output) and the server disconnected the subscriber rather than dropping chunks forever. Any queued chunks still in flight are discarded at the source — the drain runs once the subscriber registry's lock is released, never inside it — so that backlog never reaches the socket writer. That bounds what this handler can write: the queue is empty by the timebroadcastreturns, so a full backlog (~64 KB) cannot be pushed onto a socket that has already stalled. It does not order the teardown:close()readies any receiver already parked on the channel, and a readied receiver races the drain loop for the values still buffered, so a few chunks may legitimately still reach the socket before the handler sees!ok. Each of those chunks was already written to the ring buffer before being broadcast, so they come back on the reconnect's replay from the client'ssincecursor: nothing is lost that is still in the ring buffer. The one exception is an instance whose ring buffer the framework has already closed and dropped (Manager.dropBuffer→RingBuffer.Close, which nils the data and refuses further writes): a chunk broadcast after that point was never stored, so no replay can bring it back, and a still-connected client would have received it on the wire pre-drain — the same post-swap loss ARCHITECTURE §4.1 already declares intentional for the buffer swap itself. The close frame itself is BEST-EFFORT (the stalled socket may never drain it; its write carries a bounded 5 s deadline set on this teardown path only — normal-traffic writes on this socket carry no write deadline, and that is unchanged after issue #83, which deliberately scoped a write deadline out and shipped read-deadline liveness only), so the client must treat any abnormal close as "reconnect with your storedsincecursor", not only this one. The shipped UI'sws.oncloselogs the code and reason and reconnects after 5 s; the server logs the overflow at the disconnect too (tty output subscriber overflow for <id>…), which is its only server-side trace.1013with a dynamic error reason — handshake/setup failures on this endpoint (upgrade, replay-read or subscribe errors); the connection is closed rather than served half-configured, and the client retries like any other abnormal close.
Timeout & Fallback:
- Client should implement handshake timeout (recommended: 5s)
- On timeout, close WebSocket and fallback to SSE:
GET /api/instances/log/stream?id=<instanceId>
Example Flow:
Client Server
| |
|--- Connect ----------->| (optionally ?since=<cursor> on reconnect)
|<-- {"type":"ready"} ----| Handshake
| |
|-- {"type":"resize", --->| Notify terminal size
| "cols":80,"rows":24} |
| |
|<-- {"type":"sync", ----| Start offset of the replay (issue #94):
| "offset":1024, | only on a replay addressed to a cursor-
| "start":true} | bearing client (streamed catch-up and the
| | beyond-head tail degrade); S is the
| | requested `since` or the clamped oldest
| | live byte, never the raw request
|<-- binary output -------| Replay: tail, or bytes after `since`
|<-- {"type":"sync", ----| CLOSING sync: end offset of that replay —
| "offset":4096} | only when the client opted in via
| | ?caps=sync or presented ?since
| | (issue #98)
| |
|--- (50ms delay) -------|
| |
|-- {"type":"resize", --->| Trigger TUI redraw
| "cols":80,"rows":24} |
| |
|--- input bytes -------->| User input
|<-- binary output -------| Process output
| |
|<-- {"type":"ping"} -----| Heartbeat every 10s (issue #83; only when
| | the client opted in via ?caps=ping — issue
| | #98. The same tick also writes an RFC 6455
| | ping, which the browser answers invisibly
| | to JS, caps or not)
|-- {"type":"ping"} ------->| Client probe every 5s once READY
|<-- {"type":"pong"} ------| Answered - never typed into the PTY
POST /api/instances/delete
Body:
{ "id": "<instanceId>" }Deletes a stopped (non-running) instance record. The instance's in-memory log buffer is also released, decrementing the global log-buffer accounting.
GET /api/instances/log?id=<instanceId>[&since=<byteOffset>]
- Without
since: returns the recent tail (newest bytes) astext/plain; the response also includesX-Log-Offset: <endOffset>— the cursor at the end of the tail, usable assinceon a follow-up incremental read. - With
since: returns incremental content from byte offset and includes response headerX-Log-Offset: <nextByteOffset>. - This endpoint has no follow-from-the-live-end mode, and it does not pretend to have one:
since=-2returns 400 (issue #86). The sentinel means "I already painted the tail, do not send it", while this endpoint's answer to any negativesinceis the tail — the exact inversion that caused the duplication in the first place, so serving it silently would resurrect the bug invisibly. Follow-from-the-live-end is a property of a long-lived stream, so it exists only on/api/instances/log/streamand/api/instances/tty/ws. Every other negativesince(and an omitted one) keeps its tail semantics untouched; the shipped UI never sendssincehere at all, becauseloadLogdeliberately omits it so the response is always the tail plus itsX-Log-Offset— that header being present is what lets the client keep a real cursor, and its being stripped is what makes the client fall back tosince=-2on the streams. - Logs live in an in-memory ring buffer attached to the running instance (see
docs/ARCHITECTURE.md§4.1 Instance log buffer). After the instance stops, exits, or fails — or after the daemon restarts — the buffer is released and this endpoint returns200 OKwith an empty body (tail reads carryX-Log-Offset: 0; incremental reads echo the requestedsince). Unknown / never-started instance IDs behave the same. - The
byteOffsetcursor is the running total of bytes the instance has produced (monotonic; never decreases). Whensincepoints to data that has already been evicted from the ring (oldest-byte > since), the response silently clamps to the oldest live byte andX-Log-Offsetadvances accordingly.
Response: text/plain
GET /api/instances/log/stream?id=<instanceId>[&since=<byteOffset>]
- Server-Sent Events stream.
- Emits
event: logwith JSON payload:
{"chunk":"...","next":12345}- Same in-memory backing as the tail endpoint above. Without
since(or with any negativesinceother than the-2sentinel below), the stream starts from the tail (newest bytes), same as the log endpoint. The cursornextis the same monotonic byte counter; clients should echo it assinceon the next request to receive only new chunks. since=-2— follow from the live end (issue #86). For a client whose screen ALREADY holds the tail but which never learned its end offset (a reverse proxy strippedX-Log-Offsetoff the tail response): the server sends NO body, opens the stream with one EMPTYlogevent carrying the current head —{"chunk":"","next":<head>}— and then delivers only bytes produced after that instant. A tail here would be painted a second time under the screen that already shows it, which was the bug. The sentinel is branched on explicitly, ahead of thesince < 0tail branch (parseInt64Defaultpasses any negative value through untouched), so it can never be mistaken for an omittedsince. Client side:CURSOR_FOLLOW_LIVE_ENDinindex.html; its only assignment site isloadLog, and it is taken only after tail content actually reached the screen —writeSanitizedTerminalOutputreturns the number of characters it wrote (.length, not bytes) and is consumed only as a> 0gate, and an empty tail (or one that sanitizing emptied) keepsCURSOR_UNKNOWNand keeps asking for the tail, because there is nothing on screen to duplicate and suppressing the replay would hide output instead. Server side:sinceFollowLiveEndininternal/app/app.go, with the head read byManager.EndOffset(a zero-length tail read, not a 64KB copy that gets thrown away). Because this endpoint polls from the published cursor rather than subscribing, nothing produced AFTER that read is lost — it arrives as an ordinary frame. What is lost is the window BEFORE it:[H1, <head>), whereH1is the end of the tail the client actually painted. That range is in no replay, no painted body and no frame, and the client's cursor now sits past it, so no reconnect asks for it again — it is gone from that client's screen for good. See the TTY WebSocket section above for the same asymmetry, its size, and why it is accepted. A cursor of exactly0is still treated as "no cursor" by the client's> 0guards, so a proxy that REWROTE the header to0instead of stripping it gets the tail again; that is harmless only if the0is truthful (a genuine head of 0 means the ring is empty), and a fabricated0over a non-empty ring does restore the duplicate — no client-side rule can distinguish them without a second, independently sourced length, which is the very datum the proxy is already lying about.- Polling cadence: 1 s. When no new data is available, the server emits an SSE comment line (
: ping) as a keep-alive — nologevent, no cursor update. Clients should treat the absence of alogevent as "no progress" and keep using the lastnextthey saw. - Stopped / unknown / never-started instance IDs return
200 OKand emit one emptylogevent followed by: pingkeep-alives — the stream stays open; clients decide when to give up. The cursor in that first event is0for a tail read (omitted / negativesince), echoes the requestedsincefor an incremental read, and is0forsince=-2as well (Manager.EndOffsetreports0for a non-running instance, exactly asManager.Tailreturns an empty body and0).
GET /api/instances/stats
Returns per-instance resource consumption and connection status, grouped by worktree.
Note: This endpoint performs real-time process stat collection (via gopsutil). It only yields meaningful CPU% values after at least 1-2 seconds of server runtime, as CPU% requires a delta calculation from the previous measurement.
Response:
{
"instances": [
{
"id": "inst-abc123",
"name": "build-server",
"worktree_id": "wt-xyz",
"worktree_name": "feature-ui",
"pid": 12345,
"status": "running",
"cpu_percent": 3.5,
"memory_rss_bytes": 52428800,
"memory_buffer_bytes": 5242880,
"memory_buffer_cap_bytes": 33554432,
"connection_type": "websocket"
}
],
"worktrees": [
{
"worktree_id": "wt-xyz",
"name": "feature-ui",
"total_cpu": 5.2,
"total_memory": 104857600,
"instance_count": 2
}
],
"global": {
"total_cpu": 8.7,
"total_memory": 209715200,
"instance_count": 3,
"daemon_cpu_percent": 1.2,
"daemon_memory_bytes": 67108864
}
}Fields:
cpu_percent: CPU utilization as a percentage of a single core. 0% on the first measurement (no prior baseline).memory_rss_bytes: Resident Set Size — actual physical memory used by the process.memory_buffer_bytes: Actual bytes currently held in the instance's in-memory ring buffer (0 if the instance is stopped or has no buffer).memory_buffer_cap_bytes: Pre-allocated capacity of the instance's in-memory ring buffer (0 if the instance is stopped or has no buffer). When bothmemory_buffer_bytesandmemory_buffer_cap_bytesare non-zero, the buffer is active withused / capsemantics.connection_type:"websocket"if the instance has an active WebSocket TTY connection,"sse"if using the SSE fallback,"none"otherwise.- Worktree subtotals aggregate only
runninginstances (instance RSS only, not buffer memory). - Global totals include both all running instances and the daemon process itself (
daemon_cpu_percent,daemon_memory_bytes).
All page close/refresh/navigation events trigger a browser-native confirmation dialog. This is a purely client-side UX feature:
- Trigger:
beforeunloadevent onwindow - Behavior: Calls
event.preventDefault()and setsevent.returnValue = ''to force the browser to show its native confirmation dialog - No backend involvement: Instances continue running regardless of the user's choice
- Condition: Always triggered on any close action — no dependency on instance state
For kind: "reasonix" instances the web chat UI is served under:
GET /rx/<instanceId>/...
- Independent origin (loopback only): when the main listener is loopback-only (
127.0.0.1/localhost, and no TLS), the proxy is mounted on a dedicated loopback listener (127.0.0.1:<random>), reported to the frontend asweb_urlinGET/POST /api/instancesresponses (view-only field, not persisted). The iframe loads that URL, so the embedded reasonix page is cross-origin with the myworktree API — a script inside the chat iframe cannot silently call/api/*with the user's session (issue #44). - Same-origin fallback (network / TLS): when TLS is configured (
--tls-cert/--tls-key, anhttp://127.0.0.1iframe inside an https page would be blocked as mixed content) or the main listener is open to the network (default0.0.0.0, or an explicit LAN IP — a remote browser would resolve127.0.0.1to itself and the iframe would fail), the independent listener is skipped;web_urlis empty and the frontend falls back to the relative same-origin path/rx/<id>/, which follows the browser's current origin — so LAN/remote access works (same as the pre-#44 behavior). - The proxy forwards to the instance's
reasonix serveathttp://127.0.0.1:<port>(port/token cached in memory by the driver — issue #46), injectingCookie: reasonix_token=<token>(name fromreasonix.CookieName, single source — issue #45) for auth. - HTML responses get a script injected (single injection point before
</head>) that prefixes the page's root-relativefetch/EventSource/XMLHttpRequestcalls with/rx/<id>/, plus the issue #48 layout injection: the 220px sidebar is collapsed by default on desktop with a dedicated toggle button (--mw-sidebar-wCSS var makes the expanded width configurable); narrow screens keep the native mobile sidebar. - The
Accept-Encodingheader is forced toidentity; only the myworktree auth?token=parameter is stripped from the query before forwarding upstream (authq.StripToken— every other query parameter, e.g.?session=, passes through; parse-failed queries are still token-scrubbed rather than forwarded raw). - Remote-access authentication: when the UI is opened with
?token=in the address bar (portal jump / remote access), the server syncs the token into the HttpOnlymw_tokencookie on the response (withAuth); the iframe then navigates with a plain relative/rx/<id>/URL and authenticates via the cookie — the token never appears in the embedded document'slocation.search, and a rotated token self-heals on the next cookie refresh. - SSE (
/events) is streamed through (FlushInterval=-1); the upstream sends its own 15s: pingkeepalive. - Returns
404for unknown/non-reasonix instance ids,503when the instance is not running,502when the backend is unreachable. - Driver version gate:
Startrunsreasonix --versionand rejects CLIs older than1.22.0(configurable viaDriver.MinVersion); readiness failures include the tail of the instanceserve.log(issue #45). - Tag semantics apply like other kinds:
tag.Envis injected into the serve environment,tag.preStartruns before serve (withREASONIX_HOME/REASONIX_STATE_HOMEstripped exactly like serve), andtag.Commandis ignored (a reasonix instance runs the agent, not a shell command).
GET /api/instances/<id>/opencode
Returns the iframe source URL and metadata for an opencode-web instance. Only valid for instances with kind: "opencode-web"; returns 404 for other kinds or non-existent instances. Returns 503 if the opencode server process is not yet ready (port not populated).
Response (200):
{
"iframe_src": "/__opencode/<id>/",
"api_base": "/__opencode/<id>",
"worktree_path": "/abs/path/to/worktree",
"host": "127.0.0.1",
"port": 51234,
"version": "1.18.16",
"version_supported": true
}host and port are the opencode server's bound address. iframe_src is the full-page SPA root to load in the iframe (the deep /session/ link was replaced by the full page — see docs/plans/opencode-native-ui/WORKTREE-ISOLATION.md §0.4). version is the installed opencode --version probed at spawn; version_supported is false when it is outside the 1.18.x range the injected hide script targets (advisory only — the instance still starts). The upstream OPENCODE_SERVER_PASSWORD is cfg.AuthToken (unified auth token — every opencode-web instance shares the same upstream password, gated by the myworktree bearer token at the proxy); see docs/ARCHITECTURE.md §8 for the threat model and the review checklist.
/__opencode/<id>/*
Reverse proxy to the opencode HTTP server backing the given instance. Protected by myworktree's global token authentication (same as all instance routes). Go-side proxy injects Authorization: Basic base64("opencode:"+password) and adds ?directory=<worktree> to GET/HEAD API requests when missing from the original query. HTML navigation responses are rewritten (assets re-routed through the proxy, <base> + injected script for URL rewriting, localStorage server-list normalization, and cross-worktree switch-entry hiding) with a matching CSP hash.
- Returns
404if the instance does not exist orkindis not"opencode-web" - Returns
503if the opencode server is not yet listening - Returns
502if the opencode server is unreachable during proxying - The myworktree auth
?token=parameter is stripped from the query before forwarding upstream (authq.StripToken, shared with the/rx/proxy) — the opencode subprocess never sees the credential. Remote-access iframe navigations authenticate via themw_tokencookie synced bywithAuth(see §5.10), so the token is not needed in the iframe URL.
GET /api/instances/opencode/scope?id=<id>
Returns the last observed out-of-scope state for an opencode-web instance, recorded in-memory by the reverse proxy from directory-bearing requests. The frontend polls it (~1.5s) to render the persistent warning bar. See docs/plans/opencode-native-ui/WORKTREE-ISOLATION.md §4.4.
Response (200):
{
"scope": "out-of-scope",
"directory": "/abs/path/to/other/worktree",
"cross_project": false,
"at": 1753500000,
"csp_anchor_missing": false
}scope is in-scope / out-of-scope / cross-project; csp_anchor_missing marks structural drift (the homepage CSP lost the 'wasm-unsafe-eval' anchor the injected script's hash is appended after), which the frontend surfaces as the "hiding not effective" warning.
GET /api/instances/dsh?id=<id>
Returns the iframe source URL and metadata for a dsh-web instance. Only valid for instances with kind: "dsh-web"; returns 404 for other kinds or non-existent instances.
Response (200):
{
"iframe_src": "http://127.0.0.1:35422/",
"proxy_host": "127.0.0.1",
"proxy_port": 35422,
"host": "127.0.0.1",
"port": 35421,
"worktree_path": "/abs/path/to/worktree",
"version": "0.2.0",
"version_supported": true,
"remote_capable": true,
"min_remote_version": "0.2.0",
"overlay_verified": true
}Failed instance — missing_dsh is present only when spawn failed because the dsh executable could not be resolved (instance failed); there is then no live upstream, so host / port / version come back empty and never sit next to a live version:
{
"iframe_src": "",
"proxy_host": "",
"proxy_port": "",
"host": "",
"port": "",
"worktree_path": "/abs/path/to/worktree",
"version": "",
"version_supported": false,
"remote_capable": false,
"min_remote_version": "0.2.0",
"overlay_verified": false,
"missing_dsh": {"npm_available": true, "suggested_pin": "0.2.0-rc.2"}
}host/portare the upstreamdsh webserver's bound address;proxy_host/proxy_portare the per-instance myworktree reverse-proxy listener (a dedicated loopback origin — the dsh SPA hardcodes its API base tolocation.origin + '/api', so same-origin subpath mounting is not possible).iframe_srcis what the iframe loads (plainhttp://127.0.0.1:<proxyPort>/locally).- Remote access: when the main listener is non-loopback or TLS, the proxy binds the main listener's host with a mandatory token gate —
?token=or themw_tokencookie, validated on every non-loopback client request including WebSocket upgrades (loopback clients bypass the gate, the same trust model as the main UI). The embed is a dedicated origin, so the main-origin HttpOnlymw_tokencookie cannot travel to it — the server appends?token=toiframe_srcitself (page JS can never read the HttpOnly cookie, and portal/login flows carry no address-bar token); the frontend only falls back to an address-bar token for a src that somehow lacks one. The proxy validates the first navigation, sets the HttpOnlymw_tokencookie on the proxy origin, and 302-redirects to the token-free URL so the embedded document never retains the token in its ownlocation.search. The token is stripped (authq.StripToken) before anything is forwarded upstream (seedocs/ARCHITECTURE.md§9). versionis the installeddsh --versionprobed at spawn — the parsed core (x.y.z; npx mode records the raw pinned version instead);version_supportedisfalsewhen it is outside the[0.2.0, 0.3.0)range the restrict overlay and the slash-style RPC wire target (advisory — the instance still starts; the hard gate below0.2.0blocks startup — 0.1.x is out of support).overlay_verified(L2 check) isfalsewhen a spawn-timedsh web --dump-config --patch <restrict.yml>run did not confirm the four overlay rows (storage-jsonroot redirect,directory-pickercomposer disabled,directory-picker-browsehost backend inserted and not disabled,client-hmrdisabled) — the frontend then shows the "裁剪失效" (restriction not effective) warning.missing_dshbelongs to the failed instance only (second example above): it is present only when thedshexecutable was not found at spawn (instancefailed,host/port/versionempty — never alongside a liveversion);npm_availabletells the frontend whether the install option is offered,suggested_pinis the pinned npx version.- Design:
docs/plans/dsh-native-ui/FEASIBILITY.md; threat model:docs/ARCHITECTURE.md§9.
GET /api/instances/dsh/scope?id=<id>
Returns the last observed out-of-scope state for a dsh-web instance, recorded in-memory by the per-instance reverse proxy from RPC request bodies (dsh 0.2.x slash-style endpoints — session/create with payload.args.request.{cwd|workspaceId}, workspace/create with payload.args.request.path). The frontend polls it (~1.5s) to render the persistent warning bar.
Response (200):
{
"scope": "out-of-scope",
"directory": "/abs/path/to/other/worktree",
"at": 1753500000,
"foreign_active_sessions": ["session-6f1a…"]
}scope is in-scope / out-of-scope. Observation is record-only — the request is forwarded unchanged; out-of-scope sessions still succeed (their sandbox root is the out-of-scope directory; OS-level write limits still apply) and the warning stays until the user navigates back to the worktree.
foreign_active_sessions (omitempty) lists the sessions in the shared $DSH_HOME/sessions pool that the daemon's session watch classified as actively written by another dsh process (mtime within 90s, excluding sessions this daemon itself drives — own traffic is attributed from session/*-family RPC bodies through the proxy (session/prompt, session/cancel, session/fork, session/rename, session/selectModel, session/attachment, session/updateQueue, plus the plural-namespaced subagents/prompt — ids read from payload.args.request), session/create responses, and the workspace-bootstrap preseed). dsh is a single-writer-per-process system: opening such a session from the embed appends an unguarded session/end-seed and can permanently corrupt the log, so the frontend renders a warning bar telling the user to wait until the session is idle (record-only — nothing is blocked; see docs/plans/dsh-native-ui/CROSS-PROCESS-SESSION.md).
POST /api/instances/dsh/launch
Selects how a dsh-web instance resolves the dsh executable. Persisted per worktree at <DataDir>/dsh/<worktreeHash>/launch.json (survives myworktree restarts; keyed by worktree — not instance — because every Start/Restart allocates a fresh instance id and wipes the old per-instance state dir, which would lose the choice exactly when the user needs it: failed instance → choose npx → restart). Body:
{ "id": "<instance-id>", "mode": "npx" }mode is one of:
"path"(default) — resolvedshviaexec.LookPathat every Start"npx"— spawnnpx --yes @deepseek-ai/dsh@<pin> web --port 0 ...(pinned version, no interactive prompt; the process is started withSetpgidand stopped by killing the whole process group — npx is the parent of dsh, killing only the npx PID would orphan the server)"install"— runnpm install -g @deepseek-ai/dsh(see §5.17), then resolve the global bin vianpm prefix -gand spawn that absolute path
Returns 404 for unknown / non-dsh-web instances; 400 for an invalid mode. Used by the missing-dependency dialog (three options: npx launch / install now / cancel) when an instance is failed with missing_dsh reported by §5.14.
POST /api/instances/dsh/install
Runs npm install -g @deepseek-ai/dsh on behalf of the user (bounded timeout, serialized server-side — concurrent requests queue behind one install). Body: { "id": "<instance-id>" }. On success the driver resolves the global bin directory via npm prefix -g and records the absolute executable path in the worktree's launch.json (mode: "install"), so the next Start spawns it directly — no daemon restart needed (kinds re-read os.Environ() at every Start; no PATH cache). Response includes the resolved bin path. Returns 404 for unknown / non-dsh-web instances, 502 when npm is not available or the install fails (failure output is redacted and truncated). The frontend requires a two-step confirm before calling it (the install rewrites the global npm prefix).
GET /api/mcp/tools
Response:
{ "tools": ["worktree_list", "worktree_create", "..."] }POST /api/mcp/call
Body:
{ "tool": "instance_list", "args": {} }Response:
{ "result": { "instances": [] } }Supported tool names:
worktree_list,worktree_create,worktree_deletebranch_list,tag_listinstance_list,instance_start,instance_stop,instance_input,instance_delete,instance_log_tail
Args: { "id": "<instanceId>", "n": 65536 } — n is the max bytes to return (defaults to 4096 when absent or non-positive).
Returns the newest n bytes of the instance's ring buffer (tail semantics, same bytes as GET /api/instances/log without since), not the oldest. Instances that are stopped, exited, or unknown return an empty string, since the buffer is released with the running instance. Instances whose kind does not capture logs (reasonix, opencode-web, dsh-web) also return an empty string.
The Portal dashboard provides a shared entry point for discovering and accessing all running instances across repos.
Base URL: http://<host>:<portal-port>/ (default portal port: 12345).
Auth model: Portal uses mw_token HttpOnly Cookie for authentication. The token is obtained via the CSRF-protected /api/auth endpoint. Once authenticated, the Cookie is automatically sent by the browser on all subsequent requests. Cookie has 24-hour sliding expiration (refreshed on each successful auth request).
CSRF protection: /api/auth and /api/logout endpoints use double-submit cookie pattern. Client must fetch a CSRF token from /api/csrf-token, then include it in the request body. CSRF tokens are single-use with a 5-minute TTL.
GET /
Returns the embedded Portal dashboard HTML page (no authentication required).
Response headers:
Content-Security-Policy: default-src 'self'; script-src 'sha256-<hash>' ...; style-src 'self' 'sha256-<hash>' ...X-Content-Type-Options: nosniff
GET /api/csrf-token
Returns a new single-use CSRF token and sets mw_csrf Cookie.
Rate limit: 1 request per second per IP.
Response:
{ "csrf_token": "<64-char-hex>" }Sets Cookie: mw_csrf=<token>; Path=/; SameSite=Strict (non-HttpOnly — JS must read it for CSRF double-submit).
POST /api/auth
Authenticates with the global auth token. Requires valid CSRF token.
Body:
{ "token": "<auth-token>", "csrf_token": "<csrf-token>" }Rate limit: 20 attempts per minute per IP.
Success (200): Sets mw_token HttpOnly Cookie (Max-Age=86400, SameSite=Lax) and returns:
{ "status": "ok" }Errors:
400: Auth token not configured on server ({"error":"auth token not configured on server"})401: Invalid token403: CSRF token invalid/expired/used429: Rate limit exceeded
GET /api/list
Authentication required (Cookie mw_token or Bearer token).
Returns JSON with all running instances and Portal status. Each successful request refreshes the mw_token Cookie's expiration (sliding).
Response:
{
"is_portal": true,
"portal_port": 12345,
"processes": [
{
"instance_id": "12345-1710000000-a1b2c3",
"pid": 12345,
"port": 50053,
"host": "0.0.0.0",
"repo_name": "myproject",
"repo_hash": "a1b2c3d4e5f6",
"started_at": "2024-03-10T12:00:00Z",
"alive": true
}
]
}is_portal: whether the current process holds the Portal portportal_port: Portal port numberalive: determined by PID liveness and TCP port reachability
GET /api/portal-status
No authentication required. Returns whether the current instance holds the Portal port.
Response:
{ "is_portal": true }POST /api/logout
CSRF required. Clears the mw_token Cookie.
Body:
{ "csrf_token": "<csrf-token>" }Response (200):
{ "status": "ok" }Always returns 200 (idempotent — successful even if not logged in).
Errors:
403: CSRF token invalid/expired/used or missing
ANY /s/<repo-hash>/*
Status: Planned but not yet implemented. Currently the Portal dashboard links directly to instance ports (
http://<host>:<port>/) instead of using the reverse proxy path. The/s/<repo-hash>/*route handler is not registered in the Portal HTTP server.
Authentication required (Cookie mw_token or Bearer token). Each successful request refreshes the mw_token Cookie's expiration (sliding).
Proxies the request to the corresponding instance at http://127.0.0.1:<port>. Since the proxy connects via loopback, the instance's auth middleware automatically bypasses token validation.
Security:
repo-hashformat validation: only[a-f0-9]+(lowercase hex) accepted; path traversal characters (..,/,\) rejected with 400- WebSocket upgrade is automatically handled by the reverse proxy (Go's
httputil.ReverseProxynatively supports WebSocket hijacking)
Errors:
400: Invalidrepo-hashformat401: Not authenticated502: Target instance offline
GET /login, POST /login
No authentication required — this is the login page itself.
The instance server serves an HTML login form at /login for browser-based authentication (separate from the Portal JSON API). Non-loopback browser requests that lack valid auth are redirected to this page.
GET /login— Returns an HTML login page with password input and form. If the user already has a validmw_tokenCookie that matches the global auth config, they are redirected to/immediately.POST /login— Acceptstokenand optionalnextform fields. On successful auth, setsmw_tokenHttpOnly Cookie (Max-Age=86400, SameSite=Lax, Secure on HTTPS) and redirects to thenextpath (or/if not provided). Note:/loginis explicitly exempt fromwithAuthmiddleware rate limiting; no per-IP rate limits apply to this endpoint.
Errors:
401: Invalid token
GET /api/llm/config
返回当前 LLM 配置(不包含明文 API Key):
{
"protocol": "openai",
"api_address": "<provider_api_address>",
"api_key_masked": "<masked_api_key>",
"model": "<model_name>",
"reasoning_split": false,
"is_secure": true,
"available": true
}protocol:"openai"|"anthropic"api_address: API 地址(需要包含完整路径如/v1/chat/completions)api_key_masked: API Key 脱敏显示(仅显示前 3 字符 +***+ 后 3 字符)model: 当前使用的模型名称reasoning_split: 是否启用思考分离(部分 provider 支持)is_secure: 当前是否为 localhost 或 HTTPS 环境(影响 LLM Settings 按钮可见性)available: LLM 是否可用(protocol、API Key、API Address、Model 四项全部已配置)
PATCH /api/llm/config
Body:
{ "protocol": "openai", "api_address": "<provider_api_address>", "api_key": "<api_key>", "model": "<model_name>", "reasoning_split": false }环境变量 OPENAI_API_KEY / ANTHROPIC_API_KEY 优先级更高。
Response (200):
{ "status": "ok", "protocol": "openai" }POST /api/llm/test
测试当前 LLM 配置是否有效(发送一个简单的 test 分支名请求)。 用于用户在配置后验证 API Key 是否正确。
Response (200):
{ "status": "ok", "branch_name": "<branch_name>" }Response (400):
{ "error": "no LLM configured" }{ "error": "invalid API key or network error" }POST /api/llm/generate
根据任务描述调用 LLM 生成分支名。
Body:
{ "task_description": "fix the login timeout issue" }Response (200):
{ "branch_name": "fix/login-timeout" }Response (400):
{ "error": "no LLM protocol configured" }Response (500):
{ "error": "generation failed: HTTP error: status 401" }