承認 — a gated tool suspends the turn and asks #19

Closed
opened 2026-07-29 17:32:08 +00:00 by aiko · 7 comments
Owner

What to build

Hermes can gate destructive tools. A gated tool suspends the turn — the one thing allowed to interrupt you back.

The card carries 承認待ち with a live hold timer, the ask in plain language (not a raw tool signature), the specifics beneath it (fs.write · 1ファイル · 14行差分), and three actions: 許可 (44px), やめて, 差分.

  • 許可 resumes the chain in place and shows 許可した · 実行中 with a live meter.
  • やめて lets her finish the turn without that tool and records 拒否.
  • Either way it lands as a 中継 row you can reopen.
  • On the pal it takes over the transmission band and holds the mic.

Rules: one outstanding ask at a time — a second gated tool queues behind the first. Answering anywhere dismisses everywhere. No timeout ever auto-approves. Every answer lands in the log recording who answered from where.

Design reference: docs/design/denpa-receiver/README.md (card ids 1a, 4a… index into Denpa Receiver.dc.html, openable in a browser).

Acceptance criteria

  • A gated tool suspends the turn until answered; nothing auto-approves on timeout
  • 許可 resumes the chain in place; やめて completes the turn without the tool
  • Both outcomes appear in the log as a reopenable 中継 row recording the answer
  • A second gated tool queues rather than stacking
  • On the pal, the ask takes the transmission band and holds the mic
  • Tests cover approve, deny, queueing, and the no-auto-approve guarantee

Blocked by

Nothing. #17 was listed here because approvals were thought to have no delivery path; they always did (see the reopen comment). Independent of ollvt-hermes-bridge#16 and of the go-between inbound receiver.


Decision — grants one level down

Resolved. The two-key layout stays; session and always live behind a press-and-hold on 許可.

  • Tapping 許可 sends once. Press-and-hold reveals 交信中はずっと (session) and これから毎回 (always), compressed to 交信中 / ずっと where space is tight.
  • The reveal works on the full card and the 168px cuff, so the grant is in the same place on both.
  • Never render four keys. The acid 許可 must remain the one obvious thing to press, and four 44px targets do not fit a parked tab''s cuff.
  • When the API offers only ["once", "deny"], the reveal is absent, not disabled — no dead affordance.
  • The press-and-hold needs a visible tell (a corner notch on 許可). An invisible gesture is this option''s one real cost; do not ship it without the tell.

Notification shade is once/deny only, permanently. Android caps notification actions at three and 許可 / やめて / 差分を見る already fills them; action buttons cannot be long-pressed. This is a floor, not a defect — the pocket case wants the minimal answer. But it means the grant is reachable on two surfaces out of three, and the UI should state that rather than let it be discovered.

Logging: record which grant was given — 許可 · 今回だけ / 許可 · 交信中 / 許可 · ずっと / 拒否 — never a bare "approved". Wherever a standing grant is in force, the 承認後 line says so (fs.write はもうきかない), because a tool that stopped asking is otherwise invisible.

Japanese terms for session/always are newly coined and approved. Option sheet the decision came from: docs/design/denpa-receiver/proposals/approval-options.html.

Additional acceptance criteria

  • 許可 sends once; press-and-hold reveals both standing grants on card and cuff
  • Reveal is absent when the API offers only two values
  • 許可 carries a visible tell that a press-and-hold exists
  • Notification shade offers exactly 許可 / やめて / 差分を見る
  • Log rows record which of the four values was sent
  • A tool under a standing grant is visibly marked as no longer asking
  • Tests cover all four resolutions and the two-value conditional case

Blocked — there is no delivery path for an approval yet

Correcting this issue''s label. It was published as ready-for-agent; it is not buildable.

POST /v1/runs/{run_id}/approval exists for answering, but nothing tells the client an approval is pending:

  • The SSE stream (GET /v1/runs/{run_id}/events) emits message.delta, tool.started, tool.completed, run.completed, run.cancelled, run.failed/run.errored, reasoning.available. None of these carries an approval request.
  • The go-between''s polling path (_await_run_output in hermes_agent.py) handles completed / cancelled / failed only. A run suspended awaiting approval matches none of them, so it spins to poll_timeout and raises TimeoutError.

So today a gated tool does not present as "she stopped and asked". It presents as a hang, and then as a failure. That is the worst possible rendering of it.

