Onboarding — six steps ending in a paired device #28

Closed
opened 2026-07-30 00:03:48 +00:00 by aiko · 3 comments
Owner

What to build

Onboarding, six screens, ending in a paired device. Replaces #13 - the token step is gone.

Per ADR-0004 there is no token to type. A device generates a local id on first run, connects, and is inert until approved. The client shows its pairing code; you approve it with hermes pairing approve <code> on the machine running her, or from the Hermes dashboard.

The flow is: address, connect, show pairing code, wait for approval, verified, done. The waiting-for-approval state is new and needs drawing: it is not an error, and it can last as long as it takes someone to walk to a desk.

Acceptance criteria

  • No token field anywhere in onboarding
  • The pairing code is legible across a room and copyable
  • The waiting state reads as calm, not failure, and updates itself when approval lands
  • Verification is a real round trip, not an assumption
  • A revoked device returns to pairing rather than appearing broken

Reference

ADR-0004. Design: 3a.

## What to build Onboarding, six screens, ending in a paired device. Replaces #13 - the token step is gone. Per ADR-0004 there is **no token to type**. A device generates a local id on first run, connects, and is inert until approved. The client shows its **pairing code**; you approve it with `hermes pairing approve <code>` on the machine running her, or from the Hermes dashboard. The flow is: address, connect, show pairing code, wait for approval, verified, done. The waiting-for-approval state is new and needs drawing: it is not an error, and it can last as long as it takes someone to walk to a desk. ## Acceptance criteria - [ ] No token field anywhere in onboarding - [ ] The pairing code is legible across a room and copyable - [ ] The waiting state reads as calm, not failure, and updates itself when approval lands - [ ] Verification is a real round trip, not an assumption - [ ] A revoked device returns to pairing rather than appearing broken ## Reference ADR-0004. Design: 3a.
Author
Owner

Landed on issue/denpa-28-onboarding (denpa-client). 600 tests passing in 42 files, up from main's 511 in 37. npm run typecheck reports 585 error TS lines — byte-identical to main's count, and none of them names a file this branch touches.

What it is

Design § 3a's six screens: 待受 / 周波数 / 集音 / 姿 / 重ねて表示 (Android only) / 開局. The issue body's "address, connect, show code, wait, verified, done" is not six screens — it is the phase machine inside § 3a step 1, and it is built as exactly that. No step assumed a token, so none had to be replaced.

There is no token field anywhere, and a test asserts that across all six screens. 周波数 holds the address, the pairing code, the command an operator runs, and the two-hop verify block.

Verification is a real round trip

Nothing on /client-ws reports pairing state — I checked the handler map (websocket_handler.py:65-82). So the client asks and reads the answer:

  • a pairing offer arrives as {"type":"notice","text":...}, forwarded verbatim (adapter_handler.py:352-358) with Hermes' own wording and the plaintext code (hermes-agent/gateway/run.py:12269-12275) — backticks intact, never spoken;
  • {"type":"control","text":"conversation-chain-start"} is sent only from process_pushed_turn (single_conversation.py:37), reachable only from a denpa.speak frame, which only exists because Hermes ran the agent for an authorized sender (run.py:12247-12270). An unapproved device cannot cause it, so it is proof.

The request is an ordinary text-input, re-sent every 20s while unpaired and stopped the moment approval is proven. Hermes rate-limits pairing responses to one per user per 10 minutes and codes live an hour, so re-asking is free while waiting.

The wait reads as calm and elapsing (承認を待っている · 3分12秒, 失敗ではない。誰かが席まで歩く時間だけかかる。) and says plainly that the device can do nothing else until someone approves it. A notice arriving later — a revocation — clears the onboarded flag and reopens at 周波数 with the new code.

Also in this branch

The client now sends the stable device_id ADR-0004 pairs. It is minted once (crypto.randomUUID), stored under denpa.deviceId, and appended to the dialled URL; the stored address stays the operator's own. Without it every reconnect was a fresh client_uid and therefore a new unapprovable device.

Deliberately left out

  • The design doc's own token rows. § 3c still lists masked with 表示 and § 7a's reset still says it clears "address, token, scale, tabs". Those are the 設定 sheet's surfaces, which nobody has built.
  • No clarify or slash-confirm UI. Out of scope for this wave.
  • 姿 lists character configs, because config-files is the only model list on the wire. The design's downloading state is already cut.
  • 集音 calls startMic() — one call, so denpa#35's move to Rust capture can swap it underneath. That call site is the only thing #35 has to revisit here.

