@moxxy/plugin-computer-control 0.41.2 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. package/bin/win32-x64/moxxy-computer.exe +0 -0
  2. package/bin/win32-x64/moxxy-computer.exe.json +1 -1
  3. package/dist/backend/access.d.ts +129 -0
  4. package/dist/backend/access.d.ts.map +1 -0
  5. package/dist/backend/access.js +158 -0
  6. package/dist/backend/access.js.map +1 -0
  7. package/dist/backend/app-hints.d.ts +14 -0
  8. package/dist/backend/app-hints.d.ts.map +1 -0
  9. package/dist/backend/app-hints.js +30 -0
  10. package/dist/backend/app-hints.js.map +1 -0
  11. package/dist/backend/backend.d.ts +70 -0
  12. package/dist/backend/backend.d.ts.map +1 -0
  13. package/dist/backend/backend.js +466 -0
  14. package/dist/backend/backend.js.map +1 -0
  15. package/dist/backend/rpc.d.ts +1438 -0
  16. package/dist/backend/rpc.d.ts.map +1 -0
  17. package/dist/backend/rpc.js +75 -0
  18. package/dist/backend/rpc.js.map +1 -0
  19. package/dist/backend/turn-controls.d.ts +20 -0
  20. package/dist/backend/turn-controls.d.ts.map +1 -0
  21. package/dist/backend/turn-controls.js +115 -0
  22. package/dist/backend/turn-controls.js.map +1 -0
  23. package/dist/contract/guidance.d.ts +12 -0
  24. package/dist/contract/guidance.d.ts.map +1 -0
  25. package/dist/contract/guidance.js +42 -0
  26. package/dist/contract/guidance.js.map +1 -0
  27. package/dist/contract/image.d.ts +29 -0
  28. package/dist/contract/image.d.ts.map +1 -0
  29. package/dist/contract/image.js +46 -0
  30. package/dist/contract/image.js.map +1 -0
  31. package/dist/contract/keys.d.ts +15 -0
  32. package/dist/contract/keys.d.ts.map +1 -0
  33. package/dist/contract/keys.js +118 -0
  34. package/dist/contract/keys.js.map +1 -0
  35. package/dist/contract/outcome.d.ts +61 -0
  36. package/dist/contract/outcome.d.ts.map +1 -0
  37. package/dist/contract/outcome.js +63 -0
  38. package/dist/contract/outcome.js.map +1 -0
  39. package/dist/contract/progress.d.ts +27 -0
  40. package/dist/contract/progress.d.ts.map +1 -0
  41. package/dist/contract/progress.js +36 -0
  42. package/dist/contract/progress.js.map +1 -0
  43. package/dist/contract/tools.d.ts +338 -0
  44. package/dist/contract/tools.d.ts.map +1 -0
  45. package/dist/contract/tools.js +195 -0
  46. package/dist/contract/tools.js.map +1 -0
  47. package/dist/contract/untrusted.d.ts +3 -0
  48. package/dist/contract/untrusted.d.ts.map +1 -0
  49. package/dist/contract/untrusted.js +8 -0
  50. package/dist/contract/untrusted.js.map +1 -0
  51. package/dist/helper/artifact.d.ts +41 -0
  52. package/dist/helper/artifact.d.ts.map +1 -0
  53. package/dist/helper/artifact.js +189 -0
  54. package/dist/helper/artifact.js.map +1 -0
  55. package/dist/helper/protocol.d.ts +80 -0
  56. package/dist/helper/protocol.d.ts.map +1 -0
  57. package/dist/helper/protocol.js +49 -0
  58. package/dist/helper/protocol.js.map +1 -0
  59. package/dist/helper/transport.d.ts +43 -0
  60. package/dist/helper/transport.d.ts.map +1 -0
  61. package/dist/{windows → helper}/transport.js +45 -19
  62. package/dist/helper/transport.js.map +1 -0
  63. package/dist/index.d.ts +9 -22
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +35 -42
  66. package/dist/index.js.map +1 -1
  67. package/dist/jev/ladder.d.ts +32 -0
  68. package/dist/jev/ladder.d.ts.map +1 -0
  69. package/dist/jev/ladder.js +66 -0
  70. package/dist/jev/ladder.js.map +1 -0
  71. package/dist/jev/memory.d.ts +11 -0
  72. package/dist/jev/memory.d.ts.map +1 -0
  73. package/dist/jev/memory.js +16 -0
  74. package/dist/jev/memory.js.map +1 -0
  75. package/dist/jev/run.d.ts +93 -0
  76. package/dist/jev/run.d.ts.map +1 -0
  77. package/dist/jev/run.js +403 -0
  78. package/dist/jev/run.js.map +1 -0
  79. package/dist/jev/train.d.ts +97 -0
  80. package/dist/jev/train.d.ts.map +1 -0
  81. package/dist/jev/train.js +28 -0
  82. package/dist/jev/train.js.map +1 -0
  83. package/dist/linux/profile.d.ts +6 -0
  84. package/dist/linux/profile.d.ts.map +1 -0
  85. package/dist/linux/profile.js +21 -0
  86. package/dist/linux/profile.js.map +1 -0
  87. package/dist/macos/profile.d.ts +5 -0
  88. package/dist/macos/profile.d.ts.map +1 -0
  89. package/dist/macos/profile.js +17 -0
  90. package/dist/macos/profile.js.map +1 -0
  91. package/dist/preview/controller.d.ts +127 -0
  92. package/dist/preview/controller.d.ts.map +1 -0
  93. package/dist/preview/controller.js +203 -0
  94. package/dist/preview/controller.js.map +1 -0
  95. package/dist/preview/surface.d.ts +9 -0
  96. package/dist/preview/surface.d.ts.map +1 -0
  97. package/dist/preview/surface.js +50 -0
  98. package/dist/preview/surface.js.map +1 -0
  99. package/dist/windows/maintenance.d.ts +1 -1
  100. package/dist/windows/maintenance.d.ts.map +1 -1
  101. package/dist/windows/maintenance.js +6 -5
  102. package/dist/windows/maintenance.js.map +1 -1
  103. package/dist/windows/profile.d.ts +5 -0
  104. package/dist/windows/profile.d.ts.map +1 -0
  105. package/dist/windows/profile.js +17 -0
  106. package/dist/windows/profile.js.map +1 -0
  107. package/learned/README.md +26 -0
  108. package/learned/cases/com.apple.calculator.json +395 -0
  109. package/learned/cases/com.apple.finder.json +115 -0
  110. package/learned/cases/com.apple.safari.json +112 -0
  111. package/learned/cases/com.apple.systempreferences.json +255 -0
  112. package/learned/com.apple.calculator-eace95fc.json +334 -0
  113. package/learned/com.apple.finder-27cf6ce8.json +260 -0
  114. package/learned/com.apple.safari-7cd9df4f.json +143 -0
  115. package/learned/com.apple.systempreferences-02cf0b8b.json +535 -0
  116. package/package.json +13 -7
  117. package/scripts/promote-learned.mjs +8 -0
  118. package/scripts/summarize-trial.mjs +68 -0
  119. package/scripts/train-learned.mjs +64 -0
  120. package/skills/computer-apps/blender.md +29 -0
  121. package/skills/computer-apps/browsers.md +50 -0
  122. package/skills/computer-apps/design-tools.md +37 -0
  123. package/skills/computer-apps/finder.md +23 -0
  124. package/skills/computer-apps/office.md +46 -0
  125. package/skills/computer-apps/video-editors.md +44 -0
  126. package/skills/computer-control.md +154 -191
  127. package/src/backend/access.test.ts +158 -0
  128. package/src/backend/access.ts +168 -0
  129. package/src/backend/app-hints.test.ts +59 -0
  130. package/src/backend/app-hints.ts +39 -0
  131. package/src/backend/backend.test.ts +725 -0
  132. package/src/backend/backend.ts +482 -0
  133. package/src/backend/contract-helper.fixture.mjs +138 -0
  134. package/src/backend/helper.fixture.ts +38 -0
  135. package/src/backend/rpc.ts +90 -0
  136. package/src/backend/turn-controls.test.ts +178 -0
  137. package/src/backend/turn-controls.ts +120 -0
  138. package/src/contract/guidance.test.ts +75 -0
  139. package/src/contract/guidance.ts +48 -0
  140. package/src/contract/image.test.ts +74 -0
  141. package/src/contract/image.ts +54 -0
  142. package/src/contract/keys.test.ts +103 -0
  143. package/src/contract/keys.ts +117 -0
  144. package/src/contract/outcome.test.ts +71 -0
  145. package/src/contract/outcome.ts +72 -0
  146. package/src/contract/progress.test.ts +56 -0
  147. package/src/contract/progress.ts +43 -0
  148. package/src/contract/tools.test.ts +210 -0
  149. package/src/contract/tools.ts +212 -0
  150. package/src/contract/untrusted.test.ts +20 -0
  151. package/src/contract/untrusted.ts +8 -0
  152. package/src/helper/artifact.test.ts +131 -0
  153. package/src/helper/artifact.ts +182 -0
  154. package/src/helper/protocol.test.ts +29 -0
  155. package/src/helper/protocol.ts +50 -0
  156. package/src/{windows → helper}/transport.test.ts +76 -19
  157. package/src/{windows → helper}/transport.ts +54 -16
  158. package/src/index.test.ts +112 -0
  159. package/src/index.ts +36 -53
  160. package/src/jev/ladder.test.ts +113 -0
  161. package/src/jev/ladder.ts +88 -0
  162. package/src/jev/memory.ts +20 -0
  163. package/src/jev/run.test.ts +700 -0
  164. package/src/jev/run.ts +443 -0
  165. package/src/jev/train.test.ts +40 -0
  166. package/src/jev/train.ts +41 -0
  167. package/src/linux/helper.test.ts +481 -0
  168. package/src/linux/profile.ts +25 -0
  169. package/src/macos/helper.test.ts +988 -0
  170. package/src/macos/profile.ts +19 -0
  171. package/src/preview/controller.test.ts +308 -0
  172. package/src/preview/controller.ts +262 -0
  173. package/src/preview/surface.test.ts +68 -0
  174. package/src/preview/surface.ts +45 -0
  175. package/src/skill.test.ts +34 -0
  176. package/src/windows/maintenance.ts +8 -7
  177. package/src/windows/profile.ts +19 -0
  178. package/dist/shell.d.ts +0 -56
  179. package/dist/shell.d.ts.map +0 -1
  180. package/dist/shell.js +0 -189
  181. package/dist/shell.js.map +0 -1
  182. package/dist/temporary-files.d.ts +0 -2
  183. package/dist/temporary-files.d.ts.map +0 -1
  184. package/dist/temporary-files.js +0 -10
  185. package/dist/temporary-files.js.map +0 -1
  186. package/dist/tools/applescript.d.ts +0 -2
  187. package/dist/tools/applescript.d.ts.map +0 -1
  188. package/dist/tools/applescript.js +0 -50
  189. package/dist/tools/applescript.js.map +0 -1
  190. package/dist/tools/click.d.ts +0 -2
  191. package/dist/tools/click.d.ts.map +0 -1
  192. package/dist/tools/click.js +0 -58
  193. package/dist/tools/click.js.map +0 -1
  194. package/dist/tools/clipboard.d.ts +0 -2
  195. package/dist/tools/clipboard.d.ts.map +0 -1
  196. package/dist/tools/clipboard.js +0 -71
  197. package/dist/tools/clipboard.js.map +0 -1
  198. package/dist/tools/key.d.ts +0 -11
  199. package/dist/tools/key.d.ts.map +0 -1
  200. package/dist/tools/key.js +0 -143
  201. package/dist/tools/key.js.map +0 -1
  202. package/dist/tools/open.d.ts +0 -2
  203. package/dist/tools/open.d.ts.map +0 -1
  204. package/dist/tools/open.js +0 -91
  205. package/dist/tools/open.js.map +0 -1
  206. package/dist/tools/screenshot.d.ts +0 -2
  207. package/dist/tools/screenshot.d.ts.map +0 -1
  208. package/dist/tools/screenshot.js +0 -162
  209. package/dist/tools/screenshot.js.map +0 -1
  210. package/dist/tools/type.d.ts +0 -8
  211. package/dist/tools/type.d.ts.map +0 -1
  212. package/dist/tools/type.js +0 -65
  213. package/dist/tools/type.js.map +0 -1
  214. package/dist/windows/artifact.d.ts +0 -3
  215. package/dist/windows/artifact.d.ts.map +0 -1
  216. package/dist/windows/artifact.js +0 -57
  217. package/dist/windows/artifact.js.map +0 -1
  218. package/dist/windows/backend.d.ts +0 -11
  219. package/dist/windows/backend.d.ts.map +0 -1
  220. package/dist/windows/backend.js +0 -122
  221. package/dist/windows/backend.js.map +0 -1
  222. package/dist/windows/contracts.d.ts +0 -1627
  223. package/dist/windows/contracts.d.ts.map +0 -1
  224. package/dist/windows/contracts.js +0 -137
  225. package/dist/windows/contracts.js.map +0 -1
  226. package/dist/windows/control-service.d.ts +0 -12
  227. package/dist/windows/control-service.d.ts.map +0 -1
  228. package/dist/windows/control-service.js +0 -65
  229. package/dist/windows/control-service.js.map +0 -1
  230. package/dist/windows/guidance.d.ts +0 -3
  231. package/dist/windows/guidance.d.ts.map +0 -1
  232. package/dist/windows/guidance.js +0 -20
  233. package/dist/windows/guidance.js.map +0 -1
  234. package/dist/windows/protocol.d.ts +0 -9
  235. package/dist/windows/protocol.d.ts.map +0 -1
  236. package/dist/windows/protocol.js +0 -32
  237. package/dist/windows/protocol.js.map +0 -1
  238. package/dist/windows/transport.d.ts +0 -23
  239. package/dist/windows/transport.d.ts.map +0 -1
  240. package/dist/windows/transport.js.map +0 -1
  241. package/src/shell.test.ts +0 -186
  242. package/src/shell.ts +0 -213
  243. package/src/temporary-files.test.ts +0 -18
  244. package/src/temporary-files.ts +0 -9
  245. package/src/tools/applescript-serialize.test.ts +0 -74
  246. package/src/tools/applescript.ts +0 -53
  247. package/src/tools/click.ts +0 -60
  248. package/src/tools/clipboard.ts +0 -72
  249. package/src/tools/key.ts +0 -155
  250. package/src/tools/open.ts +0 -96
  251. package/src/tools/screenshot.test.ts +0 -137
  252. package/src/tools/screenshot.ts +0 -180
  253. package/src/tools/type.ts +0 -68
  254. package/src/tools.test.ts +0 -94
  255. package/src/windows/action-contracts.test.ts +0 -13
  256. package/src/windows/artifact.test.ts +0 -16
  257. package/src/windows/artifact.ts +0 -57
  258. package/src/windows/backend.test.ts +0 -41
  259. package/src/windows/backend.ts +0 -122
  260. package/src/windows/contracts.test.ts +0 -81
  261. package/src/windows/contracts.ts +0 -143
  262. package/src/windows/control-service.test.ts +0 -58
  263. package/src/windows/control-service.ts +0 -68
  264. package/src/windows/guidance.test.ts +0 -29
  265. package/src/windows/guidance.ts +0 -21
  266. package/src/windows/model-contract.test.ts +0 -37
  267. package/src/windows/protocol.ts +0 -27
  268. package/src/windows/text-contracts.test.ts +0 -14
  269. package/src/windows/window-typing.test.ts +0 -14
