# 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](https://github.com/yonaskolb/XcodeGen), an iOS 17+ simulator (CI tests on iPhone 16 **and** iPad Pro 11"). SwiftTerm is the only third-party dependency, attached to the App target only, and **pinned to an exact version — `exactVersion: 1.15.0`** (`project.yml`). The pin is exact, not `from:`, because the generated `.xcodeproj` — and with it `Package.resolved` — is gitignored, so every checkout/CI run re-resolves from scratch and a floating requirement silently drifted (1.13.0 → 1.15.0 once broke the App target on a `hasActiveSelection` name collision; the local property was dropped in favour of upstream's, so the tree now rides 1.15.0 deliberately). ```bash 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`, all routes carry the token `Cookie`) | | `Packages/ClientTLS` | Device client-certificate (mTLS) identity: Secure-Enclave key, CSR, enrolment, rotation | | `Packages/TestSupport` | `FakeTransport` / `FakeClock` / `FakeHTTPTransport` | | `IntegrationTests/` | Tests against a real Node server (`npm start`) + `scripts/coverage-gate.sh` | Per-package tests: `swift test --package-path Packages/`. Coverage gate (≥ 80 % own-sources, one package per invocation): `ios/IntegrationTests/scripts/coverage-gate.sh ` — gated packages are `WireProtocol`, `SessionCore`, `HostRegistry`, `APIClient` and (since this wave) `ClientTLS`. The server runs from the repo root: `npm install && npm start` (see the root [README](../README.md)). ### Signing, device builds, and the push-entitlements switch The signing account is a **free personal Apple team**: `DEVELOPMENT_TEAM = C738Z66SRW` is declared in `project.yml` (a Team ID is not a secret — it is public in every signed ipa), and without it any device build stops at *"Signing for 'WebTerm' requires a development team"*. `CODE_SIGN_IDENTITY` is set to the modern `Apple Development` at **target** level, because XcodeGen's iOS-application preset injects the deprecated pre-Xcode-12 `iPhone Developer` string at target level, which outranks anything set project-wide. **Device build works on the free team** (verified in this wave, commit `c4f8b5b`): with automatic signing plus `-allowProvisioningUpdates`, Xcode mints an on-the-fly **7-day** free-team provisioning profile and the real-device build reports `BUILD SUCCEEDED`. A 7-day profile expires — re-run the build to re-mint. **Push entitlements are opt-in, via one frozen env switch.** `aps-environment` lives in `App/WebTerm/WebTerm.entitlements`, which is **not attached by default**: ```bash cd ios && WEBTERM_PUSH_ENTITLEMENTS=App/WebTerm/WebTerm.entitlements xcodegen generate # push ON cd ios && xcodegen generate # push OFF (default) ``` Plainly: **on a free personal team, switching push ON makes the device build fail** — verbatim: > `Personal development teams, including "Yaojia Wang", do not support the Push Notifications capability` So the switch is usable only on a **paid** team with the Push Notifications capability enabled on the App ID, and a **release** build additionally needs `aps-environment` flipped from `development` to `production` (that flip belongs to the release pipeline, not to the checked-in file). With the variable unset, `CODE_SIGN_ENTITLEMENTS` resolves against an empty project-level default, so no entitlements file is signed in at all — which is exactly why the default build works on the free team. The switch is read by XcodeGen at **generate** time and its default is declared as a project-level build setting (which outranks the build-time process environment), so an exported shell variable can never silently attach entitlements to a project that was generated without it. `UIBackgroundModes: [remote-notification]` is unconditional: a background *mode* is not an Apple capability, so it is safe on a free team — unlike the `aps-environment` *entitlement*. ## Access token (`WEBTERM_TOKEN`) — how this client authenticates A server started with `WEBTERM_TOKEN=<16–512 chars of [A-Za-z0-9._~+/=-]>` gates **every** HTTP route *and* the WebSocket upgrade behind a `webterm_auth` cookie (`src/http/auth.ts`). A host with **no** token configured is byte-identical to before the feature: nothing is stored, so no `Cookie` header is ever sent and LAN zero-config still works. **Honest boundary, mirroring `src/http/auth.ts`: this is a bar-raiser, NOT a TLS/Tailscale substitute.** On a bare-LAN `ws://` / `http://` deployment the cookie and the token travel in **cleartext** — anyone sniffing the LAN sees the token and can **replay** it (no per-request nonce, no channel binding). It is a single shared secret: no per-user identity, no revocation except changing the env var and restarting. It only meaningfully hardens the relay/tunnel path where the edge terminates TLS. It does not make the server safe to port-forward. How the native client speaks it: - **The client hand-writes `Cookie: webterm_auth=` itself** — no `HTTPCookieStorage`, no `Set-Cookie` parsing (`httpShouldSetCookies = false`, `httpCookieAcceptPolicy = .never`, `httpCookieStorage = nil` on the WS session). Three stamping points, all through the same derivation helper: the `APIClient` route builder (`Endpoints.swift`, the same choke point that enforces Origin-iff-guarded — and it stamps the cookie **unconditionally**, read-only GETs included), the WS-upgrade request (`SessionCore/URLSessionTermTransport.swift`), and the shared production HTTP transport (`Wiring/URLSessionHTTPTransport.swift`), which resolves the token **lazily per request from the request's own origin** and therefore covers the hand-built requests that don't go through `APIClient` at all (e.g. the app-layer `GET /projects/diff` fetcher) — "every request carries the token" is true by construction rather than per call site. A request that *already* carries a `Cookie` is left untouched, because the pairing probe authenticates with a **candidate** token that is not in the Keychain yet. - **The `Cookie` is orthogonal to `Origin`**, never a replacement: the server checks Origin first, then the cookie, so guarded routes still carry `Origin` and read-only routes still carry none. - **`POST /auth` is used once, at pairing time**, as a validation probe with the four outcomes the server actually produces: `204` **with** `Set-Cookie` = the token is right (store it) · `204` **without** `Set-Cookie` = that host has auth disabled (store nothing, and do *not* treat it as authenticated) · `401` = wrong token · `429` = rate-limited (10/min/IP). The request sends `{"token":"…"}` with `Accept: application/json` — an `Accept` containing `text/html` makes the server answer a form-style `302` instead of `204`/`401`. - **Storage**: per host, in the **Keychain**, under the existing `SecItemShim` conventions — `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, no `kSecAttrSynchronizable` (never iCloud-synced, never in a device backup that leaves the phone). The `AccessToken` type validates charset/length at the keyboard (the same regex the server enforces at config load), redacts `description`/`debugDescription`/`customMirror`, and is deliberately **not** `Codable` — so no interpolation, `dump()`, or reflection-based crash reporter can serialise it by accident. It is never logged and never placed in a URL. - **401 handling is typed, not generic**: a REST 401 throws `APIClientError.unauthorized`, and a **401 on the WS upgrade is terminal** (`TermTransportError.unauthorized` → `connection(.failed(.unauthorized))`) — it never enters the reconnect back-off loop, because re-dialling the handshake with one wrong shared token is a brute-force loop with no possible upside. Same tier as `.replayTooLarge`: a terminal state with actionable copy, not a retry. - **UI**: a failure whose remedy *might* be a token offers an inline token prompt on the pairing screen (wrong token / rate-limited / "this host has no auth at all" / shape violation each get their own copy, and none of them ever echoes the token). Already-paired hosts can have their token updated or be removed outright from the same screen; removing a host also de-registers this device's APNs token for it. ## 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](https://github.com/migueldeicaza/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:port` and makes **zero network calls until you confirm** (a scanned code is untrusted input). A two-step probe verifies reachability + the `Origin` handshake before saving the host to the **Keychain**. Per-`§5.4` warning 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 via `UIKeyCommand`. - **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. The client and server halves are built and unit-tested, but the **end-to-end path needs a paid Apple account**: a `.p8` on the server *and* the Push Notifications capability, which the free personal team cannot grant — so the `aps-environment` entitlement ships behind the `WEBTERM_PUSH_ENTITLEMENTS` switch (see [Signing, device builds, and the push-entitlements switch](#signing-device-builds-and-the-push-entitlements-switch)) and lock-screen Allow/Deny has never been exercised on hardware here. Server side: [`PLAN_IOS_CLIENT.md`](../docs/PLAN_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)` + inject `claude\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 `/events` drill-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. ### P2 — polish (this wave) - **Terminal find bar** — a magnifier toolbar toggle opens a search field pinned **top-right** (mirroring the web `#searchbox`); Enter or `chevron.down` = next match, `chevron.up` = previous, `xmark` closes and clears (mirroring the web's Enter / Shift+Enter / Esc). The engine is SwiftTerm's **own** scrollback search (`findNext`/`findPrevious`/`clearSearch`) — the selection *is* the highlight. Search is **pure read**: it produces zero PTY bytes, so it also works on an exited (read-only) session. The query is passed through byte-for-byte (a single space is a legal search term; nothing is trimmed). - **Voice push-to-talk** — an extra 🎤 key appended **after** the 17 existing key-bar keys (their order and accessibility labels are unchanged, and the mic key deliberately maps to no bytes at all). Press-and-hold dictates; on release the transcript is **sanitised** (`\r`, `\n`, `\t`, ESC and every other C0/C1 control character stripped, multi-line folded to one line, length-capped) and put in front of you for **confirmation** — nothing is ever injected before you confirm, and what is injected does **not** end with `\r`, so a misheard command can never execute itself. Confirming opens a **1.5 s undo window** before the injection lands. A whole-utterance matcher (ported from the web `voice-commands.ts`, with the same negation guard and 0.6 confidence floor) can resolve a **held tool gate** by voice — exact-sentence match only, so "确认一下这个" is text, not an approval. Two **epoch** gates discard a decision if the session or connection changed between dictating and confirming. Microphone / speech permission denial and recogniser failure are distinct, actionable states, and a read-only terminal refuses to start recording at all. - **Theme + Dynamic Type** — a Settings sheet (gear in the sessions toolbar, on both the stack and the split layouts) offers **follow system / dark / light**. The default is **dark**, byte-identical to the previously hard-locked appearance; unknown persisted values fall back to dark. Light mode is a real code path, not just an unlock: status / timeline / accent tokens have light variants that clear **WCAG 1.4.11 (≥ 3:1)** against their background, and the terminal canvas has its own per-scheme palette (dark `#100F0D`/`#ECE9E3`, light `#F6F7F9`/`#1A1D24` — mirroring the web `THEMES.light`) with ≥ 7:1 foreground contrast. Dynamic Type up to **AX5** is measured, not assumed: gate banner, telemetry chips and meta rows are asserted not to overflow 320 pt, with a numeric clamp keeping tabular figures from blowing up the layout. **Known gap (measured, not fixed):** the fixed 52 pt key-bar needs ~108 pt at AX5, so key caps clip — recorded as a `withKnownIssue` in `DynamicTypeLayoutTests` so the test starts complaining once it is fixed. - **Project git panel** — the ambient git state the server has served since w6, now on the phone: a **sync band** (`↑ahead ↓behind`, upstream, detached-HEAD and fetch-staleness states — green "in sync" *only* when `↑0 ↓0` **and** the last fetch is under an hour old), a **log** with the unpushed-commit boundary drawn after the last unpushed commit, **PR status**, and **stage / commit / push / fetch** writes. Every write shows the server's own error text verbatim when it rejects; a git-credential `401` from `push` is deliberately *not* confused with an access-token `401`. - **Worktree lifecycle** — create (with the web's 9 branch-name rules enforced client-side, so an invalid name never reaches the network), **prune**, and **remove** behind two-step confirmation: a `409` promotes to an explicit force-confirm rather than auto-retrying, and the main/locked worktrees offer no remove action at all. - **`claude --resume` history** — `GET /sessions` filtered to the project's own subtree (no half-path prefix matches), server order preserved. The session id is whitelisted (`[A-Za-z0-9._-]{1,128}`) **before** it is composed into the bootstrap command line, and a row whose id fails that check offers no resume button. - **`?join=` interop with the web share QR** — the web 🔗 share link (`http(s)://[:]/?join=`) now opens the same session in the app. Because an http(s) URL is a far bigger input surface than the private `webterminal://` scheme, the whitelist is stricter: exactly one `join` key, a valid v4 UUID, empty-or-`/` path, no fragment, no userinfo, no extra query keys — anything else is ignored. The host is resolved **only** through the already-paired host list (`HostEndpoint.originHeader`); an unknown origin opens pairing with a hint that never echoes the scanned link back. ### 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 **Merged.** The client is not on a side branch any more: `feat/ios-client` was merged into **`develop`** long ago (`git merge-base --is-ancestor feat/ios-client develop` → true) and iOS work continues there. P0, P1, the iPad adaptation and P2 have all shipped. Test suites, and where each number comes from: | Layer | Size | Source of the number | |---|---|---| | Logic packages | WireProtocol 59 · SessionCore 108 · HostRegistry 73 · APIClient 125 · ClientTLS 84 (**449**), plus 3 in TestSupport | static count of `@Test` declarations (`grep -rh '@Test' ios/Packages/*/Tests`); for the four packages this wave touched, that count equals the executed counts quoted in commits `850531f` / `a5fa843` | | App bundle (`WebTermTests`) | **532** `@Test` declarations | static count (`grep -rh '@Test' ios/App/WebTermTests`) — a declaration count, not an executed count (parameterised cases expand) | | Integration (real Node server) | **10** | `grep '@Test' ios/IntegrationTests/*.swift`; the harness boots `tsx src/server.ts` itself | | XCUITest | 1 scripted happy path (pair → attach → type → approve) + an iPad `ProjectsLayout` suite | `ios/App/WebTermUITests/` | Own-sources coverage, from the runs recorded in the wave commits: APIClient **92.22 %**, HostRegistry **92.49 %**, SessionCore **96.74 %**, ClientTLS **89.49 %** (up from 55.76 %, and newly **inside** the gate), WireProtocol 100 % (P0 close-out). The gate script is `ios/IntegrationTests/scripts/coverage-gate.sh`. The last *executed* full app-suite figure this README can point at is **290 on iPhone 16 + 290 on iPad Pro 11"** from the 2026-07-05 design-polish pass (`PROGRESS_LOG.md`). This wave's authoritative run is the Wave-D acceptance pass — read its entry in [`PROGRESS_LOG.md`](../docs/PROGRESS_LOG.md) for the numbers actually measured, and trust that over any count in this file. **DEFERRED — cannot be verified in this environment:** - **Real hardware**: on-device gestures / IME / camera QR / haptics, the lock-screen Allow walkthrough (Face ID), Stage Manager and iPad pointer/hardware-keyboard passes. Manual step lists live in the T-iOS-18 / T-iOS-30 / T-iPad-5 report entries in `PROGRESS_LOG.md`. - **Paid Apple account**: APNs end-to-end, TestFlight, and the push-entitlements switch (see above — turning it on breaks the free-team device build by design). - **No end-to-end access-token leg**: the token path is covered at unit level (`AccessToken` / probe / cookie-stamping / 401-terminal tests on both the package and app layers), but neither `ios/IntegrationTests` nor the in-simulator live-server smoke starts the server with `WEBTERM_TOKEN` set, so the cookie-gated handshake has never been exercised against a real gated server here. Verified by grep: no `WEBTERM_TOKEN` / `webterm_auth` in either harness. - **GitHub Actions**: `.github/workflows/ios.yml` defines six jobs (`package-tests` matrix + coverage gate, `testsupport-tests`, `app-tests` on iPhone *and* iPad, `integration-tests`, `ui-test` on both idioms, `ios17-floor-tests`). Three previously-dead legs were fixed in `a5fa843` (the app/iPad legs hard-failed on a missing `npm ci`; the iOS-17 leg could silently report green with no runtime installed). Whether a run has since gone green on GitHub was **not** checked here (`gh` is unauthenticated in this environment). Design & task detail live in [`PLAN_IOS_CLIENT.md`](../docs/PLAN_IOS_CLIENT.md) and [`PLAN_IOS_IPAD.md`](../docs/PLAN_IOS_IPAD.md) (§7 / §5 carry a per-task status table); progress in [`PROGRESS_LOG.md`](../docs/PROGRESS_LOG.md). ## ntfy notification bridge (P0 "host finds the phone") The phone is notified via the **existing** [ntfy](https://ntfy.sh) bridge that already ships with `npm run setup-hooks`. **No new code** — this chapter documents and verifies the shipped behavior. > **Still the working channel today.** The APNs path (P1, T-iOS-20/21) is built > and unit-tested on both sides, but it cannot be used end-to-end on the current > **free** Apple team (no Push Notifications capability — see > [the entitlements switch](#signing-device-builds-and-the-push-entitlements-switch)), > so until a paid account exists this bridge is how the host reaches the phone. ### 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.env` spread (`src/session/session.ts:95-102`, comment at `:99-100`). Nothing is hardcoded server-side. ### Setup 1. **Install the ntfy app** on the iPhone (App Store) and allow notifications. 2. **Generate a random topic and subscribe to it.** On the public `ntfy.sh` server, **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`. 3. **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`): ```bash export WEBTERM_NTFY_URL=https://ntfy.sh # or your self-hosted ntfy export WEBTERM_NTFY_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/ npm start ``` Confirmation log line: `scripts/setup-hooks.mjs:296-298`. 4. Restart any running `claude` sessions (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 **`Priority` header** (`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-hooks` **without** the `WEBTERM_NTFY_*` env vars — the reinstall pass strips previously installed ntfy groups (marker cleanup, `scripts/setup-hooks.mjs:185-205`; the ntfy command contains the `WEBTERM_HOOK_URL` marker 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: 1. iPhone: install ntfy, subscribe to `` on `https://ntfy.sh`, allow notifications. 2. Mac: `export WEBTERM_NTFY_URL=https://ntfy.sh WEBTERM_NTFY_TOPIC=`, run `npm run setup-hooks`, confirm the log line `Installed ntfy bridge (NEEDS-INPUT=high, DONE=low)`, then `npm start` from the same shell. 3. 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). 4. When the gate is held (tab badge shows waiting-for-approval), the iPhone should receive a **high-priority** ntfy notification within seconds. 5. Approve and let the task finish — a **low-priority** notification arrives on `Stop`/`SessionEnd`.