Not verified

I cannot see rendered output and could not drive a live turn: there is no jsdom in this repo, so no event was fired. Interaction is covered by the reducers plus a static assertion that every control is a <button type="button"> with an onClick and no pointer-only handler — the shape of test #19's unreachable 許可 key would have failed. The end-to-end round trip (code arrives → operator approves → next ask produces a turn) has not been run against a live gateway.

One gap worth an issue: Hermes' rate-limit refusal ("Too many pairing requests right now~") has no backtick, so classify_outbound sorts it as speech and she says it out loud instead of it reaching 周波数. That fix is in hermes_plugin/denpa/protocol.py.

Thirteen entries appended to QUESTIONS.md.

Landed on `issue/denpa-28-onboarding` (denpa-client). **600 tests passing in 42 files**, up from `main`'s 511 in 37. `npm run typecheck` reports 585 `error TS` lines — byte-identical to `main`'s count, and none of them names a file this branch touches. ### What it is Design § 3a's six screens: 待受 / 周波数 / 集音 / 姿 / 重ねて表示 (Android only) / 開局. The issue body's "address, connect, show code, wait, verified, done" is not six screens — it is the phase machine *inside* § 3a step 1, and it is built as exactly that. No step assumed a token, so none had to be replaced. **There is no token field anywhere**, and a test asserts that across all six screens. 周波数 holds the address, the pairing code, the command an operator runs, and the two-hop verify block. ### Verification is a real round trip Nothing on `/client-ws` reports pairing state — I checked the handler map (`websocket_handler.py:65-82`). So the client asks and reads the answer: - a pairing offer arrives as `{"type":"notice","text":...}`, forwarded verbatim (`adapter_handler.py:352-358`) with Hermes' own wording and the plaintext code (`hermes-agent/gateway/run.py:12269-12275`) — backticks intact, never spoken; - `{"type":"control","text":"conversation-chain-start"}` is sent only from `process_pushed_turn` (`single_conversation.py:37`), reachable only from a `denpa.speak` frame, which only exists because Hermes ran the agent for an authorized sender (`run.py:12247-12270`). **An unapproved device cannot cause it**, so it is proof. The request is an ordinary `text-input`, re-sent every 20s while unpaired and stopped the moment approval is proven. Hermes rate-limits pairing responses to one per user per 10 minutes and codes live an hour, so re-asking is free while waiting. The wait reads as calm and elapsing (`承認を待っている · 3分12秒`, `失敗ではない。誰かが席まで歩く時間だけかかる。`) and says plainly that the device can do nothing else until someone approves it. A `notice` arriving later — a revocation — clears the onboarded flag and reopens at 周波数 with the new code. ### Also in this branch The client now sends the stable `device_id` ADR-0004 pairs. It is minted once (`crypto.randomUUID`), stored under `denpa.deviceId`, and appended to the dialled URL; the stored address stays the operator's own. Without it every reconnect was a fresh `client_uid` and therefore a new unapprovable device. ### Deliberately left out - **The design doc's own token rows.** § 3c still lists `鍵` masked with `表示` and § 7a's reset still says it clears "address, token, scale, tabs". Those are the 設定 sheet's surfaces, which nobody has built. - **No clarify or slash-confirm UI.** Out of scope for this wave. - **姿 lists character configs**, because `config-files` is the only model list on the wire. The design's downloading state is already cut. - **集音 calls `startMic()`** — one call, so `denpa#35`'s move to Rust capture can swap it underneath. That call site is the only thing #35 has to revisit here. ### Not verified I cannot see rendered output and could not drive a live turn: there is no jsdom in this repo, so no event was fired. Interaction is covered by the reducers plus a static assertion that every control is a `<button type="button">` with an `onClick` and no pointer-only handler — the shape of test `#19`'s unreachable 許可 key would have failed. The end-to-end round trip (code arrives → operator approves → next ask produces a turn) has not been run against a live gateway. One gap worth an issue: Hermes' rate-limit refusal ("Too many pairing requests right now~") has no backtick, so `classify_outbound` sorts it as **speech** and she says it out loud instead of it reaching 周波数. That fix is in `hermes_plugin/denpa/protocol.py`. Thirteen entries appended to `QUESTIONS.md`.
Author
Owner

Branch issue/denpa-28-onboarding, pushed. npm test: 612 passing in 42 files (main was 511 in 37). npm run typecheck is 585 errors, identical to the recorded main baseline, and grepping that output for onboarding|pairing|device-id|App.tsx|websocket-handler|use-onboarding returns nothing — no new errors in anything I touched. No tests deleted or skipped.

 Test Files  42 passed (42)
      Tests  612 passed (612)
   Duration  7.58s

