Files
web-terminal/ios
Yaojia Wang 5cc755b0b6 fix(ios,android): close the acceptance gaps the review found, add Android CI
- 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.
2026-07-30 16:46:20 +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.