- T-iOS-34's stated acceptance is "最大字号不破版" and it was failing: the key bar froze its height at 52pt while an AX5 keycap needs 108.24pt, so caps clipped — recorded as a withKnownIssue rather than fixed. Height now derives from the content size category (and tracks live changes via registerForTraitChanges); the known-issue marker is gone, replaced by positive assertions including a 12-category no-clip sweep and a guard that stays red if anyone writes the constant back. Honest tradeoff: the keycap font is clamped at .accessibility2, the same policy the design system already applies to dense content, because an unclamped AX5 bar would eat the terminal. A test pins the clamp so the two cannot drift. - Thumbnails silently 401'd on a token-gated host: the pipeline built its own transport with no token source, and by design never throws, so every preview degraded to a placeholder with no signal. Assembled from AppEnvironment now. - Android had zero CI while the token wave shipped 24 files of secret-handling code. The instrumented leg is workflow_dispatch-only and says why in the file: no one has ever seen it green on a runner, and a required leg nobody trusts just produces a false green. - Android persisted a validated token before the host probe succeeded, stranding a secret for a host that never paired. App bundle 534 -> 550 on both simulators, zero known issues. Android 687 -> 691.
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.