/ utils / computerUse / cleanup.ts
cleanup.ts
 1  import type { ToolUseContext } from '../../Tool.js'
 2  
 3  import { logForDebugging } from '../debug.js'
 4  import { errorMessage } from '../errors.js'
 5  import { withResolvers } from '../withResolvers.js'
 6  import { isLockHeldLocally, releaseComputerUseLock } from './computerUseLock.js'
 7  import { unregisterEscHotkey } from './escHotkey.js'
 8  
 9  // cu.apps.unhide is NOT one of the four @MainActor methods wrapped by
10  // drainRunLoop's 30s backstop. On abort paths (where the user hit Ctrl+C
11  // because something was slow) a hang here would wedge the abort. Generous
12  // timeout — unhide should be ~instant; if it takes 5s something is wrong
13  // and proceeding is better than waiting. The Swift call continues in the
14  // background regardless; we just stop blocking on it.
15  const UNHIDE_TIMEOUT_MS = 5000
16  
17  /**
18   * Turn-end cleanup for the chicago MCP surface: auto-unhide apps that
19   * `prepareForAction` hid, then release the file-based lock.
20   *
21   * Called from three sites: natural turn end (`stopHooks.ts`), abort during
22   * streaming (`query.ts` aborted_streaming), abort during tool execution
23   * (`query.ts` aborted_tools). All three reach this via dynamic import gated
24   * on `feature('CHICAGO_MCP')`. `executor.js` (which pulls both native
25   * modules) is dynamic-imported below so non-CU turns don't load native
26   * modules just to no-op.
27   *
28   * No-ops cheaply on non-CU turns: both gate checks are zero-syscall.
29   */
30  export async function cleanupComputerUseAfterTurn(
31    ctx: Pick<
32      ToolUseContext,
33      'getAppState' | 'setAppState' | 'sendOSNotification'
34    >,
35  ): Promise<void> {
36    const appState = ctx.getAppState()
37  
38    const hidden = appState.computerUseMcpState?.hiddenDuringTurn
39    if (hidden && hidden.size > 0) {
40      const { unhideComputerUseApps } = await import('./executor.js')
41      const unhide = unhideComputerUseApps([...hidden]).catch(err =>
42        logForDebugging(
43          `[Computer Use MCP] auto-unhide failed: ${errorMessage(err)}`,
44        ),
45      )
46      const timeout = withResolvers<void>()
47      const timer = setTimeout(timeout.resolve, UNHIDE_TIMEOUT_MS)
48      await Promise.race([unhide, timeout.promise]).finally(() =>
49        clearTimeout(timer),
50      )
51      ctx.setAppState(prev =>
52        prev.computerUseMcpState?.hiddenDuringTurn === undefined
53          ? prev
54          : {
55              ...prev,
56              computerUseMcpState: {
57                ...prev.computerUseMcpState,
58                hiddenDuringTurn: undefined,
59              },
60            },
61      )
62    }
63  
64    // Zero-syscall pre-check so non-CU turns don't touch disk. Release is still
65    // idempotent (returns false if already released or owned by another session).
66    if (!isLockHeldLocally()) return
67  
68    // Unregister before lock release so the pump-retain drops as soon as the
69    // CU session ends. Idempotent — no-ops if registration failed at acquire.
70    // Swallow throws so a NAPI unregister error never prevents lock release —
71    // a held lock blocks the next CU session with "in use by another session".
72    try {
73      unregisterEscHotkey()
74    } catch (err) {
75      logForDebugging(
76        `[Computer Use MCP] unregisterEscHotkey failed: ${errorMessage(err)}`,
77      )
78    }
79  
80    if (await releaseComputerUseLock()) {
81      ctx.sendOSNotification?.({
82        message: 'Claude is done using your computer',
83        notificationType: 'computer_use_exit',
84      })
85    }
86  }