@@ -114,8 +114,16 @@ interface DriverScopeState {
114114 takeoverActive : boolean
115115 takeoverDone : boolean
116116 takeoverResponse : string | null
117+ /** Invocation that currently owns the shared takeover response state. */
118+ takeoverInvocationEpoch : number | null
117119 /** Monotonic browser-tool invocation id used to supersede an abandoned takeover. */
118120 toolInvocationEpoch : number
121+ /** Exact client tool currently at the head of this scope's serialized queue. */
122+ activeToolCallId : string | null
123+ /** Rejects the active queue entry while its underlying Chromium work winds down. */
124+ activeToolCancel : ( ( ) => void ) | null
125+ /** Invalidates every invocation already queued when a scope-level cancel establishes a boundary. */
126+ toolQueueCancellationEpoch : number
119127 lastTabsStateFingerprint : string | null
120128 toolQueue : Promise < unknown >
121129 /** True while activation is the only operation that has touched this scope. */
@@ -132,13 +140,23 @@ interface DriverScopeState {
132140 toolExecutionEpoch : number
133141}
134142
143+ /** Captures the native queue boundary before an async authorization round trip. */
144+ export interface BrowserToolQueueBoundary {
145+ scopeId : string
146+ cancellationEpoch : number
147+ }
148+
135149function createDriverScopeState ( ) : DriverScopeState {
136150 return {
137151 pendingNotices : [ ] ,
138152 takeoverActive : false ,
139153 takeoverDone : false ,
140154 takeoverResponse : null ,
155+ takeoverInvocationEpoch : null ,
141156 toolInvocationEpoch : 0 ,
157+ activeToolCallId : null ,
158+ activeToolCancel : null ,
159+ toolQueueCancellationEpoch : 0 ,
142160 lastTabsStateFingerprint : null ,
143161 toolQueue : Promise . resolve ( ) ,
144162 activationOnly : true ,
@@ -158,6 +176,22 @@ function invalidateSnapshot(state = driverScopeState()): void {
158176
159177const driverScopeStates = new Map < string , DriverScopeState > ( )
160178const driverScopeAliases = new Map < string , string > ( )
179+ const CANCELLED_TOOL_TTL_MS = 5 * 60_000
180+ const MAX_CANCELLED_TOOL_TOMBSTONES = 256
181+ const cancelledToolCallIds = new Map < string , number > ( )
182+
183+ function pruneCancelledToolCallIds ( now = Date . now ( ) ) : void {
184+ for ( const [ toolCallId , expiresAt ] of cancelledToolCallIds ) {
185+ if ( expiresAt > now && cancelledToolCallIds . size <= MAX_CANCELLED_TOOL_TOMBSTONES ) break
186+ cancelledToolCallIds . delete ( toolCallId )
187+ }
188+ }
189+
190+ function isToolCallCancelled ( toolCallId : string | undefined ) : boolean {
191+ if ( ! toolCallId ) return false
192+ pruneCancelledToolCallIds ( )
193+ return cancelledToolCallIds . has ( toolCallId )
194+ }
161195
162196function resolveDriverScopeId ( scopeId : string ) : string {
163197 let resolved = scopeId
@@ -179,6 +213,19 @@ function driverScopeState(scopeId = session.getBrowserScopeId()): DriverScopeSta
179213 return state
180214}
181215
216+ export function captureBrowserToolQueueBoundary ( scopeId : string ) : BrowserToolQueueBoundary {
217+ const resolvedScopeId = resolveDriverScopeId ( scopeId )
218+ return {
219+ scopeId : resolvedScopeId ,
220+ cancellationEpoch : driverScopeState ( resolvedScopeId ) . toolQueueCancellationEpoch ,
221+ }
222+ }
223+
224+ function isBrowserToolQueueBoundaryCurrent ( boundary : BrowserToolQueueBoundary ) : boolean {
225+ const state = driverScopeStates . get ( resolveDriverScopeId ( boundary . scopeId ) )
226+ return state ?. toolQueueCancellationEpoch === boundary . cancellationEpoch
227+ }
228+
182229function recordNotice ( notice : string ) : void {
183230 const state = driverScopeState ( )
184231 state . activationOnly = false
@@ -313,6 +360,7 @@ export function initDriver(
313360 // first tab push as a duplicate.
314361 driverScopeStates . clear ( )
315362 driverScopeAliases . clear ( )
363+ cancelledToolCallIds . clear ( )
316364 // The serialization chain, too. A takeover from the previous session can sit
317365 // unresolved indefinitely, and its `takeoverDone` flag is reset above — so
318366 // leaving the old chain head in place would queue the new session's first
@@ -1644,6 +1692,7 @@ async function runTakeover(purpose: string | undefined, invocationEpoch: number)
16441692 state . takeoverActive = true
16451693 state . takeoverDone = false
16461694 state . takeoverResponse = null
1695+ state . takeoverInvocationEpoch = invocationEpoch
16471696 session . setAutomationNeedsAttention ( true )
16481697
16491698 const startedAt = Date . now ( )
@@ -1673,10 +1722,16 @@ async function runTakeover(purpose: string | undefined, invocationEpoch: number)
16731722 }
16741723 }
16751724 } finally {
1676- session . setAutomationNeedsAttention ( false )
1677- state . takeoverActive = false
1678- state . takeoverDone = false
1679- state . takeoverResponse = null
1725+ // Cancellation releases the serialized tool queue before this polling
1726+ // loop observes its superseding epoch. Never let that delayed cleanup
1727+ // erase a newer takeover that has already claimed the same scope state.
1728+ if ( state . takeoverInvocationEpoch === invocationEpoch ) {
1729+ session . setAutomationNeedsAttention ( false )
1730+ state . takeoverActive = false
1731+ state . takeoverDone = false
1732+ state . takeoverResponse = null
1733+ state . takeoverInvocationEpoch = null
1734+ }
16801735 }
16811736}
16821737
@@ -2987,7 +3042,9 @@ function withNotices(result: unknown): unknown {
29873042export async function executeTool (
29883043 scopeId : string ,
29893044 tool : BrowserToolName ,
2990- params : Record < string , unknown >
3045+ params : Record < string , unknown > ,
3046+ toolCallId ?: string ,
3047+ authorizationBoundary ?: BrowserToolQueueBoundary
29913048) : Promise < { ok : boolean ; result ?: unknown ; error ?: string } > {
29923049 const resolvedScopeId = resolveDriverScopeId ( scopeId )
29933050 if ( session . isBrowserScopeSuspended ( resolvedScopeId ) ) {
@@ -2999,7 +3056,21 @@ export async function executeTool(
29993056 const state = driverScopeState ( resolvedScopeId )
30003057 state . activationOnly = false
30013058 const invocationEpoch = ++ state . toolInvocationEpoch
3059+ const queueCancellationEpoch = state . toolQueueCancellationEpoch
30023060 const run = async ( ) => {
3061+ if (
3062+ ( authorizationBoundary && ! isBrowserToolQueueBoundaryCurrent ( authorizationBoundary ) ) ||
3063+ queueCancellationEpoch !== state . toolQueueCancellationEpoch ||
3064+ isToolCallCancelled ( toolCallId )
3065+ ) {
3066+ throw new ToolError ( 'This browser action was cancelled before it started.' )
3067+ }
3068+ state . activeToolCallId = toolCallId ?? null
3069+ let cancelActiveExecution : ( ) => void = ( ) => { }
3070+ const cancellation = new Promise < never > ( ( _resolve , reject ) => {
3071+ cancelActiveExecution = ( ) => reject ( new ToolError ( 'This browser action was cancelled.' ) )
3072+ } )
3073+ state . activeToolCancel = cancelActiveExecution
30033074 return await session . withBrowserScope ( resolvedScopeId , async ( ) => {
30043075 logger . info ( 'Executing browser tool' , { tool, scopeId : resolvedScopeId } )
30053076 const keepHiddenPageActive = tool !== 'browser_request_takeover'
@@ -3022,20 +3093,24 @@ export async function executeTool(
30223093 executionDeadline ,
30233094 invocationEpoch
30243095 )
3025- return withNotices (
3026- await ( watchdogMs === null
3096+ const guardedExecution =
3097+ watchdogMs === null
30273098 ? execution
30283099 : raceAgainstWatchdog ( execution , watchdogMs , ( ) => {
30293100 if ( state . toolExecutionEpoch === executionEpoch ) state . toolExecutionEpoch ++
30303101 if ( tool === 'browser_snapshot' || tool === 'browser_open_url' ) {
30313102 invalidateSnapshot ( state )
30323103 }
3033- } ) )
3034- )
3104+ } )
3105+ return withNotices ( await Promise . race ( [ guardedExecution , cancellation ] ) )
30353106 } finally {
30363107 if ( keepHiddenPageActive ) {
30373108 session . setAutomationActive ( false )
30383109 }
3110+ if ( state . activeToolCancel === cancelActiveExecution ) {
3111+ state . activeToolCallId = null
3112+ state . activeToolCancel = null
3113+ }
30393114 }
30403115 } )
30413116 }
@@ -3057,6 +3132,40 @@ export async function executeTool(
30573132 }
30583133}
30593134
3135+ /**
3136+ * Cancels one exact browser tool, including a cancellation that arrives while
3137+ * its authorization IPC is still in flight. The bounded tombstone lets that
3138+ * later invocation observe the stop without retaining call ids indefinitely.
3139+ */
3140+ export function cancelTool ( scopeId : string , toolCallId : string ) : boolean {
3141+ pruneCancelledToolCallIds ( )
3142+ cancelledToolCallIds . set ( toolCallId , Date . now ( ) + CANCELLED_TOOL_TTL_MS )
3143+ pruneCancelledToolCallIds ( )
3144+
3145+ const resolvedScopeId = resolveDriverScopeId ( scopeId )
3146+ const state = driverScopeStates . get ( resolvedScopeId )
3147+ if ( ! state || state . activeToolCallId !== toolCallId ) return true
3148+
3149+ state . toolInvocationEpoch ++
3150+ state . toolExecutionEpoch ++
3151+ state . activeToolCancel ?.( )
3152+ void session . withBrowserScope ( resolvedScopeId , ( ) => {
3153+ session . setAutomationActive ( false )
3154+ session . setAutomationNeedsAttention ( false )
3155+ } )
3156+ return true
3157+ }
3158+
3159+ /** Cancels the active tool and every older invocation already queued for this scope. */
3160+ export function cancelActiveTool ( scopeId : string ) : boolean {
3161+ const resolvedScopeId = resolveDriverScopeId ( scopeId )
3162+ const state = driverScopeStates . get ( resolvedScopeId )
3163+ if ( ! state ) return false
3164+ state . toolQueueCancellationEpoch ++
3165+ const toolCallId = state . activeToolCallId
3166+ return toolCallId ? cancelTool ( resolvedScopeId , toolCallId ) : false
3167+ }
3168+
30603169/** Browser-chrome commands from the panel header; fire-and-forget. */
30613170export async function handlePanelAction (
30623171 scopeId : string ,
0 commit comments