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
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=<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=<t>itself — noHTTPCookieStorage, noSet-Cookieparsing (httpShouldSetCookies = false,httpCookieAcceptPolicy = .never,httpCookieStorage = nilon the WS session). Three stamping points, all through the same derivation helper: theAPIClientroute 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 throughAPIClientat all (e.g. the app-layerGET /projects/difffetcher) — "every request carries the token" is true by construction rather than per call site. A request that already carries aCookieis left untouched, because the pairing probe authenticates with a candidate token that is not in the Keychain yet. - The
Cookieis orthogonal toOrigin, never a replacement: the server checks Origin first, then the cookie, so guarded routes still carryOriginand read-only routes still carry none. POST /authis used once, at pairing time, as a validation probe with the four outcomes the server actually produces:204withSet-Cookie= the token is right (store it) ·204withoutSet-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":"…"}withAccept: application/json— anAcceptcontainingtext/htmlmakes the server answer a form-style302instead of204/401.- Storage: per host, in the Keychain, under the existing
SecItemShimconventions —kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, nokSecAttrSynchronizable(never iCloud-synced, never in a device backup that leaves the phone). TheAccessTokentype validates charset/length at the keyboard (the same regex the server enforces at config load), redactsdescription/debugDescription/customMirror, and is deliberately notCodable— 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,
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. The client and server halves are built and unit-tested, but the end-to-end path needs a paid Apple account: a.p8on the server and the Push Notifications capability, which the free personal team cannot grant — so theaps-environmententitlement ships behind theWEBTERM_PUSH_ENTITLEMENTSswitch (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 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.
P2 — polish (this wave)
- Terminal find bar — a magnifier toolbar toggle opens a search field pinned
top-right (mirroring the web
#searchbox); Enter orchevron.down= next match,chevron.up= previous,xmarkcloses 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 webvoice-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 webTHEMES.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 AX3–AX5 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 XS–XL. - 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 ↓0and 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-credential401frompushis deliberately not confused with an access-token401. - 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
409promotes to an explicit force-confirm rather than auto-retrying, and the main/locked worktrees offer no remove action at all. claude --resumehistory —GET /sessionsfiltered 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 privatewebterminal://scheme, the whitelist is stricter: exactly onejoinkey, 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 neitherios/IntegrationTestsnor the in-simulator live-server smoke starts the server withWEBTERM_TOKENset, so the cookie-gated handshake has never been exercised against a real gated server here. Verified by grep: noWEBTERM_TOKEN/webterm_authin either harness. - GitHub Actions:
.github/workflows/ios.ymldefines six jobs (package-testsmatrix + coverage gate,testsupport-tests,app-testson iPhone and iPad,integration-tests,ui-teston both idioms,ios17-floor-tests). Three previously-dead legs were fixed ina5fa843(the app/iPad legs hard-failed on a missingnpm 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 (ghis 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.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.