Commit Graph

83 Commits

Author SHA1 Message Date
Yaojia Wang
217206e9a9 Merge android-blocker-fixes: make the Android client work on a device
The client reported COMPLETE with 617 green tests and a buildable APK, but had
never run on hardware, and the first interaction on a device crashed it. Seven
real defects, four of them crashes:

  - every key press NPE'd (no TerminalViewClient was ever installed)
  - the PTY never learned the real grid (no resize was ever sent)
  - bare-LAN ws:// could not connect (a STUB network-security-config)
  - swipe-scrolling inside an alternate-screen TUI NPE'd, i.e. during ordinary
    use of Claude Code, which is one
  - any password manager crashed the app (unguarded autofill)
  - onDraw tore the terminal buffer and killed the process — the defect the docs
    had explicitly classified as 'Accepted (not a defect)', and which was dormant
    only because resize never happened
  - text selection's Copy button was an NPE, so copy-out was cut rather than
    shipped broken

Also: the queue frame and status.preview were silently dropped (remote approvals
were blind), the v0.6 git panel was unrendered, ThumbnailPipeline/QuickReply/
PointerContextMenu were tested dead code with no call sites, the WEBTERM_TOKEN
gate was inert, release builds emitted unsigned APKs, and A34/A35 had no code at
all despite being marked done.

Then the audit's own leftovers: silent certificate rotation (which surfaced a bug
that would have failed every renewal, plus a latent iOS defect Android now
avoids), /device/:id/recover, the W2 inject-queue routes, unread-watermark
persistence, host removal, and /config/ui finally honoured fail-closed.

617 -> 1150 JVM tests, plus 67 instrumented tests that actually execute on an
emulator against this repo's own server. The device crashes are reproduced as
JVM tests where possible, so they are guarded without hardware.

Still open and recorded, not papered over: S2 needs two physical handsets under
Doze; the rest of DEVICE_QA_CHECKLIST has no automated cover.

# Conflicts:
#	docs/PROGRESS_LOG.md
2026-07-30 16:09:22 +02:00
Yaojia Wang
14f28e9d66 docs(android): record what the device run proved and what is still open
The correction section written earlier today said A34/A35 had no code and
device QA was ~0%. Both were true when written and are not any more, so they
are marked resolved rather than left to mislead the next reader — with the
instrumented invocation recorded (the emulator needs
ALLOWED_ORIGINS=http://10.0.2.2:<port>, because allowed origins are derived
from NIC IPs and 10.0.2.2 is not one).

What stays open is stated plainly: S2 needs two physical handsets under Doze,
the rest of DEVICE_QA_CHECKLIST has no automated cover, and one instrumented
test kills the process when driven without host arguments on a heavily-recycled
emulator while passing reproducibly under the documented invocation — which is
recorded as unresolved rather than explained away.
2026-07-30 15:38:10 +02:00
Yaojia Wang
53bee034ca docs(progress): log the desktop USE_TMUX default fix 2026-07-30 12:48:02 +02:00
Yaojia Wang
3ad8d8beba docs(progress): log the round-2 tmux target prefix-matching hole + dual clocks 2026-07-30 11:48:32 +02:00
Yaojia Wang
2bfc76b397 docs(progress): log the orphan tmux session feature + the session_activity clock bug 2026-07-30 11:30:24 +02:00
Yaojia Wang
980dbaa928 feat(android): wire the dead code, render the git panel, make the token gate real
Four agents' worth of :app wiring. Everything here existed as tested code with
zero production call sites, or as a screen rendering a shape the server stopped
sending months ago.

Dead code that shipped as features. All three were fully implemented and
unit-tested while being reachable from nothing:

  - Session-list preview thumbnails. SessionsHome called SessionListScreen
    without the `thumbnails` argument, so the `= null` default won and every
    row rendered a placeholder forever — on the home session chooser, whose
    entire purpose is seeing what each session is doing.
  - Quick-reply chips and their palette editor.
  - PointerContextMenu (large-screen secondary click).

ThumbnailPipeline's formal cross-review had been explicitly skipped when it was
written — PROGRESS_ANDROID.md said so in as many words, and it is concurrency
code, so nothing had ever reviewed OR executed it. It has now been reviewed and
kept: the LruCache key, the fair Semaphore(2), the Mutex-guarded in-flight
dedup and the NonCancellable in-flight release are all sound, and a retained
map entry would have poisoned a key forever.

The v0.6 project-detail git panel. Android rendered the pre-w6 shape: one bare
dirty dot, a bare hash+subject commit row, and a worktree heading that renamed
itself to "Branch" at n=1 — the exact behaviour G5 removed. Now the sync band,
unpushed marking with a single upstream boundary, and "Worktrees (n)". The rule
that drives it is enforced, not documented: exactly one state may render green,
and no-upstream is not that state.

Remote approvals are no longer blind. status.preview reaches GateBanner and
PlanGateSheet, so the user can see the command or diff they are approving
instead of a tool name. Absent preview stays an ordinary gate, not an error.

The WEBTERM_TOKEN gate is no longer inert. NetworkModule was calling
OkHttpClientFactory.create(identityProvider) with no cookieJar, so the shared
client ran on CookieJar.NO_COOKIES and none of the cookie plumbing did
anything. It now installs the real AuthCookieJar — one client, so the cookie
rides the WS upgrade too — backed by the Tink-sealed store, and binds
InMemoryAuthCookieStore if a cipher cannot be constructed rather than ever
persisting a shell credential in the clear. The persister runs on an IO scope
because OkHttp calls a CookieJar synchronously and must never block on
DataStore or Tink.

