Wire the SecureEnclave enroll library into a real flow (login->bearer->CSR->
/device/enroll->keychain identity), presented on the existing mTLS path; add a
rotation scheduler. Atomic keychain replace (add-before-delete); renew body is
{csr}-only; renewal-failing surfaced in the UI. ClientTLS 48 tests pass.
WebTerm iOS Client
Native iPhone + iPad client for the web-terminal server in the repository
root. It is a pure remote client: it speaks the same WebSocket wire protocol
and HTTP endpoints as the web frontend. P0 needs zero server changes; P1 adds
exactly two declared, additive server touch-points (an APNs sender + token
endpoint, and an optional lastOutputAt field on GET /live-sessions) — nothing
else in src/ / public/ changes.
The whole app is a single adaptive codebase: iPhone (and iPad Slide Over /
small splits) get the compact stack layout; iPad regular width gets a
NavigationSplitView sidebar + detail — no forked UI. Min deployment iOS
17.0; Swift 6 language mode. The look matches the desktop web theme
(amber-gold accent #E3A64A on warm-dark, dark-appearance-first).
Build & Run
Requirements: macOS 15, Xcode 16.3 (Swift 6.1), XcodeGen, an iOS 17+ simulator (CI tests on iPhone 16 and iPad Pro 11"). SwiftTerm 1.13 is resolved for the App target only.
cd ios
xcodegen generate # project.yml → WebTerm.xcodeproj
open WebTerm.xcodeproj # or run the full test suite headless (both idioms):
xcodebuild -project WebTerm.xcodeproj -scheme WebTerm \
-destination 'platform=iOS Simulator,name=iPhone 16,arch=arm64' test
xcodebuild -project WebTerm.xcodeproj -scheme WebTerm \
-destination 'platform=iOS Simulator,name=iPad Pro 11-inch (M4)' test
Layout:
| Path | What |
|---|---|
App/ |
SwiftUI app glue + WebTermTests unit-test bundle |
Packages/WireProtocol |
Frozen wire contract (frames, validation, tunables) |
Packages/SessionCore |
SessionEngine actor: connect/replay/reconnect/gate |
Packages/HostRegistry |
Paired hosts + last-session persistence |
Packages/APIClient |
HTTP endpoints (guarded routes carry Origin) |
Packages/TestSupport |
FakeTransport / FakeClock / FakeHTTPTransport |
IntegrationTests/ |
Tests against a real Node server (npm start) |
Per-package tests: swift test --package-path Packages/<Name>.
The server runs from the repo root: npm install && npm start (see the root
README).
Features
The product loop is vibe coding: kick off Claude Code on your Mac, walk away, and check / steer it from your phone — approve or reject permission gates remotely, watch status, and reattach with full scrollback.
P0 — daily-usable pocket cockpit
- Native terminal — SwiftTerm,
not a web view. Full ANSI/true-color, native selection, IME and keyboard. On
attach it replays the host's whole scrollback ring, then resumes the live
stream. The canvas matches the desktop (warm near-black
#100F0D, gold caret). - Pairing — scan the web UI's connect-device QR, or type the host URL.
A confirm step shows the parsed
scheme://host:portand makes zero network calls until you confirm (a scanned code is untrusted input). A two-step probe verifies reachability + theOriginhandshake before saving the host to the Keychain. Per-§5.4warning tiers (public host = blocking warning, plaintext LAN = notice, Tailscale = fine). - Session list (chooser + dashboard in one) — every running session with a semantic status badge (working / waiting / idle / stuck — colour and shape, never colour alone), live telemetry chips (cost / context / PR, SF Mono, greyed when stale), a preview thumbnail, cols×rows, swipe-to-kill, pull-to-refresh, and multi-host switching.
- Remote approve / reject — the core steering primitive. A tool gate shows a
prominent Approve / Reject card (≥44 pt); a plan gate shows the three-way
sheet (Approve+Auto →
acceptEdits/ Approve+Review →default/ Keep Planning →reject, matching the web client exactly). Haptic on arrival. - Away digest — on reattach, a summary of what happened while you were gone ("ran N tools · waiting · done"), expandable into the full timeline.
- Sessions survive everything — kill the app / background / lose the network → foreground reattaches and replays. One live WS for the foreground session; the rest are HTTP-polled. Reconnect uses 1s→30s backoff; a 25 s ping keeps the socket honest.
- Mobile key-bar — Esc / Esc·Esc / ⇧Tab / arrows / Enter (
\r) / ^C / ^R / ^O / ^L / ^T / ^B / ^D / Tab //, byte-for-byte from the web key-bar, sent raw (no soft-keyboard pop). Hardware-keyboard chords viaUIKeyCommand. - Privacy shade — the terminal is covered whenever the app isn't active
(
scenePhase != .active), so the multitasking snapshot never leaks terminal bytes. - Notifications (P0) — reuses the server's existing ntfy bridge (see below), zero new code, default-off.
P1 — walk-away complete
- APNs push + lock-screen Allow / Deny — the host notifies your phone on a
held gate; from the lock screen you Allow (behind Face ID / passcode,
.authenticationRequired) or Deny without opening the app. The decision is a single-use capability token; it's validated then discarded, never persisted, never logged. (Needs a paid Apple account +.p8on the server — seePLAN_IOS_CLIENT.md§T-iOS-20.) - Deep links —
webterminal://open?host=…&join=…(strict whitelist, UUID- validated) opens straight to a gated session, cold or warm; push taps use the same router. - Projects — auto-discovered git repos grouped by namespace, favourites +
collapsed state synced through the server's
/prefs(unknown keys preserved so iOS never clobbers web-written prefs), dirty badge, detail page (sessions / worktrees / CLAUDE.md), and "open Claude in this repo" (attach(cwd)+ injectclaude\r). - Multi-session switcher — unread dots (from
lastOutputAt) and sanitized OSC titles (bidi / zero-width stripped, length-capped — titles are attacker-influenced). - Activity timeline — the full
/eventsdrill-down, class → icon + colour. - Diff viewer — read-only git diff, staged/unstaged, per-file hunks.
- Quick-reply chips — tap-to-send snippets + an editable palette (floats while a gate is waiting).
- Session thumbnails — off-screen SwiftTerm renders, concurrency-capped and
cached by
(sessionId, lastOutputAt).
iPad
- Adaptive split view — regular width shows a sidebar (session list +
projects, continue-last banner) beside the terminal detail; compact width (incl.
Slide Over) collapses to the iPhone stack. One code path (
LayoutPolicy), the privacy shade covers both. - Hardware keyboard — the on-screen key-bar auto-hides when a hardware keyboard is attached.
- Pointer — right-click / long-press context menu on the terminal (open in cwd / kill / copy), routed through the same guarded endpoints.
Look & feel
A single frozen design system (App/WebTerm/DesignSystem/): one amber-gold
accent token (#E3A64A dark / #C9892F light) matching the desktop --accent,
semantic status colours from the web palette, an 8-pt spacing scale, SF Mono
tabular numbers, reduce-motion-gated animation and haptics, and reusable
primitives (StatusBadge / TelemetryChip / Card / …). The app is
dark-appearance-first to match the desktop's dark theme (gold reads best on
warm-dark). Every view consumes tokens — no hardcoded colours.
Status & what's verified
Built and green: 290 app tests (iPhone 16 + iPad Pro 11"), 261 package
tests, 10 integration tests against a real Node server, plus one XCUITest
happy-path (pair → attach → type → approve). Coverage ≥ 80 % on the four logic
packages. Not yet done / needs hardware: on-device gesture / IME / camera-QR /
haptics / lock-screen-Allow walkthroughs are DEFERRED to a real device; APNs
end-to-end needs a paid Apple account. The client is on the feat/ios-client
branch (not yet merged). Design & task detail live in
PLAN_IOS_CLIENT.md and
PLAN_IOS_IPAD.md; progress in
PROGRESS_LOG.md.
ntfy notification bridge (P0 "host finds the phone")
Until APNs push lands (P1, T-iOS-20/21), the phone is notified via the
existing ntfy bridge that already ships with
npm run setup-hooks. No new code — this chapter documents and verifies
the shipped behavior.
What it does
When Claude Code (running inside a web-terminal session on the host) fires a
hook event, an extra curl posts to your ntfy topic:
| Claude Code hook event | ntfy Priority header |
Meaning |
|---|---|---|
PermissionRequest (held gate) |
high |
NEEDS-INPUT — Claude is waiting for approval |
Stop / SessionEnd |
low |
DONE — the task finished / session ended |
Install logic: scripts/setup-hooks.mjs:229-238 (high on PermissionRequest
at :231-233, low on Stop/SessionEnd at :235-237). The command itself is
built by buildNtfyCommand (scripts/setup-hooks.mjs:91-101).
Default-off: env unset ⇒ zero side effects
The bridge is opt-in at install time: npm run setup-hooks only adds the
ntfy hooks when both WEBTERM_NTFY_URL and WEBTERM_NTFY_TOPIC are set in
the environment (gate at scripts/setup-hooks.mjs:262-264, guarded install at
:229). With the vars unset, the written settings.json contains no ntfy
entry at all (verified: fresh install without env → grep -c WEBTERM_NTFY settings.json = 0, and the install log has no ntfy line).
Two further layers keep it inert outside web-terminal:
- Every installed ntfy command starts with
[ -n "$WEBTERM_HOOK_URL" ] && …(scripts/setup-hooks.mjs:93) — in a normal terminal that var is unset, so the hook is a no-op. - The session env is the only carrier: the server forwards
WEBTERM_NTFY_*into each spawned session via the...process.envspread (src/session/session.ts:95-102, comment at:99-100). Nothing is hardcoded server-side.
Setup
-
Install the ntfy app on the iPhone (App Store) and allow notifications.
-
Generate a random topic and subscribe to it. On the public
ntfy.shserver, the topic name is the password: anyone who knows it can read your notifications and publish to the channel. Use a random string, e.g.openssl rand -hex 12. -
Export the env vars, then install the hooks and start the server from that same shell (the install gate reads env at
setup-hooks.mjs:262-264; at runtime the curl expands$WEBTERM_NTFY_*from the session env, which inherits the server's env —src/session/session.ts:95-102):export WEBTERM_NTFY_URL=https://ntfy.sh # or your self-hosted ntfy export WEBTERM_NTFY_TOPIC=<your-random-topic> # optional, self-hosted/paid access control only: export WEBTERM_NTFY_TOKEN=tk_... # env only — never a literal npm run setup-hooks # expect: Installed ntfy bridge (NEEDS-INPUT=high, DONE=low) → https://ntfy.sh/<topic> npm startConfirmation log line:
scripts/setup-hooks.mjs:296-298. -
Restart any running
claudesessions (the installer prints this reminder).
What the notification contains (payload minimization)
Verified against the shipped command (scripts/setup-hooks.mjs:91-101): the
curl sends an empty POST body — there is no -d/--data flag at all. The
only information that leaves the host is:
- which topic was posted to (your private random channel), and
- the
Priorityheader (high= needs input,low= done).
No session id, no cwd, no command content, no tool names — strictly less
than even a "sessionId prefix + status word" payload. In the ntfy app both
signals show ntfy's default message text; you tell them apart by the priority
rendering (high-priority notifications are visually marked and can bypass
quiet delivery).
The token is referenced as Bearer $WEBTERM_NTFY_TOKEN
(scripts/setup-hooks.mjs:95) — a shell variable expanded at hook-fire time.
It is never written into settings.json or any command literal (SEC-C6;
regression-tested by test/setup-hooks.test.ts:410-421, re-run for this
verification: 49/49 passed).
What it does NOT cover: STUCK
stuck is a server-derived state: manager.sweepStuck
(src/session/manager.ts:268) infers it from output silence while a session
looks busy. No Claude Code hook event fires for it, so a hook-side bridge
physically cannot send a STUCK notification. This gap is closed in P1 by APNs
(T-iOS-20), which pushes from the server's own event bus.
Turning it off / P1 supersession
Once P1 APNs lands (T-iOS-20/21), this bridge is superseded and can be disabled. Any of:
- Re-run
npm run setup-hookswithout theWEBTERM_NTFY_*env vars — the reinstall pass strips previously installed ntfy groups (marker cleanup,scripts/setup-hooks.mjs:185-205; the ntfy command contains theWEBTERM_HOOK_URLmarker via its guard at:93, verified: reinstall without env → 0 ntfy entries remain). npm run setup-hooks -- --remove— removes all web-terminal hooks.
End-to-end phone check — DEFERRED(需真机/用户操作)
Not runnable in this environment (requires the user's phone and mutating the user's live Claude Code hook config). Exact manual steps:
- iPhone: install ntfy, subscribe to
<your-random-topic>onhttps://ntfy.sh, allow notifications. - Mac:
export WEBTERM_NTFY_URL=https://ntfy.sh WEBTERM_NTFY_TOPIC=<topic>, runnpm run setup-hooks, confirm the log lineInstalled ntfy bridge (NEEDS-INPUT=high, DONE=low), thennpm startfrom the same shell. - Open the web terminal, start a session, run
claude, and give it a task that triggers a permission gate (e.g. a Bash command that is not pre-allowed). - When the gate is held (tab badge shows waiting-for-approval), the iPhone should receive a high-priority ntfy notification within seconds.
- Approve and let the task finish — a low-priority notification arrives
on
Stop/SessionEnd.