Fan ONE prompt across N branch/agent lanes: N worktrees, N Claude sessions on the
same prompt, watched side-by-side in the split-grid board, approve/kill per lane,
🏆 keep the winner (losers' worktrees removed). ~90% composition of shipped parts.
- src/http/session-groups.ts (new, pure, no git exec): deriveRepoRoot /
groupSessionsByRepo (cluster sessions by their <repo>-worktrees parent).
- GET /live-sessions/grouped (read-only; registered BEFORE /live-sessions/:id so
"grouped" isn't captured as an id). MAX_FANOUT_LANES env (default 6, = grid-6 cap).
- public/fanout.ts (new, pure): buildFanoutCmd shell-quotes the prompt (single-quote
wrap with '\''-escaping so $(...)/backticks/;/&& can't execute) + collapses newlines
+ caps 4000 chars; laneBranch/slugify. Effective N = min(lanes, maxFanoutLanes, 6),
≥2; the maxSessions cap is enforced server-side ("Started K of N" banner).
- public/tabs.ts launchFanout (N× createWorktree → openProject w/ prompt pre-injected
via the existing initialInput) + keepFanoutWinner; public/projects.ts renderFanoutForm
+ extracted shared createWorktreeReq (DRY). Byte-shuttle preserved (lane = own PTY).
One-click merge deferred (winner session stays open for a manual merge).
Reused unchanged: createWorktree/removeWorktree, addEntry/initialInput, split-grid +
per-quadrant approve/maximize/monitor + gauges. Verified: typecheck + build:web clean,
2063 pass at --test-timeout=30000 (only the known tmux/PTY flake red). Fixed 3
/config/ui exact-shape tests to include the new maxFanoutLanes field.
The walk-away endgame — review a diff on your phone, then land it without typing
git into a mobile terminal. MVP bounded to per-file stage/unstage, commit, and
push the current branch; discard/checkout/reset deliberately deferred (nothing here
mutates working-tree file contents).
- src/http/git-ops.ts (new): stageFiles/commit/push (execFile, no shell, never
throws). Path containment: every files[] entry realpath-contained under the repo,
argv after `--`, capped at diffMaxFiles. Commit message: empty/over-5000 → 400,
single -m argv. Push: current branch only — has-upstream → plain `git push`;
no-upstream + one remote → `git push -u <remote> <branch>`; 0 remotes → 400,
≥2 → 409, detached HEAD → 400. Remote AND branch read from the repo, never the
client. NEVER --force / +refspec. GIT_TERMINAL_PROMPT=0 + ssh BatchMode fail auth
fast (401). Errors classified to fixed safe strings (raw stderr never surfaced).
- POST /projects/git/{stage,commit,push} — all behind requireAllowedOrigin +
GIT_OPS_ENABLED kill-switch + per-IP rate limits (stage/commit 30/min, push 6/min)
+ isValidGitDir. public/diff.ts: per-file Stage/Unstage + a commit/push bar
(all text via textContent; re-loads the diff on success).
Verified: typecheck + build:web clean, git-ops tests 213 pass (unit covers escape
rejection / empty+overlong commit / push argv upstream-vs-no-upstream / classified
errors), full suite green at --test-timeout=30000.
Closes the create-only loop — delete losing worktrees and prune stale ones without
a terminal. Destructive, so guarded hard:
- src/http/worktrees.ts: removeWorktree + pruneWorktrees (execFile, no shell, never
rm -rf; git worktree remove [--force] / git worktree prune). Safeguards:
(1) target realpath must match an entry git itself reports in `git worktree list`
for THIS repo → 404 otherwise (arbitrary FS paths never match, so git is never
invoked against them); (2) reject the MAIN worktree → 400; (3) realpath
containment on request + each list entry, operating on git's canonical path (+ --);
(4) dirty tree without force → 409 "force required"; (5) locked → 409; (6) errors
classified to fixed safe strings (raw stderr/paths never leaked).
- DELETE /projects/worktree + POST /projects/worktree/prune — both behind
requireAllowedOrigin + worktreeEnabled + audit-logged, mirroring the create route.
- public/projects.ts: ✕ remove button per linked-worktree row (hidden for main/locked)
+ a prune button; force needs an explicit second confirm (no single-click data loss).
Verified: typecheck + build:web clean, worktree tests 108 pass (unit covers
reject-non-registered / reject-main / reject-escape / refuse-dirty-without-force /
clean-remove / force-remove / prune), full suite green at --test-timeout=30000.
Four small, high-delight features that turn passive capture into glanceable signals.
- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
into the existing concurrent per-repo metadata pass (git rev-list --count
--left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
(Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
/config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.
All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
Per-project chip: PR state · N checks passing · mergeable — glance from the phone,
re-engage only when it's red, instead of dropping into a terminal for `gh pr checks`.
- src/http/gh.ts (new): single `gh pr view --json number,state,title,url,isDraft,
mergeable,headRefName,baseRefName,statusCheckRollup` (execFile, no shell, cwd =
isValidGitDir repo, timeout + maxBuffer). summarizeChecks rolls the mixed
CheckRun/StatusContext rollup into {total,passing,failing,pending}. Never throws —
degrades to not-installed (ENOENT) / unauthenticated / no-pr / error / disabled.
Cache keyed by repoPath+branch (reuses projectScanTtlMs) with in-flight dedupe.
- src/types.ts: additive PrStatus/PrAvailability/PrCheckSummary; config GH_ENABLED
(default on) + GH_TIMEOUT_MS (8s).
- GET /projects/pr?path= (read-only, isValidGitDir); public/gh-chip.ts render-only
chip mounted in the project detail header (git repos only).
Read-only, host's own authed gh (same trust as the shell). No untrusted argv (only
the validated cwd); gh stdout/token never logged; the attacker-controllable PR title
is rendered inert via textContent (SEC-H4). Verified: typecheck + build:web clean,
1816 pass (gh tests 118). The 1 red is the known real-PTY ring-buffer timeout flake.
The git-diff viewer can now diff the current branch against a base commit-ish
(e.g. main) — review an agent's whole branch from your phone before merging, not
just uncommitted changes. Completes the long-deferred FR-B1.9.
- src/http/diff.ts: getDiff() gains an optional base. Three-layer defense so an
attacker-supplied base never reaches a shell or acts as a git option:
(1) isPlausibleRev() rejects leading '-', '..'/'...' ranges, metachars, control
chars, >250 chars; (2) git rev-parse --verify --quiet --end-of-options
<base>^{commit} — only a resolved 7-64 hex sha is accepted, else empty result;
(3) git diff --no-color <sha>... -- (three-dot = the branch's changes since
divergence, PR-style). execFile, no shell, timeout + maxBuffer bound.
- src/types.ts: additive optional base on DiffResult.
- src/server.ts GET /projects/diff reads ?base (400 on !isPlausibleRev); read-only.
- public/diff.ts: a "compare base" <select> (Working tree + one option per base),
disables Working/Staged tabs in base mode. public/projects.ts derives bases from
the worktree branches ∪ current branch.
Verified: typecheck + build:web clean, 1763 pass (diff tests 87 green). The 1 red
in the plain full run is the pre-existing real-PTY "ring buffer" flake (times out
at default 5s under sandbox load; 27/27 at --test-timeout=30000) — unrelated.
The walk-away primitive: queue follow-up prompts and let a session advance itself
while you're gone — "run the tests" drains, and when Claude next goes idle "open a
PR" drains. Injection reuses writeInput (byte-identical to a keystroke, broadcasts
to all mirrored devices); the byte-shuttle is untouched.
- POST/GET/DELETE /live-sessions/:id/queue — POST/DELETE behind requireAllowedOrigin
+ a per-IP rate limit (QUEUE_RATE_MAX=20/min) + SESSION_ID_RE; text non-empty,
byte-capped (QUEUE_ITEM_MAX_BYTES=4096, 16kb body), count-capped (QUEUE_MAX_ITEMS=10).
- Bounded per-session queue in manager (enqueueFollowup/drainOne/clearQueue);
new optional queueLength on LiveSessionInfo.
- Idle drain: the Stop/SessionEnd /hook branch scheduleDrain()s a debounced timer
(QUEUE_SETTLE_MS=1500). It drains exactly one entry only if the session still
exists, hasn't exited, produced no output during settle, and is idle — three
guards (debounced single timer + pop-one + settle re-check) → one entry per idle.
- New additive ServerMessage {type:'queue',length} broadcasts the count to mirrors
(⧗N badge); FE enqueue via the quick-reply editor + TabApp.enqueueToActive.
Injected bytes go verbatim to the PTY (never built into a shell command). Verified
independently: typecheck + build:web clean, 1737 tests pass (real-PTY integration
covers idle-drain / one-per-idle / settle-guard). Foundation for templated launches,
auto-continue, and issue-intake (docs/ROADMAP.md).
Remote one-tap approval was blind (you'd tap Approve without seeing Claude wants
to run `rm -rf` or rewrite a config). The pending tool's actual command / diff now
renders above the approval bar, on every attached device, riding the same broadcast
+ late-joiner rails as the existing `gate` field.
- src/http/approval-preview.ts (new, pure, never-throws): deriveApprovalPreview —
Bash → command; Edit/Write/MultiEdit/NotebookEdit → a synthetic DiffFile; else null.
Every line sanitized via sanitizeField (strips control/ANSI); caps 40 lines /
200 chars/line / 4KB (security limits, UTF-8-safe byte clamp).
- src/types.ts: additive optional `preview?: ApprovalPreview` on the status
ServerMessage + handleHookEvent (older clients ignore it).
- src/server.ts /hook/permission: derive preview from tool_input, store on the
PendingApproval entry, re-send to late joiners exactly like `gate`.
- manager.ts threads it; public/tabs.ts renderApprovalPreview (command → <pre>
textContent; diff → reused innerHTML-free renderDiffFile). Unknown tools /
plan gates fall back to today's name-only bar.
Attacker-influenced tool input → rendered via textContent/diff-renderer only,
never innerHTML. Verified independently: typecheck + build:web clean, 1692 pass.
Terminal output URLs and file paths (e.g. src/app.ts:42) are now tappable — the
walk-away device is a phone, so this removes soft-keyboard copy gymnastics.
- public/link-paths.ts (new, pure): findPathMatches — links tokens with a '/',
a :line suffix, or a code extension (src/app.ts:42, /abs/main.rs:10, README.md)
while rejecting example.com / v1.2.3 / 12:34 / URL tails.
- terminal-session.ts: hardened URL handler (scheme allowlist http/https/mailto +
window.open noopener,noreferrer, blocks javascript:/data:/file:) replacing the
addon default; a path link provider → openPath() POSTs {file,line} to
/open-in-editor (resolves rel paths against the OSC-7 cwd; in-flight guard).
- src/http/editor.ts: additive openFileInEditor + isGotoEditor (--goto file:line
only for goto-capable editors; execFile, no shell). openInEditor untouched.
- src/server.ts: /open-in-editor branches on body.file vs body.path (same CSRF guard).
Deviation from the "no server change" brief: an additive openFileInEditor was
required because the existing route only opens directories, not file:line (Option A
in docs/plans/w1-clickable-links.md). Backward-compatible; src/types.ts untouched.
Verified independently: typecheck + build:web clean, 1661 tests pass (link-paths
95%+ cov). Note: xterm buffer-row indexing (getLine(n-1)/range.y=n) is asserted in
jsdom but merits a real-browser smoke check.
The 'New worktree' form POSTed { repoPath, branch } to /projects/worktree, but
server.ts:706 and the documented contract (FEATURE_WALKAWAY_WORKBENCH FR-B3.1)
read body.path — so every browser worktree-create returned 400 'path and branch
are required' and git worktree add never ran. Send 'path' instead. The existing
worktree-form test had locked in the wrong key (asserted repoPath), which is why
it stayed green while the feature was broken; corrected to assert 'path'.
The focused grid cell's last terminal row was clipped by the cell border/focus
ring at large/maximized window sizes. Root cause (measured in headless Chromium):
.term-pane is box-sizing:border-box, so getComputedStyle(pane).height — which
xterm's FitAddon reads to compute rows — returns the padding-box height and
double-counts the pane's own padding, packing one extra row that overruns the
border (overflow +6px). Fix: box-sizing:content-box on the grid pane so the height
reports the content box; FitAddon drops the phantom row and the 6px bottom padding
becomes real clearance. Verified overflow +6 → −10px (16px clear) at grid-4/grid-6,
1920×1200 (DPR 1 & 2), unchanged at 1200×800. Single mode untouched.
Two fixes required to repackage the macOS app after the split-grid frontend landed:
- The bundled node_modules is copied from desktop/node_modules, which only mirrored
4 of the 6 server runtime deps. google-auth-library (imported at server startup by
dist/push/fcm.js) and qrcode were missing, so the embedded server crashed on launch
("Cannot find package 'google-auth-library'"). Added both to desktop/package.json so
they're installed + bundled. (The old build predated the FCM code, hiding this.)
- electron-builder died with "ENOENT .bin/asar": node_modules/.bin holds dev-tool
symlinks (asar/tsc/esbuild) into packages the filter excludes, leaving dangling
symlinks in the bundle that electron-builder stat()s. Excluded .bin/** from the copy
(runtime deps load by path, not via .bin).
Verified: dist:mac builds clean (signed, DMG), installed to /Applications, embedded
server serves :3000 (HTTP 200) and the split-grid frontend loads.
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>
Resizable splitters: draggable gutters between grid tracks adjust per-layout
column/row fractions (fr units), applied as an inline grid-template and persisted
(web-terminal:grid-splits). The fraction math (adjustSplit — trades delta between
adjacent tracks, clamps to a minimum, conserves the total) is a pure, unit-tested
helper in grid-layout.ts; the drag translates pixel motion into an fr delta.
Saved presets: public/grid-presets.ts — a toolbar dropdown to save the current
layout + its split under a name, re-apply it in one click, or delete it
(web-terminal:grid-presets). Desktop-only, like the layout toggle.
- grid-layout.ts: layoutTracks, defaultSplit, adjustSplit, tracksToTemplate,
load/saveGridSplits, splitForLayout (validates stored shape).
- tabs.ts: renderGutters/beginGutterDrag/setSplit; applyLayout sets the inline
template; gridArrangement()/applyGridPreset() hooks.
- main.ts: mount the presets dropdown. style.css: gutters + presets menu.
- tests: splitter math + persistence + a stubbed drag repro (1.2fr/0.8fr); presets
persistence + dropdown (save/apply/delete/close). typecheck+build clean, 1612 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A quadrant can be flipped to "monitor" mode via a 👁 header toggle: instead of
the live interactive terminal, it renders the session's screen from periodic
GET /live-sessions/:id/preview snapshots into a read-only xterm. A monitored
quadrant never attaches a WS and never sends a resize, so — unlike a live
quadrant (latest-writer-wins) — it does not drive the shared PTY size. That lets
you watch a session in a small quadrant without shrinking it for another device
using it full-screen (the cross-device shrink the split-grid design flagged).
- public/cell-monitor.ts (new): mountCellMonitor(host, id) — polling read-only
preview, scaled to fit, best-effort, disposes cleanly (no write after dispose).
- tabs.ts: per-entry monitor state; applyLayout keeps the live pane hidden and
starts/stops the monitor; 👁 toggle in the cell header; teardown on close /
leaving grid mode.
- style.css: monitor button + .cell-monitor container.
- tests: cell-monitor.test.ts (poll→write, dispose, late-resolve guard) + monitor
wiring in tabs.test.ts. typecheck + build clean, 1587 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
Mirror src/push/apns.ts behind the same NotifyService seam (combineNotifyServices),
so held-gate/done hook events fan out to Android via data-only high-priority FCM with
zero new event paths. OAuth2 bearer via google-auth-library (R7); payload minimized to
sessionId/cls/token (never cwd/command/bytes); intentionally-loose FCM-token validator;
Origin-guarded + rate-limited POST/DELETE /push/fcm-token; FCM_* env all-or-disabled.
Cross-review fix: dropped validateStatus:()=>true so google-auth-library's built-in
401 refresh-and-retry actually fires. 60 new tests; full server suite green; tsc clean.
Android SDK installed on this machine (cmdline-tools via brew;
platform-tools + platforms;android-35 + build-tools;35.0.0), local.properties
points Gradle at it (gitignored). Wired AGP 9.2.1 into the version catalog +
google() repos; proved the full chain by building a throwaway android-library
module against SDK 35 (AAR produced), then removed the probe.
Groundwork for AW2+: AGP plugins in the catalog, google() in settings, and the
working recipe in README (incl. the AGP-9-has-built-in-Kotlin gotcha — Android
modules apply ONLY the android plugin, never kotlin.android). Pure JVM modules
still green (218 tests). Framework module stubs stay gated until implemented.
R1: accept best-effort FCM background delivery as a documented limitation.
R7: server FCM sender uses google-auth-library (not hand-rolled RS256).
All three open questions (incl. R2 license) now decided; nothing blocks AW3/AW5.
Phased build spec for an Android client at functional parity with the iOS
client. Produced by a multi-agent workflow: 10 iOS-subsystem explorers + 2
Android-tech researchers -> architect synthesis -> 4-lens adversarial review
-> finalize. Mirrors the iOS SPM package set as Gradle modules; 36 tasks
across waves AW0-AW6 with Owns/Depends/Verify, two de-risking spikes.
R2 (Termux license) independently re-verified and downgraded Critical->Low:
terminal-view/terminal-emulator are Apache-2.0 and on JitPack, not GPL-vendor.
Open questions for the user: R1 (FCM background-delivery acceptance),
R7 (FCM OAuth2 dependency).
- jsdom 29's localStorage methods are undefined under this vitest config, so
every frontend test calling localStorage.clear()/setItem() threw. Add a
shared in-memory Web Storage polyfill via setupFiles (no-op when a working
Storage exists / in node env). Fixes quick-reply, push, projects-panel (135 tests).
- Update desktop prefs test: defaultPrefs gained remoteHosts/selectedHostId
(remote-host mTLS work); sync EXPECTED_DEFAULTS and the round-trip fixture.
Full suite now green: 1473 passed (was 142 failing).
Spawn the shell with -l (POSIX only) so it loads the full profile chain
and rebuilds PATH. Without it, a shell spawned by a GUI-launched app or a
long-lived tmux keepalive server inherits a minimal PATH, so nvm-managed
node (only on the login PATH) is missing and Claude Code hooks fail with
'node: command not found'. PowerShell (Windows) has no -l; skipped there.
Applies to both the tmux new-session command and the direct-spawn path.
Generated via multi-agent workflow (3 planners → adversarial review → synthesis).
Covers frp tunnel on shared :443, mTLS device-cert auth, and client-cert
implementation for iOS/Android/desktop. Trust Model A (single-owner fleet) for v1.
The message listener was attached only when the mux consumer called onMessage — which
happens inside attach(), AFTER the async mTLS-registry lookup. On loopback the agent's FIRST
heartbeat ping arrived in that gap and ws dropped it (no listener), so the agent's heartbeat
died on the first miss at 15s and the tunnel flapped forever. Attach the listener at
construction and buffer early frames (and an early close) until the consumer wires up.
Node/OpenSSL rejects an agent client leaf whose only trust anchor is a non-self-signed
intermediate (no PARTIAL_CHAIN flag) → every agent was reset at the TLS layer before attach(),
surfacing as a silent 1006. Pass the full agent-ca bundle and stop swallowing tlsClientError.
- ca/issue.ts: real X.509 v3 Ed25519 leaf with SPIFFE URI SAN, signed by the
intermediate; SAN built via relay-auth spiffeIdFor so it can't drift
- ca/csr.ts: parse real PKCS#10 (agent's PEM) + async PoP verify; decodeCsrWire
accepts PEM or base64(DER)
- main.ts/env.ts: real issuer when CA_INTERMEDIATE_KEY_PATH present, else dev fallback
- interop.test.ts: oracle proving relay-auth verifyAgentCert accepts the leaf (6 tests)
- deps: @peculiar/x509, reflect-metadata