What was built

The six-step 同調 flow from design § 3a — 待受 / 周波数 / 集音 / 姿 / 重ねて表示 / 開局 — with the pairing handshake living inside step 1 (周波数) as its own phase machine. The issue body's "address, connect, show code, wait, verified, done" is that inner machine, not a second set of steps; I read it that way. No step assumed a token, so nothing needed replacing — § 3a step 1 already says "No token field", which is ADR-0004's consequence note having landed in the doc.

New files: src/renderer/src/services/pairing.ts (wire → pairing state, pure), services/device-id.ts (mint/persist a device id), components/receiver/onboarding-spec.ts (step list + reducer, pure), components/receiver/onboarding.tsx (the screen), hooks/receiver/use-onboarding.ts (the wiring). Modified: App.tsx, services/websocket-handler.tsx. Plus four test files and the questions ledger.

The client now mints and sends a device_id on the /client-ws query string. That is new; nothing in this client sent one before.

What the tests cover

Two pure reducers (pairing.ts, onboarding-spec.ts) exhaustively: every phase transition, code extraction against real Hermes wording, refusal handling, notice-as-note, revocation, step gating, skip costs. onboarding-wired.test.ts asserts the wiring statically — the probe guard, the frequency gate, the sendRef, the ApprovalCard mount. onboarding.test.tsx renders to HTML strings and asserts content.

No interaction is exercised. There is no jsdom in this repo (vitest.config.ts sets environment: 'node'; no jsdom, happy-dom or testing-library in node_modules), so not a single event was fired anywhere. The click paths are covered only by the reducers plus a static assertion that every control renders as <button type="button"> with an onClick and that the file contains no onPointerDown/onPointerUp.

Wire claims, each with its citation

  • A pairing offer reaches the client as {"type": "notice", "text": ...}, forwarded verbatim and unspoken — denpa src/open_llm_vtuber/adapter_handler.py:352-358 (FRAME_NOTICE branch, carrying the ADR-0004 comment about backticks).
  • The plaintext pairing-code text is Hermes' own wording: Hi~ I don't recognize you yet! / Here's your pairing code: <CODE> / Ask the bot owner to run: hermes pairing approve <platform> <CODE>, code and command backticked — hermes-agent gateway/run.py:12269-12275.
  • The code alphabet excludes I, O, 0 and 1, and length is 8 — hermes-agent gateway/pairing.py:43-44 (ALPHABET, CODE_LENGTH). The extraction regex matches that shape rather than any backticked span.
  • Pending codes are stored salted-hashed and hermes pairing list's Code column is a hash prefix, so the notice is the only approvable copy — hermes-agent gateway/pairing.py:426-455 (generate_code docstring, _hash_code).
  • Pairing responses are rate-limited to one per user per 600s, codes expire after 3600s, max 3 pending per platform — hermes-agent gateway/pairing.py:47-52, :591, :646. This is why a 20s re-ask costs nothing while unpaired.
  • An unauthorized sender never reaches the agent; Hermes returns after emitting the code — hermes-agent gateway/run.py:12247-12270.
  • {"type": "control", "text": "conversation-chain-start"} is sent only by send_conversation_start_signals, called from exactly one place, process_pushed_turn — denpa conversations/conversation_utils.py:104-114 and conversations/single_conversation.py:37; grep -rn send_conversation_start_signals src/ shows no other caller. I originally treated this as proof of pairing. It is not — see the blocker below.
  • A pushed turn only reaches process_pushed_turn via enqueue_turn from the FRAME_SPEAK branch — denpa adapter_handler.py:348-351, websocket_handler.py:243-247.
  • The client-facing viewer handler map — everything this client may send — is interrupt-signal, mic-audio-data, mic-audio-end, raw-audio-data, text-input, the three *-response answers, screen-capture, fetch-configs, switch-config, fetch-backgrounds, request-init-config, heartbeat — denpa websocket_handler.py:65-82. There is no pairing-state message in either direction. That is the whole reason approval has to be inferred from a turn.
  • device_id is read off the /client-ws query string — denpa routes.py:36 — and bound at websocket_handler.py:132 (self.device_ids[client_uid] = device_id or client_uid). A frame addressed to an unmatched device fails closed (adapter_handler.py:210-226).
  • A device_id in a message body is ignored on purpose — denpa websocket_handler.py:349. The id can only be presented at the handshake, which is what this branch does.
  • upstream-status carries attached as a real boolean, pushed on connect and on every adapter attach/detach — denpa websocket_handler.py:40, :144-152. The verify block reads it through the existing two-hop.ts model and draws an unreported hop two as unknown, not down.
  • ASK_KINDS is approval/clarify/confirm with request types approval-request, clarify-request, confirm-request — denpa adapter_handler.py:59-63. Clarify and slash-confirm are translated now, as the briefing claimed. No onboarding UI was built for either, as instructed.
  • classify_outbound sorts text containing a backtick or a URL as a notice and everything else as speech — denpa hermes_plugin/denpa/protocol.py:122-141. This keeps the code intact and is also the root of the blocker.

