@aexol/spectral 0.9.212 → 0.9.216

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 (115) hide show
  1. package/dist/backend/generated-images.d.ts +180 -0
  2. package/dist/backend/generated-images.d.ts.map +1 -0
  3. package/dist/backend/generated-images.js +483 -0
  4. package/dist/backend/machine-routing.d.ts +25 -0
  5. package/dist/backend/machine-routing.d.ts.map +1 -0
  6. package/dist/backend/machine-routing.js +35 -0
  7. package/dist/commands/logout.d.ts.map +1 -1
  8. package/dist/commands/logout.js +4 -0
  9. package/dist/commands/serve.d.ts.map +1 -1
  10. package/dist/commands/serve.js +20 -2
  11. package/dist/extensions/browser/browser-service.d.ts +75 -1
  12. package/dist/extensions/browser/browser-service.d.ts.map +1 -1
  13. package/dist/extensions/browser/browser-service.js +600 -34
  14. package/dist/extensions/browser/index.d.ts +10 -4
  15. package/dist/extensions/browser/index.d.ts.map +1 -1
  16. package/dist/extensions/browser/index.js +44 -5
  17. package/dist/extensions/browser/stream/stream-tool.d.ts +10 -0
  18. package/dist/extensions/browser/stream/stream-tool.d.ts.map +1 -1
  19. package/dist/extensions/browser/stream/stream-tool.js +42 -14
  20. package/dist/extensions/browser/tools/core.d.ts.map +1 -1
  21. package/dist/extensions/browser/tools/core.js +63 -2
  22. package/dist/extensions/desktop-control/index.d.ts +55 -0
  23. package/dist/extensions/desktop-control/index.d.ts.map +1 -0
  24. package/dist/extensions/desktop-control/index.js +633 -0
  25. package/dist/extensions/desktop-control/keys.d.ts +33 -0
  26. package/dist/extensions/desktop-control/keys.d.ts.map +1 -0
  27. package/dist/extensions/desktop-control/keys.js +170 -0
  28. package/dist/extensions/desktop-control/linux.d.ts +26 -0
  29. package/dist/extensions/desktop-control/linux.d.ts.map +1 -0
  30. package/dist/extensions/desktop-control/linux.js +195 -0
  31. package/dist/extensions/desktop-control/macos.d.ts +53 -0
  32. package/dist/extensions/desktop-control/macos.d.ts.map +1 -0
  33. package/dist/extensions/desktop-control/macos.js +382 -0
  34. package/dist/extensions/desktop-control/safety.d.ts +89 -0
  35. package/dist/extensions/desktop-control/safety.d.ts.map +1 -0
  36. package/dist/extensions/desktop-control/safety.js +157 -0
  37. package/dist/extensions/desktop-control/types.d.ts +79 -0
  38. package/dist/extensions/desktop-control/types.d.ts.map +1 -0
  39. package/dist/extensions/desktop-control/types.js +10 -0
  40. package/dist/extensions/desktop-control/windows.d.ts +31 -0
  41. package/dist/extensions/desktop-control/windows.d.ts.map +1 -0
  42. package/dist/extensions/desktop-control/windows.js +276 -0
  43. package/dist/extensions/desktop-screenshot/index.d.ts +103 -0
  44. package/dist/extensions/desktop-screenshot/index.d.ts.map +1 -1
  45. package/dist/extensions/desktop-screenshot/index.js +363 -17
  46. package/dist/extensions/image-generation/index.d.ts.map +1 -1
  47. package/dist/extensions/image-generation/index.js +133 -34
  48. package/dist/extensions/seo/index.d.ts.map +1 -1
  49. package/dist/extensions/seo/index.js +4 -0
  50. package/dist/extensions/seo/tools/backend-bridge.d.ts +4 -0
  51. package/dist/extensions/seo/tools/backend-bridge.d.ts.map +1 -0
  52. package/dist/extensions/seo/tools/backend-bridge.js +378 -0
  53. package/dist/extensions/seo/tools/full-audit.d.ts +2 -0
  54. package/dist/extensions/seo/tools/full-audit.d.ts.map +1 -1
  55. package/dist/extensions/seo/tools/full-audit.js +1 -1
  56. package/dist/extensions/seo/tools/report.d.ts +10 -0
  57. package/dist/extensions/seo/tools/report.d.ts.map +1 -1
  58. package/dist/extensions/seo/tools/report.js +1 -1
  59. package/dist/extensions/spectral-vision-fallback.d.ts.map +1 -1
  60. package/dist/extensions/spectral-vision-fallback.js +11 -1
  61. package/dist/generated/zeus/const.d.ts.map +1 -1
  62. package/dist/generated/zeus/const.js +121 -4
  63. package/dist/generated/zeus/index.d.ts +527 -0
  64. package/dist/generated/zeus/index.d.ts.map +1 -1
  65. package/dist/mcp/tool-registrar.d.ts.map +1 -1
  66. package/dist/mcp/tool-registrar.js +5 -2
  67. package/dist/mcp/types.d.ts +5 -0
  68. package/dist/mcp/types.d.ts.map +1 -1
  69. package/dist/relay/dispatcher.d.ts +12 -1
  70. package/dist/relay/dispatcher.d.ts.map +1 -1
  71. package/dist/relay/dispatcher.js +44 -42
  72. package/dist/sdk/ai/providers/faux.js +1 -1
  73. package/dist/sdk/ai/providers/openai-completions.d.ts.map +1 -1
  74. package/dist/sdk/ai/providers/openai-completions.js +69 -16
  75. package/dist/sdk/ai/providers/openrouter-images.d.ts.map +1 -1
  76. package/dist/sdk/ai/providers/openrouter-images.js +58 -9
  77. package/dist/sdk/ai/providers/simple-options.d.ts.map +1 -1
  78. package/dist/sdk/ai/providers/simple-options.js +2 -0
  79. package/dist/sdk/ai/types.d.ts +37 -1
  80. package/dist/sdk/ai/types.d.ts.map +1 -1
  81. package/dist/sdk/ai/utils/hosted-image-resolver.d.ts +60 -0
  82. package/dist/sdk/ai/utils/hosted-image-resolver.d.ts.map +1 -0
  83. package/dist/sdk/ai/utils/hosted-image-resolver.js +75 -0
  84. package/dist/sdk/ai/utils/image-source.d.ts +168 -0
  85. package/dist/sdk/ai/utils/image-source.d.ts.map +1 -0
  86. package/dist/sdk/ai/utils/image-source.js +487 -0
  87. package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
  88. package/dist/sdk/coding-agent/core/extensions/native-extensions.js +14 -0
  89. package/dist/sdk/coding-agent/core/extensions/types.d.ts +9 -0
  90. package/dist/sdk/coding-agent/core/extensions/types.d.ts.map +1 -1
  91. package/dist/sdk/coding-agent/core/system-prompt.d.ts.map +1 -1
  92. package/dist/sdk/coding-agent/core/system-prompt.js +15 -9
  93. package/dist/sdk/coding-agent/modes/rpc/rpc-mode.d.ts.map +1 -1
  94. package/dist/sdk/coding-agent/modes/rpc/rpc-mode.js +16 -3
  95. package/dist/sdk/coding-agent/utils/image-resize.d.ts.map +1 -1
  96. package/dist/sdk/coding-agent/utils/image-resize.js +7 -3
  97. package/dist/server/agent-bridge.d.ts +14 -0
  98. package/dist/server/agent-bridge.d.ts.map +1 -1
  99. package/dist/server/agent-bridge.js +105 -26
  100. package/dist/server/handlers/queue.d.ts.map +1 -1
  101. package/dist/server/handlers/queue.js +11 -34
  102. package/dist/server/image-attachments.d.ts +109 -0
  103. package/dist/server/image-attachments.d.ts.map +1 -0
  104. package/dist/server/image-attachments.js +195 -0
  105. package/dist/server/session-stream.d.ts +67 -0
  106. package/dist/server/session-stream.d.ts.map +1 -1
  107. package/dist/server/session-stream.js +222 -23
  108. package/dist/server/storage.d.ts.map +1 -1
  109. package/dist/server/storage.js +10 -31
  110. package/dist/server/ui/terminal-confirm.d.ts +145 -0
  111. package/dist/server/ui/terminal-confirm.d.ts.map +1 -0
  112. package/dist/server/ui/terminal-confirm.js +333 -0
  113. package/dist/server/wire.d.ts +30 -5
  114. package/dist/server/wire.d.ts.map +1 -1
  115. package/package.json +1 -1