And the user-facing half that made the gate unusable: a token-gated host
answers the pairing probe's unauthenticated GET with 401, which the frozen
taxonomy could only report as "this port is running something else" — so such
a host was impossible to pair AND the copy was misleading. Pairing now routes
to a token prompt and resumes through the same cert-gate choke point. The
token is a parameter, never a field and never part of any UI state.

Docs corrected rather than left aspirational: the plan asserted a cleartext
allowlist "permitted only for private LAN/Tailscale CIDRs", which the platform
cannot express at all — the network-security-config format has no netmask or
prefix syntax. PROGRESS_ANDROID.md now opens with what "COMPLETE" actually
meant, and PROGRESS_LOG.md records the whole pass including what is still
undone: A34/A35 have no code, and device QA remains ~0%.

Verified: ./gradlew test :app:assembleDebug :app:testDebugUnitTest koverVerify
-> BUILD SUCCESSFUL, 893 JVM tests, 0 failures (baseline was 617). The :app
run was re-measured with outputs deleted and --no-build-cache, and the APK
re-packaged, so Hilt graph validity is an observation rather than an
up-to-date claim.
2026-07-30 11:23:23 +02:00
Yaojia Wang
029bec831a docs(progress): log the design-fidelity pass + the stale-desktop-bundle diagnosis 2026-07-30 07:59:56 +02:00
Yaojia Wang
8fe1f52e5d feat(projects): show real git state on the project detail page
The page carried one bare `●` for "dirty" and nothing else, so "do I have
commits I haven't pushed" and "which worktree am I in" still meant dropping
into a terminal. Design mock: docs/mockups/project-detail-git.html; plan and
task breakdown (G1-G7): docs/plans/w6-project-git-panel.md.

One rule drives the whole feature: ahead/behind compare against `@{u}`, a
LOCALLY CACHED remote ref that only a fetch moves. This repo was the live
example while building — `↑9` true, `↓0` false, because FETCH_HEAD had not
moved in 19 days. So:

  - `ahead` needs only local refs and is never flagged.
  - `behind` is flagged `stale` once FETCH_HEAD is older than an hour.
  - Exactly ONE state may render green: ↑0 ↓0 AND a fresh fetch. Green means
    "I checked, ignore this"; getting it wrong is lying to the user.
  - No upstream (the normal state of a fresh worktree branch) leaves ahead and
    behind undefined — it renders an explicit `no upstream`, never the green
    path. That fall-through is the easiest bug to ship here.

What landed:

G1  SyncState (upstream/ahead/behind/lastFetchMs/detached) + ProjectDetail.sync
    and .dirtyCount. All additive and optional — the Android and iOS clients
    decode these shapes. The ahead/behind helper already existed for the list
    view; buildProjectDetail had simply never called it.
    Fixes a pre-existing bug on the way: readBranch read <repo>/.git/HEAD
    directly, so it returned nothing inside a LINKED worktree, where .git is a
    file. resolveGitDirs now resolves both the per-worktree gitdir (HEAD) and
    the shared common dir (FETCH_HEAD).

G2  POST /projects/git/fetch. Same discipline as push: the remote is derived
    server-side and no remote or refspec is ever read from the body, so a
    client cannot aim it at an arbitrary URL. Touches refs/remotes only — no
    working tree, no index, no merge; it is not a pull. Own rate-limit bucket
    so refreshes cannot eat the budget a real push needs. On failure
    lastFetchMs is left alone, so the UI keeps saying "stale" instead of
    pretending it refreshed.

G3  makeSyncBand replaces the bare dot: upstream name, ↑n, ↓n, stale flag,
    dirty count, Fetch button (disabled on a detached HEAD).

G4  The commit list marks unpushed commits and draws the upstream boundary
    once, after the last of them. Marking is server-side from `rev-list`,
    deliberately NOT "the first N rows": `git log` is date-ordered, so merging
    an older branch interleaves unpushed commits BELOW pushed ones, and that
    shortcut fails in the dangerous direction — calling an unpushed commit
    pushed. A regression test builds exactly that backdated-merge shape.

G5  The worktree section is always "Worktrees (n)" (it used to rename itself
    to "Branch" at n=1) and the current row carries its own state chips.

G6  Cost control. The plan called for a .git-mtime cache; that was dropped
    during implementation because a fingerprint over HEAD/index/reflog does
    NOT move when push updates a remote-tracking ref — the cached `ahead`
    would still claim "9 to push" right after a successful push, which is the
    exact lie the feature exists to prevent. Replaced with three measures that
    cannot go stale: in-flight coalescing (N devices watching one repo cost
    one probe, entry dropped as it settles, nothing cached across time),
    skipping the re-render when the payload is byte-identical (this also stops
    the 5 s re-mount of the commit log, two more git spawns per tick), and
    pausing the timer while the document is hidden.

G7  Per-worktree state via GET /projects/worktree/state, kept narrower than
    /projects/detail so N rows do not pay for worktree listing and CLAUDE.md
    reads nothing renders. Needed an unplanned prerequisite: ProjectSessionRef
    carried no cwd, so sessions could not be attributed to a worktree. Added
    it, plus countSessionsByWorktree, which matches DEEPEST-first because
    .claude/worktrees/<name> lives INSIDE the main checkout and prefix
    matching would count every worktree session against the parent repo too.

Out of scope, unchanged: no reset, no checkout, no clean, no rebase, no
force-push. stage/commit/push stay exactly as they were.

