承認 — a gated tool suspends the turn and asks #19
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#19
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
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), やめて, 差分.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 ids1a,4a… index intoDenpa Receiver.dc.html, openable in a browser).Acceptance criteria
Blocked by
Nothing.
#17was listed here because approvals were thought to have no delivery path; they always did (see the reopen comment). Independent ofollvt-hermes-bridge#16and of the go-between inbound receiver.Decision — grants one level down
Resolved. The two-key layout stays;
sessionandalwayslive behind a press-and-hold on 許可.once. Press-and-hold reveals 交信中はずっと (session) and これから毎回 (always), compressed to 交信中 / ずっと where space is tight.["once", "deny"], the reveal is absent, not disabled — no dead affordance.Notification shade is
once/denyonly, 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/alwaysare newly coined and approved. Option sheet the decision came from:docs/design/denpa-receiver/proposals/approval-options.html.Additional acceptance criteria
once; press-and-hold reveals both standing grants on card and cuffBlocked — 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}/approvalexists for answering, but nothing tells the client an approval is pending:GET /v1/runs/{run_id}/events) emitsmessage.delta,tool.started,tool.completed,run.completed,run.cancelled,run.failed/run.errored,reasoning.available. None of these carries an approval request._await_run_outputinhermes_agent.py) handles completed / cancelled / failed only. A run suspended awaiting approval matches none of them, so it spins topoll_timeoutand raisesTimeoutError.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):
once/session/always/denyare on offer for that toolPOST /v1/runs/{run_id}/approvalThe UI decision recorded above (grants behind a press-and-hold on 許可) stands and does not need revisiting when this unblocks.
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 onlysession— allow this tool for the rest of the sessionalways— allow this tool permanentlydenyThe design (card 承認, and the 168px parked-tab cuff in card 9) draws two keys: 許可 and やめて. That maps cleanly to
onceanddeny.sessionandalwayshave 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:
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.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.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.
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-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}/approvalmessage.delta,tool.started,tool.completed,run.completed,run.cancelled,run.failed/run.errored,reasoning.availablefull-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-updatetext-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,heartbeatCut: 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 topoll_timeoutand raisesTimeoutError. A gated tool currently presents as a hang, then a failure.POST /v1/runs/{id}/approvalexists 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.
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.approval.requeston the run-events stream, command pre-redacted via_redact_approval_commandgateway/platforms/api_server.py:6250-6275choicesarray 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)waiting_for_approval— the suspended-not-dead stateapi_server.py:1348,6271POST /v1/runs/{run_id}/approval, body{choice, resolve_all}; aliasesapprove/approved/allow→onceapi_server.py:1847,6548approval_events,run_approval_responseapi_server.py:2873-2875The 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.pyreturns nothing, and_stream_run_eventsdiscardsapproval.requestalong 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 includePOST /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:
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:
approval.requestdown the client socket as a new message type, carrying the redacted command, the specifics the card shows, andchoicesverbatim._await_run_outputrecognises only completed / cancelled / failed today, sowaiting_for_approvalspins topoll_timeoutand raises. It is alive and must be distinguishable from a stall.POST /v1/runs/{run_id}/approval. The client must never hold the Hermes bearer key.Not blocked
Independent of
ollvt-hermes-bridge#16(the Platform adapter) and of the go-between inbound receiver. The adapter'ssend_exec_approvalis 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
choicesrefinement above.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.requestexists on the/v1/runsSSE stream. That surface is now abandoned entirely (ADR-0001), so the ask instead arrives through the platform path:send_exec_approvalon 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.mdin this repo holds it: two keys,sessionandalwaysbehind a press-and-hold on 許可, never four keys, the reveal gated on the event's per-requestchoicesso 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.
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 typecheckreports 585error TSlines, byte-identical tomain's baseline — the only three outside the vendoredWebSDK/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;sessionandalwayslive 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 ownoptions, so a smart-denied tool (["once","deny"]) shows no reveal at all rather than dead ones.smart_deniedis 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.tscontains 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:{"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){"type":"approval-response","approval_id","choice"}—websocket_handler.py:73-76,:454-465conversations/conversation_handler.py:93and:100No
device_idand no callback id are sent. The client answers bychoiceand 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":releaseGrantnever returnssessionoralwaysfor any hold duration on any ask,pickStandingGrantrefuses unless the sheet is open and the ask carried that grant, and a long hold on a two-value ask still sendsoncebecause there is nothing to reveal.Deliberately left out, all in the questions ledger
commandanddescriptionand 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.smart_deniednote, the hold hint, the shade-floor note), andMessage['status']gains a fifth § 4b value,denied, because 拒否 is neither 完了 nor 失敗. It draws an acid ✕, not the failure magenta.AiStateEnummember.WAITINGwas 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_idand 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.
承認 surface —
issue/denpa-19-approval-surfaceBranch
issue/denpa-19-approval-surfaceindenpa-client, pushed toorigin. 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), andanswerOutcomeFrom()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, atdensity="full"(receiver) anddensity="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, andApp.tsx(the window branch became aWindowReceivercomponent).Tests
420 passed in 33 files. Main's baseline is 352 in 30.
npm run typecheck: 585error TSlines, 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 lintis broken on main and stayed out of scope.What the tests actually cover:
answerOutcomeFromagainst both wire sentences; restore-on-undelivered; refusing to pop when the socket is not OPEN.renderToStaticMarkupassertions: text present, key count, notch attribute, 44px touch floor, therole="alert"failure line,aria-expandedon the disclosure.useApprovals, thatstopMic()fires when an ask becomes outstanding.No tests were deleted or skipped.
Wire claims, each with its citation
All in the
denpago-between unless noted.approval-requestis the client-facing name of the ask —src/open_llm_vtuber/adapter_handler.py:60,AskKind("approval", "approval_id", "approval-request", "approval-response").command,description,smart_denied, plusoptionsand 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.optionsis per-ask and authoritative: only choices inprotocol.GRANT_CHOICESthat 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.{"type":"approval-response","approval_id","choice"}and by choice only —src/open_llm_vtuber/websocket_handler.py:73-76(handler map fromASK_BY_RESPONSE_TYPE) and:454-465(_handle_answerreadskind.id_fieldandchoice, nothing else).adapter_handler.py:296-326(send_answerlooks upoffered[1].get(choice)and sendsFRAME_CALLBACK).adapter_handler.py:205-213,_offeredkeyed onclient_uid, with the comment naming the forged-grant hole. This is why nothing in the client sends adevice_id.adapter_handler.py:304-326,send_answerreturns False when the key is absent and pops on success.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.conversation_handler.py:66-71(report_undeliveredformatsf"No adapter is attached, so {what}.") withwhat="the answer was not delivered"at:93. Briefing quoted "…so the grant was not delivered"; again coded against the source.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.conversation_handler.py:95-102sends only{type: error, message}.smart_deniedis a plain bool on the ask —adapter_handler.py:121,"smart_denied": bool(frame.get("smart_denied")).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:
pressGrantopens a gesture; a release before the hold threshold yieldsonce. Holding past it reveals the standing keys and yields nothing on its own —releaseGrantreturns a null choice in that state.optionsis then its own key needing its own press. The reveal grants nothing.onGrantActivatefires on aclickwithevent.detail === 0(Enter/Space or AT, never pointer-derived) and sendsonceviaactivateGrant(ask). The reveal tell is anaria-expandeddisclosure button driven bytoggleReveal(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.pointerAnsweredref swallows the synthetic click that trails a pointer answer. Without it that stray click would have landed on the next ask in the queue.optionsrender. 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
approval-requestcarries onlycommand,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.ApprovalCardatdensity="tight"in the bubble column, which is what satisfies "the reveal works identically on the full card and the cuff".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.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
renderToStaticMarkupmarkup 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.approval-request, no answer reached Hermes.environment: 'node'with no jsdom and no testing-library, sopointerdown/pointerupcannot be dispatched. A wiring bug connectingonPointerUpto the wrong handler would pass every test here.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
APPROVAL_TREATMENT), not a seventhAiStateEnummember.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.Message['status']gains new values.granted(acid ✓) for an answered approval andunansweredfor one whose answer went nowhere. 拒否 drawsdenied— 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 noterror: 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.content, becauseupsertToolCalllockspreviewto 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.許可 · ずっと · fs.write はもうきかない) rather than in a new field or component.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.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.denpa-client\.claude\worktrees\wf_7adaad23-47a-1rather than the main checkout, because main is onissue/denpa-31-screen-share-grantwith a sibling agent's uncommitted changes to 8 files. Checking out branch 19 there would have destroyed their work.denpa, for adenpa-clientissue). En route to fixing it,git worktree removefollowed anode_modulesjunction and deleted.binand.package-lock.jsonfrom the shareddenpa-client/node_modules. Restored withnpm installin 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.
use-approvals.ts:34).App.tsxcalleduseReceiverChrome()unconditionally above the window/pal branch, so pet mode ran two queues, twowsService.onMessagesubscriptions 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 intoApprovalProvider, the only caller ofuseApprovals; both surfaces readuseApproval(); the window branch became aWindowReceivercomponent so the chrome's hooks stop running behind the pal. The false invariant comment that hid this is deleted.approval-card.tsx:213). It was pointer-events only, so the only reachable answer for a keyboard or screen-reader user was refusal. Fixed withonGrantActivate+ thearia-expandeddisclosure described above.running(approval-spec.ts:115). Nothing on the wire ever updatesapproval:<id>, so the row animated its meter on every later turn, the ✓ never landed, andchainSummarylost that turn's計 x.xsfor the rest of the session. Fixed with the terminalgrantedstatus handled inRelayGlyph/relayNameColor/relayTail. Also fixedliveChain.done, which counted onlycompleted|errorand so under-counted any chain holding a settled approval row.use-approvals.ts:95). This one changed my model of the wire. Verifying in the go-between showed both error sentences come only fromhandle_answer, so both are solicited and there is no unsolicited dead-ask error — which made the oldDEAD_ASK_GRACE_MSa guard against an impossible case that swallowed the real one.isDeadAskError/DEAD_ASK_MESSAGESare replaced byASK_RESOLVED_MESSAGE,ANSWER_UNDELIVERED_MESSAGEandanswerOutcomeFrom(), read against aninFlightrecord.undelivered→restoreAsk()puts the ask back at the head carryinganswerFailure, and the card draws arole="alert"line saying the answer went nowhere. The ask is also no longer popped at all whenwsService.getCurrentState() !== 'OPEN'.use-approvals.ts:80). NewapprovalFailureRow()upserts the same id tounansweredwith the reason in the tail (答えが届かなかった / もう待っていなかった).retireOutstandingis gone — with expiry unreported upstream, the solicited error was its only real path.micHeldonly 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:useApprovalscallsstopMic()when an ask becomes outstanding. The keys stay disabled so it cannot be reopened under a standing ask.key-row.tsx:85,pal-bar.tsx:259) — the dead affordance the issue rules out. Given the existing dim treatment plusaria-disabledparity.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. Nowdisabled={micHeld && treatment?.acidKey !== 'receive'}.approval-card.tsx:149).canGrantwas true when the ask offered a standing grant but noonce, so a tap did nothing. 許可 now renders only when the ask offersonce; otherwise the standing keys are drawn directly.commandnever reached 受信記録 (approval-spec.ts:110). The go-between permits it (str(frame.get("command") or ""),adapter_handler.py:117) andappendOrUpdateToolCallMessagerejects a falsytool_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).
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.