Nothing in the briefing was contradicted by source.

The blocker review found, and what changed because of it

Review flagged one blocker, four majors, six minors. All were fixed; nothing was skipped.

The blocker is worth reading even if you only skim. My first implementation treated conversation-chain-start as unforgeable proof that the operator had approved the device. It is not. Hermes' max-pending refusal — "Too many pairing requests right now~ Please try again later!" (hermes-agent gateway/run.py:12280-12282) — contains no backtick and no URL, so classify_outbound sorts it as FRAME_SPEAK (protocol.py:139-141), which routes to enqueue_turn (adapter_handler.py:350-352) and thence to process_pushed_turn, whose first act is send_conversation_start_signals (single_conversation.py:37, conversation_utils.py:104-114). So an unpaired device that trips MAX_PENDING_PER_PLATFORM=3 (gateway/pairing.py:51) receives chain-start, and the old code would have flipped to paired, opened the gate, drawn 端末承認済み, latched denpa.onboarded=true, and displayed the refusal as her first transmission — on a device that is inert. Same class of hole as #19's forged grant. I verified every step of that chain at source before believing it.

Fix: chain-start is no longer a transition at all. Proof is now the turn's text, which always arrives as an audio payload carrying display_text even when TTS fails (denpa conversations/tts_manager.py:155-162 queues a silent payload). A turn whose text matches the refusal holds at unpaired and shows the sentence verbatim; any other turn proves paired. New exports SPOKEN_TYPE and isPairingRefusal.

This is a client-side mitigation of an upstream design flaw. The correct fix is hop-one: all pairing text — the code and the refusal — should be classified FRAME_NOTICE explicitly rather than inferred from punctuation in classify_outbound. That is in the questions ledger. Until it lands, the client is inferring authorization from message content, which is fragile to any rewording of Hermes' refusal string.

The four majors, all fixed:

  • The bare-backtick fallback read any notice containing an 8-char backticked token from the code alphabet as a pairing offer, and that path drove revocation — a notice saying TRANSFER would have thrown a finished app back into onboarding and cleared denpa.onboarded. Replaced with wording-anchored matching ("pairing code" followed within 40 chars by a code), with the approve-command match taking priority. Revocation additionally now requires a real code offer.
  • The probe effect gated only on done/wsState/paired, so きこえてる? was sent the instant the socket opened while the user was still on 待受, and re-fired on every identity change of sendMessage (the ws context memo recomputes on each pushed upstream-status). Now gated on having reached 周波数, with sendMessage held in a ref.
  • App.tsx returned <Onboarding> early, bypassing ReceiverChrome and PalContainer — the only surfaces that draw an approval-request. Since pairing is proven at step 1, steps 2–6 ran on an already-paired device that could be sent a suspending ask with nowhere to answer it, and upstream reads silence as an approval that worked (denpa conversations/conversation_handler.py:83-95). ApprovalCard now renders in a fixed full-bleed layer above onboarding.
  • case NOTICE_TYPE: break; swallowed every notice, so operational text the go-between forwarded verbatim reached no surface. Non-offer notices now land in pairing.note and render on the wait screen.

Minors fixed: device id no longer minted as a render-time side effect inside useMemo; done dropped from OnboardingState (the localStorage flag is the single source); verify block uses denpaWashes.signalFill/signalEdge instead of borrowing the chat-bubble wash; dead aria-label on a roleless div removed; SKIP_LABEL made total over skippable keys with an assertion; two weak test assertions tightened (one passed against a permanently-disabled button, one was misnamed).

I confirmed the two new guard tests go red against the old behaviour — reinstating chain-start-as-proof and the bare-backtick fallback failed exactly is not proven by a turn merely starting and does not read a bare backticked word as an offer — then restored.

