Files
web-terminal/public/voice-confirm.ts
Yaojia Wang e4c327e25e 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.
2026-07-01 10:38:32 +02:00

66 lines
2.3 KiB
TypeScript

/**
* 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
},
}
}