feat: voice command mapping — context-gated approve/reject over voice
While a tool-permission gate is held on the active tab, spoken confirm phrases resolve it via the existing approve/reject WS channel — no server change (byte-shuttle intact). Everything else stays ordinary dictation. Safety: whole-utterance-exact matching + leading-negation guard + reject precedence + confidence gate (approve only) + a cancellable 1.5s confirm window whose commit re-validates gate identity/epoch/connectivity (TOCTOU guard) + stale-gate epoch bound at PTT-start. New pure modules public/voice-commands.ts and public/voice-confirm.ts at 100% coverage; wiring in voice/terminal-session/tabs/main. 1307 tests pass, typecheck clean, build:web OK. Independent code + security reviews flagged a confirm-window TOCTOU and an unwired cancel path — both fixed and covered. Plan: docs/PLAN_VOICE_COMMANDS.md.
This commit is contained in:
65
public/voice-confirm.ts
Normal file
65
public/voice-confirm.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* public/voice-confirm.ts — Approve confirm window controller (VC / V1b, decision B).
|
||||
*
|
||||
* PLAN_VOICE_COMMANDS.md §4: a spoken "approve" is irreversible (grants shell
|
||||
* command execution via the held permission gate), so instead of committing
|
||||
* immediately it arms a short cancellable window. `arm()` fires `onHeard`
|
||||
* synchronously (so the caller can show a "听到:批准" style affordance) and
|
||||
* starts a `windowMs` timer; if `cancel()` is not called first, `onCommit`
|
||||
* fires when the timer elapses.
|
||||
*
|
||||
* Pure timing logic — NO DOM here. The caller (tabs.ts, lane V2) owns the
|
||||
* overlay UI and calls `session.approve()` inside `onCommit`. Kept dependency-
|
||||
* free (setTimeout/clearTimeout only) so tests can use vi.useFakeTimers
|
||||
* without any DOM/WS coupling.
|
||||
*/
|
||||
|
||||
/** Default confirm-window duration in milliseconds (PLAN_VOICE_COMMANDS §4). */
|
||||
const DEFAULT_WINDOW_MS = 1500
|
||||
|
||||
/** A cancellable approve confirm window. Obtain via createApproveConfirm(). */
|
||||
export interface ApproveConfirm {
|
||||
/**
|
||||
* Arm the confirm window: calls `onHeard` immediately, then starts a
|
||||
* `windowMs` timer that calls `onCommit` unless `cancel()` runs first.
|
||||
* Re-arming (calling `arm` again while already armed) clears the prior
|
||||
* timer, so only the latest arm's `onCommit` can ever fire.
|
||||
*/
|
||||
arm(onHeard: () => void, onCommit: () => void): void
|
||||
/** Cancel the pending window, if any. No-op (does not throw) if not armed. */
|
||||
cancel(): void
|
||||
/** True while a confirm window is pending (armed and not yet committed/cancelled). */
|
||||
isArmed(): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an approve confirm window controller.
|
||||
*
|
||||
* @param windowMs Duration of the cancellable window in ms. Default: 1500 (B).
|
||||
*/
|
||||
export function createApproveConfirm(windowMs: number = DEFAULT_WINDOW_MS): ApproveConfirm {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
return {
|
||||
arm(onHeard: () => void, onCommit: () => void): void {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
onHeard()
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined
|
||||
onCommit()
|
||||
}, windowMs)
|
||||
},
|
||||
|
||||
cancel(): void {
|
||||
if (timer === undefined) return
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
},
|
||||
|
||||
isArmed(): boolean {
|
||||
return timer !== undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user