From dafd7d0afd4024b7cc55247299520b407d1419e0 Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Thu, 27 Aug 2026 14:40:56 +0530 Subject: [PATCH 1/6] feat(rails): pause the supervised run when the user touches the machine Closes the documented-but-unwired defense (SAFETY_REVIEW 'any user touch pauses'): a watchdog shared by the vision and AX hosts pauses the guard on human input. Synthetic suppression has one owner - the actuation adapter brackets every action in a tracker (in-flight + parked cursor), so the rail's own output never reads as a takeover, and neither do clicks on our own overlay (or resuming would instantly re-pause). Detection: uiohook-napi (new OPTIONAL native addon, same load-by-variable pattern as nut-js) for mouse + keyboard; a dependency-free cursor-poll fallback covers mouse when the addon is absent. 15 tests over the decision rule and the poll path; the hook path is owned by the real-machine pass like actuation itself. Co-Authored-By: Claude Fable 5 --- package-lock.json | 30 +++- package.json | 3 +- src/main/accessibility/ax-host.ts | 4 + .../input/__tests__/synthetic-tracker.test.ts | 92 +++++++++++++ .../input/__tests__/user-input-watch.test.ts | 106 ++++++++++++++ src/main/input/actuation.ts | 63 ++++++--- src/main/input/synthetic-tracker.ts | 100 ++++++++++++++ src/main/input/user-input-watch.ts | 129 ++++++++++++++++++ src/main/vision/vision-host.ts | 5 + 9 files changed, 510 insertions(+), 22 deletions(-) create mode 100644 src/main/input/__tests__/synthetic-tracker.test.ts create mode 100644 src/main/input/__tests__/user-input-watch.test.ts create mode 100644 src/main/input/synthetic-tracker.ts create mode 100644 src/main/input/user-input-watch.ts diff --git a/package-lock.json b/package-lock.json index 9630c3c8..443bce14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "tailwind-merge": "^3.4.0", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1", + "uiohook-napi": "^1.5.4", "unified": "^11.0.5" }, "devDependencies": { @@ -100,7 +101,8 @@ "vitest": "^4.0.17" }, "optionalDependencies": { - "@nut-tree-fork/nut-js": "^4.2.6" + "@nut-tree-fork/nut-js": "^4.2.6", + "uiohook-napi": "^1.5.5" } }, "../shared/packages/design": { @@ -16109,6 +16111,18 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-gyp/node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -19976,6 +19990,20 @@ "node": ">=8" } }, + "node_modules/uiohook-napi": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/uiohook-napi/-/uiohook-napi-1.5.5.tgz", + "integrity": "sha512-oSlTdnECw2GBfsJPTbBQBeE4v/EXP0EZmX6BJq5nzH/JgFaBE8JpFwEA/kLhiEP7HxQw28FViWiYgdIZzWuuJQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/unbash": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.2.tgz", diff --git a/package.json b/package.json index 49fc14de..387812c2 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,8 @@ "unified": "^11.0.5" }, "optionalDependencies": { - "@nut-tree-fork/nut-js": "^4.2.6" + "@nut-tree-fork/nut-js": "^4.2.6", + "uiohook-napi": "^1.5.5" }, "devDependencies": { "@electron-toolkit/eslint-config-prettier": "^3.0.0", diff --git a/src/main/accessibility/ax-host.ts b/src/main/accessibility/ax-host.ts index 49e7ab2c..d41e5ab9 100644 --- a/src/main/accessibility/ax-host.ts +++ b/src/main/accessibility/ax-host.ts @@ -23,6 +23,7 @@ import { globalShortcut, systemPreferences } from 'electron' import { binRoots, exe } from '../runtime-env' import { llm } from '../llm' import { loadActuation, type ActuationPort } from '../input/actuation' +import { startUserInputWatch } from '../input/user-input-watch' import { parseAxElements, type AxElement, type AxSnapshot } from './ax-elements' import { windowsAxBackend, type AxBackend } from './ax-win' import { pickTargetApp } from './ax-target' @@ -237,6 +238,8 @@ class AxRailHost { // The kill switch: Esc halts for good. The overlay's Stop routes to the SAME // guard through the controller session, so both paths end one run. globalShortcut.register('Escape', () => guard.halt('stopped with Esc')) + // Pause on user input - same defense as the vision rail, same watchdog. + const stopInputWatch = startUserInputWatch((why) => guard.pauseForUser(why)) const releaseSession = registerVisionSession(guard) // The AX rail is model-agnostic and needs no grounder, so there is no // grounder notice here (unlike the vision rail). @@ -288,6 +291,7 @@ class AxRailHost { return { ok: false, summary, steps: [] } } finally { globalShortcut.unregister('Escape') + stopInputWatch() releaseSession() hideSupervisorWindow() } diff --git a/src/main/input/__tests__/synthetic-tracker.test.ts b/src/main/input/__tests__/synthetic-tracker.test.ts new file mode 100644 index 00000000..58e1dc39 --- /dev/null +++ b/src/main/input/__tests__/synthetic-tracker.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + beginSynthetic, + endSynthetic, + insideAnyWindow, + isUserInput, + resetSynthetic, + syntheticSnapshot, + DEFAULT_USER_INPUT_RULE +} from '../synthetic-tracker' + +afterEach(() => { + resetSynthetic() + vi.useRealTimers() +}) + +const RULE = DEFAULT_USER_INPUT_RULE + +describe('isUserInput (the takeover decision)', () => { + it('never fires while a synthetic action is in flight', () => { + expect( + isUserInput( + { kind: 'key', at: 1_000 }, + { inFlight: true, lastEndedAt: 0, cursor: null }, + RULE + ) + ).toBe(false) + }) + + it('stays quiet inside the grace window after a synthetic action settles', () => { + const synth = { inFlight: false, lastEndedAt: 10_000, cursor: null } + expect(isUserInput({ kind: 'key', at: 10_000 + RULE.graceMs - 1 }, synth, RULE)).toBe(false) + expect(isUserInput({ kind: 'key', at: 10_000 + RULE.graceMs + 1 }, synth, RULE)).toBe(true) + }) + + it('a cursor resting where the rail parked it is not a takeover; real drift is', () => { + const synth = { inFlight: false, lastEndedAt: 1_000, cursor: { x: 500, y: 500 } } + const later = 1_000 + RULE.graceMs + 1 + expect( + isUserInput({ kind: 'mouse', at: later, point: { x: 505, y: 495 } }, synth, RULE) + ).toBe(false) // within tolerance - jitter, not a human + expect( + isUserInput({ kind: 'mouse', at: later, point: { x: 700, y: 500 } }, synth, RULE) + ).toBe(true) + }) + + it('with no synthetic history at all, any input is the user', () => { + const synth = { inFlight: false, lastEndedAt: 0, cursor: null } + expect(isUserInput({ kind: 'mouse', at: 5, point: { x: 1, y: 1 } }, synth, RULE)).toBe(true) + expect(isUserInput({ kind: 'key', at: 5 }, synth, RULE)).toBe(true) + }) +}) + +describe('the tracker lifecycle', () => { + it('brackets: in flight while begun, settles with a timestamp, notes the cursor', () => { + vi.useFakeTimers() + vi.setSystemTime(50_000) + beginSynthetic({ x: 10, y: 20 }) + expect(syntheticSnapshot()).toMatchObject({ inFlight: true, cursor: { x: 10, y: 20 } }) + endSynthetic() + expect(syntheticSnapshot()).toMatchObject({ inFlight: false, lastEndedAt: 50_000 }) + }) + + it('nested actions stay in-flight until the last one settles', () => { + beginSynthetic() + beginSynthetic({ x: 1, y: 1 }) + endSynthetic() + expect(syntheticSnapshot().inFlight).toBe(true) + endSynthetic() + expect(syntheticSnapshot().inFlight).toBe(false) + }) + + it('reset clears everything for a fresh run', () => { + beginSynthetic({ x: 9, y: 9 }) + endSynthetic() + resetSynthetic() + expect(syntheticSnapshot()).toEqual({ inFlight: false, lastEndedAt: 0, cursor: null }) + }) +}) + +describe('insideAnyWindow (own-overlay suppression)', () => { + const windows = [{ x: 100, y: 100, width: 200, height: 50 }] + it('a click on our own overlay never counts as a takeover', () => { + expect(insideAnyWindow({ x: 150, y: 120 }, windows)).toBe(true) + expect(insideAnyWindow({ x: 100, y: 100 }, windows)).toBe(true) // edge inclusive + }) + it('outside is outside', () => { + expect(insideAnyWindow({ x: 99, y: 120 }, windows)).toBe(false) + expect(insideAnyWindow({ x: 150, y: 151 }, windows)).toBe(false) + expect(insideAnyWindow({ x: 150, y: 120 }, [])).toBe(false) + }) +}) diff --git a/src/main/input/__tests__/user-input-watch.test.ts b/src/main/input/__tests__/user-input-watch.test.ts new file mode 100644 index 00000000..09079ff6 --- /dev/null +++ b/src/main/input/__tests__/user-input-watch.test.ts @@ -0,0 +1,106 @@ +// The watchdog's poll strategy against a REAL tracker and fake Electron +// boundaries: parked cursor stays quiet, human drift pauses, our own overlay +// never counts, and stop() ends the watch. (The uiohook strategy is the native +// addon path - owned by the real-machine pass, like actuation itself.) +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const boundary = vi.hoisted(() => ({ + cursor: { x: 500, y: 500 }, + windows: [] as { x: number; y: number; width: number; height: number }[] +})) + +vi.mock('electron', () => ({ + screen: { getCursorScreenPoint: () => ({ ...boundary.cursor }) }, + BrowserWindow: { + getAllWindows: () => + boundary.windows.map((b) => ({ + isDestroyed: () => false, + isVisible: () => true, + getBounds: () => b + })) + } +})) + +import { startUserInputWatch } from '../user-input-watch' +import { beginSynthetic, endSynthetic, resetSynthetic } from '../synthetic-tracker' + +let stop: (() => void) | null = null + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(100_000) + boundary.cursor = { x: 500, y: 500 } + boundary.windows = [] +}) +afterEach(() => { + stop?.() + stop = null + resetSynthetic() + vi.useRealTimers() +}) + +/** Park the synthetic cursor at 500,500 and get past the grace window. */ +function parkSyntheticCursor(): void { + beginSynthetic({ x: 500, y: 500 }) + endSynthetic() + vi.advanceTimersByTime(1_000) // > graceMs +} + +describe('startUserInputWatch (poll fallback)', () => { + it('stays quiet while the cursor rests where the rail parked it', () => { + const onUserInput = vi.fn() + stop = startUserInputWatch(onUserInput, 'poll') + parkSyntheticCursor() + vi.advanceTimersByTime(2_000) + expect(onUserInput).not.toHaveBeenCalled() + }) + + it('pauses when a human moves the mouse away', () => { + const onUserInput = vi.fn() + stop = startUserInputWatch(onUserInput, 'poll') + parkSyntheticCursor() + boundary.cursor = { x: 900, y: 200 } + vi.advanceTimersByTime(300) + expect(onUserInput).toHaveBeenCalledWith('you moved the mouse') + }) + + it('interacting with our own window (the overlay) never pauses', () => { + const onUserInput = vi.fn() + boundary.windows = [{ x: 850, y: 150, width: 200, height: 100 }] + stop = startUserInputWatch(onUserInput, 'poll') + parkSyntheticCursor() + boundary.cursor = { x: 900, y: 200 } // inside the overlay bounds + vi.advanceTimersByTime(1_000) + expect(onUserInput).not.toHaveBeenCalled() + }) + + it('stays quiet while a synthetic action is in flight, even mid-move', () => { + const onUserInput = vi.fn() + stop = startUserInputWatch(onUserInput, 'poll') + beginSynthetic({ x: 500, y: 500 }) + boundary.cursor = { x: 700, y: 700 } // the rail itself is dragging + vi.advanceTimersByTime(1_000) + expect(onUserInput).not.toHaveBeenCalled() + endSynthetic() + }) + + it('stop() ends the watch - no pauses after teardown', () => { + const onUserInput = vi.fn() + stop = startUserInputWatch(onUserInput, 'poll') + parkSyntheticCursor() + stop() + stop = null + boundary.cursor = { x: 0, y: 0 } + vi.advanceTimersByTime(2_000) + expect(onUserInput).not.toHaveBeenCalled() + }) + + it('a fresh watch resets stale synthetic state from the previous run', () => { + beginSynthetic({ x: 1, y: 1 }) // stale, never ended - would suppress forever + const onUserInput = vi.fn() + stop = startUserInputWatch(onUserInput, 'poll') + boundary.cursor = { x: 300, y: 300 } + vi.advanceTimersByTime(300) + expect(onUserInput).toHaveBeenCalled() + }) +}) diff --git a/src/main/input/actuation.ts b/src/main/input/actuation.ts index b147d619..b40ae1bf 100644 --- a/src/main/input/actuation.ts +++ b/src/main/input/actuation.ts @@ -9,6 +9,7 @@ * second synthetic-input surface. */ import { hotkeyToKeyNames } from '../vision/vision-keys' +import { beginSynthetic, endSynthetic } from './synthetic-tracker' export interface ActuationPort { moveMouse(x: number, y: number): Promise @@ -57,22 +58,40 @@ export function loadActuation(): ActuationPort | null { return null } const { mouse, keyboard, Point, Button, Key } = nut + // Every action is bracketed by the synthetic tracker, so the user-input + // watchdog can tell the rail's own output from a human takeover. + const tracked = async (work: () => Promise, target?: { x: number; y: number }) => { + beginSynthetic(target) + try { + await work() + } finally { + endSynthetic() + } + } return { async moveMouse(x, y) { - await mouse.setPosition(new Point(x, y)) + await tracked(async () => { + await mouse.setPosition(new Point(x, y)) + }, { x, y }) }, async click(button, double) { - if (double) { - await mouse.doubleClick(Button.LEFT) - return - } - await (button === 'right' ? mouse.rightClick() : mouse.leftClick()) + await tracked(async () => { + if (double) { + await mouse.doubleClick(Button.LEFT) + return + } + await (button === 'right' ? mouse.rightClick() : mouse.leftClick()) + }) }, async dragTo(x, y) { - await mouse.drag([new Point(x, y)]) + await tracked(async () => { + await mouse.drag([new Point(x, y)]) + }, { x, y }) }, async typeText(text) { - await keyboard.type(text) + await tracked(async () => { + await keyboard.type(text) + }) }, async tapKeys(keys) { const names = hotkeyToKeyNames(keys) @@ -83,20 +102,24 @@ export function loadActuation(): ActuationPort | null { if (codes.length !== names.length) { return // an unmapped key - refuse the partial combo } - await keyboard.pressKey(...codes) - await keyboard.releaseKey(...codes) + await tracked(async () => { + await keyboard.pressKey(...codes) + await keyboard.releaseKey(...codes) + }) }, async scroll(direction) { - const steps = 3 - if (direction === 'up') { - await mouse.scrollUp(steps) - } else if (direction === 'down') { - await mouse.scrollDown(steps) - } else if (direction === 'left') { - await mouse.scrollLeft(steps) - } else { - await mouse.scrollRight(steps) - } + await tracked(async () => { + const steps = 3 + if (direction === 'up') { + await mouse.scrollUp(steps) + } else if (direction === 'down') { + await mouse.scrollDown(steps) + } else if (direction === 'left') { + await mouse.scrollLeft(steps) + } else { + await mouse.scrollRight(steps) + } + }) } } } diff --git a/src/main/input/synthetic-tracker.ts b/src/main/input/synthetic-tracker.ts new file mode 100644 index 00000000..3cba620b --- /dev/null +++ b/src/main/input/synthetic-tracker.ts @@ -0,0 +1,100 @@ +/** + * The single owner of "the rail is sending synthetic input right now". The + * actuation adapter marks every synthetic action (in-flight + where it left the + * cursor); the user-input watchdog consults this to tell a human touching the + * mouse/keyboard apart from the rail's own output. Pure state - no Electron, + * no timers - so the decision rule is unit-testable. + */ + +export interface SyntheticSnapshot { + /** A synthetic action's promise is currently in flight. */ + inFlight: boolean + /** When the last synthetic action settled (epoch ms; 0 = never). */ + lastEndedAt: number + /** Where synthetic input last left the cursor (screen px), if known. */ + cursor: { x: number; y: number } | null +} + +let inFlightCount = 0 +let lastEndedAt = 0 +let cursor: { x: number; y: number } | null = null + +export function beginSynthetic(target?: { x: number; y: number }): void { + inFlightCount += 1 + if (target) { + cursor = { ...target } + } +} + +export function endSynthetic(): void { + inFlightCount = Math.max(0, inFlightCount - 1) + lastEndedAt = Date.now() +} + +export function syntheticSnapshot(): SyntheticSnapshot { + return { inFlight: inFlightCount > 0, lastEndedAt, cursor: cursor && { ...cursor } } +} + +/** Test/lifecycle reset - a new supervised run starts from a clean slate. */ +export function resetSynthetic(): void { + inFlightCount = 0 + lastEndedAt = 0 + cursor = null +} + +export interface InputEvent { + kind: 'mouse' | 'key' + at: number + /** Screen position for mouse events (poll fallback + hook events). */ + point?: { x: number; y: number } +} + +export interface UserInputRule { + /** Quiet period after a synthetic action settles - system echo, focus shifts + * and the OS delivering our own events land inside it. */ + graceMs: number + /** Cursor drift below this many px from where synthetic input parked it is + * not a takeover (sub-pixel jitter, high-DPI rounding). */ + tolerancePx: number +} + +export const DEFAULT_USER_INPUT_RULE: UserInputRule = { graceMs: 600, tolerancePx: 24 } + +/** + * Is this event a HUMAN touching the machine? False while a synthetic action is + * in flight or just settled, and for mouse positions still resting where the + * rail parked the cursor. Everything else is the user - the guard pauses. + */ +export function isUserInput( + event: InputEvent, + synth: SyntheticSnapshot, + rule: UserInputRule = DEFAULT_USER_INPUT_RULE +): boolean { + if (synth.inFlight) { + return false + } + if (synth.lastEndedAt > 0 && event.at - synth.lastEndedAt < rule.graceMs) { + return false + } + if (event.kind === 'mouse' && event.point && synth.cursor) { + const dx = event.point.x - synth.cursor.x + const dy = event.point.y - synth.cursor.y + if (Math.hypot(dx, dy) <= rule.tolerancePx) { + return false + } + } + return true +} + +/** Is a screen point inside any of the given window rectangles? Interactions + * with our OWN windows (the supervisor overlay's Pause/Resume/Stop) must never + * count as a takeover, or resuming would instantly re-pause. */ +export function insideAnyWindow( + point: { x: number; y: number }, + windows: readonly { x: number; y: number; width: number; height: number }[] +): boolean { + return windows.some( + (w) => + point.x >= w.x && point.x <= w.x + w.width && point.y >= w.y && point.y <= w.y + w.height + ) +} diff --git a/src/main/input/user-input-watch.ts b/src/main/input/user-input-watch.ts new file mode 100644 index 00000000..47642cf5 --- /dev/null +++ b/src/main/input/user-input-watch.ts @@ -0,0 +1,129 @@ +/** + * The "pause when the user touches the machine" defense (SAFETY_REVIEW: any + * user touch pauses the supervised run until they resume). Two strategies + * behind one seam: + * + * - uiohook-napi (OPTIONAL native addon, same pattern as nut-js): global + * mouse + keyboard events. Keyboard coverage needs it - and on newer macOS + * the Input Monitoring grant. + * - cursor polling (always available, no deps): Electron's + * screen.getCursorScreenPoint() every 150ms - catches the user moving the + * mouse, which is the dominant takeover signal. Keyboard is not visible to + * this strategy; the Esc kill switch still works regardless (globalShortcut). + * + * Events that are the rail's own output are filtered by the synthetic tracker + * (one owner: input/synthetic-tracker.ts), and interactions with our own + * windows (the supervisor overlay's buttons) never count as a takeover. + */ +import { BrowserWindow, screen } from 'electron' +import { + DEFAULT_USER_INPUT_RULE, + insideAnyWindow, + isUserInput, + resetSynthetic, + syntheticSnapshot, + type InputEvent +} from './synthetic-tracker' + +const POLL_MS = 150 + +interface UioHookApi { + uIOhook: { + on(event: string, handler: (e: { x?: number; y?: number }) => void): void + removeAllListeners(event?: string): void + start(): void + stop(): void + } +} + +/** Load the OPTIONAL global-input addon; absent/unbuilt -> null (poll fallback). */ +function loadInputHook(): UioHookApi['uIOhook'] | null { + try { + const load = (m: string): UioHookApi => (require as NodeRequire)(m) as UioHookApi + return load('uiohook-napi').uIOhook + } catch { + return null + } +} + +function appWindowRects(): { x: number; y: number; width: number; height: number }[] { + return BrowserWindow.getAllWindows() + .filter((w) => !w.isDestroyed() && w.isVisible()) + .map((w) => w.getBounds()) +} + +function userEvent(event: InputEvent): boolean { + if (event.point && insideAnyWindow(event.point, appWindowRects())) { + return false + } + return isUserInput(event, syntheticSnapshot(), DEFAULT_USER_INPUT_RULE) +} + +/** + * Watch for human input for the duration of a supervised run. `onUserInput` + * may fire more than once (pause is idempotent; a takeover after a resume + * pauses again). Returns stop(). Also resets the synthetic tracker so a run + * starts from a clean slate. + * + * `strategy` exists for tests and diagnostics: 'poll' skips the native hook + * (a test must never start a real global input hook on the machine). + */ +export function startUserInputWatch( + onUserInput: (why: string) => void, + strategy: 'auto' | 'poll' = 'auto' +): () => void { + resetSynthetic() + const hook = strategy === 'auto' ? loadInputHook() : null + if (hook) { + const onMouse = (e: { x?: number; y?: number }): void => { + const point = + typeof e.x === 'number' && typeof e.y === 'number' ? { x: e.x, y: e.y } : undefined + if (userEvent({ kind: 'mouse', at: Date.now(), ...(point ? { point } : {}) })) { + onUserInput('you moved the mouse') + } + } + const onKey = (): void => { + if (userEvent({ kind: 'key', at: Date.now() })) { + onUserInput('you typed') + } + } + hook.on('mousemove', onMouse) + hook.on('mousedown', onMouse) + hook.on('wheel', onMouse) + hook.on('keydown', onKey) + try { + hook.start() + } catch { + /* hook failed to start (missing OS grant) - fall through to polling below */ + return startCursorPollWatch(onUserInput) + } + return () => { + try { + hook.removeAllListeners('mousemove') + hook.removeAllListeners('mousedown') + hook.removeAllListeners('wheel') + hook.removeAllListeners('keydown') + hook.stop() + } catch { + /* already stopped */ + } + } + } + return startCursorPollWatch(onUserInput) +} + +function startCursorPollWatch(onUserInput: (why: string) => void): () => void { + const timer = setInterval(() => { + let point: { x: number; y: number } + try { + point = screen.getCursorScreenPoint() + } catch { + return + } + if (userEvent({ kind: 'mouse', at: Date.now(), point })) { + onUserInput('you moved the mouse') + } + }, POLL_MS) + timer.unref?.() + return () => clearInterval(timer) +} diff --git a/src/main/vision/vision-host.ts b/src/main/vision/vision-host.ts index 34c39683..1252389d 100644 --- a/src/main/vision/vision-host.ts +++ b/src/main/vision/vision-host.ts @@ -31,6 +31,7 @@ import { showSupervisorWindow, hideSupervisorWindow } from './supervisor-window' import { visionModelNotice } from './vision-model-notice' import { getTakeoverCoordinator } from '../browser/takeover' import { loadActuation, actuationAvailable, type ActuationPort } from '../input/actuation' +import { startUserInputWatch } from '../input/user-input-watch' import { mapActionToScreen, type DisplayGeometry } from '../input/coordinate-mapping' export type { ActuationPort } @@ -165,6 +166,9 @@ class VisionHost { // The kill switch: Esc halts the run and consumes the keypress. The overlay's // Stop routes to the SAME guard via the controller session. globalShortcut.register('Escape', () => guard.halt('stopped with Esc')) + // Pause on user input: any human mouse/keyboard touch (outside our own + // windows, and not the rail's own synthetic output) pauses the run. + const stopInputWatch = startUserInputWatch((why) => guard.pauseForUser(why)) const releaseSession = registerVisionSession(guard) const coordinator = getTakeoverCoordinator() // Model-agnostic, but honest: warn (do not block) when the loaded model is @@ -194,6 +198,7 @@ class VisionHost { return result } finally { globalShortcut.unregister('Escape') + stopInputWatch() releaseSession() hideSupervisorWindow() } From 6ba3626cfee0beb61841c2e2607fdd46d4f98781 Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Thu, 27 Aug 2026 14:47:20 +0530 Subject: [PATCH 2/6] feat(rails): sends gate for approval alongside computer-use tasks The approval policy diverged from the safety story: mail_send and messages_send ran with no human in the loop while every doc and the model-facing prompts promised gating. One rule now, in needsApproval: computer-use rails AND send action types gate; web_task stays unprompted (it acts in Off Grid's own watched pane); undoable mutations keep the auto-run + Undo tier. The pro auto-approve toggle is scoped to computer use only - sends ask every time. Model hints and SAFETY_REVIEW updated to say exactly this. Tests: the policy matrix + sends parking (even in auto mode) + resolve round-trips. Co-Authored-By: Claude Fable 5 --- docs/SAFETY_REVIEW.md | 23 ++++++--- src/main/actions/__tests__/gate-host.test.ts | 51 ++++++++++++++++--- src/main/actions/gate-host.ts | 36 ++++++++----- .../tools/nativeActionToolExtension-logic.ts | 4 +- 4 files changed, 85 insertions(+), 29 deletions(-) diff --git a/docs/SAFETY_REVIEW.md b/docs/SAFETY_REVIEW.md index 8f17513c..2a80961d 100644 --- a/docs/SAFETY_REVIEW.md +++ b/docs/SAFETY_REVIEW.md @@ -8,11 +8,19 @@ and where that defense is tested - so a later change that weakens a defense fails a test instead of shipping. The governing principle: **the model only proposes; the pipeline guarantees.** -Every mutation is a durable Action that gates for approval, binds its payload -by hash, executes once, and verifies. Injection cannot manufacture an approved -action out of nothing - it can only try to steer a task the user already -approved. So the defenses below are about bounding that steering, and about -never letting the agent cross an identity or payment boundary on its own. +Every mutation is a durable Action that binds its payload by hash, executes +once, and verifies. The approval policy is risk-tiered (one rule, in +`gate-host.ts needsApproval`): **sends (message, email) and computer-use tasks +(the accessibility/vision rails) gate for human approval every time** - a send +is irreversible with no reliable read-back, and computer use takes over the +cursor. Undoable mutations (calendar, reminders) auto-run with an Undo chip; +reads run free; web_task runs unprompted because it acts inside Off Grid's own +watched browser pane - supervised by design, hands back at any sign-in or +payment. The pro "auto-approve" toggle covers computer-use tasks ONLY; sends +ask every time regardless. Injection cannot manufacture an approved action out +of nothing - it can only try to steer a task the user already approved. So the +defenses below are about bounding that steering, and about never letting the +agent cross an identity or payment boundary on its own. ## The threats and the defenses, per rail @@ -20,8 +28,9 @@ never letting the agent cross an identity or payment boundary on its own. - **Threat:** low. The arguments come from the user's chat turn, not from scraped content. The model fills a typed tool schema. -- **Defense:** the payload-hash gate - what the user approves is byte-for-byte - what runs; an edit re-binds and re-gates. Sends are `none_fuzzy` and single- +- **Defense:** sends (message, email) gate for approval every time, and the + payload-hash binding means what the user approves is byte-for-byte what + runs; an edit re-binds and re-gates. Sends are `none_fuzzy` and single- attempt, so a wrong verify can never double-send. - **Tested:** `shared/packages/use` retry + machine tests (never-double-fire), `use-runtime.integration.dbtest.ts` (real propose -> verify -> undo). diff --git a/src/main/actions/__tests__/gate-host.test.ts b/src/main/actions/__tests__/gate-host.test.ts index 478aaa0d..7653abdc 100644 --- a/src/main/actions/__tests__/gate-host.test.ts +++ b/src/main/actions/__tests__/gate-host.test.ts @@ -284,13 +284,19 @@ describe('parseGateDecision', () => { }) }) -describe('needsApproval (only computer use is gated)', () => { - it('gates the computer-use rails, runs in-app actions straight through', () => { - expect(needsApproval('accessibility')).toBe(true) - expect(needsApproval('vision')).toBe(true) - expect(needsApproval('browser')).toBe(false) // web_task runs in-app - expect(needsApproval('semantic')).toBe(false) // native actions - expect(needsApproval(undefined)).toBe(false) +describe('needsApproval (computer use AND sends are gated)', () => { + it('gates the computer-use rails and send actions; everything else runs through', () => { + expect(needsApproval({ rail: 'accessibility', type: 'computer' })).toBe(true) + expect(needsApproval({ rail: 'vision', type: 'computer' })).toBe(true) + // Sends are irreversible with no reliable read-back - always confirmed. + expect(needsApproval({ rail: 'semantic', type: 'message' })).toBe(true) + expect(needsApproval({ rail: 'semantic', type: 'email' })).toBe(true) + // web_task acts in Off Grid's own watched pane - supervised, not gated. + expect(needsApproval({ rail: 'browser', type: 'web' })).toBe(false) + // Undoable mutations and reads run through (calendar/reminders auto-run + Undo). + expect(needsApproval({ rail: 'semantic', type: 'calendar' })).toBe(false) + expect(needsApproval({ rail: 'semantic', type: 'lookup' })).toBe(false) + expect(needsApproval({ type: 'calendar' })).toBe(false) }) it('gateHost auto-approves a browser (web_task) action even with a surface listening', async () => { @@ -304,6 +310,37 @@ describe('needsApproval (only computer use is gated)', () => { }) }) +describe('send gating (mail_send / messages_send confirm every time)', () => { + it('parks an email send for approval when a surface is listening', async () => { + const seen: InlineGateRequest[] = [] + const dispose = registerInlineGateSurface((request) => void seen.push(request)) + const parked = gateHost({ + action: record({ rail: 'semantic', type: 'email', intent: 'email the deck to Sam' }) + }) + expect(pendingActionGateCount()).toBe(1) + expect(seen[0]).toMatchObject({ actionType: 'email' }) + resolveActionGate('act_1', { kind: 'approve' }) + expect(await parked).toEqual({ kind: 'approve' }) + dispose() + }) + + it('the auto toggle covers computer use only - a send still parks in auto mode', async () => { + const unregister = registerApprovalModeProvider(() => 'auto') + const dispose = registerInlineGateSurface(() => {}) + try { + const parked = gateHost({ + action: record({ rail: 'semantic', type: 'message', intent: 'text Sam' }) + }) + expect(pendingActionGateCount()).toBe(1) // parked despite auto mode + resolveActionGate('act_1', { kind: 'reject' }) + expect(await parked).toMatchObject({ kind: 'reject' }) + } finally { + dispose() + unregister() + } + }) +}) + describe('computerApprovalMode (the Sync-sharing auto/ask setting)', () => { afterEach(() => { // Ensure no provider leaks into other tests (default must be 'ask'). diff --git a/src/main/actions/gate-host.ts b/src/main/actions/gate-host.ts index 6f7f1b3a..0ce21792 100644 --- a/src/main/actions/gate-host.ts +++ b/src/main/actions/gate-host.ts @@ -199,26 +199,36 @@ export function computerApprovalMode(): ComputerApprovalMode { return approvalModeProvider?.() ?? 'ask' } -/** Only COMPUTER-USE tasks ask for approval. The accessibility / vision rails - * drive the real desktop - they take over the user's cursor and keyboard - so - * the user confirms before that happens. Every other action runs IN-APP without - * taking over the machine (the browser rail acts in Off Grid's own page; native - * actions call an API), so it runs without a prompt. */ -export function needsApproval(rail: Rail | undefined): boolean { +/** The rails that take over the user's cursor and keyboard. */ +function isComputerRail(rail: Rail | undefined): boolean { return rail === 'accessibility' || rail === 'vision' } +/** The action types that SEND on the user's behalf (iMessage, email). A send is + * irreversible and has no reliable read-back, so a wrong one cannot be undone + * or even verified - the user confirms before it leaves. */ +const SEND_ACTION_TYPES: ReadonlySet = new Set(['message', 'email']) + +/** The approval policy, in one place: COMPUTER-USE tasks gate (the + * accessibility / vision rails take over the user's cursor and keyboard) and + * SENDS gate (irreversible, invisible until too late). Everything else runs + * without a prompt: reads are safe, undoable mutations (calendar, reminders) + * auto-run with the Undo chip, and web_task acts inside Off Grid's own watched + * browser pane - supervised by design, never touching the user's cursor. */ +export function needsApproval(action: { rail?: Rail; type: string }): boolean { + return isComputerRail(action.rail) || SEND_ACTION_TYPES.has(action.type) +} + /** The GateCallback the engine host is constructed with. */ export async function gateHost({ action }: { action: ActionRecord }): Promise { - // In-app actions run straight through; only computer use is gated. The env - // flag bypasses even that, for headless testing. - if (approvalBypassed() || !needsApproval(action.rail)) { + // The env flag bypasses the gate entirely, for headless testing. + if (approvalBypassed() || !needsApproval(action)) { return { kind: 'approve' } } - // The user's Sync-sharing policy: "Auto-approve" runs computer-use tasks with no - // prompt (they still journal, and the outcome shows in chat); "Ask every time" - // (the default) falls through to park for approval below. - if (computerApprovalMode() === 'auto') { + // The user's Sync-sharing policy: "Auto-approve" runs COMPUTER-USE tasks with + // no prompt (they still journal, and the outcome shows in chat). It never + // covers sends - those ask every time; the toggle's scope is computer use. + if (isComputerRail(action.rail) && computerApprovalMode() === 'auto') { return { kind: 'approve' } } const queued = proposeActionApproval({ diff --git a/src/main/tools/nativeActionToolExtension-logic.ts b/src/main/tools/nativeActionToolExtension-logic.ts index a3b1a6d8..ff63f94b 100644 --- a/src/main/tools/nativeActionToolExtension-logic.ts +++ b/src/main/tools/nativeActionToolExtension-logic.ts @@ -300,10 +300,10 @@ export function specsForPlatform(platform: NodeJS.Platform): NativeToolSpec[] { * platform does not expose. */ export function systemHintForPlatform(platform: NodeJS.Platform): string { if (platform === 'darwin') { - return "You can act on the user's Mac: manage calendar events (calendar_create_event, calendar_list_events) and reminders (reminders_create, reminders_list), look up people (contacts_search), and send an iMessage (messages_send) or email (mail_send). Resolve a name to a handle with contacts_search before sending. Open a link or app scheme (like whatsapp://send) with open_url - it ONLY opens, no interaction. To actually DO something on a website - play or watch a video, search and click a result, check in, order, fill a form, log in - use web_task (NOT open_url); it runs the task inside Off Grid's own built-in browser without touching the user's cursor or their own browser, so they keep working, and hands back for any sign-in or payment. For a task that needs to control a desktop app with no web version, use computer_task - the user watches and can take over. Prefer the direct tools and web_task when they fit. Use ISO 8601 for all times. Anything that creates, sends, or runs a task needs the user's approval; tell them it is pending until they approve." + return "You can act on the user's Mac: manage calendar events (calendar_create_event, calendar_list_events) and reminders (reminders_create, reminders_list), look up people (contacts_search), and send an iMessage (messages_send) or email (mail_send). Resolve a name to a handle with contacts_search before sending. Open a link or app scheme (like whatsapp://send) with open_url - it ONLY opens, no interaction. To actually DO something on a website - play or watch a video, search and click a result, check in, order, fill a form, log in - use web_task (NOT open_url); it runs the task inside Off Grid's own built-in browser without touching the user's cursor or their own browser, so they keep working, and hands back for any sign-in or payment. For a task that needs to control a desktop app with no web version, use computer_task - the user watches and can take over. Prefer the direct tools and web_task when they fit. Use ISO 8601 for all times. Sending a message or email, and running a computer_task, needs the user's approval - tell them it is pending until they approve. Other changes (calendar, reminders) run immediately and can be undone." } if (platform === 'win32') { - return "You can act on the user's PC through Outlook: create calendar events (calendar_create_event) and tasks (reminders_create), and send an email (mail_send). Open a link or app with open_url - it ONLY opens, no interaction. To DO something on a website - play or watch a video, search and click a result, check in, order, fill a form, log in - use web_task (NOT open_url); it runs the task inside Off Grid's own built-in browser without touching the user's cursor or their own browser, so they keep working, and hands back for any sign-in or payment. For a task that needs to control a desktop app with no web version, use computer_task - the user watches and can take over. Prefer the direct tools and web_task when they fit. Use ISO 8601 for all times. There is no message or contact lookup tool on Windows. Anything that creates, sends, or runs a task needs the user's approval; tell them it is pending until they approve." + return "You can act on the user's PC through Outlook: create calendar events (calendar_create_event) and tasks (reminders_create), and send an email (mail_send). Open a link or app with open_url - it ONLY opens, no interaction. To DO something on a website - play or watch a video, search and click a result, check in, order, fill a form, log in - use web_task (NOT open_url); it runs the task inside Off Grid's own built-in browser without touching the user's cursor or their own browser, so they keep working, and hands back for any sign-in or payment. For a task that needs to control a desktop app with no web version, use computer_task - the user watches and can take over. Prefer the direct tools and web_task when they fit. Use ISO 8601 for all times. There is no message or contact lookup tool on Windows. Sending an email, and running a computer_task, needs the user's approval - tell them it is pending until they approve. Other changes (calendar, tasks) run immediately and can be undone." } return '' } From 7de938fc046bda55c681f86156299fdd138f235a Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Thu, 27 Aug 2026 15:39:09 +0530 Subject: [PATCH 3/6] ci: install X11 dev headers so uiohook-napi compiles on the Linux runner Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94b5ad78..60930831 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,12 @@ jobs: npm --prefix ../shared ci npm --prefix ../shared/packages/sync run build npm --prefix ../shared/packages/models run build + # uiohook-napi (the rails' pause-on-user-input hook, an optional dep) has no + # Linux prebuild for Electron's ABI, so install-app-deps compiles libuiohook + # from source - which needs the X11 dev headers. The app never ships Linux; + # these packages exist only so `npm ci` completes on this runner. + - name: X11 headers for uiohook-napi + run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev libxkbcommon-dev libxkbcommon-x11-dev - run: npm ci # Hard gates: types + the full test suite. - name: Typecheck (core) From 2b0b2744a69f956ce29d1a00bad783aa73711c41 Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Thu, 27 Aug 2026 15:46:28 +0530 Subject: [PATCH 4/6] ci: the full libuiohook X11 header set, not one at a time Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60930831..d9bcd090 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,7 +112,7 @@ jobs: # from source - which needs the X11 dev headers. The app never ships Linux; # these packages exist only so `npm ci` completes on this runner. - name: X11 headers for uiohook-napi - run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev libxkbcommon-dev libxkbcommon-x11-dev + run: sudo apt-get update && sudo apt-get install -y libx11-dev libxtst-dev libxt-dev libxinerama-dev libx11-xcb-dev libxkbcommon-dev libxkbcommon-x11-dev libxkbfile-dev libxrandr-dev - run: npm ci # Hard gates: types + the full test suite. - name: Typecheck (core) From 163203d0c98134f1b6f8ee85da1bdfa49a1baeda Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Thu, 27 Aug 2026 16:28:30 +0530 Subject: [PATCH 5/6] test(db): repair the two dbtest suites the orchestrator + gate policy left behind The planning pass (shouldPlan -> planTask) was consuming every tools-loop test's first scripted turn - the fake llama now answers PLAN_SCHEMA requests out-of-band with an empty plan (recorded separately as plannerRequests), so tests drive the reactive loop they script. The gate-seam journey's fixture becomes a SEND ('email') - under the current policy only sends and computer-use gate, so that is the type that parks. Both files were red on feat/windows-parity already (db suite is non-blocking on push and the branch never ran CI); 29/29 now pass. fresh-setup-first-use still fails on the base branch (pre-existing, 'LLM Service not ready' after relaunch) - tracked separately. Co-Authored-By: Claude Fable 5 --- .../__tests__/gate-host.integration.dbtest.ts | 12 ++++++---- .../__tests__/harness/fake-llama-server.ts | 22 ++++++++++++++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/main/__tests__/gate-host.integration.dbtest.ts b/src/main/__tests__/gate-host.integration.dbtest.ts index 39ed1743..de191904 100644 --- a/src/main/__tests__/gate-host.integration.dbtest.ts +++ b/src/main/__tests__/gate-host.integration.dbtest.ts @@ -39,8 +39,12 @@ function makeWorld() { db.exec(`CREATE TABLE test_reminders (title TEXT NOT NULL)`) const registry = new HandlerRegistry() + // The fixture is a SEND ('email'): under the current approval policy the gate + // covers sends + the computer-use rails, so the seam these tests exercise - + // park, approve, reject, edit-rebind - only fires for those types. (It was a + // 'reminder' when every mutation gated; reminders now auto-run with Undo.) registry.register({ - type: 'reminder', + type: 'email', rail: 'semantic', defaultRisk: 'mutate', verification: 'read_back', @@ -102,8 +106,8 @@ function requestAt(requests: Record[], index: number): Record { expect(request).toMatchObject({ kind: 'native', risk: 'mutate', - actionType: 'reminder', + actionType: 'email', args: { title: 'Send the deck' } }) resolveActionGate(String(request.actionId), { kind: 'approve' }) diff --git a/src/main/__tests__/harness/fake-llama-server.ts b/src/main/__tests__/harness/fake-llama-server.ts index 4d72448a..8946623c 100644 --- a/src/main/__tests__/harness/fake-llama-server.ts +++ b/src/main/__tests__/harness/fake-llama-server.ts @@ -51,8 +51,10 @@ export interface FakeLlamaServer { /** Clear any queued-but-unconsumed turns + the recorded requests — call between tests * so a case that over-enqueues (e.g. the step-budget cap) can't leak into the next. */ reset(): void - /** The request bodies received, parsed — for asserting what the REAL llm actually sent. */ + /** The request bodies received, parsed — for asserting what the REAL llm actually sent. + * Planner (PLAN_SCHEMA) calls are answered out-of-band and recorded separately. */ readonly requests: Array> + readonly plannerRequests: Array> close(): Promise } @@ -101,6 +103,7 @@ function sseFramesFor(turn: FakeTurn): string[] { export async function startFakeLlamaServer(): Promise { const queue: FakeTurn[] = [] const requests: Array> = [] + const plannerRequests: Array> = [] const server = http.createServer((req, res) => { if (req.method === 'GET' && (req.url === '/health' || req.url === '/v1/models')) { @@ -120,6 +123,21 @@ export async function startFakeLlamaServer(): Promise { } catch { /* keep {} */ } + // The orchestrator's PLANNING pass (tools.ts shouldPlan -> planTask) fires + // before the reactive loop on action-shaped queries, grammar-constrained to + // PLAN_SCHEMA. Answer it with an EMPTY plan out-of-band - it never consumes + // a queued turn and never lands in `requests` - so every test keeps driving + // the reactive loop it scripts, exactly as before the orchestrator existed. + // (A test that wants to exercise planning itself can assert plannerRequests.) + const responseFormat = JSON.stringify(parsed.response_format ?? '') + if (responseFormat.includes('"steps"') && responseFormat.includes('"bindings"')) { + plannerRequests.push(parsed) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end( + JSON.stringify({ choices: [{ message: { content: '{"steps":[]}' } }] }) + ) + return + } requests.push(parsed) const turn = queue.shift() ?? { content: '' } if (turn.errorStatus) { @@ -189,12 +207,14 @@ export async function startFakeLlamaServer(): Promise { return { port, requests, + plannerRequests, enqueue: (...turns: FakeTurn[]) => { queue.push(...turns) }, reset: () => { queue.length = 0 requests.length = 0 + plannerRequests.length = 0 }, close: () => new Promise((r) => server.close(() => r())) } From 404cc9d7481d48a96989e03702ddaaeadb6cf23a Mon Sep 17 00:00:00 2001 From: siddWednesday Date: Thu, 27 Aug 2026 16:44:21 +0530 Subject: [PATCH 6/6] refactor(tools): break the tools <-> plan-executor require cycle The orchestrator's plan-executor imported ToolCall/UnifiedSource from the big tools.ts, which imports the executor back - a no-circular violation the dependency-boundaries gate rejects. Move the two shared leaf types to tools/tool-types.ts (imported by both, importing nothing back); tools.ts re-exports them so external importers are unchanged. Co-Authored-By: Claude Fable 5 --- src/main/tools.ts | 15 ++------------- src/main/tools/plan-executor.ts | 2 +- src/main/tools/tool-types.ts | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 14 deletions(-) create mode 100644 src/main/tools/tool-types.ts diff --git a/src/main/tools.ts b/src/main/tools.ts index e7fa3634..589946f6 100644 --- a/src/main/tools.ts +++ b/src/main/tools.ts @@ -17,6 +17,8 @@ import { stripTags, htmlToText, decodeDdgHref } from './tools-parsers' import { evaluateArithmetic } from './calculator' import { selectToolExtensions } from './tools/extension-select' import { planTask } from './tools/planner' +import type { ToolCall, UnifiedSource } from './tools/tool-types' +export type { ToolCall, UnifiedSource } from './tools/tool-types' import { makePlanExecutor } from './tools/plan-executor' import { shouldPlan, backfillGoals, preferNativeApp } from './tools/planner-logic' import { resolveNativeApp } from './accessibility/ax-host' @@ -415,19 +417,6 @@ export function getToolExtensions(): ToolExtension[] { return toolExtensions } -export type ToolCall = { name: string; args: Record; result: string } -// Structured sources surfaced by search_memory so the chat can render them as -// interactive citation cards (thumbnail + open-in-Replay), same as the RAG path. -export type UnifiedSource = { - key: string - kind: string - refId: number - title: string - snippet: string - surface: string - ts: number - imagePath: string | null -} /** * Run a chat turn with tool-calling. STREAMS by default (thinking -> tool-call activity diff --git a/src/main/tools/plan-executor.ts b/src/main/tools/plan-executor.ts index e44d95ee..e19dfc2b 100644 --- a/src/main/tools/plan-executor.ts +++ b/src/main/tools/plan-executor.ts @@ -11,7 +11,7 @@ * asserted with a fake dispatch. */ import { resolveContactHandle, type Plan, type PlanStep } from './planner-logic' -import type { ToolCall, UnifiedSource } from '../tools' +import type { ToolCall, UnifiedSource } from './tool-types' /** What a dispatched tool returns - structurally the ToolResult of runTool. */ export interface DispatchResult { diff --git a/src/main/tools/tool-types.ts b/src/main/tools/tool-types.ts new file mode 100644 index 00000000..26e80cdf --- /dev/null +++ b/src/main/tools/tool-types.ts @@ -0,0 +1,17 @@ +// Leaf types shared by tools.ts and its helpers (the plan executor). They live +// here, not in tools.ts, so a helper can import them without importing the big +// module back - which was a require cycle (tools -> plan-executor -> tools). +export type ToolCall = { name: string; args: Record; result: string } + +// Structured sources surfaced by search_memory so the chat can render them as +// interactive citation cards (thumbnail + open-in-Replay), same as the RAG path. +export type UnifiedSource = { + key: string + kind: string + refId: number + title: string + snippet: string + surface: string + ts: number + imagePath: string | null +}