画面共有 — a grant, not a panel #31
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#31
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
画面共有, a grant rather than a panel. Replaces #21, and it is no longer blocked.
It was cut because
HermesAgentnever readinput_data.imagesand/v1/runscould not accept them. Under ADR-0001 we are in-process: a captured frame is aMessageType.PHOTOMessageEvent, Hermes routes it through its own vision pipeline, and there is nothing to wait for upstream.Per the design: an acid indicator that never hides while sharing (blinking dot, 見えている, which display, inline やめる), a display and window picker, and every frame she takes landing in the log with a thumbnail - so "when did she look" is answerable after the fact rather than a matter of trust.
Acceptance criteria
Reference
ADR-0001. Design: 5d. The screen-capture path was deliberately kept in the tree for exactly this.
Branch:
issue/denpa-31-screen-share-grant(denpa-client).What landed. 画面共有 as a grant under client ADR-0001, not a panel.
見せる/やめて, with交信中はずっとandこれから毎回one level down behind a press-and-hold on見せるand a corner notch as the tell. The reducer incomponents/receiver/screen-share.tsis what refuses a standing scope that no hold opened — the timer only opens the reveal, so a caller wiring the keys differently still cannot widen the grant.The grant is the only thing that decides whether a capture stream exists. Nothing is held before it;
やめるon the indicator stops capture rather than hiding a button;今回だけexpires on the frame it paid for; and leaving window mode revokes, because the receiver chrome is the only surface that draws the acid indicator and a grant in force behind a surface that cannot show it is exactly criterion 2's failure.A frame goes up as
{"type": "screen-capture", "image_b64", "mime"}— bytes inline, nodevice_id, no callback id. Both refusals the go-between sends (That capture is too large to send; nothing was sent.andNo adapter is attached, so the captured screen was not delivered.) are matched verbatim and shown on the indicator, not only in a toast. Every frame lands in 受信記録 as a newscreen_capturerow with a thumbnail, a timestamp and the grant it was taken under.Tests.
npm test: 418 passed in 34 files (main was 352 in 30). 63 new across four files. The load-bearing ones are inscreen-share.test.ts: a tap only ever gives今回だけ, twenty taps never reach a standing scope,choose('always')with no reveal open is refused, and opening the reveal grants nothing by itself. Verified red for the right reason first — removing the reveal guard fails exactly those four on assertions.npm run typecheck: 585error TS, byte-identical to main's count, none in any file this branch touches.Deliberately left out. No frame-request listener: no such message exists in either direction (
websocket_handler.py:64-83), so the client takes a frame when the grant lands and at the head of each turn — building a listener would be another #27. No indicator on the pal or the Android overlay; §5d draws no band for a 40px bar, so the mode switch revokes instead.これから毎回is not persisted across a relaunch. No camera, no browser panel, noScreenPanelresurrection.Assumed. The ask is reached from a
画面key in the station bar, because §5d's home for it is the 電波 sheet and that sheet is not built — a grant nobody can reach is a grant nobody can revoke. All three scopes are always offered here, since unlike an approval there is nooptionsarray on the wire to gate them.ScreenCaptureContext.startCapturenow returnsPromise<MediaStream | null>: it swallowed a dismissed OS picker, which would have left the indicator reading見えているover no stream.Not verified. No rendered output was seen and no live capture was driven end to end — the OS picker, the real
getDisplayMediastream, the thumbnail as pixels and a frame actually reaching Hermes' vision pipeline are all unexercised.vitestruns innodewith no jsdom, so the effects that start and stop the stream are pinned by source assertions inscreen-share-wired.test.tsrather than by running them.Eight ledger entries appended to
.denpa-work/QUESTIONS.md.画面共有 — branch
issue/denpa-31-screen-share-grant, pushed toaiko/denpaBranched from
mainat0e91bd9("Merge #27's two-hop connection model").npm test: 443 passed in 34 files (main baseline per the brief: 352 in 30). No pre-existing test deleted or skipped.npm run typecheckreports 585error TSlines — the same count the brief and the ledger record formain. Grepping that output for every path this branch touches (screen-share*,log-rail,log-rows,station-bar,receiver-chrome,chat-history-context,screen-capture-context,websocket-service,use-receiver-chrome,use-screen-share) returns zero matches. No new errors.What was built
A grant model with three scopes — 今回だけ / 交信中はずっと / ずっと — governing a real
getDisplayMediastream, plus the surfaces that make the grant visible and revocable.New:
src/renderer/src/components/receiver/screen-share.ts— the reducer, the wire builders, the copy.src/renderer/src/components/receiver/screen-share-grant.tsx— the two-key ask with the notch, and the acid indicator.src/renderer/src/hooks/receiver/use-screen-share.ts— the grant governing the stream, the capture path, the socket binding.screen-share.test.ts(reducer and wire),screen-share-grant.test.tsx,screen-share-log.test.tsx,screen-share-wired.test.ts.Changed:
receiver-chrome.tsx(indicator + ask over the stage, share key through to the station bar),station-bar.tsx(an optional 画面 key at a 44px target),log-rail.tsx(CaptureRow— thumbnail, timestamp, grant label),log-rows.ts(a fourth row kind,capture),chat-history-context.tsx(appendScreenCaptureMessage),screen-capture-context.tsx(startCapturereturns the stream ornull),websocket-service.tsx(Message.typegainsscreen_capture;Message.image),use-receiver-chrome.ts(mountsuseScreenShare; takes a frame at the head of a turn). Eight entries appended to.denpa-work/QUESTIONS.md.What the tests cover
The reducer end to end: every scope transition, the hold gesture as the reducer can see it, self-revocation of 今回だけ on
captured, revoke from every entry point, and the negative property that no sequence of taps the reducer can see produces a standing grant. The wire builders: exact key set, the two refusal strings, absence ofdevice_id, absence of anydenpa.*string in client code. The log: the capture row's shape, its filtering under 中継のみ, its exclusion from relay-chain counting, and the thumbnail cap. The ask's markup: the notch, the three keys, the keyboard routes, the ARIA. The wiring file asserts over source text — see "could not verify".Wire claims, each with its citation
Verified against the go-between source on
main, not against issue comments.{"type": "screen-capture", "image_b64": ..., "mime": ...}. Handler map entry atdenpa/src/open_llm_vtuber/websocket_handler.py:77("screen-capture": self._handle_screen_capture), dispatched at:468-477. Payload fields read atdenpa/src/open_llm_vtuber/conversations/conversation_handler.py:122(image_b64) and:138(mime, defaulting toimage/png).conversation_handler.py:106-120— the docstring saysprotocolalso accepts apath, but only the inlined image is relayed, because a path the receiver names would be read on the machine running her (ADR-0004).captureMessageemits exactly three keys and a test assertsObject.keys(...).sort() === ['image_b64','mime','type'].device_idis sent: the go-between resolves the device from the connection atwebsocket_handler.py:474(self.device_ids.get(client_uid, client_uid)), and the ledger entry "an ask is owned by the connection, not the device id" records that_route_messageno longer rebinds it from a message body (adapter_handler.py:205-213). Nothing here sends one; a test greps for it.That capture is too large to send; nothing was sent.atconversation_handler.py:135, guarded bylen(image_b64) > protocol.MAX_PHOTO_B64_LENGTHat:126, constant8 * 1024 * 1024atdenpa/hermes_plugin/denpa/protocol.py:42.No adapter is attached, so the captured screen was not delivered.— composed byreport_undeliveredatconversation_handler.py:67-72(f"No adapter is attached, so {what}."), called with"the captured screen was not delivered"at:148.{"type": "error", "message": ...}— built inline atconversation_handler.py:130-137and:70-72. The client already routeserroratwebsocket-handler.tsx:225; this branch additionally subscribes towsService.onMessage, checkstype === 'error', and matches only those two strings, leaving every other error to the toast.websocket_handler.py:64-83:interrupt-signal,mic-audio-data,mic-audio-end,raw-audio-data,text-input, the*-responseanswers,screen-capture,fetch-configs,switch-config,fetch-backgrounds,request-init-config,heartbeat. Nothing is a frame request, so the client decides when to capture rather than listening for one.denpa.*.denpa.photois minted on the far side of the translation, inadapter.send_up(protocol.FRAME_PHOTO_IN, ...)atconversation_handler.py:139-143. A test strips comments and asserts nodenpa.string appears inscreen-share.ts,use-screen-share.tsorreceiver-chrome.tsx.AskKind("approval", "approval_id", "approval-request", "approval-response")atadapter_handler.py:60, with theappr:<id>:<choice>id minted go-between-side per the ledger entry atadapter_handler.py:141-215. This branch sends no approval message at all — the screen-share grant is the client's own decision, which is why all three scopes are always offered.How the two-key hold is enforced, and tested
Nothing standing (交信中はずっと, ずっと) can be reached by a tap. The 見せる key dispatches
pressonpointerdown; the reveal that exposes the two standing scopes only opens after the hold timer fires.releaseonpointerupinside the hold window grants only 今回だけ.pointercanceldispatches a distinctcancelaction (pressedAt: null,revealed: false, no grant) — a press the browser aborts (scroll takeover, palm rejection, window losing the pointer) grants nothing, and a laterreleasecannot cash the cancelled press in.press,release,chooseandactivateall requirestate.asking, so a stray dispatch with no ask on screen cannot mint a grant or open the OS picker.Keyboard and AT: Space/Enter
keydown/keyupdrive the same press/release pair (repeatignored, so a held key reaches the reveal), ArrowDown/ArrowUp dispatchreveal, and a bareclickdispatchesactivate→ 今回だけ. AclickSpentflag swallows exactly one trailing click after release/cancel/grant, so the click the browser fires after a hold's pointerup cannot answer the options the hold just opened.aria-haspopup/aria-expanded/aria-keyshortcutsannounce the hidden options.Tested: the whole transition table, twelve reducer tests specifically on the hold and cancel paths (press+cancel, cancel after the reveal opened, a later release not cashing in a cancelled press, the trailing click being swallowed, a click with no gesture behind it granting 今回だけ), and the negative property above. The gesture timing on a real touch surface is not exercised — see below.
Revocation:
captureNowmirrors the grant and the stream into refs, and afterawait captureAllMedia()re-checks both beforesendMessage/appendScreenCaptureMessage, so a やめる landing during the await drops the frame instead of putting it on the wire. Acapturingref makes one grab at a time. A new effect revokes wheneverwsState !== 'OPEN'(deliberately includingCONNECTING, so a reconnect blip costs the grant rather than carrying sight of the screen into a session nobody granted it for), per ADR-0004's connection binding. Leaving window mode also revokes, routed throughonScreenShareActionso the pending hold timer is cleared.Deliberately left out
ずっとis not persisted across a relaunch. A standing grant to see the screen that returns silently on the next launch is exactly the invisible-grant case ADR-0001 warns about.onShareKey, so existing station-bar callers are unchanged.screen-panel.tsx,use-capture-screen.tsand thebottom-tab.tsxScreenPanel entry are untouched —cut-surfaces.test.tspins their existence and deleting the upstream sidebar is #9's recorded follow-up.What could not be verified
I could not see rendered output and could not drive a live capture end to end. Specifically unverified: the OS display/window picker (that is
getDisplayMedia's own picker — the client draws none), a realMediaStreamand its track label as the indicator's display name, the thumbnail as actual pixels, and a frame arriving at Hermes' vision pipeline.Acceptance criterion 1 ("a captured frame reaches the agent and she can describe it") is unproven by me. Only the message shape is proven, against the go-between source.
vitestrunsenvironment: 'node'with no jsdom and no testing-library, so the effects that start and stop the stream, the hold timer, the socket-revoke effect and the refusal subscription cannot be exercised by rendering. They are pinned by source assertions inscreen-share-wired.test.ts, in the shapechrome-is-mounted.test.tsestablished. That is weaker than running them and I am not claiming otherwise: those assertions pass on the presence of a string, would stay green if a branch became unreachable, and break on an unrelated reformat.The press-and-hold gesture itself (pointerdown/pointerup timing on a real touch surface) is unexercised. What is tested is the reducer's response to every event sequence.
I also did not run a review subagent — no agent-spawn tool is available in this harness. The two defects I fixed before review (the swallowed picker refusal, and
startCapture's unstable identity re-prompting every render) came from my own reading.Assumptions
denpa), not ofdenpa-clientwhere this issue lives, and the isolation guard refuses any git operation aimed at the shareddenpa-clientcheckout. I made a local clone at<worktree>/client, branched from itsmainat0e91bd9, junctionednode_modulesper the brief, and pushed tohttps://git.aiko.works/aiko/denpa.git. The branch is on the real remote and is what should be merged; nothing of value lives only in the worktree. I removed the junction afterwards and confirmed the shared install is intact, per the #9 ledger hazard.optionsarray on the wire here, because there is no ask on the wire at all. ADR-0001's "suppress the notch when only once/deny are offered" belongs to #19's approval card.今回だけmeans one frame: the scope self-revokes oncaptured, which the stream effect then acts on.交信中andずっとdiffer today in what the log records and in surviving turn boundaries, not in surviving a relaunch.Messageoftype: 'screen_capture'carrying animagedata URL, in the same shapereasoningtook. Not counted in a relay chain, filtered out by 中継のみ.ScreenCaptureContext.startCapturereturningPromise<MediaStream | null>is in scope: asPromise<void>it swallowed a dismissed OS picker, which would have left the acid indicator reading 見えている over no stream. The one other caller ignores the return.websocket-handler.tsx:174doessetMessages(message.messages)onhistory-data, which would wipe capture rows. I verified thathistory-datacurrently has no producer: ADR-0002 deleted the go-between's conversation store anddenpa/src/open_llm_vtuber/chat_history_manager.pyno longer exists (only a stale.pyc). So rather than build a parallel client-owned message list for a wipe that cannot currently happen, the comment inwebsocket-service.tsxstates plainly that the record is session-scoped and names 記録 (#30, held) as where a durable one would come from. AC 3 holds for the session, not across a reload.mainitself from this isolation, and my test count is 443 rather than the 418 I first reported because the review fixes added 25 tests.Review findings and how they were handled
Twelve findings, six major. Nothing was skipped outright. Two were fixed differently from the literal fix text:
pointercancelgranted 今回だけ (major) — fixed as written: a distinctcancelaction, four new reducer tests.onClick → choose {scope:'once'}as a bug in its own right: the browser firesclickafter thepointerupthat ends a hold, so that wiring would have granted 今回だけ and answered the very options the hold just opened. Replaced with the guardedactivateaction, theclickSpentflag, and Space/Enter/Arrow handlers. Eight new reducer tests plus markup and source assertions.captureNowraced a revoke (major) — fixed with thesharingNow/streamNow/capturingrefs described above, plus an early return after the frame when the scope isonce, because the ref only catches up on the next render.wsState !== 'OPEN'revoke effect.thumbnailFromdownscales to a 128px-wide q=0.6 JPEG for the stored row, andappendCaptureclearsimageon every capture row past the newestCAPTURE_THUMBNAIL_LIMIT(40) while keeping the row, its time and its grant.captureRownow takesstring | nulland returns aMessage, so a frame the browser cannot decode yields a row with no picture rather than no row. Seven new tests.onScreenShareActionso the hold timer is cleared; the refusal subscription now guards onmessage.type === 'error'; the 画面 key drops the misleadingaria-pressedforaria-expanded+aria-label="画面を見せる"and a non-colour sharing cue;press/release/choosenow requirestate.asking;framesis now rendered on the indicator rather than kept for its own sake.Both refusal strings, the wire shape and the 8 MiB constant are cited above rather than restated from memory; if any of them has drifted since I read it, the corresponding test is what will catch it.
Shipped. Merged to
mainat8237457(phase C2).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.