Files
web-terminal/ios
Yaojia Wang 97e39949a8 Merge ios-completion: device builds unblocked, access token on both clients, P2 wave
Closes the six remediation items from the 2026-07-29 iOS completion audit plus the
whole P2 wave and Android access-token parity.

The audit's headline was that the client was code-complete but stuck at the device
door: no DEVELOPMENT_TEAM, no entitlements, so it had never run on real hardware
once, and it had fallen two months behind the server (Android had the git panel,
iOS had none) while neither native client could connect at all once WEBTERM_TOKEN
was set.

Package tests 310 -> 452, app bundle 296 -> 550 (iPhone and iPad, zero known
issues), integration 10 -> 32, Android 687 -> 691. ClientTLS went 55.76% -> 89.49%
and is now actually in the coverage gate, which it never was. Device build now
succeeds on the free personal team.

src/ and public/ are untouched — the git-panel endpoints already existed
server-side; iOS simply never consumed them.

# Conflicts:
#	android/.gitignore
#	android/README.md
#	android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt
#	android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt
#	android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt
#	android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt
#	android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt
#	docs/PROGRESS_LOG.md
2026-07-30 18:12:03 +02:00
..

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 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).

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/<Name>. Coverage gate (≥ 80 % own-sources, one package per invocation): ios/IntegrationTests/scripts/coverage-gate.sh <Package> — 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).

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:

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=<16512 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=<t> 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.unauthorizedconnection(.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 terminalSwiftTerm, 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) and lock-screen Allow/Deny has never been exercised on hardware here. Server side: PLAN_IOS_CLIENT.md §T-iOS-20.
  • Deep linkswebterminal://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. The key-bar height is derived from the content size category rather than frozen at 52 pt, so nothing clips at any size. Its keycap font is clamped at .accessibilityLarge — the same dense-content policy the design system already applies to telemetry chips — because an unclamped AX5 bar wants ~108 pt of the 17-key strip and would eat the terminal. So AX3AX5 render keycaps at AX2 size; a test pins the clamp to the design-system constant so the two cannot drift, and another keeps the bar at exactly 52 pt for XSXL.
  • 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 historyGET /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)://<host>[:<port>]/?join=<uuid>) 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 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 and PLAN_IOS_IPAD.md (§7 / §5 carry a per-task status table); progress in PROGRESS_LOG.md.

ntfy notification bridge (P0 "host finds the phone")

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.

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), 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):

    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 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 <your-random-topic> on https://ntfy.sh, allow notifications.
  2. Mac: export WEBTERM_NTFY_URL=https://ntfy.sh WEBTERM_NTFY_TOPIC=<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.