Needed upstream before this can be built (tracked at aiko/ollvt-hermes-bridge#17):

  1. An event announcing a pending approval, carrying the ask, the tool, the specifics, and which of once/session/always/deny are on offer for that tool
  2. A run status the go-between recognises as suspended-not-dead, so the poll loop waits instead of timing out
  3. A path for the answer to reach POST /v1/runs/{run_id}/approval

The UI decision recorded above (grants behind a press-and-hold on 許可) stands and does not need revisiting when this unblocks.

## What to build Hermes can gate destructive tools. A gated tool **suspends the turn** — the one thing allowed to interrupt *you* back. The card carries 承認待ち with a live hold timer, **the ask in plain language** (not a raw tool signature), the specifics beneath it (`fs.write · 1ファイル · 14行差分`), and three actions: 許可 (44px), やめて, 差分. - **許可** resumes the chain in place and shows 許可した · 実行中 with a live meter. - **やめて** lets her finish the turn **without** that tool and records 拒否. - Either way it lands as a 中継 row you can reopen. - On the pal it **takes over the transmission band and holds the mic**. **Rules:** one outstanding ask at a time — a second gated tool queues behind the first. Answering anywhere dismisses everywhere. **No timeout ever auto-approves.** Every answer lands in the log recording who answered from where. Design reference: `docs/design/denpa-receiver/README.md` (card ids `1a`, `4a`… index into `Denpa Receiver.dc.html`, openable in a browser). ## Acceptance criteria - [ ] A gated tool suspends the turn until answered; nothing auto-approves on timeout - [ ] 許可 resumes the chain in place; やめて completes the turn without the tool - [ ] Both outcomes appear in the log as a reopenable 中継 row recording the answer - [ ] A second gated tool queues rather than stacking - [ ] On the pal, the ask takes the transmission band and holds the mic - [ ] Tests cover approve, deny, queueing, and the no-auto-approve guarantee ## Blocked by Nothing. `#17` was listed here because approvals were thought to have no delivery path; they always did (see the reopen comment). Independent of `ollvt-hermes-bridge#16` and of the go-between inbound receiver. --- ## Decision — grants one level down Resolved. **The two-key layout stays; `session` and `always` live behind a press-and-hold on 許可.** - Tapping 許可 sends `once`. Press-and-hold reveals 交信中はずっと (`session`) and これから毎回 (`always`), compressed to 交信中 / ずっと where space is tight. - The reveal works on the **full card and the 168px cuff**, so the grant is in the same place on both. - Never render four keys. The acid 許可 must remain the one obvious thing to press, and four 44px targets do not fit a parked tab''s cuff. - When the API offers only `["once", "deny"]`, the reveal is **absent**, not disabled — no dead affordance. - The press-and-hold needs a visible tell (a corner notch on 許可). An invisible gesture is this option''s one real cost; do not ship it without the tell. **Notification shade is `once`/`deny` only, permanently.** Android caps notification actions at three and 許可 / やめて / 差分を見る already fills them; action buttons cannot be long-pressed. This is a floor, not a defect — the pocket case wants the minimal answer. But it means the grant is reachable on two surfaces out of three, and the UI should state that rather than let it be discovered. **Logging:** record *which* grant was given — `許可 · 今回だけ` / `許可 · 交信中` / `許可 · ずっと` / `拒否` — never a bare "approved". Wherever a standing grant is in force, the 承認後 line says so (`fs.write はもうきかない`), because a tool that stopped asking is otherwise invisible. Japanese terms for `session`/`always` are newly coined and approved. Option sheet the decision came from: `docs/design/denpa-receiver/proposals/approval-options.html`. ## Additional acceptance criteria - [ ] 許可 sends `once`; press-and-hold reveals both standing grants on card and cuff - [ ] Reveal is absent when the API offers only two values - [ ] 許可 carries a visible tell that a press-and-hold exists - [ ] Notification shade offers exactly 許可 / やめて / 差分を見る - [ ] Log rows record which of the four values was sent - [ ] A tool under a standing grant is visibly marked as no longer asking - [ ] Tests cover all four resolutions and the two-value conditional case --- ## Blocked — there is no delivery path for an approval yet Correcting this issue''s label. It was published as `ready-for-agent`; it is not buildable. `POST /v1/runs/{run_id}/approval` exists for *answering*, but nothing tells the client an approval is **pending**: - The SSE stream (`GET /v1/runs/{run_id}/events`) emits `message.delta`, `tool.started`, `tool.completed`, `run.completed`, `run.cancelled`, `run.failed`/`run.errored`, `reasoning.available`. **None of these carries an approval request.** - The go-between''s polling path (`_await_run_output` in `hermes_agent.py`) handles completed / cancelled / failed only. A run suspended awaiting approval matches none of them, so it spins to `poll_timeout` and raises `TimeoutError`. So today a gated tool does not present as "she stopped and asked". **It presents as a hang, and then as a failure.** That is the worst possible rendering of it. Needed upstream before this can be built (tracked at aiko/ollvt-hermes-bridge#17): 1. An event announcing a pending approval, carrying the ask, the tool, the specifics, and which of `once`/`session`/`always`/`deny` are on offer for that tool 2. A run status the go-between recognises as suspended-not-dead, so the poll loop waits instead of timing out 3. A path for the answer to reach `POST /v1/runs/{run_id}/approval` The UI decision recorded above (grants behind a press-and-hold on 許可) stands and does not need revisiting when this unblocks.
Author
Owner

Design gap: Hermes offers four approval choices, the design draws two

Hermes'' approval endpoint (POST /v1/runs/{run_id}/approval) resolves with one of:

  • once — allow this call only
  • session — allow this tool for the rest of the session
  • always — allow this tool permanently
  • deny

The design (card 承認, and the 168px parked-tab cuff in card 9) draws two keys: 許可 and やめて. That maps cleanly to once and deny. session and always have nowhere to go.

Availability is conditional — the API offers ["once", "deny"] or the full four depending on the gated tool, so the UI cannot assume four are always on the table.

Needs a decision before this is built:

  1. Two keys only — always send once/deny, never surface the standing grants. Simplest, matches the design as drawn, and keeps the "she stops and asks" property intact every time. Costs repetition on a tool you approve constantly.
  2. Long-press or secondary reveal on 許可 for session/always. Keeps the two-key layout and the 44px targets, hides the escalation one level down. The parked-tab cuff at 168px can hold two keys but not four.
  3. Four keys on the full card, two on the cuff — the pal and the notification shade stay once/deny; the full-size card offers all four.

Whichever is chosen, the log row must record which grant was given, not just that it was approved — a standing grant is the thing you most want to find later.

## Design gap: Hermes offers four approval choices, the design draws two Hermes'' approval endpoint (`POST /v1/runs/{run_id}/approval`) resolves with one of: - `once` — allow this call only - `session` — allow this tool for the rest of the session - `always` — allow this tool permanently - `deny` The design (card 承認, and the 168px parked-tab cuff in card 9) draws **two** keys: 許可 and やめて. That maps cleanly to `once` and `deny`. **`session` and `always` have nowhere to go.** Availability is conditional — the API offers `["once", "deny"]` or the full four depending on the gated tool, so the UI cannot assume four are always on the table. Needs a decision before this is built: 1. **Two keys only** — always send `once`/`deny`, never surface the standing grants. Simplest, matches the design as drawn, and keeps the "she stops and asks" property intact every time. Costs repetition on a tool you approve constantly. 2. **Long-press or secondary reveal on 許可** for `session`/`always`. Keeps the two-key layout and the 44px targets, hides the escalation one level down. The parked-tab cuff at 168px can hold two keys but not four. 3. **Four keys on the full card, two on the cuff** — the pal and the notification shade stay `once`/`deny`; the full-size card offers all four. Whichever is chosen, the log row must record **which** grant was given, not just that it was approved — a standing grant is the thing you most want to find later.
Author
Owner

Warning

Partly superseded — the approval claim below is wrong. Hermes 0.19.0 emits approval.request on the run-events stream with a per-request choices array and sets run status waiting_for_approval (gateway/platforms/api_server.py:6250-6275, :71-74, :1348). The go-between discards the event; Hermes has always had it. See the correction later in this thread and #19. Everything else in this comment stands.


Reality audit

Governing rule: build to reality, Hermes leads. Not doing something is acceptable if it does not break core functionality. Breaking the design is acceptable. Building UI that does nothing is not.

Audited against the actual wire surface:

  • Hermes (hermes-agent 0.19.0, verified running): /health, /v1/models, /v1/runs, /v1/runs/{id}, /v1/runs/{id}/events, /v1/runs/{id}/stop, /v1/runs/{id}/approval
  • Run events: message.delta, tool.started, tool.completed, run.completed, run.cancelled, run.failed/run.errored, reasoning.available
  • Go-between → client: full-text, audio, control, error, set-model-and-conf, config-switched, config-files, background-files, history-list, history-data, new-history-created, history-deleted, user-input-transcription, backend-synth-complete, force-new-message, tool_call_status, interrupt-signal, heartbeat-ack, group-update
  • Client → go-between: text-input, mic-audio-data, mic-audio-end, raw-audio-data, ai-speak-signal, interrupt-signal, fetch-history-list, fetch-and-set-history, create-new-history, delete-history, fetch-configs, switch-config, fetch-backgrounds, audio-play-start, request-init-config, heartbeat

Cut: no delivery path exists

Nothing announces a pending approval. The SSE stream has no approval event, and the go-between''s poll loop (_await_run_output) recognises only completed / cancelled / failed — a suspended run spins to poll_timeout and raises TimeoutError. A gated tool currently presents as a hang, then a failure.

POST /v1/runs/{id}/approval exists for answering, but the client is never told there is anything to answer.

Building the 承認 card now would be a card nothing can trigger. Cut.

Approval gating is not core functionality — she answers, tools run, the log records them. What is lost is the ability to stop a destructive tool mid-turn, which matters only once Hermes actually gates one and tells us.

Reopen when Hermes emits a pending-approval event and a suspended-not-dead run status (aiko/ollvt-hermes-bridge#17). The UI decision recorded above — grants behind a press-and-hold on 許可, shade stays once/deny — stands and does not need re-deciding then.

> [!WARNING] > **Partly superseded — the approval claim below is wrong.** Hermes 0.19.0 emits `approval.request` on the run-events stream with a per-request `choices` array and sets run status `waiting_for_approval` (`gateway/platforms/api_server.py:6250-6275`, `:71-74`, `:1348`). The go-between discards the event; Hermes has always had it. See the correction later in this thread and #19. Everything else in this comment stands. --- ## Reality audit Governing rule: **build to reality, Hermes leads. Not doing something is acceptable if it does not break core functionality. Breaking the design is acceptable. Building UI that does nothing is not.** Audited against the actual wire surface: - **Hermes** (`hermes-agent 0.19.0`, verified running): `/health`, `/v1/models`, `/v1/runs`, `/v1/runs/{id}`, `/v1/runs/{id}/events`, `/v1/runs/{id}/stop`, `/v1/runs/{id}/approval` - **Run events**: `message.delta`, `tool.started`, `tool.completed`, `run.completed`, `run.cancelled`, `run.failed`/`run.errored`, `reasoning.available` - **Go-between → client**: `full-text`, `audio`, `control`, `error`, `set-model-and-conf`, `config-switched`, `config-files`, `background-files`, `history-list`, `history-data`, `new-history-created`, `history-deleted`, `user-input-transcription`, `backend-synth-complete`, `force-new-message`, `tool_call_status`, `interrupt-signal`, `heartbeat-ack`, `group-update` - **Client → go-between**: `text-input`, `mic-audio-data`, `mic-audio-end`, `raw-audio-data`, `ai-speak-signal`, `interrupt-signal`, `fetch-history-list`, `fetch-and-set-history`, `create-new-history`, `delete-history`, `fetch-configs`, `switch-config`, `fetch-backgrounds`, `audio-play-start`, `request-init-config`, `heartbeat` ### Cut: no delivery path exists Nothing announces a pending approval. The SSE stream has no approval event, and the go-between''s poll loop (`_await_run_output`) recognises only completed / cancelled / failed — a suspended run spins to `poll_timeout` and raises `TimeoutError`. A gated tool currently presents as a hang, then a failure. `POST /v1/runs/{id}/approval` exists for *answering*, but the client is never told there is anything to answer. Building the 承認 card now would be a card nothing can trigger. **Cut.** Approval gating is not core functionality — she answers, tools run, the log records them. What is lost is the ability to stop a destructive tool mid-turn, which matters only once Hermes actually gates one and tells us. Reopen when Hermes emits a pending-approval event and a suspended-not-dead run status (aiko/ollvt-hermes-bridge#17). The UI decision recorded above — grants behind a press-and-hold on 許可, shade stays once/deny — stands and does not need re-deciding then.
aiko closed this issue 2026-07-29 18:11:14 +00:00
Author
Owner

Reopening — the cut was based on a false reading of the wire

This issue was cut on the claim "Nothing announces a pending approval. The SSE stream has no approval event." That is wrong. Its own reopen condition — "when Hermes emits a pending-approval event and a suspended-not-dead run status" — was already satisfied by hermes-agent 0.19.0, the version audited in that same comment.

Piece Where
approval.request on the run-events stream, command pre-redacted via _redact_approval_command gateway/platforms/api_server.py:6250-6275
choices array on the event — ["once","deny"] when smart-denied, else ["once","session","always","deny"] or ["once","session","deny"] api_server.py:71-74 (_approval_event_choices)
Run status waiting_for_approval — the suspended-not-dead state api_server.py:1348, 6271
POST /v1/runs/{run_id}/approval, body {choice, resolve_all}; aliases approve/approved/allowonce api_server.py:1847, 6548
Capability flags approval_events, run_approval_response api_server.py:2873-2875

The mistake was reading "the go-between has no approval handling" as "Hermes has no approval surface." Only the first is true: grep -n approval src/open_llm_vtuber/agent/agents/hermes_agent.py returns nothing, and _stream_run_events discards approval.request along with every other unrecognised event. That is why a gated tool presents as a hang and then a failure — the symptom the cut comment correctly described, wrongly attributed.

The reality-audit block pasted into this and several sibling issues lists the run-events stream without approval.request; that list is incomplete, not authoritative. Note the endpoint audit in the same comment does include POST /v1/runs/{id}/approval.

What this adds to the original scope

Everything already agreed stands, including the grants-one-level-down decision (press-and-hold on 許可, never four keys, notch tell, same placement on card and 168px cuff). One refinement it did not have:

  • Gate the press-and-hold on the event's choices. It is per-request. A smart-denied tool offers only ["once","deny"], so there is nothing behind the hold and the notch tell should be suppressed rather than revealing dead options.

Go-between scope, which the original issue did not cover because the path looked absent:

  • Forward approval.request down the client socket as a new message type, carrying the redacted command, the specifics the card shows, and choices verbatim.
  • Stop treating a suspended run as a timeout — _await_run_output recognises only completed / cancelled / failed today, so waiting_for_approval spins to poll_timeout and raises. It is alive and must be distinguishable from a stall.
  • Relay the client's answer to POST /v1/runs/{run_id}/approval. The client must never hold the Hermes bearer key.
  • Persist the outcome so the log can show which grant was given after the fact.

Not blocked

Independent of ollvt-hermes-bridge#16 (the Platform adapter) and of the go-between inbound receiver. The adapter's send_exec_approval is a second path to a capability that already has one, not a prerequisite. The original Blocked by #17 no longer applies for this reason.

Superseded duplicate: #26, which I filed before finding this issue. Closed; its only novel content is the choices refinement above.

## Reopening — the cut was based on a false reading of the wire This issue was cut on the claim *"Nothing announces a pending approval. The SSE stream has no approval event."* **That is wrong.** Its own reopen condition — *"when Hermes emits a pending-approval event and a suspended-not-dead run status"* — was already satisfied by `hermes-agent 0.19.0`, the version audited in that same comment. | Piece | Where | |---|---| | `approval.request` on the run-events stream, command pre-redacted via `_redact_approval_command` | `gateway/platforms/api_server.py:6250-6275` | | `choices` array on the event — `["once","deny"]` when smart-denied, else `["once","session","always","deny"]` or `["once","session","deny"]` | `api_server.py:71-74` (`_approval_event_choices`) | | Run status `waiting_for_approval` — the suspended-not-dead state | `api_server.py:1348`, `6271` | | `POST /v1/runs/{run_id}/approval`, body `{choice, resolve_all}`; aliases `approve`/`approved`/`allow` → `once` | `api_server.py:1847`, `6548` | | Capability flags `approval_events`, `run_approval_response` | `api_server.py:2873-2875` | The mistake was reading "the go-between has no approval handling" as "Hermes has no approval surface." Only the first is true: `grep -n approval src/open_llm_vtuber/agent/agents/hermes_agent.py` returns nothing, and `_stream_run_events` discards `approval.request` along with every other unrecognised event. That is why a gated tool presents as a hang and then a failure — the symptom the cut comment correctly described, wrongly attributed. The reality-audit block pasted into this and several sibling issues lists the run-events stream without `approval.request`; that list is incomplete, not authoritative. Note the endpoint audit in the same comment *does* include `POST /v1/runs/{id}/approval`. ### What this adds to the original scope Everything already agreed stands, including the **grants-one-level-down decision** (press-and-hold on 許可, never four keys, notch tell, same placement on card and 168px cuff). One refinement it did not have: - **Gate the press-and-hold on the event's `choices`.** It is per-request. A smart-denied tool offers only `["once","deny"]`, so there is nothing behind the hold and the notch tell should be suppressed rather than revealing dead options. Go-between scope, which the original issue did not cover because the path looked absent: - Forward `approval.request` down the client socket as a new message type, carrying the redacted command, the specifics the card shows, and `choices` verbatim. - Stop treating a suspended run as a timeout — `_await_run_output` recognises only completed / cancelled / failed today, so `waiting_for_approval` spins to `poll_timeout` and raises. It is alive and must be distinguishable from a stall. - Relay the client's answer to `POST /v1/runs/{run_id}/approval`. **The client must never hold the Hermes bearer key.** - Persist the outcome so the log can show which grant was given after the fact. ### Not blocked Independent of `ollvt-hermes-bridge#16` (the Platform adapter) and of the go-between inbound receiver. The adapter's `send_exec_approval` is a second path to a capability that already has one, not a prerequisite. The original *Blocked by #17* no longer applies for this reason. Superseded duplicate: #26, which I filed before finding this issue. Closed; its only novel content is the `choices` refinement above.
aiko 2026-07-29 23:02:45 +00:00
Author
Owner

Still correct; the source changed and the UI decision is now an ADR

承認 survives intact. Two updates.

Where the ask comes from. This issue was reopened on the finding that approval.request exists on the /v1/runs SSE stream. That surface is now abandoned entirely (ADR-0001), so the ask instead arrives through the platform path: send_exec_approval on the adapter, with the gateway-side resolvers working unmodified provided the callback-id convention is matched (appr:<id>:<choice>). Same capability, same four grant values, no HTTP.

That also means this issue no longer depends on anything in the old bridge protocol work - those issues are closed as superseded.

The UI decision is recorded. docs/adr/0001-two-key-grants-with-standing-grants-behind-a-hold.md in this repo holds it: two keys, session and always behind a press-and-hold on 許可, never four keys, the reveal gated on the event's per-request choices so a smart-denied tool suppresses the notch tell rather than revealing dead options. It does not need re-deciding.

Adapter-side work is tracked in the go-between repo's platform-adapter issue.

## Still correct; the source changed and the UI decision is now an ADR 承認 survives intact. Two updates. **Where the ask comes from.** This issue was reopened on the finding that `approval.request` exists on the `/v1/runs` SSE stream. That surface is now abandoned entirely (ADR-0001), so the ask instead arrives through the platform path: `send_exec_approval` on the adapter, with the gateway-side resolvers working unmodified provided the callback-id convention is matched (`appr:<id>:<choice>`). Same capability, same four grant values, no HTTP. That also means this issue no longer depends on anything in the old bridge protocol work - those issues are closed as superseded. **The UI decision is recorded.** `docs/adr/0001-two-key-grants-with-standing-grants-behind-a-hold.md` in this repo holds it: two keys, `session` and `always` behind a press-and-hold on 許可, never four keys, the reveal gated on the event's per-request `choices` so a smart-denied tool suppresses the notch tell rather than revealing dead options. It does not need re-deciding. Adapter-side work is tracked in the go-between repo's platform-adapter issue.
Author
Owner

Landed on issue/denpa-19-approval-surface (denpa-client)

Tests: 352 passed in 30 files on main412 passed in 33 files on the branch. Nothing pre-existing deleted or skipped. npm run typecheck reports 585 error TS lines, byte-identical to main's baseline — the only three outside the vendored WebSDK/ tree are the same pre-existing unused-import errors the ledger already records, and none names a file this branch touches.

What it does

A gated tool's ask now arrives on approval-request, takes the transmission band over at both densities and on the desktop pal, and holds the mic until a person answers. 割り込み stays live — an ask never decides for you, so the turn has to stay killable while it waits.

Two keys, never four. 許可 taps to once; session and always live behind a 450ms press-and-hold with the corner notch tell, per this repo's ADR-0001. The reveal and the notch are gated on the ask's own options, so a smart-denied tool (["once","deny"]) shows no reveal at all rather than dead ones. smart_denied is surfaced as its own line rather than softened.

Nothing turns time into an answer. The 保留 readout is a separate hook with no grant, no queue and no socket in scope, and services/approvals.ts contains no timer at all — both facts are asserted by tests rather than claimed in a comment.

The wire it codes against

Verified in the go-between at main, not from the design doc:

  • down {"type":"approval-request","approval_id","command","description","smart_denied","options":[{"choice","label"}]}adapter_handler.py:60 (the name), :90-122 (the body), :278-289 (the send)
  • up {"type":"approval-response","approval_id","choice"}websocket_handler.py:73-76, :454-465
  • the dead-ask errors — conversations/conversation_handler.py:93 and :100

No device_id and no callback id are sent. The client answers by choice and a test asserts the outgoing message has exactly the three keys.

The test that matters most

services/approvals.test.ts"a standing grant cannot be given by one reflexive tap": releaseGrant never returns session or always for any hold duration on any ask, pickStandingGrant refuses unless the sheet is open and the ask carried that grant, and a long hold on a two-value ask still sends once because there is nothing to reveal.

Deliberately left out, all in the questions ledger

  • 差分 is not drawn. The ask carries command and description and nothing else, so the key would open nothing. The notification shade's three actions go with it — same missing diff, and it is #23's surface.
  • The Android overlay has no approval surface. Its window is cut to the pal's box, and Turn 9's cuff plus the shade answer are both marked DEFERRED in the design. On Android today a gated tool suspends the turn with no way to answer it from the overlay.
  • Three Japanese strings are coinages (the smart_denied note, the hold hint, the shade-floor note), and Message['status'] gains a fifth § 4b value, denied, because 拒否 is neither 完了 nor 失敗. It draws an acid ✕, not the failure magenta.
  • 承認待ち is a faceplate treatment, not a seventh AiStateEnum member. WAITING was unusable: it means the user is typing and auto-clears after 2s.

One thing that needs a human eye

The dead-ask error carries no approval_id and a successful answer is never acknowledged, so "the ask expired" and "your answer went nowhere" are indistinguishable except by arrival time. The client retires the outstanding ask only outside a 3s grace after its own send; without that, answering ask A on a detached adapter would retire ask B. The honest fix is upstream — put the id on the error, or acknowledge a delivered answer.

I could not drive a live approval end to end and cannot see rendered output; the card is covered by SSR markup assertions only.

## Landed on `issue/denpa-19-approval-surface` (denpa-client) **Tests:** 352 passed in 30 files on `main` → **412 passed in 33 files** on the branch. Nothing pre-existing deleted or skipped. `npm run typecheck` reports 585 `error TS` lines, byte-identical to `main`'s baseline — the only three outside the vendored `WebSDK/` tree are the same pre-existing unused-import errors the ledger already records, and none names a file this branch touches. ### What it does A gated tool's ask now arrives on `approval-request`, takes the transmission band over at both densities and on the desktop pal, and holds the mic until a person answers. 割り込み stays live — an ask never decides for you, so the turn has to stay killable while it waits. **Two keys, never four.** 許可 taps to `once`; `session` and `always` live behind a 450ms press-and-hold with the corner notch tell, per this repo's ADR-0001. The reveal and the notch are gated on the ask's own `options`, so a smart-denied tool (`["once","deny"]`) shows no reveal at all rather than dead ones. `smart_denied` is surfaced as its own line rather than softened. **Nothing turns time into an answer.** The 保留 readout is a separate hook with no grant, no queue and no socket in scope, and `services/approvals.ts` contains no timer at all — both facts are asserted by tests rather than claimed in a comment. ### The wire it codes against Verified in the go-between at `main`, not from the design doc: - down `{"type":"approval-request","approval_id","command","description","smart_denied","options":[{"choice","label"}]}` — `adapter_handler.py:60` (the name), `:90-122` (the body), `:278-289` (the send) - up `{"type":"approval-response","approval_id","choice"}` — `websocket_handler.py:73-76`, `:454-465` - the dead-ask errors — `conversations/conversation_handler.py:93` and `:100` **No `device_id` and no callback id are sent.** The client answers by `choice` and a test asserts the outgoing message has exactly the three keys. ### The test that matters most `services/approvals.test.ts` — *"a standing grant cannot be given by one reflexive tap"*: `releaseGrant` never returns `session` or `always` for any hold duration on any ask, `pickStandingGrant` refuses unless the sheet is open **and** the ask carried that grant, and a long hold on a two-value ask still sends `once` because there is nothing to reveal. ### Deliberately left out, all in the questions ledger - **差分 is not drawn.** The ask carries `command` and `description` and nothing else, so the key would open nothing. The notification shade's three actions go with it — same missing diff, and it is `#23`'s surface. - **The Android overlay has no approval surface.** Its window is cut to the pal's box, and Turn 9's cuff plus the shade answer are both marked DEFERRED in the design. On Android today a gated tool suspends the turn with no way to answer it from the overlay. - Three Japanese strings are coinages (the `smart_denied` note, the hold hint, the shade-floor note), and `Message['status']` gains a fifth § 4b value, `denied`, because 拒否 is neither 完了 nor 失敗. It draws an acid ✕, not the failure magenta. - 承認待ち is a faceplate *treatment*, not a seventh `AiStateEnum` member. `WAITING` was unusable: it means the user is typing and auto-clears after 2s. ### One thing that needs a human eye The dead-ask error carries no `approval_id` and a successful answer is never acknowledged, so "the ask expired" and "your answer went nowhere" are indistinguishable except by arrival time. The client retires the outstanding ask only outside a 3s grace after its own send; without that, answering ask A on a detached adapter would retire ask B. The honest fix is upstream — put the id on the error, or acknowledge a delivered answer. I could not drive a live approval end to end and cannot see rendered output; the card is covered by SSR markup assertions only.
Author
Owner

承認 surface — issue/denpa-19-approval-surface

Branch issue/denpa-19-approval-surface in denpa-client, pushed to origin. Not merged, no PR.

What landed

An approval queue plus a card that draws it, on the receiver window and the desktop pal.

New files:

  • src/renderer/src/services/approvals.ts — the pure state machine: the two-key gesture (pressGrant / releaseGrant / activateGrant / pickStandingGrant), the reveal (toggleReveal), and answerOutcomeFrom() which reads the go-between's two error sentences.
  • src/renderer/src/components/receiver/approval-spec.ts — presentation spec plus the 中継 log rows (approvalLogRow, approvalFailureRow).
  • src/renderer/src/components/receiver/approval-card.tsx — the card, at density="full" (receiver) and density="tight" (pal bubble column).
  • src/renderer/src/hooks/receiver/use-approvals.ts, use-hold-clock.ts — queue + subscription, hold timer.
  • src/renderer/src/context/approval-context.tsx — single owner of the queue.

Touched, in short: transmission-band, receiver-chrome, dock, key-row, faceplate-spec, state-faceplate, log-rail, desktop-pal, pal-bar, pal-container, use-receiver-chrome, websocket-handler, websocket-service, denpa-tokens, and App.tsx (the window branch became a WindowReceiver component).

Tests

420 passed in 33 files. Main's baseline is 352 in 30.

 Test Files  33 passed (33)
      Tests  420 passed (420)
   Duration  7.37s

npm run typecheck: 585 error TS lines, identical to main's documented baseline. Grepping the output for every path this branch touches (approval*, App.tsx, log-rail, log-rows, key-row, pal-container, use-receiver-chrome, websocket-service) returns nothing. Filtering the vendored WebSDK/MotionSync trees leaves exactly the three pre-existing errors the ledger already records. No new errors. npm run lint is broken on main and stayed out of scope.

What the tests actually cover:

  • approvals.ts (29 tests) — the gesture as a state machine; which choice a tap vs a hold yields; the null-choice case; answerOutcomeFrom against both wire sentences; restore-on-undelivered; refusing to pop when the socket is not OPEN.
  • approval-card.test.tsx (20 tests)renderToStaticMarkup assertions: text present, key count, notch attribute, 44px touch floor, the role="alert" failure line, aria-expanded on the disclosure.
  • approval-wiring.test.ts (11 tests) — source-level assertions that the handlers are wired to the state machine, that the provider is the only caller of useApprovals, that stopMic() fires when an ask becomes outstanding.

No tests were deleted or skipped.

Wire claims, each with its citation

All in the denpa go-between unless noted.

  • approval-request is the client-facing name of the ask — src/open_llm_vtuber/adapter_handler.py:60, AskKind("approval", "approval_id", "approval-request", "approval-response").
  • The ask body is exactly command, description, smart_denied, plus options and the id — adapter_handler.py:90-122 (read_approval) and :278-289 (offer, sending {type: request_type, id_field: answer_id, **body, options}). There is no diff field.
  • options is per-ask and authoritative: only choices in protocol.GRANT_CHOICES that have a callback id survive, so a tool refusing standing grants sends ["once","deny"]adapter_handler.py:100-122, hermes_plugin/denpa/protocol.py:64-70.
  • The client answers {"type":"approval-response","approval_id","choice"} and by choice only — src/open_llm_vtuber/websocket_handler.py:73-76 (handler map from ASK_BY_RESPONSE_TYPE) and :454-465 (_handle_answer reads kind.id_field and choice, nothing else).
  • The go-between mints the callback id itself; the client never holds one — adapter_handler.py:296-326 (send_answer looks up offered[1].get(choice) and sends FRAME_CALLBACK).
  • An ask is owned by the connection, not by a claimed device id — adapter_handler.py:205-213, _offered keyed on client_uid, with the comment naming the forged-grant hole. This is why nothing in the client sends a device_id.
  • A second answer to a resolved ask is refused — adapter_handler.py:304-326, send_answer returns False when the key is absent and pops on success.
  • The resolved-ask sentence is "That request is no longer waiting for an answer."src/open_llm_vtuber/conversations/conversation_handler.py:96-102. The briefing quoted it as "That approval is no longer waiting…"; the source says "request". Coded against the source.
  • The undeliverable-answer sentence is "No adapter is attached, so the answer was not delivered."conversation_handler.py:66-71 (report_undelivered formats f"No adapter is attached, so {what}.") with what="the answer was not delivered" at :93. Briefing quoted "…so the grant was not delivered"; again coded against the source.
  • Both sentences are emitted only from handle_answer (conversation_handler.py:74-102), so both are solicited — there is no unsolicited dead-ask error on this wire at all. This is load-bearing; see the review section.
  • Neither error carries an approval id — conversation_handler.py:95-102 sends only {type: error, message}.
  • smart_denied is a plain bool on the ask — adapter_handler.py:121, "smart_denied": bool(frame.get("smart_denied")).
  • clarify and slash-confirm are now translated (clarify-request / confirm-request) — adapter_handler.py:59-63 (ASK_KINDS). This supersedes the briefing's claim that they stay dropped. No UI was built for them; this issue is approvals only.

The two-key hold

ADR-0001's rule is that a standing grant must cost a second, distinct activation. Enforcement:

  • The acid 許可 key is press-and-hold. pressGrant opens a gesture; a release before the hold threshold yields once. Holding past it reveals the standing keys and yields nothing on its own — releaseGrant returns a null choice in that state.
  • Each standing grant carried in the ask's options is then its own key needing its own press. The reveal grants nothing.
  • Keyboard/AT route: onGrantActivate fires on a click with event.detail === 0 (Enter/Space or AT, never pointer-derived) and sends once via activateGrant(ask). The reveal tell is an aria-expanded disclosure button driven by toggleReveal(gesture, ask) — that is the keyboard path to the standing keys, since a press-and-hold has none. The disclosure still grants nothing, so the two-activation rule holds on both routes.
  • A pointerAnswered ref swallows the synthetic click that trails a pointer answer. Without it that stray click would have landed on the next ask in the queue.
  • Only choices present in the ask's options render. A tool refusing standing grants gets 今回だけ / やめて and no hold affordance.

Tested via the pure functions (hold threshold, tap-vs-hold, null choice, disclosure toggle, activateGrant) plus wiring assertions that the card's handlers call them. Not tested as a real gesture — see "could not verify".

Deliberately left out

  • 差分 / 差分を見る. Not rendered. approval-request carries only command, description, smart_denied, options (adapter_handler.py:90-122), so the key would open nothing, and ADR-0001 draws two keys and never names a third. The Android notification shade's three actions go with it — same missing diff, and it is #23's surface.
  • Turn 9's parked-tab cuff (34x52 → 168px widen, 30s retract, the acid count). The design marks all of Turn 9 DEFERRED and the desktop pal has no parked state in this client. The pal instead renders the same ApprovalCard at density="tight" in the bubble column, which is what satisfies "the reveal works identically on the full card and the cuff".
  • The Android overlay pal (OverlayPalContainer) has no approval surface at all. Its window is cut to the pal's box, so a card needs its own window height the way the composer did. On Android today a gated tool suspends the turn with no way to answer from the overlay. Ledgered.
  • denpa.clarify / denpa.slash-confirm — no UI, as instructed.
  • No answering / in-flight card state. The go-between never acknowledges a delivered answer, so the card unmounts on send. A disabled-but-visible key would be a dead affordance.

Could not verify

  • I cannot see rendered output. Every visual claim rests on renderToStaticMarkup markup assertions, not pixels. The acid ring/glow, the legibility of the 9px corner-notch triangle, and whether the tight card fits the pal's 300px bubble column are all unverified by eye.
  • No live approval was driven end to end. No adapter was attached, nothing sent an approval-request, no answer reached Hermes.
  • The press-and-hold gesture is untested as a gesture. The suite runs environment: 'node' with no jsdom and no testing-library, so pointerdown/pointerup cannot be dispatched. A wiring bug connecting onPointerUp to the wrong handler would pass every test here.
  • The React effect paths (subscribe, retire-on-error, log-row append) are untested as effects — same environment limit #27's agent recorded. Covered by pure functions plus source-grep wiring assertions.
  • I did not run caveman:cavecrew-reviewer. Global instructions ask for it after non-trivial changes; no Task/subagent tool exists in this harness. A careful self-review substituted, and it is what found the trailing-synthetic-click double-answer risk.

Assumptions

  1. 承認待ち is a faceplate treatment (APPROVAL_TREATMENT), not a seventh AiStateEnum member. WAITING/入力中 was unusable: it means the user is typing and auto-returns to IDLE after 2s (ai-state-context.tsx:44-48), so it would clear itself out from under a live ask.
  2. Three Japanese strings are coinages, because the design names wording for none of them: the smart-denied note (注意 · 断る方が無難と言われてる), the hold hint beside the notch (長押しでずっと許可), the shade-floor note (通知からは 今回だけ / やめて だけ).
  3. Message['status'] gains new values. granted (acid ✓) for an answered approval and unanswered for one whose answer went nowhere. 拒否 draws denied — an acid ✕, not the failure magenta — because it is neither 完了 (a ✓ would claim the tool ran) nor 失敗 (she finished the turn without it on purpose). Deliberately not error: that is the wire's word for a tool that ran and failed, and reusing it would put a false 1失敗 in the chain footer.
  4. The pending log row carries an empty content, because upsertToolCall locks preview to the first non-empty content it sees and a row reading 保留 · 0秒 forever would record the wrong thing. The live hold timer lives on the card.
  5. The standing-grant 承認後 line rides in the same row's narrative column (許可 · ずっと · fs.write はもうきかない) rather than in a new field or component.
  6. Two tokens were added to denpa-tokens.ts (denpaWashes.unpromptedGlow, denpaShadows.approval) — the card's acid ring and glow have no existing token at those alphas. This is the one shared file touched that could conflict with a sibling branch.
  7. An ask that expires inside Hermes leaves its card standing on this client forever. Nothing on the wire reports an expiry and a delivered answer is never acknowledged (verified in conversation_handler.py), so there is no client-side signal to act on. The earlier code only appeared to handle this. Needs an upstream fix: an id on the error, an expiry frame, or an ack. Ledgered.
  8. Worked in the existing worktree denpa-client\.claude\worktrees\wf_7adaad23-47a-1 rather than the main checkout, because main is on issue/denpa-31-screen-share-grant with a sibling agent's uncommitted changes to 8 files. Checking out branch 19 there would have destroyed their work.
  9. Process hazard worth recording: the orchestrator handed this agent a worktree of the wrong repo (denpa, for a denpa-client issue). En route to fixing it, git worktree remove followed a node_modules junction and deleted .bin and .package-lock.json from the shared denpa-client/node_modules. Restored with npm install in the main checkout and re-verified at the 352/30 baseline before starting. Same hazard denpa#9 recorded. Both worktrees are clean now.

Review findings and what happened to them

Ten findings, six major. All addressed; nothing skipped.

  1. Two live queues in pet mode (use-approvals.ts:34). App.tsx called useReceiverChrome() unconditionally above the window/pal branch, so pet mode ran two queues, two wsService.onMessage subscriptions and two hold clocks. Each ask was enqueued twice, only the pal's copy was ever answered, and a later error could retire an ask in the invisible queue. Fixed: queue hoisted into ApprovalProvider, the only caller of useApprovals; both surfaces read useApproval(); the window branch became a WindowReceiver component so the chrome's hooks stop running behind the pal. The false invariant comment that hid this is deleted.
  2. 許可 unreachable by keyboard/AT (approval-card.tsx:213). It was pointer-events only, so the only reachable answer for a keyboard or screen-reader user was refusal. Fixed with onGrantActivate + the aria-expanded disclosure described above.
  3. Granted row stuck at running (approval-spec.ts:115). Nothing on the wire ever updates approval:<id>, so the row animated its meter on every later turn, the ✓ never landed, and chainSummary lost that turn's 計 x.xs for the rest of the session. Fixed with the terminal granted status handled in RelayGlyph / relayNameColor / relayTail. Also fixed liveChain.done, which counted only completed|error and so under-counted any chain holding a settled approval row.
  4. Silent loss of an answer (use-approvals.ts:95). This one changed my model of the wire. Verifying in the go-between showed both error sentences come only from handle_answer, so both are solicited and there is no unsolicited dead-ask error — which made the old DEAD_ASK_GRACE_MS a guard against an impossible case that swallowed the real one. isDeadAskError/DEAD_ASK_MESSAGES are replaced by ASK_RESOLVED_MESSAGE, ANSWER_UNDELIVERED_MESSAGE and answerOutcomeFrom(), read against an inFlight record. undeliveredrestoreAsk() puts the ask back at the head carrying answerFailure, and the card draws a role="alert" line saying the answer went nowhere. The ask is also no longer popped at all when wsService.getCurrentState() !== 'OPEN'.
  5. Retired ask left its 中継 row at 承認待ち (use-approvals.ts:80). New approvalFailureRow() upserts the same id to unanswered with the reason in the tail (答えが届かなかった / もう待っていなかった). retireOutstanding is gone — with expiry unreported upstream, the solicited error was its only real path.
  6. micHeld only greyed the toggle (key-row.tsx:62). If hands-free was on when the ask arrived, VAD kept capturing for the whole suspended turn and the one control that would stop it was disabled. Fixed: useApprovals calls stopMic() when an ask becomes outstanding. The keys stay disabled so it cannot be reopened under a standing ask.
  7. Held keys looked live (key-row.tsx:85, pal-bar.tsx:259) — the dead affordance the issue rules out. Given the existing dim treatment plus aria-disabled parity.
  8. The pal's 受信 key doubles as the fault 「なぜ」 control (pal-bar.tsx:259), so disabling it unconditionally killed the only explanation affordance for a two-hop fault — the most likely reason an answer will not be delivered. Now disabled={micHeld && treatment?.acidKey !== 'receive'}.
  9. Inert 許可 tap (approval-card.tsx:149). canGrant was true when the ask offered a standing grant but no once, so a tap did nothing. 許可 now renders only when the ask offers once; otherwise the standing keys are drawn directly.
  10. Ask with an empty command never reached 受信記録 (approval-spec.ts:110). The go-between permits it (str(frame.get("command") or ""), adapter_handler.py:117) and appendOrUpdateToolCallMessage rejects a falsy tool_name (chat-history-context.tsx:177), so the row was dropped while the turn stayed suspended. Now falls back to a non-empty marker.

Honest summary of what did not pass

Nothing failed. The suite is green at 420 and typecheck adds no new errors. But the confidence gap is real and narrow in one specific place: no pointer gesture, no React effect, and no live round trip was ever executed. The two-key hold is proven as a state machine and grepped as wiring, not exercised. If a reviewer wants one thing checked by hand before merging, it is a real press-and-hold on 許可 in the running client, plus the keyboard route (Tab to 許可, Enter → once; Space on the disclosure → standing keys).

## 承認 surface — `issue/denpa-19-approval-surface` Branch `issue/denpa-19-approval-surface` in `denpa-client`, pushed to `origin`. Not merged, no PR. ### What landed An approval queue plus a card that draws it, on the receiver window and the desktop pal. New files: - `src/renderer/src/services/approvals.ts` — the pure state machine: the two-key gesture (`pressGrant` / `releaseGrant` / `activateGrant` / `pickStandingGrant`), the reveal (`toggleReveal`), and `answerOutcomeFrom()` which reads the go-between's two error sentences. - `src/renderer/src/components/receiver/approval-spec.ts` — presentation spec plus the 中継 log rows (`approvalLogRow`, `approvalFailureRow`). - `src/renderer/src/components/receiver/approval-card.tsx` — the card, at `density="full"` (receiver) and `density="tight"` (pal bubble column). - `src/renderer/src/hooks/receiver/use-approvals.ts`, `use-hold-clock.ts` — queue + subscription, hold timer. - `src/renderer/src/context/approval-context.tsx` — single owner of the queue. Touched, in short: `transmission-band`, `receiver-chrome`, `dock`, `key-row`, `faceplate-spec`, `state-faceplate`, `log-rail`, `desktop-pal`, `pal-bar`, `pal-container`, `use-receiver-chrome`, `websocket-handler`, `websocket-service`, `denpa-tokens`, and `App.tsx` (the window branch became a `WindowReceiver` component). ### Tests 420 passed in 33 files. Main's baseline is 352 in 30. ``` Test Files 33 passed (33) Tests 420 passed (420) Duration 7.37s ``` `npm run typecheck`: 585 `error TS` lines, identical to main's documented baseline. Grepping the output for every path this branch touches (`approval*`, `App.tsx`, `log-rail`, `log-rows`, `key-row`, `pal-container`, `use-receiver-chrome`, `websocket-service`) returns nothing. Filtering the vendored WebSDK/MotionSync trees leaves exactly the three pre-existing errors the ledger already records. No new errors. `npm run lint` is broken on main and stayed out of scope. What the tests actually cover: - **approvals.ts (29 tests)** — the gesture as a state machine; which choice a tap vs a hold yields; the null-choice case; `answerOutcomeFrom` against both wire sentences; restore-on-undelivered; refusing to pop when the socket is not OPEN. - **approval-card.test.tsx (20 tests)** — `renderToStaticMarkup` assertions: text present, key count, notch attribute, 44px touch floor, the `role="alert"` failure line, `aria-expanded` on the disclosure. - **approval-wiring.test.ts (11 tests)** — source-level assertions that the handlers are wired to the state machine, that the provider is the only caller of `useApprovals`, that `stopMic()` fires when an ask becomes outstanding. No tests were deleted or skipped. ### Wire claims, each with its citation All in the `denpa` go-between unless noted. - `approval-request` is the client-facing name of the ask — `src/open_llm_vtuber/adapter_handler.py:60`, `AskKind("approval", "approval_id", "approval-request", "approval-response")`. - The ask body is exactly `command`, `description`, `smart_denied`, plus `options` and the id — `adapter_handler.py:90-122` (`read_approval`) and `:278-289` (`offer`, sending `{type: request_type, id_field: answer_id, **body, options}`). **There is no diff field.** - `options` is per-ask and authoritative: only choices in `protocol.GRANT_CHOICES` that have a callback id survive, so a tool refusing standing grants sends `["once","deny"]` — `adapter_handler.py:100-122`, `hermes_plugin/denpa/protocol.py:64-70`. - The client answers `{"type":"approval-response","approval_id","choice"}` and by choice only — `src/open_llm_vtuber/websocket_handler.py:73-76` (handler map from `ASK_BY_RESPONSE_TYPE`) and `:454-465` (`_handle_answer` reads `kind.id_field` and `choice`, nothing else). - The go-between mints the callback id itself; the client never holds one — `adapter_handler.py:296-326` (`send_answer` looks up `offered[1].get(choice)` and sends `FRAME_CALLBACK`). - An ask is owned by the connection, not by a claimed device id — `adapter_handler.py:205-213`, `_offered` keyed on `client_uid`, with the comment naming the forged-grant hole. This is why nothing in the client sends a `device_id`. - A second answer to a resolved ask is refused — `adapter_handler.py:304-326`, `send_answer` returns False when the key is absent and pops on success. - The resolved-ask sentence is **"That request is no longer waiting for an answer."** — `src/open_llm_vtuber/conversations/conversation_handler.py:96-102`. The briefing quoted it as "That approval is no longer waiting…"; the source says "request". Coded against the source. - The undeliverable-answer sentence is **"No adapter is attached, so the answer was not delivered."** — `conversation_handler.py:66-71` (`report_undelivered` formats `f"No adapter is attached, so {what}."`) with `what="the answer was not delivered"` at `:93`. Briefing quoted "…so the grant was not delivered"; again coded against the source. - Both sentences are emitted **only** from `handle_answer` (`conversation_handler.py:74-102`), so both are solicited — there is no unsolicited dead-ask error on this wire at all. This is load-bearing; see the review section. - Neither error carries an approval id — `conversation_handler.py:95-102` sends only `{type: error, message}`. - `smart_denied` is a plain bool on the ask — `adapter_handler.py:121`, `"smart_denied": bool(frame.get("smart_denied"))`. - clarify and slash-confirm **are** now translated (`clarify-request` / `confirm-request`) — `adapter_handler.py:59-63` (`ASK_KINDS`). This supersedes the briefing's claim that they stay dropped. No UI was built for them; this issue is approvals only. ### The two-key hold ADR-0001's rule is that a standing grant must cost a second, distinct activation. Enforcement: - The acid 許可 key is press-and-hold. `pressGrant` opens a gesture; a **release before the hold threshold** yields `once`. Holding past it reveals the standing keys and yields nothing on its own — `releaseGrant` returns a null choice in that state. - Each standing grant carried in the ask's `options` is then its own key needing its own press. The reveal grants nothing. - Keyboard/AT route: `onGrantActivate` fires on a `click` with `event.detail === 0` (Enter/Space or AT, never pointer-derived) and sends `once` via `activateGrant(ask)`. The reveal tell is an `aria-expanded` disclosure button driven by `toggleReveal(gesture, ask)` — that is the keyboard path to the standing keys, since a press-and-hold has none. The disclosure still grants nothing, so the two-activation rule holds on both routes. - A `pointerAnswered` ref swallows the synthetic click that trails a pointer answer. Without it that stray click would have landed on the **next** ask in the queue. - Only choices present in the ask's `options` render. A tool refusing standing grants gets 今回だけ / やめて and no hold affordance. Tested via the pure functions (hold threshold, tap-vs-hold, null choice, disclosure toggle, `activateGrant`) plus wiring assertions that the card's handlers call them. **Not** tested as a real gesture — see "could not verify". ### Deliberately left out - **差分 / 差分を見る.** Not rendered. `approval-request` carries only `command`, `description`, `smart_denied`, `options` (`adapter_handler.py:90-122`), so the key would open nothing, and ADR-0001 draws two keys and never names a third. The Android notification shade's three actions go with it — same missing diff, and it is #23's surface. - **Turn 9's parked-tab cuff** (34x52 → 168px widen, 30s retract, the acid count). The design marks all of Turn 9 DEFERRED and the desktop pal has no parked state in this client. The pal instead renders the same `ApprovalCard` at `density="tight"` in the bubble column, which is what satisfies "the reveal works identically on the full card and the cuff". - **The Android overlay pal (`OverlayPalContainer`) has no approval surface at all.** Its window is cut to the pal's box, so a card needs its own window height the way the composer did. On Android today a gated tool suspends the turn with no way to answer from the overlay. Ledgered. - **`denpa.clarify` / `denpa.slash-confirm`** — no UI, as instructed. - **No `answering` / in-flight card state.** The go-between never acknowledges a delivered answer, so the card unmounts on send. A disabled-but-visible key would be a dead affordance. ### Could not verify - **I cannot see rendered output.** Every visual claim rests on `renderToStaticMarkup` markup assertions, not pixels. The acid ring/glow, the legibility of the 9px corner-notch triangle, and whether the tight card fits the pal's 300px bubble column are all unverified by eye. - **No live approval was driven end to end.** No adapter was attached, nothing sent an `approval-request`, no answer reached Hermes. - **The press-and-hold gesture is untested as a gesture.** The suite runs `environment: 'node'` with no jsdom and no testing-library, so `pointerdown`/`pointerup` cannot be dispatched. A wiring bug connecting `onPointerUp` to the wrong handler would pass every test here. - **The React effect paths** (subscribe, retire-on-error, log-row append) are untested as effects — same environment limit #27's agent recorded. Covered by pure functions plus source-grep wiring assertions. - **I did not run `caveman:cavecrew-reviewer`.** Global instructions ask for it after non-trivial changes; no Task/subagent tool exists in this harness. A careful self-review substituted, and it is what found the trailing-synthetic-click double-answer risk. ### Assumptions 1. 承認待ち is a faceplate **treatment** (`APPROVAL_TREATMENT`), not a seventh `AiStateEnum` member. `WAITING`/入力中 was unusable: it means the user is typing and auto-returns to IDLE after 2s (`ai-state-context.tsx:44-48`), so it would clear itself out from under a live ask. 2. Three Japanese strings are coinages, because the design names wording for none of them: the smart-denied note (注意 · 断る方が無難と言われてる), the hold hint beside the notch (長押しでずっと許可), the shade-floor note (通知からは 今回だけ / やめて だけ). 3. `Message['status']` gains new values. `granted` (acid ✓) for an answered approval and `unanswered` for one whose answer went nowhere. 拒否 draws `denied` — an acid ✕, not the failure magenta — because it is neither 完了 (a ✓ would claim the tool ran) nor 失敗 (she finished the turn without it on purpose). Deliberately **not** `error`: that is the wire's word for a tool that ran and failed, and reusing it would put a false 1失敗 in the chain footer. 4. The pending log row carries an empty `content`, because `upsertToolCall` locks `preview` to the first non-empty content it sees and a row reading 保留 · 0秒 forever would record the wrong thing. The live hold timer lives on the card. 5. The standing-grant 承認後 line rides in the same row's narrative column (`許可 · ずっと · fs.write はもうきかない`) rather than in a new field or component. 6. Two tokens were added to `denpa-tokens.ts` (`denpaWashes.unpromptedGlow`, `denpaShadows.approval`) — the card's acid ring and glow have no existing token at those alphas. **This is the one shared file touched that could conflict with a sibling branch.** 7. **An ask that expires inside Hermes leaves its card standing on this client forever.** Nothing on the wire reports an expiry and a delivered answer is never acknowledged (verified in `conversation_handler.py`), so there is no client-side signal to act on. The earlier code only *appeared* to handle this. Needs an upstream fix: an id on the error, an expiry frame, or an ack. Ledgered. 8. Worked in the existing worktree `denpa-client\.claude\worktrees\wf_7adaad23-47a-1` rather than the main checkout, because main is on `issue/denpa-31-screen-share-grant` with a sibling agent's uncommitted changes to 8 files. Checking out branch 19 there would have destroyed their work. 9. Process hazard worth recording: the orchestrator handed this agent a worktree of the **wrong repo** (`denpa`, for a `denpa-client` issue). En route to fixing it, `git worktree remove` followed a `node_modules` junction and deleted `.bin` and `.package-lock.json` from the **shared** `denpa-client/node_modules`. Restored with `npm install` in the main checkout and re-verified at the 352/30 baseline before starting. Same hazard denpa#9 recorded. Both worktrees are clean now. ### Review findings and what happened to them Ten findings, six major. All addressed; nothing skipped. 1. **Two live queues in pet mode** (`use-approvals.ts:34`). `App.tsx` called `useReceiverChrome()` unconditionally above the window/pal branch, so pet mode ran two queues, two `wsService.onMessage` subscriptions and two hold clocks. Each ask was enqueued twice, only the pal's copy was ever answered, and a later error could retire an ask in the invisible queue. Fixed: queue hoisted into `ApprovalProvider`, the only caller of `useApprovals`; both surfaces read `useApproval()`; the window branch became a `WindowReceiver` component so the chrome's hooks stop running behind the pal. The false invariant comment that hid this is deleted. 2. **許可 unreachable by keyboard/AT** (`approval-card.tsx:213`). It was pointer-events only, so the only reachable answer for a keyboard or screen-reader user was refusal. Fixed with `onGrantActivate` + the `aria-expanded` disclosure described above. 3. **Granted row stuck at `running`** (`approval-spec.ts:115`). Nothing on the wire ever updates `approval:<id>`, so the row animated its meter on every later turn, the ✓ never landed, and `chainSummary` lost that turn's `計 x.xs` for the rest of the session. Fixed with the terminal `granted` status handled in `RelayGlyph` / `relayNameColor` / `relayTail`. Also fixed `liveChain.done`, which counted only `completed|error` and so under-counted any chain holding a settled approval row. 4. **Silent loss of an answer** (`use-approvals.ts:95`). This one changed my model of the wire. Verifying in the go-between showed both error sentences come only from `handle_answer`, so both are solicited and there is no unsolicited dead-ask error — which made the old `DEAD_ASK_GRACE_MS` a guard against an impossible case that swallowed the real one. `isDeadAskError`/`DEAD_ASK_MESSAGES` are replaced by `ASK_RESOLVED_MESSAGE`, `ANSWER_UNDELIVERED_MESSAGE` and `answerOutcomeFrom()`, read against an `inFlight` record. `undelivered` → `restoreAsk()` puts the ask back at the head carrying `answerFailure`, and the card draws a `role="alert"` line saying the answer went nowhere. The ask is also no longer popped at all when `wsService.getCurrentState() !== 'OPEN'`. 5. **Retired ask left its 中継 row at 承認待ち** (`use-approvals.ts:80`). New `approvalFailureRow()` upserts the same id to `unanswered` with the reason in the tail (答えが届かなかった / もう待っていなかった). `retireOutstanding` is gone — with expiry unreported upstream, the solicited error was its only real path. 6. **`micHeld` only greyed the toggle** (`key-row.tsx:62`). If hands-free was on when the ask arrived, VAD kept capturing for the whole suspended turn and the one control that would stop it was disabled. Fixed: `useApprovals` calls `stopMic()` when an ask becomes outstanding. The keys stay disabled so it cannot be reopened under a standing ask. 7. **Held keys looked live** (`key-row.tsx:85`, `pal-bar.tsx:259`) — the dead affordance the issue rules out. Given the existing dim treatment plus `aria-disabled` parity. 8. **The pal's 受信 key doubles as the fault 「なぜ」 control** (`pal-bar.tsx:259`), so disabling it unconditionally killed the only explanation affordance for a two-hop fault — the most likely reason an answer will not be delivered. Now `disabled={micHeld && treatment?.acidKey !== 'receive'}`. 9. **Inert 許可 tap** (`approval-card.tsx:149`). `canGrant` was true when the ask offered a standing grant but no `once`, so a tap did nothing. 許可 now renders only when the ask offers `once`; otherwise the standing keys are drawn directly. 10. **Ask with an empty `command` never reached 受信記録** (`approval-spec.ts:110`). The go-between permits it (`str(frame.get("command") or "")`, `adapter_handler.py:117`) and `appendOrUpdateToolCallMessage` rejects a falsy `tool_name` (`chat-history-context.tsx:177`), so the row was dropped while the turn stayed suspended. Now falls back to a non-empty marker. ### Honest summary of what did not pass Nothing failed. The suite is green at 420 and typecheck adds no new errors. But the confidence gap is real and narrow in one specific place: **no pointer gesture, no React effect, and no live round trip was ever executed.** The two-key hold is proven as a state machine and grepped as wiring, not exercised. If a reviewer wants one thing checked by hand before merging, it is a real press-and-hold on 許可 in the running client, plus the keyboard route (Tab to 許可, Enter → once; Space on the disclosure → standing keys).
Author
Owner

Shipped. Merged to main at 8237457 (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.

Shipped. Merged to `main` at [`8237457`](https://git.aiko.works/aiko/denpa/commit/8237457) (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.
aiko closed this issue 2026-07-30 13:32:56 +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#19
No description provided.