Deliberately left out

  • No clarify or slash-confirm UI. Explicitly out of scope for the wave.
  • No edits to the design doc's remaining token rows. § 3c still lists a masked with a 表示 reveal, and § 7a's reset still says it clears "address, token, scale, tabs". Both belong to the 設定 sheet, which nobody has built. Editing a doc a sibling branch may also be touching seemed worse than recording it.
  • No 姿 downloading / not-fetched states — the design's own Build-status table cuts them.
  • No OS-level mic permission command. None exists in src-tauri today, so 集音 calls the existing startMic().
  • No persisted pairing state beyond the denpa.onboarded flag and the device id. No settings-side re-entry into onboarding; a 最初からやる reset row is § 7a's surface.
  • two-hop.ts untouched on purpose — the 端末承認済み clause is composed in onboarding-spec.ts instead, to avoid the add/add conflicts B2 recorded.

Could not verify

Everything visual, and the live round trip. I did not run the app, did not connect to a live gateway, and did not observe a real pairing code arrive, an operator approve it, or the next ask produce a turn. Unexercised: the 20s poll interval, navigator.clipboard, the elapsed-time tick, and openOverlaySettings() on a real Android device.

This diff has had no independent review. The global instructions ask for caveman:cavecrew-reviewer on non-trivial changes; no Task/subagent tool exists in this context, so the post-fix state was reviewed by nothing but me.

Assumptions

  1. The six steps are design § 3a's, not the issue body's six-phase list; the body's list is the phase machine inside step 1.
  2. Approval is inferred from turn text, and re-offer from a notice carrying a code, because no pairing-state message exists on the viewer socket. The request is an ordinary text-input (きこえてる?) re-sent every 20s while unpaired and never once pairing is proven.
  3. The client mints and sends a device_id — ADR-0004 requires it, and #21's ledger put the client half here. The residual hole C0's review recorded stands: device_id is unauthenticated at the handshake, so a LAN viewer claiming an offline victim's id can receive that device's notices, pairing code included. Closing it needs a client-presented per-device secret; not this issue.
  4. The verify block is always drawn on 周波数 and shows unknown as unknown — socket 開けない / 確認できない before connecting, まだ報告がない for an unreported hop two. Nothing latches; つぎへ is gated on local ok && upstream ok && paired.
  5. 開局 renders the display_text of the first audio message that actually arrived, and まだ彼女の声は届いていない。 when none has. § 3a's 聞こえてる。何から始める? was read as a mock line, not copy to hard-code.
  6. 姿 lists character configs from config-files via the existing useSwitchCharacter — the only model list the go-between sends.
  7. 集音 is one call to the existing startMic(). It still lands at the WebView prompt, since no Rust mic-permission command exists yet.
  8. Eleven Japanese strings were coined (合図 · PAIRING CODE, これを実行してもらう, 承認を待っている, the calm note, the inertness note, つぎへ / もどる / 今はしない / はじめる, three skip costs) because § 3a specifies no wording for the pairing block. These are my words, not the designer's.

What denpa#35 has to revisit

One call site: 集音's single startMic(). Swap capture underneath it and the screen needs no other change. That is also where the OS permission screen belongs — the current step lands the user at the WebView prompt, which ADR-0002 says should never happen. Noted in the ledger.

Environment note

This branch was already held by a worktree at F:/Projects/denpa-repos/denpa-client/.claude/worktrees/wf_8f392aa4-202-1, so the main checkout could not check it out; I worked in that worktree. Its node_modules was an empty real directory shadowing the shared install, which made every Chakra test file fail to collect — I replaced it with a junction to F:\Projects\denpa-repos\denpa-client\node_modules. No npm install was run.