Verified: tsc and build clean; 46 new tests.
2026-07-29 17:12:00 +02:00
Yaojia Wang
553a00c32f docs(progress): log the three leftover fixes + live verification 2026-07-29 10:45:46 +02:00
Yaojia Wang
0970c623eb docs(progress): log the expired-leaf renewal deadlock fix + live self-heal proof 2026-07-29 10:08:58 +02:00
Yaojia Wang
b1bc50ccd1 fix(session): give the PTY a UTF-8 locale so tmux stops mangling CJK
A launchd/Finder-launched server has no LANG/LC_* at all, so tmux — which
decides per client whether the terminal is UTF-8 capable purely from those
variables — fell back to its non-UTF-8 mode and rewrote the byte stream
server-side, before it ever reached xterm:

  - every wide character became `_`  (中文测试 → ________)
  - `✓` / `⏺` / emoji became `_`
  - `═║╔╗` was downgraded to DEC Special Graphics (ESC ( 0), so rounded
    corners vanished and boxes rendered as loose horizontal lines

The `-l` login shell cannot fix this: tmux is the PTY's root process and has
already made its decision by the time the shell sources ~/.zprofile.

withUtf8Locale() fills a locale in on the spawn env, and only when needed —
an existing UTF-8 LC_ALL / LC_CTYPE / LANG (zh_CN.UTF-8, ja_JP.UTF-8, …) is
left untouched; only a missing or non-UTF-8 charset is replaced.

Existing tmux sessions do not need recreating: re-attaching with a UTF-8
client restores correct output (already-scrolled `_` stays mangled).
2026-07-29 08:09:48 +02:00
Yaojia Wang
1dbed54581 docs(progress): log the zero-touch enrollment + control-panel session
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
Host track live+verified (h7fd8, auto-renew), phone track server-verified + iOS/
Android code-complete, control-panel deployed (panel.terminal.yaojia.wang),
Android app run on emulator. ~14 real-deploy bugs fixed. Branch merged to develop.
2026-07-23 06:28:59 +02:00
Yaojia Wang
ca9eaa8f1f docs: mark all of Wave 5 done (fan-out, access token, android parity)
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
2026-07-13 05:59:29 +02:00
Yaojia Wang
a7eba2d43b docs(progress): log the Wave 1-4 roadmap batch (8 features, multi-agent plan+build+verify) 2026-07-12 22:10:57 +02:00
Yaojia Wang
733c8a8318 fix(grid): v3 review fixes — split-null crash, monitor race, drag-safe gutters
Adversarial review (4 lenses → per-finding verify) of v3 confirmed 6 issues:

- HIGH: splitForLayout({"grid-4":null}) threw a TypeError — null passed the
  `!== undefined` guard, then null.cols threw. Since splitForLayout runs on every
  grid render, one corrupt localStorage entry would brick the tab UI. Now guards
  `!== null && typeof === 'object'`.
- MED: a monitor toggled before the session finished attaching (id still null)
  showed the button active while the pane stayed live and sent a resize, and did
  not self-correct. onSessionId now reconciles a pending monitor once the id lands.
- MED: renderGutters destroyed + recreated handles on every applyLayout, dropping
  the drag listeners if a re-render fired mid-drag. A draggingGutter flag now skips
  the rebuild while dragging.
- LOW: grid-presets hardcoded the 1024px breakpoint → now uses GRID_MIN_WIDTH.

Regression tests added (null-split no-throw, attach reconcile, drag survives a
concurrent re-render). typecheck + build:web clean, 1615 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 20:25:04 +02:00
Yaojia Wang
5475b661ae feat(grid): split-grid v2 — 1×3/2×3, drag-to-quadrant, Ctrl+` cycle, maximize
Builds on the v1 watch board:
- Two more layouts: row-3 (1×3) and grid-6 (2×3). A single `grid` marker class on
  #term now carries the shared cell chrome so it no longer enumerates each lay-*.
- Ctrl+` cycles the focused quadrant (Ctrl+Shift+` reverses); matchFocusCycleKey
  is an exported pure helper so it's unit-tested. No-op / passthrough in single mode.
- Per-quadrant ⛶ maximize: the focused cell expands to fill the grid as an absolute
  overlay while siblings stay live behind it; follows focus, resets on layout
  change / tab close.
- Drag a tab from the tab bar onto a quadrant to assign it there (reuses the
  existing tab-drag dragIndex); grid-only.

Adversarial review (3 lenses → per-finding verify, incl. a headless-Chrome repro)
caught a HIGH: maximizing via `grid-column/row: 1/-1` shoved siblings into implicit
rows — a strip instead of fullscreen AND a spurious resize to backgrounded live
PTYs. Fixed with an absolute-overlay (`position:absolute; inset:0`), then verified
in real Chromium (Playwright): maximized cell fills #term, siblings 0px size delta.
Also: silence a covered pane's pending pulse under maximize; coarse-pointer target
for .cell-max. Verified: typecheck + build:web clean, 1579 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:49:39 +02:00
Yaojia Wang
06814ba276 feat(grid): desktop split-grid watch board — v1 (single/1×2/2×2)
When several sessions are open on a large screen (≥1024px), the terminal
area can split into 1×2 or 2×2 so multiple LIVE, interactive terminals show
at once — a monitoring convenience for vibe-coding several Claude sessions.

Design keeps activeIndex as the single focused pane, so keybar/voice/approval
routing is unchanged; a new gridLayout + derived visible-set lets several
.term-cell wrappers show together inside a CSS-grid #term. The server and WS
protocol are untouched.

- public/grid-layout.ts (new): layout types/capacity, visibleIndices,
  matchMedia desktop gate (GRID_MIN_WIDTH=1024), persistence, toolbar toggle.
- tabs.ts: pane→.term-cell wrapper (header + terminal + inline-approve footer);
  applyLayout() owns show/hide + grid class + cell order + placeholders;
  board-aware activate() (never focuses a hidden pane); setFocused/setGridLayout
  delegate to it; renderCell/renderInlineApprove; refitVisible; notification
  suppression for on-screen panes (factoring in the home overlay).
- terminal-session.ts: show({focus}) so non-focused quadrants don't steal
  keyboard focus; onFocus callback (capture-phase pointerdown).
- main.ts: mount the toggle; window-focus refit → refitVisible.
- style.css: cell/grid model (.term-pane → relative flex child), focus ring,
  pending pulse, inline approve, placeholder, toggle + coarse-pointer targets.
- tests: grid-layout.test.ts + split-grid block in tabs.test.ts (+33).

Adversarial review (4 lenses → per-finding verify) caught and fixed a HIGH:
activate() was not board-aware, so opening a tab on a full grid focused a
hidden pane (typing into an invisible session). Verified: typecheck + build:web
clean, 1566 tests pass, grid-layout 95% / tabs 94% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:19:53 +02:00
Yaojia Wang
e7f3bd05f0 feat(tunnel): zero-touch tunnel enrollment — control-plane PKI, host agent, iOS, nginx isolation
Customers install one command / log in once; hardware-generated keys never leave the
device; CSRs return certs + subdomain; frpc + base-app run as durable services. No .p12,
no manual cert import. Implements the MVP fast-path of docs/PLAN_TUNNEL_AUTOMATION.md.

Control-plane / PKI (control-plane/):
- ca/x509-assembler.ts: single KMS-signed real X.509 issuance primitive (Ed25519 + P-256)
- ca/csr-ec.ts: P-256 PKCS#10 proof-of-possession (verifyCsrPoPEc) + CSR-key routing
- ca/frpclient-issue.ts, ca/device-issue.ts: P-256 frp-client + device leaf signers
- ca/rotate.ts + api/renew.ts: real-X.509 /renew + /device/:id/renew (mTLS current cert)
- registry/devices.ts: device registry + per-account cap/rate-limit
- auth/session.ts: device:enroll capability token mint/verify
- api/device-enroll.ts: POST /device/enroll (ownership-gated, deny-by-default)
- pairing/native-redeem.ts + shared gateAndConsumePairingCode; api/provision.ts native arm
- boot/native-ca.ts + main.ts: wire two P-256 CAs + issuers + routers (dev / KMS fail-fast)

Contracts: relay-contracts enroll right; relay-auth SPIFFE /device/ arm + spiffeIdFor(kind)

Host agent (agent/):
- transport/frpcToml.ts; provision/frpcBinary.ts + untar.ts (verify-download + traversal-safe extract)
- keys P-256 keygen/CSR/loadIdentity; service two-unit install + BIND_HOST loopback S-GATE
- net/loopbackLiteral.ts strict guard; health/probe.ts + transport/frpSupervise.ts; cli pair --install

iOS (ios/Packages/ClientTLS): SecureEnclaveKey + CertificateSigningRequest + DeviceEnrollmentClient
+ Keychain enroll refactor (SecKey/Security.framework end-to-end, avoids the -25300 trap)

Isolation (deploy/nginx): njs/getCertSub.js SAN parser + zone-anchored map -> 403

Verified: 758 tests green (control-plane 246, agent 267, relay-auth 133, relay-contracts 85,
iOS ClientTLS 27), all tsc clean; real nginx+njs docker 403/200/400; Swift CSR accepted by
the real control-plane verifier; frpc extract byte-identical to `tar -xO`. Cross-validation
caught + fixed 5 real defects (1 critical, 4 high). Remaining = infra (KMS, nginx deploy,
VPS frps, physical iPhone) per PROGRESS_LOG runbook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:11:13 +02:00
Yaojia Wang
7b3fe1b124 docs(progress): log Android client AW0+AW1 pure-Kotlin foundation (218 tests green) 2026-07-08 13:45:54 +02:00
Yaojia Wang
a24465623e docs: LE wildcard cert live — native tunnel M2-ready (public trust verified, no -k) 2026-07-07 20:57:27 +02:00
Yaojia Wang
a25633a63b docs: native tunnel M1 achieved on VPS (frps + device-CA mTLS + nginx :8470 + SNI merge) 2026-07-07 20:28:27 +02:00
Yaojia Wang
cb04516d52 docs: log native mTLS reverse-tunnel — four tracks landed + two HIGH fixups 2026-07-07 09:43:35 +02:00
Yaojia Wang
bfe1be1dfe feat(relay): B7 — close browser DPoP-subprotocol loop + harden staging mint
- Read DPoP proof from term.dpop.<b64u> WS subprotocol (browsers can't set WS headers);
  header wins, else subprotocol. Fail-closed decoder. Unblocks real browser connect.
- F1: rate-limit /auth/mint per-IP via Redis token bucket (salted-hash key, 429 on burst,
  before password compare).
- F2: wire real per-tenant active WS count into activeSessionCount (was hardcoded 0).
- F5: scrub error logs to e.message/.code (no DSN leak, INV9).
relay-run: tsc clean, 92 tests pass (+18). F3/F4 -> Phase 2 backlog.
2026-07-06 16:26:05 +02:00
Yaojia Wang
aa1912b962 feat(relay): Phase1 waves A2-E — server entry, shared-store data plane, agent runtime, deploy artifacts
RELAY-PHASE1 Wave A2/B/C/D/E (12-agent workflow, all tsc-clean, 314/314 tests pass):
- A2: control-plane server.ts entry + boot/redis.ts revocation-bus wiring + start script.
- B1: relay-run shared-store EnforceDeps (relay-auth ports over the SAME Postgres+Redis as P3).
- B2: registry-backed MtlsVerifier (verifyAgentCert, fail-closed, INV14).
- B3: store-backed RouteResolver (subdomain->hostId).
- B4: Redis relay:revocations subscriber -> tunnel teardown (INV12).
- B5: main-phase1.ts production entry (public bind, real TLS, async-mTLS prefetch bridge) + staging /auth/mint.
- B6 (PARTIAL): relay-web operator login + browser DPoP; proof offered via term.dpop.<b64u> subprotocol.
- C: agent dist/cli.js build (esbuild) + runTunnel run-loop + CliDeps.
- D1: same-origin static serve of relay-web/public from the browser WSS.
- E: systemd units + gen-ca/gen-capability-key/issue-tls-cert scripts + deploy/RUNBOOK.md.
Adversarial review: all hard invariants PASS. Follow-ups (B7): close DPoP-subprotocol read on
browser-server (blocks browser connect); rate-limit /auth/mint (F1); wire activeSessionCount (F2);
scrub error logs (F5). Excludes unrelated public/style.css (concurrent iOS job).
2026-07-06 16:13:34 +02:00
Yaojia Wang
95b9cccf07 feat(relay): Phase1 foundation — P3 Postgres store + async capability verifier + compose
RELAY-PHASE1 Wave A (2/3) + E1 infra:
- A1: createPgStores() Postgres adapter for all 9 P3 store ports + runMigrations();
  writable-CTE atomic INV8 status swaps; 0002_routes.sql. 23/23 pg tests, 96.72% cov.
- A3: real capability verifier delegating to relay-auth verifyCapabilityToken; sync->async
  seam across authz/provision/main. Full CP suite 15 files/101 pass, tsc clean.
- E1: deploy/docker-compose.yml (Postgres16+Redis7, loopback-only) + .env.example.
- docs: PLAN_RELAY_PHASE1.md file-level execution spec; PROGRESS_LOG RELAY-PHASE1 section.
2026-07-06 14:51:37 +02:00
Yaojia Wang
242a4e0dc1 docs: log Orbit icon rollout across iOS/desktop/tray/web + desktop repackage
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
2026-07-06 11:54:48 +02:00
Yaojia Wang
3492f0cf1f fix(projects): drop parent folders; attribute sessions to the deepest project
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
The projects panel listed bare parent folders (~, ~/Documents) as projects and
a single session lit up every ancestor project's 'Active now'. dropParentFolders
filters non-git entries that merely contain other listed projects; assignSessions
attributes each live session to the DEEPEST containing project (detail page keeps
prefix matching). +3 tests; projects 29/29, tsc clean. Frontend unchanged.
2026-07-06 11:00:45 +02:00
Yaojia Wang
c44725618c docs(ios): log comprehensive UX/UI polish (design system, 8.5/10, 290 tests both sims) 2026-07-05 22:01:27 +02:00
Yaojia Wang
823432b1c8 feat(ipad): W1-W3 — adaptive split-view layout + finding fixes
T-iPad-2: AdaptiveRootView/LayoutPolicy (sole size-class decision), SplitRootView
(NavigationSplitView sidebar+detail), StackRootView (iPhone path verbatim, zero
regression); privacy shade hoisted to shared ZStack top for both branches
T-iPad-3: KeyBarVisibility predicate (hide when hardware keyboard present),
pointer context menu (copy/new-in-cwd/kill, all via existing channels)
T-iPad-4: Projects multi-column grid on iPad (idiom-gated), adaptive sheet detents
T-iPad-5 findings (4/4 fixed): kill onKillSession thread-through +
AppCoordinator.killCurrentSession; split route-gated to .sessions (iPad first-run
pairing); continue-last banner in split sidebar; iPad XCUITest deferred (covered
by SidebarSelectionTests)
Verified: iPhone 16 277 + iPad Pro 11 278 tests green; packages 261 + integration 10;
zero changes under ios/Packages, src/, public/
2026-07-05 19:58:30 +02:00
Yaojia Wang
9c0097a305 docs(ios): P1 complete — T-iOS-30 acceptance + security re-audit both PASS (zero findings)
Part A: 261 pkg + 247 app + 10 integration re-run green; boot-crash fix re-verified (3 launches,
0 crashes, permission prompt reached); simctl push renders minimized WEBTERM_GATE banner; F-iOS-15
server-slice (distinct lastOutputAt) executed; old-server compat degradation verified against the
user's live 127.0.0.1:3000 (no unread dot, stuck red dot, correct meta)
Part B: APNs payload minimization holds; capability token clean end-to-end (validate→use→never
persisted→single-use→zero in logs); deep-link 50+ fuzz all safe; lock-screen .authenticationRequired
green; prefs empty-base guard intact; P1-B attack surface swept clean
P1 total: 518 automated checks + server 1470 green; server changed only at the 2 declared §0.3
touch-points. Real-device/APNs items DEFERRED (need device + paid account)
2026-07-05 16:42:50 +02:00
Yaojia Wang
f40b8f9400 feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests
T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand
T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly)
T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner)
T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state),
prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap
T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize
T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller
SwiftTerm view bug via .id(controller.id)
T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp)
T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) +
NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback)
CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited
closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports,
permission prompt reached, privacy shade correctly covering during system alert)
Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
2026-07-05 16:15:57 +02:00
Yaojia Wang
4871e8ac3d feat(ios): P1-A — server touch-points (lastOutputAt, APNs sender+token endpoint) + APIClient P1 contract
T-iOS-37: LiveSessionInfo.lastOutputAt additive-optional field + manager.list() mapping (+4 tests; web consumers unaffected)
T-iOS-20: src/push/apns.ts — env-gated (all-or-disabled, no crash, zero key-material logging), hand-rolled ES256 JWT
(node:crypto ieee-p1363, zero new deps), NEEDS-INPUT/DONE payloads with structural minimization, token store per
subscription-store conventions, combineNotifyServices parallel to web-push, POST/DELETE /push/apns-token per frozen
wire shape (G-guard, 5/min/IP, 8kb); 65 tests incl. dual-channel e2e vs local fake APNs
T-iOS-38: APIClient builders — apns-token (client-side hex mirror, pre-network reject), projects/detail (lossy decode,
single-point percent-encoding), prefs (unknown-key byte-exact round-trip preservation), public four-tier HostNetworkTier
Coordination: webterminal:// CFBundleURLTypes pre-registered in project.yml for T-iOS-22
Verified: root 1470 tests + tsc clean; APIClient 76 tests, 95.26% coverage; wire-shape cross-check zero mismatches
2026-07-05 13:34:01 +02:00
Yaojia Wang
aa956fcbb4 feat(ios): W5 acceptance + finding fixes — XCUITest happy path, CI legs, privacy/device-family hardening
T-iOS-18 (F-iOS-1..13 walkthrough) + T-iOS-19 (security audit vs built artifacts): both
PASS_WITH_FINDINGS, 0 CRITICAL/HIGH; real-device items DEFERRED with manual checklists.
Fixes (1 MED + 3 LOW, all closed):
- WebTermUITests: the single scripted happy path (manual-entry pair → list → attach →
  KeyBar ^L → injected held gate via POST /hook/permission → tap Approve → server-side
  behavior==allow assertion). Green twice locally (fresh + already-paired branches).
- ios.yml: ui-test leg (server boot + TEST_RUNNER_ env as PROCESS env) + iOS17-floor leg
  (loud-skip if runtime absent, hard-fail if present and red); dropped CODE_SIGNING_ALLOWED=NO
  from app-tests leg (unsigned hosts get -34018 from the real keychain — measured)
- URLSessionHTTPTransport defaults to .ephemeral (no disk cache of preview terminal bytes)
- TARGETED_DEVICE_FAMILY moved to target level (built plist now UIDeviceFamily [1] only)
Final: 306 automated checks green (214 pkg + 76 app + 10 integration + XCUITest); server src/
untouched; P0 complete (19/19 tasks)
2026-07-05 02:16:52 +02:00
Yaojia Wang
cc4d3129cc feat(ios): W4 app wiring — DI graph, event fan-out, lifecycle, privacy shade
T-iOS-15: AppEnvironment production DI (real Keychain/probe/transports), EventFanOut
(engine.events → TerminalVM + GateVM + activity bridge), scenePhase lifecycle
(.background→close, suspend→rebuild+reopen reclaims size), privacy shade on != .active,
spike screen deleted, cold-start routing
Walkthrough proxies: hosted live-server smoke over the production DI graph (probe→store→
attach→echo→close) + real-Keychain kSecAttrAccessible assertion (closes T-iOS-7 deferral)
Deviation closed: TerminalViewModel.lastSentDims (valid-only) + alive-engine .active →
notifyForegrounded(dims:) (orchestrator fix on behalf of T-iOS-11 owner)
Env finding: repo under ~/Documents → TCC blocks sim-spawned node; SimServerHarness dual-mode
(self-bootstrap / WEBTERM_SERVER_URL via simctl launchctl setenv)
Verified: 214 pkg + 76 app + 10 integration tests green; verify agent 8/8 PASS
2026-07-05 01:04:42 +02:00
Yaojia Wang
5098643355 feat(ios): W3 UI layer + integration CI + ntfy docs
T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM)
T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling
T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field)
T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics
T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed)
T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites)
Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
2026-07-05 00:13:14 +02:00
Yaojia Wang
a2b14ab6e7 feat(ios): W2 connection core — URLSessionTermTransport + SessionEngine actor
T-iOS-9: TermTransport impl (Origin single-source, 16MiB from Tunables, re-arm loop,
EMSGSIZE(40)/ENOBUFS(55)→typed replayTooLarge, delegate-driven state, RFC6455 scripted test server), 11 tests
T-iOS-10: SessionEngine actor (attach-first ordering, generation guard, terminal states never backoff,
exactly-once digest, gate canDecide second line, defensive input validation), 20 tests
Verified: 209 tests green across 5 packages; SessionCore own-src coverage 96.68%; 4/4 semantic invariants
audited in source; frozen contracts untouched
2026-07-04 22:55:56 +02:00
Yaojia Wang
95438cdc12 feat(ios): W1 leaf packages — ReconnectMachine/PingScheduler, GateState/AwayDigest, HostRegistry, APIClient+pairing probe
T-iOS-5: pure reconnect reducer (1s→30s cap, mirrors terminal-session.ts) + 25s PingScheduler, 16 tests
T-iOS-6: gate epoch tracker (rising-edge semantics, canDecide guard) + AwayDigest reducer, 25 tests
T-iOS-7: HostRegistry with SecItemShim keychain seam, 30 tests, 88.1% own-src coverage
T-iOS-8: APIClient (Origin iff-G invariant) + two-step pairing probe, 45 tests, 98.1% coverage
Contract ruling: probe returns Result<HostEndpoint,_> (Host{id,name} built by pairing VM) —
resolves frozen-contract contradiction reported via BLOCKED protocol; adds Tunables.pairingProbeTimeout(10s)
Verified: 178 tests green across 5 packages; coverage gates pass; zero Owns violations
2026-07-04 21:53:41 +02:00
Yaojia Wang
cbaa08daba feat(ios): W0 scaffold + day-1 spike + WireProtocol frozen contract + TestSupport doubles
T-iOS-1: ios/ XcodeGen project (iOS 17, Swift 6 strict concurrency, ATS per PLAN §5.2), 5 SPM package shells, CI skeleton
T-iOS-2: Origin spike vs real server — URLSessionWebSocketTask custom Origin CONFIRMED (no Starscream); 16MiB replay + EMSGSIZE(40) errno correction written back to plan
T-iOS-3: WireProtocol frozen contract, 59 tests, 100% line coverage, cross-impl vectors vs src/protocol.ts via tsx
T-iOS-4: FakeTransport/FakeClock/FakeHTTPTransport doubles
Verify: independent agent re-ran all acceptance — 6/6 PASS
2026-07-04 21:19:30 +02:00
Yaojia Wang
9b41ffa574 docs(ios): iOS client implementation plan (PLAN_IOS_CLIENT.md) + progress log entry 2026-07-04 20:23:25 +02:00
Yaojia Wang
0ad7c31549 docs(relay): security audit report + progress log for the relay findings
REVIEW_RELAY_SECURITY.md: two-round multi-agent audit — F1–F6 with fix plan/
resolution, refuted-but-recorded items, and the e2e harness coverage table.
PROGRESS_LOG.md: cross-session record of the audit + fixes + harness.
2026-07-02 16:41:19 +02:00
Yaojia Wang
cf8cfccab4 feat(desktop): Electron all-in-one desktop shell (Mac/Windows) embedding the server
Add a `desktop/` Electron app that embeds the existing Node server + node-pty
(all-in-one): the window loads http://127.0.0.1:<port>/ and reuses the frontend
unchanged; other LAN devices can still connect. The server needs zero changes —
startServer(cfg)/loadConfig already support programmatic embedding.

- Pure, unit-tested modules: port, shell, server-config, deep-link, notify-policy,
  notifications, live-poll, prefs, settings-store (94 tests; desktop/src ~97% cov)
- Electron glue: main/window/tray/menu/preload/embedded-server/logger
  (hardened: contextIsolation, sandbox, deny foreign-origin navigation)
- Native value: OS notifications driven by /live-sessions status, tray, deep links
- Packaging (electron-builder -> arm64 .dmg): ships dist/ + public/ + node_modules
  on-disk under Resources so the server resolves its deps from /Applications;
  node-pty rebuilt for the Electron ABI
- Docs: docs/DESKTOP_PLAN.md; PROGRESS_LOG updated

Verified: desktop tsc clean; 1401 tests pass; coverage >=80% (desktop/src 97/95/100/97);
.dmg built and launch-tested on arm64 (server boots, UI serves 200, node-pty loads).
2026-07-02 06:13:17 +02:00
Yaojia Wang
e4c327e25e feat: voice command mapping — context-gated approve/reject over voice
While a tool-permission gate is held on the active tab, spoken confirm
phrases resolve it via the existing approve/reject WS channel — no server
change (byte-shuttle intact). Everything else stays ordinary dictation.

Safety: whole-utterance-exact matching + leading-negation guard + reject
precedence + confidence gate (approve only) + a cancellable 1.5s confirm
window whose commit re-validates gate identity/epoch/connectivity (TOCTOU
guard) + stale-gate epoch bound at PTT-start.

New pure modules public/voice-commands.ts and public/voice-confirm.ts at
100% coverage; wiring in voice/terminal-session/tabs/main. 1307 tests pass,
typecheck clean, build:web OK. Independent code + security reviews flagged a
confirm-window TOCTOU and an unwired cancel path — both fixed and covered.

Plan: docs/PLAN_VOICE_COMMANDS.md.
2026-07-01 10:38:32 +02:00
Yaojia Wang
03612323c0 feat(v0.6): auto-group Projects by namespace + Active-now band + cross-device prefs
The flat Projects grid was overwhelming at 50-100 repos. Explored the UX with
4 parallel agents (auto-grouping / manual folders / density-segmentation /
competitive research); all converged on namespace auto-grouping as the primary
fix — the dotted repo names (Billo.Platform.*, Billo.Infrastructure.*) already
encode the hierarchy, so it's a string-split, not a taxonomy the user builds.

Frontend:
- groupProjects(): depth-2 namespace grouping, >=2-member threshold (singletons
  fall to "Other"), degrade-to-flat when repos share no prefix, running projects
  duplicated into a pinned "Active now" section.
- displayLabel(): cards show the namespace tail (Payment, not
  Billo.Platform.Payment); full name in Active/Other.
- Collapsible sticky group headers with an "N active" badge so a collapsed
  section never hides running work; search force-expands matching groups.
- public/prefs.ts: server-first load with a localStorage offline mirror, migrates
  the legacy per-device proj-favs.

Backend (server-side prefs so favourites + collapse-state follow the user across
devices — localStorage was per-device, which broke the multi-device premise):
- src/http/prefs-store.ts: ~/.web-terminal-prefs.json store (mirrors push-store),
  every field bounded + sanitized.
- GET /prefs (read-only) + PUT /prefs (Origin-guarded); UiPrefs type +
  PREFS_STORE_PATH config.

Tests: +12 prefs-store, +21 groupProjects/displayLabel. Full suite 1227 passed.
Verified live: grouping renders, collapse persists to the server and survives a
full page reload (cross-device sync proven end-to-end).
2026-07-01 04:25:57 +02:00
Yaojia Wang
d6809c65c4 feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.

Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.

New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).

Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).

Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
2026-06-30 17:42:18 +02:00
Yaojia Wang
88b960bac5 feat(v0.6): show CLAUDE.md on the project detail page + /init generate/update button
The detail page now reads <repo>/CLAUDE.md and shows it in a scrollable
monospace box, with one smart button: "↻ Update" when it exists / " Generate"
when it doesn't. The button opens an interactive Claude session in the repo
running /init (claude "/init") so you review what it writes — reuses the project
launcher, no new backend orchestration, stays a byte-shuttle.

- ProjectDetail += hasClaudeMd / claudeMd; buildProjectDetail reads CLAUDE.md
  (truncated 64KB, best-effort, in the existing Promise.all)
- renderProjectDetail: CLAUDE.md section (content box or empty state) + button
- style.css: .proj-section-row / .proj-claudemd-btn / .proj-claudemd

Tests +5 (458): backend read present/absent; frontend content+Update,
empty+Generate, button opens `claude "/init"`. Verified: tsc clean, full suite
green, build OK, curl returns the content, in-browser shows the box + button.
2026-06-30 14:51:42 +02:00
Yaojia Wang
67b0a43b39 polish(v0.6): replace toolbar emoji with themed line icons + hover tooltips
The glossy emoji (🔍⚙▦🕘🔗📱) clashed with the Amber dark theme. Swap them
for clean lucide line icons (MIT, stroke=currentColor) so they take the theme
colour — muted by default, Amber on hover — and add a styled CSS hover tooltip
(data-tip, right-anchored so it never clips the edge) showing what each does.

- public/icons.ts: 7 line icons (search/settings/dashboard/history/keyboard/
  share/device), script-generated from lucide
- 7 toolbar modules: innerHTML = ICON_* + data-tip + aria-label
- style.css .toolbtn: 18px svg, --text-dim → --accent on hover, [data-tip] tooltip

Frontend-only. web typecheck + build clean; bundle has 0 toolbar emoji; verified
in-browser: line icons in theme colour, amber + tooltip on hover.
2026-06-30 14:36:26 +02:00
Yaojia Wang
59de784c8a polish(v0.6): make "New session" a grid tile instead of a heavy header button
The big solid-amber "+ New session" button clashed with the refined segmented
control. Replace it with a dashed "+" tile that leads the session grid (same
size as the session cards, re-prepended each refresh), so it reads as a natural
"create" cell. Header is now just the title + Sessions/Projects toggle; the
redundant empty-state message is gone (the tile is the CTA).

Frontend-only (launcher.ts + style.css). Verified: web typecheck + build clean,
preview-grid tests green, in-browser the grid leads with the New-session tile
followed by the session cards (Open/Kill).
2026-06-30 14:25:56 +02:00
Yaojia Wang
bf5241e87b feat(v0.6): remove standalone Manage page — manage sessions on the Sessions view
The Sessions chooser already shows every running session with a live thumbnail
and Open + New session, so fold delete into it and drop the separate page:

- add a Kill ✕ button to each session card (DELETE /live-sessions/:id + refresh),
  reusing makePreviewCard's extraActions and the existing .mg-kill style
- remove public/manage.html + public/manage.ts and the esbuild manage entry
- remove the 🗂 toolbar button (main.ts) and the 🗂 Manage header link (launcher.ts)

Backend DELETE/preview routes stay (used by the launcher kill, thumbnails, and
the project pages). Frontend + build-config only.

Verified: full suite 453 green, web typecheck clean, build:web emits only main
(no manage.* artifacts), and in-browser the Sessions cards have Open + Kill (2→1
on kill), no Manage entry, and GET /manage.html → 404.
2026-06-30 14:08:00 +02:00
Yaojia Wang
46698b8b5e feat(v0.6): kill a session directly from the Projects grid cards
The detail view could kill sessions, but the grid card's session rows were
enter-only. Add a ✕ kill button to each card session row (stopPropagation so it
doesn't also enter the session); makeSessionRow gains an optional onKill,
makeProjectCard an optional onKillSession, and mountProjects wires it to
DELETE /live-sessions/:id followed by a refresh.

Frontend-only. projects-panel tests +1 (51): ✕ present only with the callback,
click kills the right id without entering. Verified in-browser: a card with 2
sessions dropped to 1 after clicking ✕.
2026-06-30 13:35:53 +02:00
Yaojia Wang
99bc6fd9f6 feat(v0.6): per-project detail view — branch/worktrees + active sessions
Clicking a project's name opens an in-app detail view (back button returns to
the grid) with:
- header: name, path, branch, dirty
- Branch / Worktrees: each `git worktree list` entry (branch or detached@head,
  main/current/locked/prunable tags, path) — a single-worktree repo shows its
  branch; a multi-worktree repo shows them all
- Active sessions (N): detailed rows (status, devices watching, started-ago)
  with Open + Kill
- the Claude/Codex/VS Code launcher row

Backend: src/http/worktrees.ts (parseWorktrees pure + listWorktrees via
execFile, no shell, best-effort); buildProjectDetail(cfg, path, liveSessions)
in projects.ts (validates absolute existing dir, reuses branch/dirty/session
helpers, uncached — detail is on-demand and wants fresh state); read-only
GET /projects/detail?path= (no Origin guard, like /projects; 400 missing /
404 invalid). Types: WorktreeInfo, ProjectDetail.

Frontend: detail view + navigation in projects.ts (grid<->detail), project
name becomes a link, Kill via DELETE /live-sessions/:id.

Tests +21 (452 total): worktree parser, buildProjectDetail (validation,
non-git, session merge, real git-init worktree), renderProjectDetail.
worktrees.ts 100% / projects.ts 93.8% cov. Verified in-browser: opened a
Claude session, then the detail showed its branch+worktree and the live
session with Open/Kill.
2026-06-30 13:29:41 +02:00