@@ -0,0 +1,633 @@
1
+ /**
2
+ * Desktop Control Extension (computer-use with a safety layer)
3
+ *
4
+ * Registers snake_case `desktop_*` tools that inject REAL mouse/keyboard
5
+ * input via native OS commands:
6
+ * - macOS: JXA (osascript -l JavaScript) + CoreGraphics CGEventPost
7
+ * - Windows: PowerShell + Add-Type user32 SendInput/SetCursorPos
8
+ * - Linux: xdotool (X11) / wtype (Wayland, keyboard-only)
9
+ *
10
+ * Tools are registered ONLY when the platform/session is capable. Every
11
+ * mutating action is wrapped by the safety layer:
12
+ * 1. By default there is NO confirmation prompt: enabling the extension
13
+ * (enabledNativeExtensions contains "desktop-control") is the explicit
14
+ * user consent, so actions run in interactive, headless, serve and RPC
15
+ * sessions alike. Opt-in SPECTRAL_DESKTOP_REQUIRE_CONFIRM=1|true restores
16
+ * the per-session interactive confirmation (ctx.hasUI; headless
17
+ * auto-denies single actions without locking the session).
18
+ * 2. Optional SPECTRAL_DESKTOP_ALLOWLIST restricts actions to matching
19
+ * frontmost apps.
20
+ * 3. Screenshot BEFORE -> execute -> screenshot AFTER; both images are
21
+ * returned as image content so the model verifies the outcome
22
+ * (spectral-vision-fallback analyzes them automatically). A pointer
23
+ * read-back and a "no visible change" hint expose silent injection
24
+ * failures (CGEventPost never errors).
25
+ * 4. SPECTRAL_DESKTOP_DRY_RUN=1 returns action plans without executing.
26
+ *
27
+ * This extension is registered with defaultEnabled: false — it must be
28
+ * opted in via Settings.enabledNativeExtensions: ["desktop-control"].
29
+ *
30
+ * Zero external npm dependencies beyond cross-spawn (Windows PATH is trimmed
31
+ * under Electron). No native modules.
32
+ */
33
+ import { listWindows, screenshot, formatWindowList, } from "../desktop-screenshot/index.js";
34
+ import { parseConfirmTimeoutMs } from "../../server/ui/terminal-confirm.js";
35
+ import { createLinuxAdapter } from "./linux.js";
36
+ import { macosAdapter } from "./macos.js";
37
+ import { win32Adapter } from "./windows.js";
38
+ import { DesktopActionPolicy, describeAction, isDesktopControlTool, isDryRun, matchesAllowlist, isRequireConfirm, parseAllowlist, } from "./safety.js";
39
+ import { isModifierToken, parseChord } from "./keys.js";
40
+ const SCREENSHOT_FORMAT = "png";
41
+ // ---------------------------------------------------------------------------
42
+ // Adapter selection
43
+ // ---------------------------------------------------------------------------
44
+ export async function createAdapter(env = process.env) {
45
+ switch (process.platform) {
46
+ case "darwin":
47
+ return { adapter: macosAdapter };
48
+ case "win32":
49
+ return { adapter: win32Adapter };
50
+ case "linux":
51
+ return createLinuxAdapter(env);
52
+ default:
53
+ return { adapter: null, reason: `unsupported platform: ${process.platform}` };
54
+ }
55
+ }
56
+ function textContent(text) {
57
+ return { type: "text", text };
58
+ }
59
+ function imageContent(buf) {
60
+ return { type: "image", data: buf.toString("base64"), mimeType: "image/png" };
61
+ }
62
+ function errorResult(msg, details = {}) {
63
+ return {
64
+ content: [textContent(`❌ ${msg}`)],
65
+ details: { isError: true, error: msg, ...details },
66
+ };
67
+ }
68
+ function dryRunResult(action, adapter) {
69
+ return {
70
+ content: [
71
+ textContent(`DRY-RUN — nothing was injected into the desktop.\n` +
72
+ `Would execute: ${describeAction(action)}\n` +
73
+ `Command: ${adapter.buildPlan(action)}\n` +
74
+ `Unset SPECTRAL_DESKTOP_DRY_RUN to execute for real.`),
75
+ ],
76
+ details: { dryRun: true, action, plan: adapter.buildPlan(action) },
77
+ };
78
+ }
79
+ /** Parses the macOS pointer read-back ("x,y" cursor position from CGEventGetLocation). */
80
+ function parsePointer(readback) {
81
+ const m = /^(-?\d+),(-?\d+)$/.exec(readback.trim());
82
+ if (!m)
83
+ return null;
84
+ return { x: Number(m[1]), y: Number(m[2]) };
85
+ }
86
+ /** Cursor position a pointer-targeting action SHOULD leave behind (silent-failure heuristic). */
87
+ function pointerTarget(action) {
88
+ switch (action.kind) {
89
+ case "mouse_move":
90
+ return { x: action.x, y: action.y };
91
+ case "mouse_click":
92
+ return { x: action.x, y: action.y };
93
+ case "mouse_drag":
94
+ return { x: action.toX, y: action.toY };
95
+ default:
96
+ return null;
97
+ }
98
+ }
99
+ const NO_VISIBLE_CHANGE_HINT = "no visible change — verify BEFORE/AFTER screenshots; check Accessibility permission";
100
+ /**
101
+ * Execute a mutating action inside the safety envelope:
102
+ * permissions -> allowlist -> BEFORE screenshot -> execute -> AFTER screenshot.
103
+ * Returns both screenshots as image content for vision verification.
104
+ */
105
+ async function runMutating(action, deps) {
106
+ const { adapter } = deps;
107
+ const env = deps.env ?? process.env;
108
+ if (isDryRun(env))
109
+ return dryRunResult(action, adapter);
110
+ if (adapter.checkPermissions) {
111
+ try {
112
+ const perm = await adapter.checkPermissions();
113
+ if (!perm.ok)
114
+ return errorResult(perm.error ?? "permission check failed", { action });
115
+ }
116
+ catch (err) {
117
+ return errorResult(`permission check failed: ${err instanceof Error ? err.message : String(err)}`, { action });
118
+ }
119
+ }
120
+ // Allowlist: mutating actions only run while a permitted app is focused.
121
+ const allowlist = parseAllowlist(env.SPECTRAL_DESKTOP_ALLOWLIST);
122
+ let frontBefore = null;
123
+ if (allowlist.length > 0) {
124
+ if (action.kind === "focus_window") {
125
+ const owner = action.owner ?? "";
126
+ if (!owner || !matchesAllowlist(owner, allowlist)) {
127
+ return errorResult(`SPECTRAL_DESKTOP_ALLOWLIST blocks focusing "${action.title}" (owner "${owner || "unknown"}" does not match ${allowlist.join(", ")})`, { action, allowlist });
128
+ }
129
+ }
130
+ else {
131
+ frontBefore = await adapter.frontmostApp().catch(() => null);
132
+ if (!frontBefore || !matchesAllowlist(frontBefore, allowlist)) {
133
+ return errorResult(`SPECTRAL_DESKTOP_ALLOWLIST blocks this action: frontmost app is "${frontBefore ?? "unknown"}", expected one of ${allowlist.join(", ")}`, { action, allowlist, frontmost: frontBefore });
134
+ }
135
+ }
136
+ }
137
+ // BEFORE screenshot — refuse to mutate blind.
138
+ const capture = deps.capture ?? ((opts) => screenshot({ format: SCREENSHOT_FORMAT, ...opts }));
139
+ let before;
140
+ try {
141
+ before = await capture({ format: SCREENSHOT_FORMAT });
142
+ }
143
+ catch (err) {
144
+ return errorResult(`Refusing to mutate: BEFORE screenshot failed (${err instanceof Error ? err.message : String(err)}).`, { action });
145
+ }
146
+ let readback;
147
+ try {
148
+ // TOCTOU hardening: the frontmost app may have changed between the
149
+ // allowlist check above and execution (e.g. while capturing the
150
+ // BEFORE screenshot). Re-probe immediately before injecting and fail
151
+ // closed on mismatch. focus_window is exempt: its allowlist check
152
+ // targets the window owner, and the action itself changes frontmost.
153
+ if (allowlist.length > 0 && action.kind !== "focus_window") {
154
+ const frontNow = await adapter.frontmostApp().catch(() => null);
155
+ if (!frontNow || !matchesAllowlist(frontNow, allowlist)) {
156
+ const msg = `SPECTRAL_DESKTOP_ALLOWLIST blocked this action right before execution: frontmost app is "${frontNow ?? "unknown"}", expected one of ${allowlist.join(", ")}`;
157
+ return {
158
+ content: [textContent(`❌ ${msg}`), imageContent(before)],
159
+ details: {
160
+ isError: true,
161
+ error: msg,
162
+ action,
163
+ allowlist,
164
+ frontmost: frontNow,
165
+ beforeScreenshot: true,
166
+ },
167
+ };
168
+ }
169
+ }
170
+ readback = await adapter.execute(action);
171
+ }
172
+ catch (err) {
173
+ const msg = `Action failed: ${err instanceof Error ? err.message : String(err)}`;
174
+ return {
175
+ content: [textContent(`❌ ${msg}`), imageContent(before)],
176
+ details: { isError: true, error: msg, action, beforeScreenshot: true },
177
+ };
178
+ }
179
+ // AFTER screenshot — verification may fail even though the action ran.
180
+ let after = null;
181
+ let afterError = null;
182
+ try {
183
+ after = await capture({ format: SCREENSHOT_FORMAT });
184
+ }
185
+ catch (err) {
186
+ afterError = err instanceof Error ? err.message : String(err);
187
+ }
188
+ const frontAfter = await adapter.frontmostApp().catch(() => null);
189
+ // Post-action read-back: CGEventPost never errors, so a cursor that did not
190
+ // reach the requested coordinates (plus unchanged frontmost) is the only
191
+ // programmatic signal that the injection silently no-opped. Heuristic only
192
+ // — never a hard failure.
193
+ const pointerAfter = typeof readback === "string" ? parsePointer(readback) : null;
194
+ const target = pointerTarget(action);
195
+ const pointerMissedTarget = pointerAfter !== null && target !== null &&
196
+ (Math.abs(pointerAfter.x - target.x) > 1 || Math.abs(pointerAfter.y - target.y) > 1);
197
+ const pointerLine = pointerAfter ? `\nPointer now at (${pointerAfter.x}, ${pointerAfter.y}).` : "";
198
+ const hintLine = pointerMissedTarget ? `\n⚠️ ${NO_VISIBLE_CHANGE_HINT}.` : "";
199
+ const frontLine = frontBefore || frontAfter
200
+ ? `\nFrontmost: ${frontBefore ?? "?"} -> ${frontAfter ?? "?"}.`
201
+ : "";
202
+ const afterLine = after
203
+ ? "BEFORE and AFTER screenshots are attached — verify the UI state before continuing."
204
+ : `AFTER screenshot failed (${afterError}) — the action DID run; take a desktop_screenshot to verify.`;
205
+ return {
206
+ content: [
207
+ textContent(`✓ ${describeAction(action)} — executed on ${adapter.platform}.${frontLine}${pointerLine}${hintLine}\n${afterLine}`),
208
+ imageContent(before),
209
+ ...(after ? [imageContent(after)] : []),
210
+ ],
211
+ details: {
212
+ ok: true,
213
+ action,
214
+ frontmostBefore: frontBefore,
215
+ frontmostAfter: frontAfter,
216
+ ...(pointerAfter ? { pointerAfter } : {}),
217
+ ...(pointerMissedTarget ? { diagnosticHint: NO_VISIBLE_CHANGE_HINT } : {}),
218
+ afterScreenshotError: afterError,
219
+ },
220
+ };
221
+ }
222
+ // ---------------------------------------------------------------------------
223
+ // Tool definitions
224
+ // ---------------------------------------------------------------------------
225
+ const SAFETY_DOC = "\n\nSafety: real input injection — enabling the extension is the explicit user consent, so actions run without a confirmation prompt. " +
226
+ "Opt-in SPECTRAL_DESKTOP_REQUIRE_CONFIRM=1 restores a per-session confirmation prompt (denied by default in headless mode). " +
227
+ 'Every action returns BEFORE/AFTER screenshots for verification plus a pointer read-back; a "no visible change" hint flags actions that likely did not land (check Accessibility permission). ' +
228
+ "Env config: SPECTRAL_DESKTOP_DRY_RUN=1 previews without executing; " +
229
+ "SPECTRAL_DESKTOP_ALLOWLIST=Safari,Code restricts actions to matching focused apps. " +
230
+ "Allowlist entries are case-insensitive substrings matched in both directions against the frontmost app, and clicks are coordinate-based, so they may land on non-frontmost overlapping windows — the allowlist is best-effort, not a sandbox.";
231
+ const numberParam = (description) => ({ type: "number", description });
232
+ function actionTool(spec, build, deps) {
233
+ return {
234
+ name: spec.name,
235
+ label: spec.label,
236
+ description: spec.description + SAFETY_DOC,
237
+ promptSnippet: `\`${spec.name}\` — ${spec.label.toLowerCase()} (desktop input injection, safety-gated)`,
238
+ executionMode: "sequential",
239
+ parameters: {
240
+ type: "object",
241
+ properties: spec.parameters,
242
+ },
243
+ async execute(_toolCallId, _params) {
244
+ const built = build(_params);
245
+ if ("error" in built)
246
+ return errorResult(built.error, { tool: spec.name });
247
+ return runMutating(built.action, deps);
248
+ },
249
+ };
250
+ }
251
+ function requireNumber(params, key) {
252
+ const v = params[key];
253
+ if (typeof v !== "number" || !Number.isFinite(v))
254
+ return { error: `Parameter "${key}" must be a finite number.` };
255
+ return { value: v };
256
+ }
257
+ /** Max |dy|/|dx| in lines; keeps adapters in safe integer ranges (Windows: 100*120=12000 fits int32; Linux xdotool --repeat bounded). */
258
+ const SCROLL_LINE_LIMIT = 100;
259
+ /** Clamp (never error) scroll magnitude so wheel deltas cannot overflow int32. */
260
+ function clampScrollLines(v) {
261
+ return Math.max(-SCROLL_LINE_LIMIT, Math.min(SCROLL_LINE_LIMIT, v));
262
+ }
263
+ function optionalNumber(params, key) {
264
+ const v = params[key];
265
+ if (v === undefined)
266
+ return { value: undefined };
267
+ if (typeof v !== "number" || !Number.isFinite(v))
268
+ return { error: `Parameter "${key}" must be a finite number.` };
269
+ return { value: v };
270
+ }
271
+ const BUTTONS = ["left", "right", "middle"];
272
+ /** Build the 8 desktop tools supported by the adapter's capabilities. */
273
+ export function createDesktopTools(deps) {
274
+ const caps = new Set(deps.adapter.capabilities);
275
+ const tools = [];
276
+ const has = (k) => caps.has(k);
277
+ if (has("mouse_move")) {
278
+ tools.push(actionTool({
279
+ name: "desktop_mouse_move",
280
+ kind: "mouse_move",
281
+ label: "Desktop Mouse Move",
282
+ description: "Move the mouse cursor to absolute screen coordinates (x, y).",
283
+ parameters: {
284
+ x: numberParam("Absolute X screen coordinate."),
285
+ y: numberParam("Absolute Y screen coordinate."),
286
+ },
287
+ }, (params) => {
288
+ const x = requireNumber(params, "x");
289
+ if ("error" in x)
290
+ return x;
291
+ const y = requireNumber(params, "y");
292
+ if ("error" in y)
293
+ return y;
294
+ return { action: { kind: "mouse_move", x: x.value, y: y.value } };
295
+ }, deps));
296
+ }
297
+ if (has("mouse_click")) {
298
+ tools.push(actionTool({
299
+ name: "desktop_mouse_click",
300
+ kind: "mouse_click",
301
+ label: "Desktop Mouse Click",
302
+ description: "Click the mouse at absolute screen coordinates. Moves the cursor there first. " +
303
+ "Use clickCount=2 for double-click. Use list_windows + desktop_screenshot to locate targets before clicking.",
304
+ parameters: {
305
+ x: numberParam("Absolute X screen coordinate."),
306
+ y: numberParam("Absolute Y screen coordinate."),
307
+ button: { type: "string", enum: [...BUTTONS], description: "Mouse button. Default: left." },
308
+ clickCount: numberParam("1=single, 2=double, 3=triple. Default: 1."),
309
+ },
310
+ }, (params) => {
311
+ const x = requireNumber(params, "x");
312
+ if ("error" in x)
313
+ return x;
314
+ const y = requireNumber(params, "y");
315
+ if ("error" in y)
316
+ return y;
317
+ const button = params.button === undefined ? "left" : params.button;
318
+ if (!BUTTONS.includes(button)) {
319
+ return { error: `Parameter "button" must be one of ${BUTTONS.join(", ")}.` };
320
+ }
321
+ let clickCount = 1;
322
+ if (params.clickCount !== undefined) {
323
+ if (typeof params.clickCount !== "number" || !Number.isInteger(params.clickCount) || params.clickCount < 1 || params.clickCount > 3) {
324
+ return { error: 'Parameter "clickCount" must be an integer between 1 and 3.' };
325
+ }
326
+ clickCount = params.clickCount;
327
+ }
328
+ return {
329
+ action: {
330
+ kind: "mouse_click",
331
+ x: x.value,
332
+ y: y.value,
333
+ button: button,
334
+ clickCount,
335
+ },
336
+ };
337
+ }, deps));
338
+ }
339
+ if (has("mouse_drag")) {
340
+ tools.push(actionTool({
341
+ name: "desktop_mouse_drag",
342
+ kind: "mouse_drag",
343
+ label: "Desktop Mouse Drag",
344
+ description: "Press the left button at (fromX, fromY), drag to (toX, toY), release.",
345
+ parameters: {
346
+ fromX: numberParam("X coordinate to press at."),
347
+ fromY: numberParam("Y coordinate to press at."),
348
+ toX: numberParam("X coordinate to release at."),
349
+ toY: numberParam("Y coordinate to release at."),
350
+ },
351
+ }, (params) => {
352
+ const coords = [];
353
+ for (const key of ["fromX", "fromY", "toX", "toY"]) {
354
+ const v = requireNumber(params, key);
355
+ if ("error" in v)
356
+ return v;
357
+ coords.push(v.value);
358
+ }
359
+ return { action: { kind: "mouse_drag", fromX: coords[0], fromY: coords[1], toX: coords[2], toY: coords[3] } };
360
+ }, deps));
361
+ }
362
+ if (has("scroll")) {
363
+ tools.push(actionTool({
364
+ name: "desktop_scroll",
365
+ kind: "scroll",
366
+ label: "Desktop Scroll",
367
+ description: "Scroll the wheel. dy is vertical lines (positive = down, negative = up), dx is horizontal lines (positive = right, negative = left). " +
368
+ "Optionally move the cursor to (x, y) first.",
369
+ parameters: {
370
+ dy: numberParam("Vertical lines to scroll. Positive = down, negative = up."),
371
+ dx: numberParam("Horizontal lines to scroll. Positive = right, negative = left. Optional."),
372
+ x: numberParam("Optional X coordinate to move the cursor to first."),
373
+ y: numberParam("Optional Y coordinate to move the cursor to first."),
374
+ },
375
+ }, (params) => {
376
+ const dy = optionalNumber(params, "dy");
377
+ if ("error" in dy)
378
+ return dy;
379
+ const dx = optionalNumber(params, "dx");
380
+ if ("error" in dx)
381
+ return dx;
382
+ if (dy.value === undefined && dx.value === undefined) {
383
+ return { error: 'Provide "dy" (vertical lines, +down/-up) and/or "dx" (horizontal, +right/-left).' };
384
+ }
385
+ const x = optionalNumber(params, "x");
386
+ if ("error" in x)
387
+ return x;
388
+ const y = optionalNumber(params, "y");
389
+ if ("error" in y)
390
+ return y;
391
+ const hasX = x.value !== undefined;
392
+ const hasY = y.value !== undefined;
393
+ if (hasX !== hasY) {
394
+ return { error: 'Provide both "x" and "y", or neither.' };
395
+ }
396
+ return {
397
+ action: {
398
+ kind: "scroll",
399
+ dx: clampScrollLines(dx.value ?? 0),
400
+ dy: clampScrollLines(dy.value ?? 0),
401
+ ...(x.value !== undefined && y.value !== undefined ? { x: x.value, y: y.value } : {}),
402
+ },
403
+ };
404
+ }, deps));
405
+ }
406
+ if (has("type_text")) {
407
+ tools.push(actionTool({
408
+ name: "desktop_type_text",
409
+ kind: "type_text",
410
+ label: "Desktop Type Text",
411
+ description: "Type text into the currently focused window as if on a keyboard. Newlines become Return presses. " +
412
+ "Focus the right window first (desktop_focus_window or a click).",
413
+ parameters: {
414
+ text: { type: "string", description: "Text to type (unicode supported; sent as keyboard events)." },
415
+ },
416
+ }, (params) => {
417
+ const text = params.text;
418
+ if (typeof text !== "string" || text.length === 0)
419
+ return { error: 'Parameter "text" must be a non-empty string.' };
420
+ if (text.length > 5000)
421
+ return { error: 'Parameter "text" is too long (max 5000 chars); split into chunks.' };
422
+ return { action: { kind: "type_text", text } };
423
+ }, deps));
424
+ }
425
+ if (has("hotkey")) {
426
+ tools.push(actionTool({
427
+ name: "desktop_press_key",
428
+ kind: "hotkey",
429
+ label: "Desktop Press Key",
430
+ description: "Press a single key or chord, e.g. \"enter\", \"esc\", \"cmd+c\", \"ctrl+shift+t\". " +
431
+ "Modifier aliases: cmd/command/meta/super/win, ctrl/control, alt/option, shift.",
432
+ parameters: {
433
+ key: { type: "string", description: 'Key or chord, e.g. "return", "cmd+c", "ctrl+alt+delete".' },
434
+ },
435
+ }, (params) => {
436
+ const raw = params.key;
437
+ if (typeof raw !== "string" || raw.trim().length === 0)
438
+ return { error: 'Parameter "key" must be a non-empty string.' };
439
+ const chord = parseChord(raw);
440
+ if (!chord)
441
+ return { error: `Cannot parse key "${raw}". Use "mod+key" chords like "cmd+c" or a single key like "enter".` };
442
+ return { action: { kind: "hotkey", tokens: [...chord.modifiers, chord.key] } };
443
+ }, deps));
444
+ tools.push(actionTool({
445
+ name: "desktop_hotkey",
446
+ kind: "hotkey",
447
+ label: "Desktop Hotkey",
448
+ description: 'Press a hotkey combination from separate tokens, e.g. ["ctrl", "shift", "t"] or ["cmd", "c"]. ' +
449
+ "Exactly one non-modifier token is required. " +
450
+ "Modifier aliases: cmd/command/meta/super/win, ctrl/control, alt/option, shift.",
451
+ parameters: {
452
+ keys: {
453
+ type: "array",
454
+ items: { type: "string" },
455
+ description: 'Chord tokens, e.g. ["ctrl", "shift", "esc"].',
456
+ },
457
+ },
458
+ }, (params) => {
459
+ const keys = params.keys;
460
+ if (!Array.isArray(keys) || keys.length === 0 || !keys.every((k) => typeof k === "string" && k.trim().length > 0)) {
461
+ return { error: 'Parameter "keys" must be a non-empty array of strings, e.g. ["ctrl", "c"].' };
462
+ }
463
+ const tokens = [];
464
+ let keyToken = null;
465
+ for (const entry of keys) {
466
+ const chord = parseChord(entry);
467
+ if (!chord)
468
+ return { error: `Cannot parse key token "${entry}".` };
469
+ if (chord.modifiers.length > 0) {
470
+ return { error: `Token "${entry}" contains modifiers — pass each modifier as its own array entry (e.g. ["ctrl", "c"]).` };
471
+ }
472
+ if (isModifierToken(chord.key)) {
473
+ if (!tokens.includes(chord.key))
474
+ tokens.push(chord.key);
475
+ }
476
+ else if (!keyToken) {
477
+ keyToken = chord.key;
478
+ }
479
+ else {
480
+ return { error: `Multiple keys given (${keyToken}, ${chord.key}) — a hotkey supports exactly one key plus modifiers.` };
481
+ }
482
+ }
483
+ if (!keyToken)
484
+ return { error: 'Parameter "keys" must include exactly one non-modifier key.' };
485
+ tokens.push(keyToken);
486
+ return { action: { kind: "hotkey", tokens } };
487
+ }, deps));
488
+ }
489
+ if (has("focus_window")) {
490
+ tools.push({
491
+ name: "desktop_focus_window",
492
+ label: "Desktop Focus Window",
493
+ description: "Bring an application window to the foreground by title (case-insensitive substring match from list_windows). " +
494
+ SAFETY_DOC,
495
+ promptSnippet: "`desktop_focus_window` — bring a window to the foreground (desktop input injection, safety-gated)",
496
+ executionMode: "sequential",
497
+ parameters: {
498
+ type: "object",
499
+ properties: {
500
+ title: { type: "string", description: "Window title (substring, case-insensitive; from list_windows)." },
501
+ },
502
+ },
503
+ async execute(_toolCallId, _params) {
504
+ const params = _params;
505
+ const title = params.title;
506
+ if (typeof title !== "string" || title.trim().length === 0) {
507
+ return errorResult('Parameter "title" must be a non-empty string.', { tool: "desktop_focus_window" });
508
+ }
509
+ const listWindowsFn = deps.listWindowsFn ?? listWindows;
510
+ let windows;
511
+ try {
512
+ windows = await listWindowsFn();
513
+ }
514
+ catch (err) {
515
+ return errorResult(`Cannot list windows: ${err instanceof Error ? err.message : String(err)}`, { tool: "desktop_focus_window" });
516
+ }
517
+ const search = title.toLowerCase();
518
+ const match = windows.find((w) => w.title.toLowerCase().includes(search))
519
+ ?? windows.find((w) => w.owner.toLowerCase().includes(search));
520
+ if (!match) {
521
+ return errorResult(`No window matching "${title}" found.`, { windowList: formatWindowList(windows, 25) });
522
+ }
523
+ return runMutating({ kind: "focus_window", title: match.title, owner: match.owner, windowId: match.id }, deps);
524
+ },
525
+ });
526
+ }
527
+ return tools;
528
+ }
529
+ // ---------------------------------------------------------------------------
530
+ // Extension entry
531
+ // ---------------------------------------------------------------------------
532
+ export default async function desktopControlExtension(ext) {
533
+ const { adapter, reason } = await createAdapter();
534
+ if (!adapter) {
535
+ process.stderr.write(`[desktop-control] Not available: ${reason}\n`);
536
+ return;
537
+ }
538
+ const deps = { adapter };
539
+ const policy = deps.policy ?? new DesktopActionPolicy();
540
+ const tools = createDesktopTools(deps);
541
+ for (const tool of tools)
542
+ ext.registerTool(tool);
543
+ ext.on("tool_call", async (event, ctx) => {
544
+ if (!isDesktopControlTool(event.toolName))
545
+ return;
546
+ if (isDryRun())
547
+ return; // dry-run executes nothing, no gate needed
548
+ if (!isRequireConfirm())
549
+ return; // default: enabling the extension IS the consent — no prompt, no block
550
+ const decision = await policy.authorize({
551
+ dryRun: false,
552
+ hasUI: ctx.hasUI,
553
+ actionSummary: summarizeInput(event.toolName, event.input),
554
+ confirm: (title, message) => confirmWithOutcome(ctx, title, message),
555
+ });
556
+ if (!decision.allowed) {
557
+ return { block: true, reason: decision.reason };
558
+ }
559
+ });
560
+ process.stderr.write(`[desktop-control] Registered ${tools.map((t) => t.name).join(", ")} (platform: ${adapter.platform}${isDryRun() ? ", DRY-RUN" : ""}${isRequireConfirm() ? ", opt-in confirm" : ", no confirm prompt"}).\n`);
561
+ }
562
+ /**
563
+ * Run `ctx.ui.confirm` and translate the dialog outcome into the rich
564
+ * ConfirmResult contract used by DesktopActionPolicy:
565
+ * - "deny" (a human answered No in an interactive dialog) -> explicit user
566
+ * denial, which locks the session;
567
+ * - "timeout" / "no_tty" / "cancelled" -> auto-deny for the current action
568
+ * only (no user decision was made, so the session must not be locked);
569
+ * - modes that never report an outcome: a plain `false` stays a user denial.
570
+ *
571
+ * A per-dialog timeout (SPECTRAL_CONFIRM_TIMEOUT_MS, default 5 min) is passed
572
+ * explicitly so an unresponsive RPC client cannot hang the dialog forever.
573
+ *
574
+ * Only used in opt-in confirm mode (SPECTRAL_DESKTOP_REQUIRE_CONFIRM=1|true);
575
+ * the default flow never reaches the confirmation dialog.
576
+ */
577
+ async function confirmWithOutcome(ctx, title, message) {
578
+ const timeoutMs = parseConfirmTimeoutMs();
579
+ let outcome;
580
+ try {
581
+ const ok = await ctx.ui.confirm(title, message, {
582
+ timeout: timeoutMs,
583
+ onOutcome: (o) => {
584
+ outcome = o;
585
+ },
586
+ });
587
+ if (ok)
588
+ return { allowed: true };
589
+ switch (outcome) {
590
+ case "timeout":
591
+ return { allowed: false, failure: { kind: "timeout", timeoutMs } };
592
+ case "no_tty":
593
+ return { allowed: false, failure: { kind: "no_tty" } };
594
+ case "cancelled":
595
+ return { allowed: false, failure: { kind: "cancelled" } };
596
+ default:
597
+ // "deny" (explicit human refusal) or unreported outcome.
598
+ return false;
599
+ }
600
+ }
601
+ catch {
602
+ // Confirmation infrastructure failed (e.g. RPC channel closed): fail
603
+ // closed for this action without a user decision — do not lock.
604
+ return { allowed: false, failure: { kind: "cancelled" } };
605
+ }
606
+ }
607
+ /** Best-effort summary of raw tool input for the confirmation dialog. */
608
+ function summarizeInput(toolName, input) {
609
+ const brief = (v) => {
610
+ const s = String(v ?? "");
611
+ return s.length > 60 ? s.slice(0, 59) + "…" : s;
612
+ };
613
+ switch (toolName) {
614
+ case "desktop_mouse_move":
615
+ return `move mouse to (${brief(input.x)}, ${brief(input.y)})`;
616
+ case "desktop_mouse_click":
617
+ return `click ${brief(input.button ?? "left")} at (${brief(input.x)}, ${brief(input.y)})`;
618
+ case "desktop_mouse_drag":
619
+ return `drag from (${brief(input.fromX)}, ${brief(input.fromY)}) to (${brief(input.toX)}, ${brief(input.toY)})`;
620
+ case "desktop_scroll":
621
+ return `scroll dy=${brief(input.dy)} dx=${brief(input.dx)}`;
622
+ case "desktop_type_text":
623
+ return `type "${brief(input.text)}"`;
624
+ case "desktop_press_key":
625
+ return `press ${brief(input.key)}`;
626
+ case "desktop_hotkey":
627
+ return `press hotkey ${Array.isArray(input.keys) ? input.keys.join("+") : brief(input.keys)}`;
628
+ case "desktop_focus_window":
629
+ return `focus window "${brief(input.title)}"`;
630
+ default:
631
+ return toolName;
632
+ }
633
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Key name normalization + platform key tables.
3
+ *
4
+ * Chord notation: "cmd+c", "ctrl+shift+t", "enter", "alt+F4".
5
+ * Modifier aliases: cmd/command/meta/super/win -> "meta", option/alt -> "alt",
6
+ * control/ctrl -> "ctrl", plus the unicode glyphs ⌘ ⌥ ⇧ ⌃.
7
+ */
8
+ export type ModifierToken = "ctrl" | "shift" | "alt" | "meta";
9
+ /** macOS virtual keycodes for common keys (kVK_* from Events.h). */
10
+ export declare const MACOS_KEYCODES: Record<string, number>;
11
+ /** Shifted ASCII char -> base key (typed with FLAG_SHIFT). */
12
+ export declare const MACOS_SHIFTED: Record<string, string>;
13
+ /** CGEventFlagMask values. */
14
+ export declare const MACOS_MODIFIER_FLAGS: Record<ModifierToken, number>;
15
+ /** Windows virtual-key codes. */
16
+ export declare const WINDOWS_VK: Record<string, number>;
17
+ export declare const WINDOWS_MODIFIER_VK: Record<ModifierToken, number>;
18
+ /** X keysym names for xdotool/wtype. */
19
+ export declare const XDOTOOL_KEYS: Record<string, string>;
20
+ export declare const XDOTOOL_MODIFIERS: Record<ModifierToken, string>;
21
+ export declare function asModifier(raw: string): ModifierToken | null;
22
+ /** Normalize a key name; returns the input lowercased when unknown (callers decide what to do). */
23
+ export declare function normalizeKey(raw: string): string;
24
+ export declare function isModifierToken(raw: string): boolean;
25
+ /**
26
+ * Parse a chord like "cmd+shift+t" into modifiers + final key.
27
+ * Returns null for empty input or multiple non-modifier keys.
28
+ */
29
+ export declare function parseChord(raw: string): {
30
+ modifiers: ModifierToken[];
31
+ key: string;
32
+ } | null;
33
+ //# sourceMappingURL=keys.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../../../src/extensions/desktop-control/keys.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,OAAO,GAAG,KAAK,GAAG,MAAM,CAAC;AAuD9D,oEAAoE;AACpE,eAAO,MAAM,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAajD,CAAC;AAEF,8DAA8D;AAC9D,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAIhD,CAAC;AAOF,8BAA8B;AAC9B,eAAO,MAAM,oBAAoB,EAAE,MAAM,CAAC,aAAa,EAAE,MAAM,CAK9D,CAAC;AAEF,iCAAiC;AACjC,eAAO,MAAM,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAc7C,CAAC;AAEF,eAAO,MAAM,mBAAmB,EAAE,MAAM,CAAC,aAAa,EAAE,MAAM,CAK7D,CAAC;AAEF,wCAAwC;AACxC,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAQ/C,CAAC;AAEF,eAAO,MAAM,iBAAiB,EAAE,MAAM,CAAC,aAAa,EAAE,MAAM,CAE3D,CAAC;AAEF,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAE5D;AAED,mGAAmG;AACnG,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAOhD;AAED,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAEpD;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,SAAS,EAAE,aAAa,EAAE,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAiB1F"}