Branch `issue/denpa-28-onboarding`, pushed. `npm test`: 612 passing in 42 files (main was 511 in 37). `npm run typecheck` is 585 errors, identical to the recorded `main` baseline, and grepping that output for `onboarding|pairing|device-id|App.tsx|websocket-handler|use-onboarding` returns nothing — no new errors in anything I touched. No tests deleted or skipped. ``` Test Files 42 passed (42) Tests 612 passed (612) Duration 7.58s ``` ## What was built The six-step 同調 flow from design § 3a — 待受 / 周波数 / 集音 / 姿 / 重ねて表示 / 開局 — with the pairing handshake living inside step 1 (周波数) as its own phase machine. The issue body's "address, connect, show code, wait, verified, done" is that inner machine, not a second set of steps; I read it that way. No step assumed a token, so nothing needed replacing — § 3a step 1 already says "No token field", which is ADR-0004's consequence note having landed in the doc. New files: `src/renderer/src/services/pairing.ts` (wire → pairing state, pure), `services/device-id.ts` (mint/persist a device id), `components/receiver/onboarding-spec.ts` (step list + reducer, pure), `components/receiver/onboarding.tsx` (the screen), `hooks/receiver/use-onboarding.ts` (the wiring). Modified: `App.tsx`, `services/websocket-handler.tsx`. Plus four test files and the questions ledger. The client now mints and sends a `device_id` on the `/client-ws` query string. That is new; nothing in this client sent one before. ## What the tests cover Two pure reducers (`pairing.ts`, `onboarding-spec.ts`) exhaustively: every phase transition, code extraction against real Hermes wording, refusal handling, notice-as-note, revocation, step gating, skip costs. `onboarding-wired.test.ts` asserts the wiring statically — the probe guard, the frequency gate, the `sendRef`, the ApprovalCard mount. `onboarding.test.tsx` renders to HTML strings and asserts content. **No interaction is exercised.** There is no jsdom in this repo (`vitest.config.ts` sets `environment: 'node'`; no jsdom, happy-dom or testing-library in `node_modules`), so not a single event was fired anywhere. The click paths are covered only by the reducers plus a static assertion that every control renders as `<button type="button">` with an `onClick` and that the file contains no `onPointerDown`/`onPointerUp`. ## Wire claims, each with its citation - A pairing offer reaches the client as `{"type": "notice", "text": ...}`, forwarded verbatim and unspoken — denpa `src/open_llm_vtuber/adapter_handler.py:352-358` (`FRAME_NOTICE` branch, carrying the ADR-0004 comment about backticks). - The plaintext pairing-code text is Hermes' own wording: `Hi~ I don't recognize you yet!` / `Here's your pairing code: <CODE>` / `Ask the bot owner to run: hermes pairing approve <platform> <CODE>`, code and command backticked — hermes-agent `gateway/run.py:12269-12275`. - The code alphabet excludes I, O, 0 and 1, and length is 8 — hermes-agent `gateway/pairing.py:43-44` (`ALPHABET`, `CODE_LENGTH`). The extraction regex matches that shape rather than any backticked span. - Pending codes are stored salted-hashed and `hermes pairing list`'s Code column is a hash prefix, so the notice is the only approvable copy — hermes-agent `gateway/pairing.py:426-455` (`generate_code` docstring, `_hash_code`). - Pairing responses are rate-limited to one per user per 600s, codes expire after 3600s, max 3 pending per platform — hermes-agent `gateway/pairing.py:47-52`, `:591`, `:646`. This is why a 20s re-ask costs nothing while unpaired. - An unauthorized sender never reaches the agent; Hermes returns after emitting the code — hermes-agent `gateway/run.py:12247-12270`. - `{"type": "control", "text": "conversation-chain-start"}` is sent only by `send_conversation_start_signals`, called from exactly one place, `process_pushed_turn` — denpa `conversations/conversation_utils.py:104-114` and `conversations/single_conversation.py:37`; `grep -rn send_conversation_start_signals src/` shows no other caller. **I originally treated this as proof of pairing. It is not — see the blocker below.** - A pushed turn only reaches `process_pushed_turn` via `enqueue_turn` from the `FRAME_SPEAK` branch — denpa `adapter_handler.py:348-351`, `websocket_handler.py:243-247`. - The client-facing viewer handler map — everything this client may send — is `interrupt-signal`, `mic-audio-data`, `mic-audio-end`, `raw-audio-data`, `text-input`, the three `*-response` answers, `screen-capture`, `fetch-configs`, `switch-config`, `fetch-backgrounds`, `request-init-config`, `heartbeat` — denpa `websocket_handler.py:65-82`. There is **no** pairing-state message in either direction. That is the whole reason approval has to be inferred from a turn. - `device_id` is read off the `/client-ws` query string — denpa `routes.py:36` — and bound at `websocket_handler.py:132` (`self.device_ids[client_uid] = device_id or client_uid`). A frame addressed to an unmatched device fails closed (`adapter_handler.py:210-226`). - A `device_id` in a message *body* is ignored on purpose — denpa `websocket_handler.py:349`. The id can only be presented at the handshake, which is what this branch does. - `upstream-status` carries `attached` as a real boolean, pushed on connect and on every adapter attach/detach — denpa `websocket_handler.py:40`, `:144-152`. The verify block reads it through the existing `two-hop.ts` model and draws an unreported hop two as unknown, not down. - `ASK_KINDS` is `approval`/`clarify`/`confirm` with request types `approval-request`, `clarify-request`, `confirm-request` — denpa `adapter_handler.py:59-63`. Clarify and slash-confirm are translated now, as the briefing claimed. No onboarding UI was built for either, as instructed. - `classify_outbound` sorts text containing a backtick or a URL as a notice and everything else as speech — denpa `hermes_plugin/denpa/protocol.py:122-141`. This keeps the code intact and is also the root of the blocker. Nothing in the briefing was contradicted by source. ## The blocker review found, and what changed because of it Review flagged one blocker, four majors, six minors. All were fixed; nothing was skipped. **The blocker is worth reading even if you only skim.** My first implementation treated `conversation-chain-start` as unforgeable proof that the operator had approved the device. It is not. Hermes' max-pending refusal — "Too many pairing requests right now~ Please try again later!" (hermes-agent `gateway/run.py:12280-12282`) — contains no backtick and no URL, so `classify_outbound` sorts it as `FRAME_SPEAK` (`protocol.py:139-141`), which routes to `enqueue_turn` (`adapter_handler.py:350-352`) and thence to `process_pushed_turn`, whose first act is `send_conversation_start_signals` (`single_conversation.py:37`, `conversation_utils.py:104-114`). So an **unpaired** device that trips `MAX_PENDING_PER_PLATFORM=3` (`gateway/pairing.py:51`) receives chain-start, and the old code would have flipped to paired, opened the gate, drawn 端末承認済み, latched `denpa.onboarded=true`, and displayed the refusal as her first transmission — on a device that is inert. Same class of hole as #19's forged grant. I verified every step of that chain at source before believing it. Fix: chain-start is no longer a transition at all. Proof is now the turn's *text*, which always arrives as an `audio` payload carrying `display_text` even when TTS fails (denpa `conversations/tts_manager.py:155-162` queues a silent payload). A turn whose text matches the refusal holds at `unpaired` and shows the sentence verbatim; any other turn proves paired. New exports `SPOKEN_TYPE` and `isPairingRefusal`. **This is a client-side mitigation of an upstream design flaw.** The correct fix is hop-one: all pairing text — the code *and* the refusal — should be classified `FRAME_NOTICE` explicitly rather than inferred from punctuation in `classify_outbound`. That is in the questions ledger. Until it lands, the client is inferring authorization from message content, which is fragile to any rewording of Hermes' refusal string. The four majors, all fixed: - The bare-backtick fallback read *any* notice containing an 8-char backticked token from the code alphabet as a pairing offer, and that path drove revocation — a notice saying `TRANSFER` would have thrown a finished app back into onboarding and cleared `denpa.onboarded`. Replaced with wording-anchored matching ("pairing code" followed within 40 chars by a code), with the approve-command match taking priority. Revocation additionally now requires a real code offer. - The probe effect gated only on `done`/`wsState`/`paired`, so `きこえてる?` was sent the instant the socket opened while the user was still on 待受, and re-fired on every identity change of `sendMessage` (the ws context memo recomputes on each pushed `upstream-status`). Now gated on having reached 周波数, with `sendMessage` held in a ref. - `App.tsx` returned `<Onboarding>` early, bypassing ReceiverChrome and PalContainer — the only surfaces that draw an `approval-request`. Since pairing is proven at step 1, steps 2–6 ran on an already-paired device that could be sent a suspending ask with nowhere to answer it, and upstream reads silence as an approval that worked (denpa `conversations/conversation_handler.py:83-95`). ApprovalCard now renders in a fixed full-bleed layer above onboarding. - `case NOTICE_TYPE: break;` swallowed every notice, so operational text the go-between forwarded verbatim reached no surface. Non-offer notices now land in `pairing.note` and render on the wait screen. Minors fixed: device id no longer minted as a render-time side effect inside `useMemo`; `done` dropped from `OnboardingState` (the localStorage flag is the single source); verify block uses `denpaWashes.signalFill`/`signalEdge` instead of borrowing the chat-bubble wash; dead `aria-label` on a roleless div removed; `SKIP_LABEL` made total over skippable keys with an assertion; two weak test assertions tightened (one passed against a permanently-disabled button, one was misnamed). I confirmed the two new guard tests go red against the old behaviour — reinstating chain-start-as-proof and the bare-backtick fallback failed exactly `is not proven by a turn merely starting` and `does not read a bare backticked word as an offer` — then restored. ## Deliberately left out - No clarify or slash-confirm UI. Explicitly out of scope for the wave. - No edits to the design doc's remaining token rows. § 3c still lists a masked `鍵` with a `表示` reveal, and § 7a's reset still says it clears "address, token, scale, tabs". Both belong to the 設定 sheet, which nobody has built. Editing a doc a sibling branch may also be touching seemed worse than recording it. - No 姿 downloading / not-fetched states — the design's own Build-status table cuts them. - No OS-level mic permission command. None exists in `src-tauri` today, so 集音 calls the existing `startMic()`. - No persisted pairing state beyond the `denpa.onboarded` flag and the device id. No settings-side re-entry into onboarding; a 最初からやる reset row is § 7a's surface. - `two-hop.ts` untouched on purpose — the 端末承認済み clause is composed in `onboarding-spec.ts` instead, to avoid the add/add conflicts B2 recorded. ## Could not verify Everything visual, and the live round trip. I did not run the app, did not connect to a live gateway, and did not observe a real pairing code arrive, an operator approve it, or the next ask produce a turn. Unexercised: the 20s poll interval, `navigator.clipboard`, the elapsed-time tick, and `openOverlaySettings()` on a real Android device. **This diff has had no independent review.** The global instructions ask for `caveman:cavecrew-reviewer` on non-trivial changes; no Task/subagent tool exists in this context, so the post-fix state was reviewed by nothing but me. ## Assumptions 1. The six steps are design § 3a's, not the issue body's six-phase list; the body's list is the phase machine inside step 1. 2. Approval is inferred from turn *text*, and re-offer from a `notice` carrying a code, because no pairing-state message exists on the viewer socket. The request is an ordinary `text-input` (`きこえてる?`) re-sent every 20s while unpaired and never once pairing is proven. 3. The client mints and sends a `device_id` — ADR-0004 requires it, and #21's ledger put the client half here. **The residual hole C0's review recorded stands: `device_id` is unauthenticated at the handshake, so a LAN viewer claiming an offline victim's id can receive that device's notices, pairing code included.** Closing it needs a client-presented per-device secret; not this issue. 4. The verify block is always drawn on 周波数 and shows unknown as unknown — `socket 開けない` / `確認できない` before connecting, `まだ報告がない` for an unreported hop two. Nothing latches; つぎへ is gated on `local ok && upstream ok && paired`. 5. 開局 renders the `display_text` of the first `audio` message that actually arrived, and `まだ彼女の声は届いていない。` when none has. § 3a's `聞こえてる。何から始める?` was read as a mock line, not copy to hard-code. 6. 姿 lists character configs from `config-files` via the existing `useSwitchCharacter` — the only model list the go-between sends. 7. 集音 is one call to the existing `startMic()`. It still lands at the WebView prompt, since no Rust mic-permission command exists yet. 8. Eleven Japanese strings were coined (合図 · PAIRING CODE, これを実行してもらう, 承認を待っている, the calm note, the inertness note, つぎへ / もどる / 今はしない / はじめる, three skip costs) because § 3a specifies no wording for the pairing block. These are my words, not the designer's. ## What `denpa#35` has to revisit One call site: 集音's single `startMic()`. Swap capture underneath it and the screen needs no other change. That is also where the OS permission screen belongs — the current step lands the user at the WebView prompt, which ADR-0002 says should never happen. Noted in the ledger. ## Environment note This branch was already held by a worktree at `F:/Projects/denpa-repos/denpa-client/.claude/worktrees/wf_8f392aa4-202-1`, so the main checkout could not check it out; I worked in that worktree. Its `node_modules` was an empty real directory shadowing the shared install, which made every Chakra test file fail to collect — I replaced it with a junction to `F:\Projects\denpa-repos\denpa-client\node_modules`. No `npm install` was run.
aiko referenced this issue from a commit 2026-07-30 10:01:36 +00:00
aiko referenced this issue from a commit 2026-07-30 10:01:36 +00:00
Author
Owner

Shipped. Merged to main at 8504a2b (phase C3).

Closed as part of reconciling the board with main: the autonomous run's policy was not to auto-close, so shipped work stayed open and the tracker read as though nothing had landed. That policy is now reversed — issues close when they land.

Shipped. Merged to `main` at [`8504a2b`](https://git.aiko.works/aiko/denpa/commit/8504a2b) (phase C3). Closed as part of reconciling the board with `main`: the autonomous run's policy was not to auto-close, so shipped work stayed open and the tracker read as though nothing had landed. That policy is now reversed — issues close when they land.
aiko closed this issue 2026-07-30 13:32:57 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiko/denpa#28
No description provided.