@@ -0,0 +1,482 @@
1
+ import { defineTool, zodToJsonSchema, type LifecycleHooks, type SurfaceDef, type ToolContext, type ToolDef, type ToolImageResult } from '@moxxy/sdk';
2
+ import { z } from 'zod';
3
+ import { FILE_PANEL_NOTE, withComputerGuidance } from '../contract/guidance.js';
4
+ import { parseKeyCombo, type KeyPlatform } from '../contract/keys.js';
5
+ import { ComputerUseError, describeResult, isErrorCode, type ActionResult } from '../contract/outcome.js';
6
+ import { ProgressTracker, fingerprint } from '../contract/progress.js';
7
+ import { computerTools, type ComputerAction, type RunStep } from '../contract/tools.js';
8
+ import { diffTrees, formatTree, sameElements, type AppTree, type TreeView } from '@moxxy/jev';
9
+ import { JEV_HOST, JEV_OFF, JEV_SECRET, jevClient, type AskJev } from '@moxxy/jev';
10
+ import { traceRun, tracedFromEnv } from '@moxxy/jev';
11
+ import { RunMemory, describeRoutes, guess, labelOf, recall, shippedLearned, targetOf } from '../jev/memory.js';
12
+ import { describeRun, pictured, runSteps, type ActWait, type RunReport } from '../jev/run.js';
13
+ import { wrapUntrusted } from '../contract/untrusted.js';
14
+ import { controlStateSchemaFor } from '../helper/protocol.js';
15
+ import { HelperError, HelperTransport } from '../helper/transport.js';
16
+ import {
17
+ accessFromLog, accessGrantSchema, approvedThroughRun, categorize, checkAccess, checkKeys, defaultTier, maxTier, requiredTier,
18
+ type AccessGrant, type AccessTier, type AppGrant,
19
+ type AccessState,
20
+ } from './access.js';
21
+ import { hintFor, loadAppHints, type AppHint } from './app-hints.js';
22
+ import {
23
+ actResultSchema, appStateSchema, batchResultSchema, contractEventsFor, imageSchema, listAppsResultSchema, readTextResultSchema, resolveAppsResultSchema, statusResultSchema,
24
+ type AppState, type HelperImage,
25
+ } from './rpc.js';
26
+ import { PreviewController, type PreviewCodec, type PreviewSource } from '../preview/controller.js';
27
+ import { buildComputerPreviewSurface } from '../preview/surface.js';
28
+ import { TurnControls } from './turn-controls.js';
29
+
30
+ /** Everything platform-specific the shared backend needs: which helper to start and which key rules apply. */
31
+ export interface PlatformProfile {
32
+ readonly platform: KeyPlatform;
33
+ readonly protocolVersion: number;
34
+ readonly helperPath: string;
35
+ readonly helperArgs: readonly string[];
36
+ readonly verifyHelper: () => Promise<void>;
37
+ readonly unavailableMessage: string;
38
+ readonly timeoutMs?: number;
39
+ /** What the helper's live preview can produce; JPEG frames only when absent. */
40
+ readonly previewCodecs?: readonly PreviewCodec[];
41
+ }
42
+
43
+ interface Turn {
44
+ readonly sessionId: string;
45
+ readonly turnId: string;
46
+ readonly transport: HelperTransport;
47
+ /** Last tree the model saw per app; indices belong to this helper, so the map dies with it. */
48
+ readonly trees: Map<string, AppTree>;
49
+ /** What the model last saw of each app, to notice an action that changed nothing. */
50
+ readonly seen: Map<string, string>;
51
+ readonly progress: ProgressTracker;
52
+ /** Apps whose hint the model has already been shown in this turn. */
53
+ readonly hinted: Set<string>;
54
+ /** Per app, the step a run could not do: what the model does next with a single tool teaches it. */
55
+ readonly failed: Map<string, RunStep>;
56
+ dispose(): void;
57
+ }
58
+
59
+ /** The single tool that does what a step of a run does. */
60
+ const SINGLE: Partial<Record<RunStep['do'], ComputerAction['action']>> = { click: 'click', type: 'type_text', set_value: 'set_value' };
61
+
62
+ type ToolName = keyof typeof computerTools;
63
+ type Input<N extends ToolName> = z.output<(typeof computerTools)[N]['input']>;
64
+ type Handlers = { readonly [N in ToolName]: (input: Input<N>, ctx: ToolContext) => Promise<unknown> };
65
+
66
+ const turnKey = (sessionId: string, turnId: string) => JSON.stringify([sessionId, turnId]);
67
+
68
+ function withImage(text: string, image: HelperImage | undefined): string | ToolImageResult {
69
+ return image ? { mediaType: image.mediaType, base64: image.base64, forModel: text } : text;
70
+ }
71
+
72
+ /** Long enough for an app to read the gesture as a drag, not a click. */
73
+ const DRAG_MS = 600;
74
+
75
+ /** Tools that only look: the same call between actions is how the agent watches an app. */
76
+ const LOOKING: ReadonlySet<string> = new Set(['computer_status', 'computer_list_apps', 'computer_get_app_state', 'computer_zoom']);
77
+ /** What a task starts with: sent even when the tool list is gated, since loading them first is a model round. */
78
+ const ENTRY: ReadonlySet<string> = new Set(['computer_request_access', 'computer_run']);
79
+
80
+ /** Helpers get chords from the one xdotool parser instead of parsing key syntax themselves, and a drag as a path. */
81
+ function forHelper(step: ComputerAction): Record<string, unknown> {
82
+ if (step.action === 'drag') {
83
+ return { action: 'drag', path: [[step.from_x, step.from_y], [step.to_x, step.to_y]], duration_ms: DRAG_MS, mouse_button: 'left' };
84
+ }
85
+ const fields: Record<string, unknown> = { ...step };
86
+ if (typeof fields.key === 'string') fields.chord = parseKeyCombo(fields.key);
87
+ if (typeof fields.modifiers === 'string') fields.held = parseKeyCombo(fields.modifiers).modifiers;
88
+ return fields;
89
+ }
90
+
91
+ /** A coded helper refusal becomes a Computer Use error carrying the model's next step. */
92
+ function asComputerUseError(error: unknown): unknown {
93
+ return error instanceof HelperError && isErrorCode(error.code) ? new ComputerUseError(error.code, error.detail) : error;
94
+ }
95
+
96
+ /** The Codex/Claude-style tool set on top of one disposable native helper per session turn. */
97
+ export class ComputerBackend {
98
+ private readonly turns = new Map<string, Turn>();
99
+ readonly controls = new TurnControls();
100
+ /** The human's live picture of the app in use; frames never reach the model or the session log. */
101
+ readonly preview = new PreviewController();
102
+
103
+ readonly hooks: LifecycleHooks;
104
+
105
+ constructor(
106
+ private readonly profile: PlatformProfile,
107
+ private readonly hints: ReadonlyArray<AppHint> = loadAppHints(),
108
+ /** Jev for a TypeSafe key; a seam so tests need no network. */
109
+ private readonly jev: (apiKey: string) => AskJev = (apiKey) => tracedFromEnv(jevClient(apiKey)),
110
+ private readonly memory: RunMemory = new RunMemory(undefined, Date.now, shippedLearned),
111
+ ) {
112
+ this.hooks = {
113
+ // Until a tool call has looked into the vault, the environment says whether there is a key.
114
+ onBeforeProviderCall: withComputerGuidance(profile.platform, (sessionId) => this.keyed.get(sessionId) ?? Boolean(process.env[JEV_SECRET])),
115
+ onInit: (ctx) => { ctx.services.register('computerControl', this.controls.forSession(ctx.sessionId)); },
116
+ onTurnEnd: (ctx) => this.release(ctx.sessionId, ctx.turnId),
117
+ onShutdown: (ctx) => this.release(ctx.sessionId),
118
+ };
119
+ }
120
+
121
+ /** Per session, whether the last tool call found a TypeSafe key: without one, runs of steps are not offered. */
122
+ private readonly keyed = new Map<string, boolean>();
123
+ /** Per session, the names apps were asked for under and the app each resolved to: the model keeps using its own name. */
124
+ private readonly asked = new Map<string, Map<string, string>>();
125
+
126
+ /** Per session, the apps approved through a run, placed by the helper once: the name asked for → its grant, or null when it has none. */
127
+ private readonly reached = new Map<string, Map<string, AppGrant | null>>();
128
+
129
+ private async jevKey(ctx: ToolContext): Promise<string | undefined> {
130
+ const secret = async (name: string) => (await ctx.getSecret?.(name)?.catch(() => null)) || undefined;
131
+ // The switch is a vault entry, so every surface of the session reads the same one.
132
+ const key = (await secret(JEV_OFF)) ? undefined : (await secret(JEV_SECRET)) || process.env[JEV_SECRET] || undefined;
133
+ this.keyed.set(ctx.sessionId, key !== undefined);
134
+ return key;
135
+ }
136
+
137
+ surfaces(): SurfaceDef[] {
138
+ return [buildComputerPreviewSurface(this.preview)];
139
+ }
140
+
141
+ tools(): ToolDef[] {
142
+ return (Object.keys(computerTools) as ToolName[]).map((name) => {
143
+ const { description, input } = computerTools[name];
144
+ const handle = this.handlers[name] as (input: unknown, ctx: ToolContext) => Promise<unknown>;
145
+ const handler = async (input: unknown, ctx: ToolContext) => { await this.jevKey(ctx); return handle(input, ctx); };
146
+ return defineTool({
147
+ name, description, inputSchema: input,
148
+ inputJsonSchema: zodToJsonSchema(input),
149
+ permission: { action: 'prompt' }, icon: 'workspace',
150
+ ...(LOOKING.has(name) ? { liveState: true } : {}),
151
+ ...(ENTRY.has(name) ? { alwaysLoaded: true } : {}),
152
+ // Only a run of steps leaves the machine: it asks Jev where each element is.
153
+ isolation: { capabilities: { subprocess: true, commands: [this.profile.helperPath], net: name === 'computer_run' ? { mode: 'allowlist', hosts: [JEV_HOST] } : { mode: 'none' } } },
154
+ handler,
155
+ });
156
+ });
157
+ }
158
+
159
+ async release(sessionId: string, turnId?: string): Promise<void> {
160
+ if (turnId === undefined) this.keyed.delete(sessionId);
161
+ if (turnId === undefined) this.asked.delete(sessionId);
162
+ if (turnId === undefined) this.reached.delete(sessionId);
163
+ const closing: Promise<void>[] = [];
164
+ for (const [key, turn] of this.turns) {
165
+ if (turn.sessionId !== sessionId || (turnId !== undefined && key !== turnKey(sessionId, turnId))) continue;
166
+ this.turns.delete(key);
167
+ turn.dispose();
168
+ closing.push(turn.transport.close());
169
+ this.controls.detach(turn.sessionId, turn.turnId);
170
+ }
171
+ await Promise.all(closing);
172
+ }
173
+
174
+ private readonly handlers: Handlers = {
175
+ computer_status: async (input, ctx) => {
176
+ const { turn, result } = await this.call(ctx, 'status', {}, statusResultSchema);
177
+ // A model fills this optional field even when nothing is missing; a pane is opened only for a missing permission.
178
+ const missing = input.open_settings === 'accessibility' ? !result.permissions.accessibility : !result.permissions.screenRecording;
179
+ if (!input.open_settings || !missing) return { platform: this.profile.platform, ...result };
180
+ const opened = z.object({ opened: z.boolean() }).parse(await turn.transport.request('permissions.request', { kind: input.open_settings }, ctx.signal));
181
+ return { platform: this.profile.platform, ...result, settings_opened: opened.opened };
182
+ },
183
+ computer_list_apps: async (input, ctx) => {
184
+ const { result } = await this.call(ctx, 'list_apps', input, listAppsResultSchema);
185
+ const granted = new Map((await this.access(ctx)).apps.map((app) => [app.id, app.tier]));
186
+ return { ...result, apps: result.apps.map((app) => ({ ...app, ...(granted.has(app.id) ? { access: granted.get(app.id) } : {}) })) };
187
+ },
188
+ computer_request_access: async (input, ctx) => {
189
+ const { result } = await this.call(ctx, 'resolve_apps', { names: input.apps }, resolveAppsResultSchema);
190
+ const elevated = new Set((input.full_access ?? []).map((name) => name.toLowerCase()));
191
+ const granted = new Map<string, AppGrant>();
192
+ const unresolved: AccessGrant['unresolved'] = [];
193
+ const asked = this.asked.get(ctx.sessionId) ?? new Map<string, string>();
194
+ this.asked.set(ctx.sessionId, asked);
195
+ for (const app of result.apps) {
196
+ if (app.status === 'resolved') asked.set(app.request.toLowerCase(), app.id);
197
+ if (app.status === 'not_found') { unresolved.push({ app: app.request, reason: 'not_found' }); continue; }
198
+ if (app.status === 'ambiguous') { unresolved.push({ app: app.request, reason: 'ambiguous', candidates: app.candidates }); continue; }
199
+ const tier: AccessTier = elevated.has(app.request.toLowerCase()) ? 'full' : defaultTier(categorize(app));
200
+ const previous = granted.get(app.id);
201
+ granted.set(app.id, { id: app.id, name: app.name, tier: previous ? maxTier(previous.tier, tier) : tier });
202
+ }
203
+ return accessGrantSchema.parse({
204
+ kind: 'computer_access', granted: [...granted.values()], unresolved,
205
+ clipboard_read: input.clipboard_read ?? false, clipboard_write: input.clipboard_write ?? false,
206
+ system_key_combos: input.system_key_combos ?? false,
207
+ });
208
+ },
209
+ computer_get_app_state: async (input, ctx) => {
210
+ const grant = this.granted(ctx, await this.access(ctx), input.app, 'read');
211
+ const { turn, result } = await this.observe(ctx, grant, input.window_id);
212
+ const shown = `${grant.id}#routes`;
213
+ const routes = this.keyed.get(ctx.sessionId) && !turn.hinted.has(shown) ? describeRoutes(await this.memory.read(grant.id)) : undefined;
214
+ turn.hinted.add(shown);
215
+ return this.present(turn, grant, result, routes ? [routes] : [], input.disable_diff);
216
+ },
217
+ computer_run: async (input, ctx) => {
218
+ const grant = this.granted(ctx, await this.access(ctx), input.app, 'read');
219
+ const apiKey = await this.jevKey(ctx);
220
+ // The run looks without pictures except around clicks; the model gets one with the state at the end.
221
+ const { turn, result: initial } = await this.observe(ctx, grant, undefined, !apiKey || pictured(input.steps[0]));
222
+ if (!apiKey) {
223
+ return this.present(turn, grant, initial, [`computer_run is off: there is no TypeSafe key (secret ${JEV_SECRET}), or Jev is switched off (secret ${JEV_OFF}). Nothing was done. Carry out the steps with the single tools on the state below.`]);
224
+ }
225
+ const learned = await this.memory.read(grant.id);
226
+ const report = await runSteps(input.goal, input.steps, initial, {
227
+ ask: this.jev(apiKey), signal: ctx.signal, known: (step, tree) => recall(learned, step, tree), guess: (step, tree) => guess(learned, step, tree),
228
+ selectAll: this.profile.platform === 'darwin' ? 'super+a' : 'ctrl+a',
229
+ observe: async (picture) => (await this.observe(ctx, grant, undefined, picture === true)).result,
230
+ act: async (step, wait) => (await this.perform(input.app, step, ctx, undefined, wait)).result,
231
+ ...(this.profile.platform === 'darwin' ? {
232
+ batch: (actions: readonly ComputerAction[], picture: boolean) => this.performAll(input.app, actions, ctx, picture),
233
+ readText: async () => {
234
+ const allowed = (await this.access(ctx)).apps.map((app) => app.id);
235
+ return (await this.call(ctx, 'read_text', { app: grant.id, allowed }, readTextResultSchema, grant.name)).result.lines;
236
+ },
237
+ } : {}),
238
+ });
239
+ turn.progress.forget(grant.id);
240
+ await traceRun(process.env.MOXXY_JEV_TRACE, {
241
+ app: grant.name, goal: input.goal, steps: input.steps.length, ms: report.ms, asks: report.asks, time: report.time,
242
+ outcomes: report.outcomes.map((outcome) => outcome.status),
243
+ });
244
+ await this.learn(turn, grant.id, input.goal, input.steps, report);
245
+ const final = report.state.screenshot || report.state.screenshotUnavailable ? report.state : (await this.observe(ctx, grant)).result;
246
+ return this.present(turn, grant, final, [describeRun(report, input.steps)]);
247
+ },
248
+ computer_click: (input, ctx) => this.act(input, 'click', ctx),
249
+ computer_type_text: (input, ctx) => this.act(input, 'type_text', ctx),
250
+ computer_press_key: (input, ctx) => this.act(input, 'press_key', ctx),
251
+ computer_scroll: (input, ctx) => this.act(input, 'scroll', ctx),
252
+ computer_drag: (input, ctx) => this.act(input, 'drag', ctx),
253
+ computer_set_value: (input, ctx) => this.act(input, 'set_value', ctx),
254
+ computer_perform_secondary_action: (input, ctx) => this.act(input, 'perform_secondary_action', ctx),
255
+ computer_zoom: async (input, ctx) => {
256
+ const access = await this.access(ctx);
257
+ const grant = this.granted(ctx, access, input.app, 'read');
258
+ const params = { region: input.region, app: grant.id, allowed: access.apps.map((app) => app.id) };
259
+ const { result } = await this.call(ctx, 'zoom', params, imageSchema, grant.name);
260
+ return withImage(`Zoomed region [${input.region.join(', ')}] at ${result.width}x${result.height}. Reading aid only: coordinates keep referring to the screenshot, never to this image.`, result);
261
+ },
262
+ };
263
+
264
+ private observe(ctx: ToolContext, grant: AppGrant, windowId?: string, picture = true) {
265
+ const web = categorize(grant) === 'browser' ? { web: true } : {};
266
+ return this.call(ctx, 'get_app_state', { app: grant.id, ...(windowId ? { window_id: windowId } : {}), screenshot: picture, ...web }, appStateSchema, grant.name);
267
+ }
268
+
269
+ /**
270
+ * What the conversation may use: the grants of the access dialog, then the apps approved through a run at their
271
+ * default level. Auto-approve raises none of them: it skips the prompt for a request of full control, not the
272
+ * policy that request goes through.
273
+ */
274
+ private async access(ctx: ToolContext): Promise<AccessState> {
275
+ const access = accessFromLog(ctx.log);
276
+ const reached = this.reached.get(ctx.sessionId) ?? new Map<string, AppGrant | null>();
277
+ this.reached.set(ctx.sessionId, reached);
278
+ const asked = this.asked.get(ctx.sessionId) ?? new Map<string, string>();
279
+ this.asked.set(ctx.sessionId, asked);
280
+ const held = (name: string) => access.apps.some((grant) => [grant.id, grant.name].some((known) => known.toLowerCase() === name) || grant.id === asked.get(name));
281
+ const names = approvedThroughRun(ctx.log).map((name) => name.toLowerCase()).filter((name) => !held(name));
282
+ const unplaced = names.filter((name) => !reached.has(name));
283
+ if (unplaced.length > 0) {
284
+ const { result } = await this.call(ctx, 'resolve_apps', { names: unplaced }, resolveAppsResultSchema);
285
+ for (const app of result.apps) {
286
+ reached.set(app.request.toLowerCase(), app.status === 'resolved' ? { id: app.id, name: app.name, tier: defaultTier(categorize(app)) } : null);
287
+ }
288
+ }
289
+ const apps = new Map(access.apps.map((grant) => [grant.id, grant]));
290
+ for (const name of names) {
291
+ const grant = reached.get(name);
292
+ if (!grant) continue;
293
+ asked.set(name, grant.id);
294
+ if (!apps.has(grant.id)) apps.set(grant.id, grant);
295
+ }
296
+ return { apps: [...apps.values()], flags: access.flags };
297
+ }
298
+
299
+ /** The grant for `app`, which may be the name it was asked for under instead of the one it has on this system. */
300
+ private granted(ctx: ToolContext, access: AccessState, app: string, needed: AccessTier): AppGrant {
301
+ const known = access.apps.some((grant) => [grant.id, grant.name].some((name) => name.toLowerCase() === app.toLowerCase()));
302
+ return checkAccess(access, known ? app : this.asked.get(ctx.sessionId)?.get(app.toLowerCase()) ?? app, needed);
303
+ }
304
+
305
+ /** One action on an app, behind the grant's level and the key rules. */
306
+ private async perform(app: string, step: ComputerAction, ctx: ToolContext, beforeSending: (turn: Turn, grant: AppGrant) => void = () => undefined, wait: ActWait = { picture: true }) {
307
+ const access = await this.access(ctx);
308
+ const grant = this.granted(ctx, access, app, requiredTier(step));
309
+ checkKeys(step, access.flags, this.profile.platform);
310
+ // Only the macOS helper knows how to wait for an effect or a typed text, and how to leave the picture out of the state after an action.
311
+ const darwin = this.profile.platform === 'darwin';
312
+ const awaited = darwin ? { ...(wait.until?.length ? { until: wait.until } : {}) } : {};
313
+ const pictured = !wait.picture && darwin ? { screenshot: false } : {};
314
+ const params = { app: grant.id, action: forHelper(step), allowed: access.apps.map((granted) => granted.id), ...awaited, ...pictured };
315
+ beforeSending(await this.turn(ctx), grant);
316
+ return { grant, ...(await this.call(ctx, 'act', params, actResultSchema, grant.name)) };
317
+ }
318
+
319
+ /** Several actions on one app in one helper request, each behind the grant's level and the key rules. */
320
+ private async performAll(app: string, steps: readonly ComputerAction[], ctx: ToolContext, picture: boolean) {
321
+ const access = await this.access(ctx);
322
+ for (const step of steps) {
323
+ this.granted(ctx, access, app, requiredTier(step));
324
+ checkKeys(step, access.flags, this.profile.platform);
325
+ }
326
+ const grant = this.granted(ctx, access, app, 'read');
327
+ const params = { app: grant.id, actions: steps.map(forHelper), allowed: access.apps.map((granted) => granted.id), screenshot: picture };
328
+ return (await this.call(ctx, 'batch', params, batchResultSchema, grant.name)).result;
329
+ }
330
+
331
+ private async act(input: { app: string } & Record<string, unknown>, action: ComputerAction['action'], ctx: ToolContext): Promise<unknown> {
332
+ const { app, ...fields } = input;
333
+ const step = { action, ...fields } as ComputerAction;
334
+ const signature = JSON.stringify(step);
335
+ const { turn, grant, result } = await this.perform(app, step, ctx, (current, granted) => current.progress.check(granted.id, signature));
336
+ const { state } = result;
337
+ if (!state) return describeResult(result.result);
338
+ await this.learnFromModel(turn, grant.id, step, result.result, state);
339
+ const [outcome, note] = this.judge(turn, grant.id, signature, result.result, state);
340
+ return this.present(turn, grant, state, [describeResult(outcome), ...note]);
341
+ }
342
+
343
+ /** What a run teaches: the element of every verified step, a memory that proved stale, and a route that reached its end. */
344
+ private async learn(turn: Turn, app: string, goal: string, steps: readonly RunStep[], report: RunReport): Promise<void> {
345
+ const targets: Array<{ do: string; target: string; key: string; label: string; way: number; effect?: string[] }> = [];
346
+ for (const [index, outcome] of report.outcomes.entries()) {
347
+ const step = steps[index];
348
+ const target = step ? targetOf(step) : undefined;
349
+ if (!step || target === undefined) continue;
350
+ if (outcome.used) {
351
+ const { effect, ...element } = outcome.used;
352
+ targets.push({ do: step.do, target, ...element, ...(effect ? { effect: [...effect] } : {}) });
353
+ } else if (outcome.stale) await this.memory.forget(app, { do: step.do, target });
354
+ if (outcome.status === 'failed') turn.failed.set(app, step);
355
+ }
356
+ const finished = report.outcomes.length === steps.length && report.outcomes.every((outcome) => outcome.status !== 'failed');
357
+ if (finished) turn.failed.delete(app);
358
+ const route = finished && report.outcomes.some((outcome) => outcome.status === 'verified') ? { goal, steps } : undefined;
359
+ if (targets.length > 0 || route) await this.memory.learn(app, { targets, ...(route ? { route } : {}) });
360
+ }
361
+
362
+ /** After a run could not do a step, the element the model then uses the same way, with effect, is that step's element. */
363
+ private async learnFromModel(turn: Turn, app: string, action: ComputerAction, result: ActionResult, state: AppState): Promise<void> {
364
+ const step = turn.failed.get(app);
365
+ if (!step || step.target === undefined) return;
366
+ turn.failed.delete(app);
367
+ const index = (action as { element_index?: number }).element_index;
368
+ if (SINGLE[step.do] !== action.action || index === undefined || result.outcome !== 'delivered') return;
369
+ const before = turn.trees.get(app);
370
+ const element = before?.elements.find((candidate) => candidate.index === index);
371
+ // A click that changed only the picture may have lit the element up and done nothing else.
372
+ if (!before || !element || sameElements(before, state.tree)) return;
373
+ await this.memory.learn(app, { targets: [{ do: step.do, target: step.target, key: element.key, label: labelOf(element), way: 0 }] });
374
+ }
375
+
376
+ /** A delivered action that leaves the app looking the same twice in a row did not work: say so. */
377
+ private judge(turn: Turn, app: string, signature: string, result: ActionResult, state: AppState): [ActionResult, string[]] {
378
+ if (result.outcome === 'ineffective') {
379
+ // The helper saw no change itself: that counts toward the limit like an unchanged state.
380
+ turn.progress.record(app, signature, false);
381
+ return [result, []];
382
+ }
383
+ if (result.outcome !== 'delivered') {
384
+ turn.progress.forget(app);
385
+ return [result, []];
386
+ }
387
+ const before = turn.seen.get(app);
388
+ const unchanged = turn.progress.record(app, signature, before === undefined || before !== fingerprint(state.tree, state.screenshot));
389
+ if (unchanged >= 2) return [{ outcome: 'ineffective', code: 'no_progress' }, []];
390
+ return [result, unchanged === 1 ? ['Nothing visible changed after this action: the tree and screenshot are the same as before it.'] : []];
391
+ }
392
+
393
+ private present(turn: Turn, grant: AppGrant, state: AppState, prefix: string[], disableDiff = false): string | ToolImageResult {
394
+ this.controls.target(turn.sessionId, turn.turnId, { app: grant.name, window: state.tree.window ?? null });
395
+ const view: TreeView = disableDiff ? { kind: 'full', text: formatTree(state.tree) } : diffTrees(turn.trees.get(grant.id), state.tree);
396
+ turn.trees.set(grant.id, state.tree);
397
+ turn.seen.set(grant.id, fingerprint(state.tree, state.screenshot));
398
+ const hint = turn.hinted.has(grant.id) ? undefined : hintFor(this.hints, grant);
399
+ turn.hinted.add(grant.id);
400
+ const panel = `${grant.id}#file-panel`;
401
+ const panelNote = state.filePanel && !turn.hinted.has(panel) ? [FILE_PANEL_NOTE] : [];
402
+ if (state.filePanel) turn.hinted.add(panel);
403
+ const parts = [...prefix, ...(hint ? [`Notes for ${grant.name}:\n${hint}`] : []), ...panelNote, wrapUntrusted(view.text, grant.name)];
404
+ if (state.screenshot) parts.push(`Screenshot ${state.screenshot.width}x${state.screenshot.height}: x and y in actions are pixels of this image.`);
405
+ else if (state.screenshotUnavailable) parts.push(`No screenshot: ${state.screenshotUnavailable}`);
406
+ if (state.contentPending) parts.push('The page content is not readable yet (still loading, or the window is hidden): look again before you report what the page says.');
407
+ return withImage(parts.join('\n\n'), state.screenshot);
408
+ }
409
+
410
+ private async call<T>(ctx: ToolContext, method: string, params: unknown, schema: z.ZodType<T, z.ZodTypeDef, unknown>, target?: string): Promise<{ turn: Turn; result: T }> {
411
+ const turn = await this.turn(ctx);
412
+ this.controls.activity(ctx.sessionId, ctx.turnId, 'recovering', target);
413
+ let raw: unknown;
414
+ try {
415
+ raw = await turn.transport.request(method, params, ctx.signal);
416
+ } catch (error) {
417
+ throw asComputerUseError(error);
418
+ } finally {
419
+ this.controls.activity(ctx.sessionId, ctx.turnId, 'idle');
420
+ }
421
+ const parsed = schema.safeParse(raw);
422
+ if (!parsed.success) {
423
+ const [issue] = parsed.error.issues;
424
+ const where = issue ? ` (${issue.path.join('.')}: ${issue.message})` : '';
425
+ throw new ComputerUseError('helper_failed', `The helper returned an invalid ${method} result${where}`);
426
+ }
427
+ return { turn, result: parsed.data };
428
+ }
429
+
430
+ private async turn(ctx: ToolContext): Promise<Turn> {
431
+ ctx.signal.throwIfAborted();
432
+ const key = turnKey(ctx.sessionId, ctx.turnId);
433
+ const previous = this.turns.get(key);
434
+ if (previous && !previous.transport.closed) return previous;
435
+ if (previous) throw new Error('Computer Use stopped for this turn. Start a new turn to regain control and observe again.');
436
+ try { await this.profile.verifyHelper(); } catch { throw new Error(this.profile.unavailableMessage); }
437
+ ctx.signal.throwIfAborted();
438
+ // No await between the final lookup and registration: parallel calls share one child.
439
+ const existing = this.turns.get(key);
440
+ if (existing) return existing;
441
+ const controlState = controlStateSchemaFor(this.profile.protocolVersion);
442
+ const events = contractEventsFor(this.profile.protocolVersion);
443
+ // Preview requests carry a signal that never aborts: an aborted request ends the helper.
444
+ const idle = new AbortController().signal;
445
+ const source: PreviewSource = {
446
+ codecs: this.profile.previewCodecs ?? ['jpeg'],
447
+ // A helper that only makes pictures does not know the `codec` field.
448
+ start: async (fps, codec) => { await transport.request('preview.start', codec === 'jpeg' ? { fps } : { fps, codec }, idle); },
449
+ stop: async () => { if (!transport.closed) await transport.request('preview.stop', {}, idle); },
450
+ keyframe: async () => { if (!transport.closed) await transport.request('preview.keyframe', {}, idle); },
451
+ };
452
+ const transport: HelperTransport = new HelperTransport(this.profile.helperPath, [...this.profile.helperArgs, '--parent', String(process.pid)], {
453
+ protocolVersion: this.profile.protocolVersion,
454
+ timeoutMs: this.profile.timeoutMs ?? 15_000,
455
+ events,
456
+ onEvent: (event) => {
457
+ if (event.event === 'control_state') this.controls.update(ctx.sessionId, ctx.turnId, controlState.parse(event).state);
458
+ else if (event.event === 'cursor') this.controls.cursor(ctx.sessionId, ctx.turnId, events.cursor.parse(event).cursor);
459
+ else if (event.event === 'preview_frame') {
460
+ const frame = events.preview_frame.parse(event);
461
+ if (frame.error) this.preview.failed(source, frame.error);
462
+ else this.preview.frame(source, frame.image);
463
+ } else if (event.event === 'preview_chunk') {
464
+ const { version: _version, event: _event, ...chunk } = events.preview_chunk.parse(event);
465
+ this.preview.chunk(source, chunk);
466
+ }
467
+ },
468
+ });
469
+ const abort = () => { void this.release(ctx.sessionId, ctx.turnId); };
470
+ ctx.signal.addEventListener('abort', abort, { once: true });
471
+ const detachPreview = this.preview.attach(source);
472
+ // A helper that stopped or died has no picture to show any more.
473
+ void transport.done.then(detachPreview);
474
+ const turn: Turn = {
475
+ sessionId: ctx.sessionId, turnId: ctx.turnId, transport, trees: new Map(), seen: new Map(), progress: new ProgressTracker(), hinted: new Set(), failed: new Map(),
476
+ dispose: () => { ctx.signal.removeEventListener('abort', abort); detachPreview(); },
477
+ };
478
+ this.turns.set(key, turn);
479
+ this.controls.attach(ctx.sessionId, ctx.turnId, transport);
480
+ return turn;
481
+ }
482
+ }
@@ -0,0 +1,138 @@
1
+ // A scripted native helper for backend tests: speaks the shared JSON-lines
2
+ // protocol over stdio and models three apps in memory. `--log <file>` records
3
+ // every request so tests can prove a refused action never reached the helper.
4
+ import { appendFileSync } from 'node:fs';
5
+ import { createInterface } from 'node:readline';
6
+
7
+ const VERSION = 5;
8
+ const logIndex = process.argv.indexOf('--log');
9
+ const logFile = logIndex > 0 ? process.argv[logIndex + 1] : undefined;
10
+ const PNG = 'iVBORw0KGgo=';
11
+
12
+ const apps = [
13
+ { id: 'com.apple.TextEdit', name: 'TextEdit', running: true, also: 'text editor' },
14
+ { id: 'com.apple.Safari', name: 'Safari', running: false },
15
+ { id: 'com.apple.Terminal', name: 'Terminal', running: true },
16
+ { id: 'com.example.notes.one', name: 'Notes', running: false },
17
+ { id: 'com.example.notes.two', name: 'Notes', running: false },
18
+ ];
19
+ const documents = new Map(apps.map((app) => [app.id, 'hello']));
20
+
21
+ function tree(id) {
22
+ const app = apps.find((candidate) => candidate.id === id);
23
+ return {
24
+ app: app.name, window: 'Untitled',
25
+ elements: [
26
+ { key: 'w', index: 0, depth: 0, role: 'window', title: 'Untitled' },
27
+ { key: 'w/text', index: 1, depth: 1, role: 'text area', value: documents.get(id), states: ['focused'] },
28
+ { key: 'w/save', index: 2, depth: 1, role: 'button', title: 'Save', actions: ['AXPress'] },
29
+ { key: 'w/broken', index: 3, depth: 1, role: 'button', title: 'Broken' },
30
+ // A helper bug the contract must catch: two elements under one key.
31
+ ...(documents.get(id).includes('<twins>') ? [{ key: 'w/broken', index: 4, depth: 1, role: 'button', title: 'Twin' }] : []),
32
+ ],
33
+ };
34
+ }
35
+
36
+ /** Clicks on Save in a document holding `<hover>`: each lights the button up anew, which only the picture shows. */
37
+ let highlights = 0;
38
+
39
+ const state = (id, screenshot = true) => ({
40
+ tree: tree(id),
41
+ ...(screenshot ? { screenshot: { mediaType: 'image/png', base64: `${PNG}${'A'.repeat(highlights)}`, width: 800, height: 600 } } : {}),
42
+ // The system's open or save panel is what the app shows.
43
+ ...(documents.get(id).includes('<file-panel>') ? { filePanel: true } : {}),
44
+ });
45
+
46
+ class Refusal extends Error {
47
+ constructor(code, message) { super(message); this.code = code; }
48
+ }
49
+
50
+ let stubborn = 0;
51
+
52
+ function perform(app, step) {
53
+ if (step.action === 'click' && step.element_index === 3) return { outcome: 'blocked', code: 'target_blocked' };
54
+ // A control the helper itself gives up on once it is asked for it a second time.
55
+ if (step.action === 'click' && step.element_index === 4) return (stubborn += 1) > 1 ? { outcome: 'ineffective', hint: 'A real click changed nothing either.' } : { outcome: 'delivered', method: 'ax' };
56
+ if (step.action === 'type_text') documents.set(app, documents.get(app) + step.text);
57
+ if (step.action === 'click' && step.element_index === 2 && documents.get(app).includes('<hover>')) highlights += 1;
58
+ return { outcome: 'delivered', method: 'ax' };
59
+ }
60
+
61
+ function requireAllowed(params) {
62
+ if (!params.allowed.includes(params.app)) throw new Refusal('app_not_allowed', `${params.app} is not granted`);
63
+ }
64
+
65
+ const methods = {
66
+ status: () => ({
67
+ ready: false, permissions: { accessibility: true, screenRecording: false },
68
+ limitations: ['Screen Recording is not allowed: the helper cannot capture windows.'],
69
+ }),
70
+ 'permissions.request': () => ({ opened: true }),
71
+ // Like the native helper: capture answers at once and frames follow as events.
72
+ 'preview.start': ({ fps, codec }) => {
73
+ const image = { mediaType: 'image/jpeg', base64: `frame@${fps}`, width: 640, height: 400 };
74
+ const chunk = { seq: 1, key: true, codec: 'avc1.4d001f', data: 'dmlkZW8=', timestamp: 0, width: 640, height: 400 };
75
+ const event = codec === 'h264' ? { event: 'preview_chunk', ...chunk } : { event: 'preview_frame', seq: 1, image };
76
+ setImmediate(() => process.stdout.write(JSON.stringify({ version: VERSION, ...event }) + '\n'));
77
+ return { started: true };
78
+ },
79
+ 'preview.keyframe': () => ({ requested: true }),
80
+ 'preview.stop': () => ({ stopped: true }),
81
+ list_apps: ({ query, limit }) => {
82
+ const matching = apps.filter((app) => !query || `${app.name} ${app.id}`.toLowerCase().includes(query.toLowerCase()));
83
+ return { apps: matching.slice(0, limit).map(({ also, ...app }) => app), truncated: matching.length > limit };
84
+ },
85
+ resolve_apps: ({ names }) => ({
86
+ apps: names.map((request) => {
87
+ const found = apps.filter((app) => [app.id, app.name, app.also ?? ''].some((name) => name.toLowerCase() === request.toLowerCase()));
88
+ if (found.length === 0) return { request, status: 'not_found' };
89
+ if (found.length > 1) return { request, status: 'ambiguous', candidates: found.map(({ id, name }) => ({ id, name })) };
90
+ return { request, status: 'resolved', id: found[0].id, name: found[0].name };
91
+ }),
92
+ }),
93
+ get_app_state: ({ app, screenshot, web }) => {
94
+ // A browser whose page has not reached accessibility yet.
95
+ if (web) return { ...state(app, screenshot), contentPending: true };
96
+ if (app === 'com.apple.Terminal') throw new Refusal('permissions_not_granted', 'Accessibility is off');
97
+ // Like the native helper: the cursor appears over the observed window before the answer.
98
+ process.stdout.write(JSON.stringify({ version: VERSION, event: 'cursor', cursor: { phase: 'idle', x: 0.5, y: 0.5 } }) + '\n');
99
+ return state(app, screenshot);
100
+ },
101
+ act: (params) => {
102
+ requireAllowed(params);
103
+ return { result: perform(params.app, params.action), state: state(params.app, params.screenshot !== false) };
104
+ },
105
+ batch: (params) => {
106
+ requireAllowed(params);
107
+ const results = [];
108
+ for (const step of params.actions) {
109
+ const result = perform(params.app, step);
110
+ results.push(result);
111
+ if (result.outcome !== 'delivered') break;
112
+ }
113
+ return { results, state: state(params.app, params.screenshot !== false) };
114
+ },
115
+ screenshot: () => ({ mediaType: 'image/png', base64: PNG, width: 1440, height: 900 }),
116
+ zoom: () => ({ mediaType: 'image/png', base64: PNG, width: 400, height: 200 }),
117
+ // A control the screenshot shows under a name accessibility does not give it.
118
+ read_text: (params) => {
119
+ requireAllowed(params);
120
+ return { lines: [{ text: 'Publish', x: 300, y: 40, width: 60, height: 20 }] };
121
+ },
122
+ };
123
+
124
+ const lines = createInterface({ input: process.stdin });
125
+ lines.on('line', (line) => {
126
+ const frame = JSON.parse(line);
127
+ if (frame.control) return;
128
+ if (logFile) appendFileSync(logFile, JSON.stringify({ method: frame.method, params: frame.params }) + '\n');
129
+ const reply = (body) => process.stdout.write(JSON.stringify({ version: VERSION, id: frame.id, ...body }) + '\n');
130
+ try {
131
+ const handler = methods[frame.method];
132
+ if (!handler) throw new Refusal('unsupported_action', `unknown method ${frame.method}`);
133
+ reply({ ok: true, result: handler(frame.params) });
134
+ } catch (error) {
135
+ reply({ ok: false, error: { code: error.code ?? 'helper_failed', message: error.message } });
136
+ }
137
+ });
138
+ lines.on('close', () => process.exit(0));
@@ -0,0 +1,38 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import type { EventLogReader, MoxxyEvent, SessionId, ToolCallId, ToolContext, TurnId } from '@moxxy/sdk';
4
+
5
+ /** The scripted helper process used by backend tests (a real child process, not a stub). */
6
+ export const contractHelperScript = fileURLToPath(new URL('./contract-helper.fixture.mjs', import.meta.url));
7
+
8
+ /** An in-memory, append-only event log reader over a plain array. */
9
+ export function memoryLog(events: MoxxyEvent[]): EventLogReader {
10
+ return {
11
+ get length() { return events.length; },
12
+ at: (index) => events[index],
13
+ slice: (from, to) => events.slice(from, to),
14
+ ofType: ((type: MoxxyEvent['type']) => events.filter((event) => event.type === type)) as EventLogReader['ofType'],
15
+ byTurn: (turnId) => events.filter((event) => event.turnId === turnId),
16
+ toJSON: () => events,
17
+ };
18
+ }
19
+
20
+ export function toolContext(log: EventLogReader, opts: { sessionId?: string; turnId?: string; callId?: string; signal?: AbortSignal; getSecret?: ToolContext['getSecret'] } = {}): ToolContext {
21
+ const quiet = () => undefined;
22
+ return {
23
+ sessionId: (opts.sessionId ?? 'session') as SessionId,
24
+ turnId: (opts.turnId ?? 'turn') as TurnId,
25
+ callId: (opts.callId ?? 'call') as ToolCallId,
26
+ cwd: process.cwd(),
27
+ signal: opts.signal ?? new AbortController().signal,
28
+ log,
29
+ logger: { debug: quiet, info: quiet, warn: quiet, error: quiet },
30
+ ...(opts.getSecret ? { getSecret: opts.getSecret } : {}),
31
+ };
32
+ }
33
+
34
+ /** Methods the helper received, in order. */
35
+ export function helperRequests(file: string): Array<{ method: string; params: Record<string, unknown> }> {
36
+ if (!existsSync(file)) return [];
37
+ return readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map((line) => JSON.parse(line) as { method: string; params: Record<string, unknown> });
38
+ }