Onboarding — six steps ending in a paired device #28
Labels
No labels
needs-info
needs-triage
ready-for-agent
ready-for-human
v2
wayfinder:grilling
wayfinder:map
wayfinder:prototype
wayfinder:research
wayfinder:task
wayfinder:ticket
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
aiko/denpa#28
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
Reference
ADR-0004. Design: 3a.
Landed on
issue/denpa-28-onboarding(denpa-client). 600 tests passing in 42 files, up frommain's 511 in 37.npm run typecheckreports 585error TSlines — byte-identical tomain'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-wsreports pairing state — I checked the handler map (websocket_handler.py:65-82). So the client asks and reads the answer:{"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 fromprocess_pushed_turn(single_conversation.py:37), reachable only from adenpa.speakframe, 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. Anoticearriving 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_idADR-0004 pairs. It is minted once (crypto.randomUUID), stored underdenpa.deviceId, and appended to the dialled URL; the stored address stays the operator's own. Without it every reconnect was a freshclient_uidand therefore a new unapprovable device.Deliberately left out
鍵masked with表示and § 7a's reset still says it clears "address, token, scale, tabs". Those are the 設定 sheet's surfaces, which nobody has built.config-filesis the only model list on the wire. The design's downloading state is already cut.startMic()— one call, sodenpa#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 anonClickand 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_outboundsorts it as speech and she says it out loud instead of it reaching 周波数. That fix is inhermes_plugin/denpa/protocol.py.Thirteen entries appended to
QUESTIONS.md.Branch
issue/denpa-28-onboarding, pushed.npm test: 612 passing in 42 files (main was 511 in 37).npm run typecheckis 585 errors, identical to the recordedmainbaseline, and grepping that output foronboarding|pairing|device-id|App.tsx|websocket-handler|use-onboardingreturns nothing — no new errors in anything I touched. No tests deleted or skipped.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_idon the/client-wsquery 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.tsasserts the wiring statically — the probe guard, the frequency gate, thesendRef, the ApprovalCard mount.onboarding.test.tsxrenders to HTML strings and asserts content.No interaction is exercised. There is no jsdom in this repo (
vitest.config.tssetsenvironment: 'node'; no jsdom, happy-dom or testing-library innode_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 anonClickand that the file contains noonPointerDown/onPointerUp.Wire claims, each with its citation
{"type": "notice", "text": ...}, forwarded verbatim and unspoken — denpasrc/open_llm_vtuber/adapter_handler.py:352-358(FRAME_NOTICEbranch, carrying the ADR-0004 comment about backticks).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-agentgateway/run.py:12269-12275.gateway/pairing.py:43-44(ALPHABET,CODE_LENGTH). The extraction regex matches that shape rather than any backticked span.hermes pairing list's Code column is a hash prefix, so the notice is the only approvable copy — hermes-agentgateway/pairing.py:426-455(generate_codedocstring,_hash_code).gateway/pairing.py:47-52,:591,:646. This is why a 20s re-ask costs nothing while unpaired.gateway/run.py:12247-12270.{"type": "control", "text": "conversation-chain-start"}is sent only bysend_conversation_start_signals, called from exactly one place,process_pushed_turn— denpaconversations/conversation_utils.py:104-114andconversations/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.process_pushed_turnviaenqueue_turnfrom theFRAME_SPEAKbranch — denpaadapter_handler.py:348-351,websocket_handler.py:243-247.interrupt-signal,mic-audio-data,mic-audio-end,raw-audio-data,text-input, the three*-responseanswers,screen-capture,fetch-configs,switch-config,fetch-backgrounds,request-init-config,heartbeat— denpawebsocket_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_idis read off the/client-wsquery string — denparoutes.py:36— and bound atwebsocket_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).device_idin a message body is ignored on purpose — denpawebsocket_handler.py:349. The id can only be presented at the handshake, which is what this branch does.upstream-statuscarriesattachedas a real boolean, pushed on connect and on every adapter attach/detach — denpawebsocket_handler.py:40,:144-152. The verify block reads it through the existingtwo-hop.tsmodel and draws an unreported hop two as unknown, not down.ASK_KINDSisapproval/clarify/confirmwith request typesapproval-request,clarify-request,confirm-request— denpaadapter_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_outboundsorts text containing a backtick or a URL as a notice and everything else as speech — denpahermes_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-startas 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-agentgateway/run.py:12280-12282) — contains no backtick and no URL, soclassify_outboundsorts it asFRAME_SPEAK(protocol.py:139-141), which routes toenqueue_turn(adapter_handler.py:350-352) and thence toprocess_pushed_turn, whose first act issend_conversation_start_signals(single_conversation.py:37,conversation_utils.py:104-114). So an unpaired device that tripsMAX_PENDING_PER_PLATFORM=3(gateway/pairing.py:51) receives chain-start, and the old code would have flipped to paired, opened the gate, drawn 端末承認済み, latcheddenpa.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
audiopayload carryingdisplay_texteven when TTS fails (denpaconversations/tts_manager.py:155-162queues a silent payload). A turn whose text matches the refusal holds atunpairedand shows the sentence verbatim; any other turn proves paired. New exportsSPOKEN_TYPEandisPairingRefusal.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_NOTICEexplicitly rather than inferred from punctuation inclassify_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:
TRANSFERwould have thrown a finished app back into onboarding and cleareddenpa.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.done/wsState/paired, soきこえてる?was sent the instant the socket opened while the user was still on 待受, and re-fired on every identity change ofsendMessage(the ws context memo recomputes on each pushedupstream-status). Now gated on having reached 周波数, withsendMessageheld in a ref.App.tsxreturned<Onboarding>early, bypassing ReceiverChrome and PalContainer — the only surfaces that draw anapproval-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 (denpaconversations/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 inpairing.noteand render on the wait screen.Minors fixed: device id no longer minted as a render-time side effect inside
useMemo;donedropped fromOnboardingState(the localStorage flag is the single source); verify block usesdenpaWashes.signalFill/signalEdgeinstead of borrowing the chat-bubble wash; deadaria-labelon a roleless div removed;SKIP_LABELmade 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 startinganddoes not read a bare backticked word as an offer— then restored.Deliberately left out
鍵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.src-tauritoday, so 集音 calls the existingstartMic().denpa.onboardedflag and the device id. No settings-side re-entry into onboarding; a 最初からやる reset row is § 7a's surface.two-hop.tsuntouched on purpose — the 端末承認済み clause is composed inonboarding-spec.tsinstead, 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, andopenOverlaySettings()on a real Android device.This diff has had no independent review. The global instructions ask for
caveman:cavecrew-revieweron non-trivial changes; no Task/subagent tool exists in this context, so the post-fix state was reviewed by nothing but me.Assumptions
noticecarrying a code, because no pairing-state message exists on the viewer socket. The request is an ordinarytext-input(きこえてる?) re-sent every 20s while unpaired and never once pairing is proven.device_id— ADR-0004 requires it, and #21's ledger put the client half here. The residual hole C0's review recorded stands:device_idis 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.socket 開けない/確認できないbefore connecting,まだ報告がないfor an unreported hop two. Nothing latches; つぎへ is gated onlocal ok && upstream ok && paired.display_textof the firstaudiomessage that actually arrived, andまだ彼女の声は届いていない。when none has. § 3a's聞こえてる。何から始める?was read as a mock line, not copy to hard-code.config-filesvia the existinguseSwitchCharacter— the only model list the go-between sends.startMic(). It still lands at the WebView prompt, since no Rust mic-permission command exists yet.What
denpa#35has to revisitOne 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. Itsnode_moduleswas an empty real directory shadowing the shared install, which made every Chakra test file fail to collect — I replaced it with a junction toF:\Projects\denpa-repos\denpa-client\node_modules. Nonpm installwas run.Shipped. Merged to
mainat8504a2b(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.