privateer-agent 0.12.29 → 0.12.31

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.
@@ -165,6 +165,35 @@ if (NO_QUARTER) {
165
165
  );
166
166
  }
167
167
 
168
+ // `--allow-computer-control` — arm GUI control (screenshots, mouse, keyboard) for this
169
+ // session. Stripped before Pi's cli.js sees it, exactly like --no-quarter above.
170
+ //
171
+ // A FLAG AND NOT A SETTING, deliberately. Screen control is the one capability that
172
+ // reaches outside every other limit the gate enforces — a mouse can open a terminal and
173
+ // type what the denylist would have caught — so it is armed per session, by the person
174
+ // starting it, rather than left on in a config file from a week ago. Arming is not
175
+ // approving: every action still prompts (permissions/mode.ts), and the tools do not
176
+ // exist at all without this (config/computerControl.ts).
177
+ const ALLOW_COMPUTER = args.some((a) => a === "--allow-computer-control");
178
+ if (ALLOW_COMPUTER) {
179
+ for (let i = args.length - 1; i >= 0; i--) if (args[i] === "--allow-computer-control") args.splice(i, 1);
180
+ process.env.PRIVATEER_COMPUTER_CONTROL = "1";
181
+ process.stderr.write(
182
+ [
183
+ "",
184
+ " ⚓ \x1b[1;33mScreen control armed\x1b[0m — this session may see your screen and move your mouse.",
185
+ " Every action still asks first. It can reach anything you can: other apps, other windows,",
186
+ " a browser you are signed into. Your OS will also ask for screen and accessibility permission.",
187
+ NO_QUARTER
188
+ ? " \x1b[1;31mNo quarter is ALSO on — screen actions will run with NO prompt.\x1b[0m"
189
+ : "",
190
+ "",
191
+ ]
192
+ .filter(Boolean)
193
+ .join("\n") + "\n",
194
+ );
195
+ }
196
+
168
197
  const sub = args[0];
169
198
 
170
199
  // `privateer --version` — report OUR version, not Pi's. Left to Pi's cli.js it would
@@ -615,6 +615,13 @@ export default function privateerBrand(pi: any): void {
615
615
  ctx?.ui?.notify?.(`Signed in. Run /models to pick a model — ${spec} isn't loaded yet.`, "warning");
616
616
  return;
617
617
  }
618
+ // Not persisted, on purpose. Every DELIBERATE switch now writes itself into Pi's
619
+ // settings.json (see writePiDefaultModel in providers/defaultModel.ts), but this
620
+ // one is ours, not the user's — we move them onto the confidential model because
621
+ // they signed in. Writing it would pin a BYO-keyed user to `privateer/…` for
622
+ // good, and hand them a model with no credential the day they log out. The check
623
+ // above already stays off a saved pick; this leaves the unsaved case resolving
624
+ // fresh every launch, which is what it did before signing in.
618
625
  for (let attempt = 0; attempt < 4; attempt++) {
619
626
  try {
620
627
  const ok = await pi.setModel(model);
@@ -0,0 +1,24 @@
1
+ // GUI control for Pi's TUI: see the screen, move the mouse, type.
2
+ //
3
+ // Registered ONLY when the machine has been armed — `privateer --allow-computer-control`,
4
+ // or the desktop's Screen control switch. Omitting the factory rather than hiding the
5
+ // tools is the same call privateer-media.ts makes for the same reason: a tool that
6
+ // exists and refuses every call teaches the model to keep retrying, where a tool that
7
+ // isn't there makes it say what the user would need to do and move on.
8
+ //
9
+ // A SUBAGENT CHILD NEVER GETS THESE, and unlike media there is no grant that lifts it.
10
+ // A child is a headless process with nobody to approve an action, and every computer
11
+ // action asks (permissions/mode.ts) — so the tools could only ever wedge on a prompt
12
+ // with no one to answer it. Media has childSpend.ts because a parent can meaningfully
13
+ // pre-authorize a bounded, billed call it named itself; there is no equivalent for
14
+ // "click wherever you decide to click", and inventing one would be inventing the
15
+ // unattended GUI agent this whole design is arranged to avoid.
16
+ import { makeComputerTools } from "../src/tools/computer.ts";
17
+ import { computerControlArmed } from "../src/config/computerControl.ts";
18
+ import { isSubagentChild } from "../src/remote/subagentRelay.ts";
19
+
20
+ export default function privateerComputer(pi: any): void {
21
+ if (!computerControlArmed()) return;
22
+ if (isSubagentChild()) return;
23
+ makeComputerTools()(pi);
24
+ }
@@ -23,24 +23,38 @@ import {
23
23
  } from "../src/context.ts";
24
24
 
25
25
  // Honor Pi's own "disable context files" switch, so --no-context-files / -nc silences
26
- // PRIVATEER.md too (not just AGENTS.md/CLAUDE.md) — otherwise the flag would half-work.
26
+ // everything this extension injects, not just PRIVATEER.md — otherwise the flag would
27
+ // half-work. A user who asks for a bare prompt gets a bare prompt.
27
28
  const CONTEXT_FILES_DISABLED =
28
29
  process.argv.includes("--no-context-files") || process.argv.includes("-nc");
29
30
 
30
31
  export default function privateerContext(pi: any): void {
31
- // Inject PRIVATEER.md into every turn's system prompt. The prompt is rebuilt per turn
32
- // and chained across before_agent_start handlers, so appending here is idempotent for
33
- // the turn; the marker guard makes it a no-op if an earlier handler already added it.
32
+ // Inject the runtime guidelines + PRIVATEER.md into every turn's system prompt. The
33
+ // prompt is rebuilt per turn and chained across before_agent_start handlers, so
34
+ // appending here is idempotent for the turn; each marker guard makes its own block a
35
+ // no-op if an earlier handler already added it.
36
+ //
37
+ // ONLY EVER APPEND, AND ONLY RETURN WHEN WE ADDED SOMETHING. Pi chains these handlers
38
+ // and a returned `systemPrompt` REPLACES what the chain has built so far, so both
39
+ // halves matter:
40
+ //
41
+ // • A host that doesn't populate `event.systemPrompt` must not be handed a prompt
42
+ // synthesised from "" — that gives back our two blocks as the ENTIRE system prompt
43
+ // and silently drops the real one. `typeof base !== "string"` is what separates
44
+ // "here is a prompt to chain onto" (possibly empty, legitimate) from "no field".
45
+ // • When both markers are already present (a re-entrant chain) we have nothing to
46
+ // contribute, and undefined leaves what is there alone.
34
47
  pi.on("before_agent_start", (event: any) => {
35
- let prompt: string = event?.systemPrompt ?? "";
36
- if (!prompt.includes(RUNTIME_GUIDELINES_MARKER)) {
37
- prompt += runtimeGuidelinesBlock();
38
- }
39
- if (!CONTEXT_FILES_DISABLED && !prompt.includes(CONTEXT_BLOCK_MARKER)) {
48
+ if (CONTEXT_FILES_DISABLED) return;
49
+ const base = event?.systemPrompt;
50
+ if (typeof base !== "string") return;
51
+ let prompt = base;
52
+ if (!prompt.includes(RUNTIME_GUIDELINES_MARKER)) prompt += runtimeGuidelinesBlock();
53
+ if (!prompt.includes(CONTEXT_BLOCK_MARKER)) {
40
54
  const cwd = event?.systemPromptOptions?.cwd ?? process.cwd();
41
- const block = contextBlock(cwd);
42
- if (block) prompt += block;
55
+ prompt += contextBlock(cwd); // "" when there is no PRIVATEER.md anywhere
43
56
  }
57
+ if (prompt === base) return; // nothing to add — leave the chain alone
44
58
  return { systemPrompt: prompt };
45
59
  });
46
60
 
@@ -0,0 +1,536 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * privateer-computer (Linux) — the Linux screen-control helper.
4
+ *
5
+ * Same newline-delimited JSON protocol as the macOS and Windows helpers; the client is
6
+ * src/computer/helper.ts and the coordinate contract is src/computer/space.ts. Read that
7
+ * file before changing anything here.
8
+ *
9
+ * ── Why this one is a script and not a compiled binary ───────────────────────
10
+ *
11
+ * There is no Linux equivalent of CGEvent or SendInput — no single system API that every
12
+ * desktop implements. X11 has XTEST, Wayland deliberately has nothing (a client cannot
13
+ * see or synthesise input outside its own surface, which is a security property, not an
14
+ * oversight). What exists instead is a set of small, standard command-line tools that
15
+ * already hold the necessary privileges. Shelling out to those is not a shortcut around
16
+ * writing a real helper; it IS the Linux way to do this, and it means no compiler, no
17
+ * per-distro binary, and nothing to keep signed.
18
+ *
19
+ * The cost is that they must be installed, so every one is PROBED and the absence is
20
+ * reported as a sentence naming the package — the posture the sprite pipeline's ffmpeg
21
+ * probe already takes. A missing tool must never surface as a spawn error at the moment
22
+ * the user asks for something.
23
+ *
24
+ * ── What is supported, honestly ──────────────────────────────────────────────
25
+ *
26
+ * X11 — fully. xrandr to enumerate, xdotool for input, and any of
27
+ * ImageMagick / maim / ffmpeg to capture.
28
+ * Wayland — wlroots compositors only (Sway, Hyprland, river): wlr-randr + grim +
29
+ * ydotool. GNOME and KDE on Wayland expose no generic screenshot or input
30
+ * CLI at all — everything goes through xdg-desktop-portal, which is an
31
+ * interactive, per-request consent flow that cannot serve an agent loop.
32
+ * Those sessions are told exactly that rather than being left to fail.
33
+ *
34
+ * ── The exact-size rule ──────────────────────────────────────────────────────
35
+ *
36
+ * A capture MUST come back at exactly the requested size, because that size defines the
37
+ * coordinate space the model is given. Unlike CoreGraphics and System.Drawing, nothing
38
+ * here resizes natively — so when no resizer is installed the capture is REFUSED. It is
39
+ * tempting to return the native frame instead; that would put agent space and the
40
+ * picture into silent disagreement and every click would be off by the reduction factor.
41
+ */
42
+
43
+ import { spawnSync } from 'node:child_process';
44
+
45
+ // ─── Frames ──────────────────────────────────────────────────────────────────
46
+ // stdout carries protocol frames ONLY; diagnostics go to stderr, or the client's line
47
+ // reader desynchronises.
48
+
49
+ function emit(obj) {
50
+ process.stdout.write(`${JSON.stringify(obj)}\n`);
51
+ }
52
+ const ok = (id, fields = {}) => emit({ id: id ?? null, ok: true, ...fields });
53
+ const fail = (id, error) => emit({ id: id ?? null, ok: false, error });
54
+
55
+ // ─── Tool discovery ──────────────────────────────────────────────────────────
56
+
57
+ const isWayland = !!process.env.WAYLAND_DISPLAY || process.env.XDG_SESSION_TYPE === 'wayland';
58
+
59
+ /** Is `bin` on PATH? Cached per process — PATH does not change under us. */
60
+ const haveCache = new Map();
61
+ function have(bin) {
62
+ if (!haveCache.has(bin)) {
63
+ const r = spawnSync('sh', ['-c', `command -v ${bin} >/dev/null 2>&1`]);
64
+ haveCache.set(bin, r.status === 0);
65
+ }
66
+ return haveCache.get(bin);
67
+ }
68
+
69
+ function run(cmd, args, opts = {}) {
70
+ return spawnSync(cmd, args, { maxBuffer: 128 * 1024 * 1024, ...opts });
71
+ }
72
+
73
+ /** The ImageMagick entry point, which changed name in v7. */
74
+ function magick() {
75
+ if (have('magick')) return ['magick'];
76
+ if (have('convert')) return ['convert'];
77
+ return null;
78
+ }
79
+
80
+ // ─── Displays ────────────────────────────────────────────────────────────────
81
+
82
+ /**
83
+ * X11: `xrandr --listmonitors` is the right source rather than `xrandr --query`, because
84
+ * it reports the MONITOR layout (what the user sees as screens, including any the
85
+ * compositor has combined) with position, in one stable line each:
86
+ *
87
+ * 0: +*eDP-1 1920/344x1080/193+0+0 eDP-1
88
+ *
89
+ * The `*` marks the primary. The `/344` and `/193` are physical millimetres and are
90
+ * deliberately ignored — they are the panel's size, not its pixels.
91
+ */
92
+ function x11Displays() {
93
+ const r = run('xrandr', ['--listmonitors']);
94
+ if (r.status !== 0) return [];
95
+ const out = [];
96
+ for (const line of String(r.stdout).split('\n')) {
97
+ const m = line.match(/^\s*\d+:\s+\+(\*?)(\S+)\s+(\d+)\/\d+x(\d+)\/\d+\+(-?\d+)\+(-?\d+)/);
98
+ if (!m) continue;
99
+ out.push({
100
+ id: m[2],
101
+ label: m[2],
102
+ width: Number(m[3]),
103
+ height: Number(m[4]),
104
+ // X11 hands out one flat pixel grid; there is no per-monitor scale factor in the
105
+ // coordinate space xdotool works in, so this is always 1 and the agent never
106
+ // converts through it.
107
+ scale: 1,
108
+ originX: Number(m[5]),
109
+ originY: Number(m[6]),
110
+ primary: m[1] === '*',
111
+ });
112
+ }
113
+ return out;
114
+ }
115
+
116
+ /**
117
+ * Wayland (wlroots): `wlr-randr --json` where available, falling back to its text form.
118
+ * Both report logical size and scale; the agent's device pixels are logical x scale,
119
+ * which is what grim writes.
120
+ */
121
+ function waylandDisplays() {
122
+ if (!have('wlr-randr')) return [];
123
+ const asJson = run('wlr-randr', ['--json']);
124
+ if (asJson.status === 0) {
125
+ try {
126
+ const parsed = JSON.parse(String(asJson.stdout));
127
+ return parsed
128
+ .filter((o) => o.enabled !== false)
129
+ .map((o, i) => {
130
+ const mode = (o.modes || []).find((m) => m.current) || {};
131
+ const scale = Number(o.scale) || 1;
132
+ return {
133
+ id: o.name,
134
+ label: o.description || o.name,
135
+ width: Math.round(Number(mode.width) || 0),
136
+ height: Math.round(Number(mode.height) || 0),
137
+ scale,
138
+ originX: Math.round(Number(o.position?.x) || 0),
139
+ originY: Math.round(Number(o.position?.y) || 0),
140
+ primary: i === 0,
141
+ };
142
+ })
143
+ .filter((d) => d.width > 0 && d.height > 0);
144
+ } catch {
145
+ /* fall through to the text parser */
146
+ }
147
+ }
148
+
149
+ // Text form: an output name at column 0, then indented fields until the next name.
150
+ const r = run('wlr-randr', []);
151
+ if (r.status !== 0) return [];
152
+ const out = [];
153
+ let cur = null;
154
+ for (const line of String(r.stdout).split('\n')) {
155
+ const head = line.match(/^(\S+)\s+"(.*)"/);
156
+ if (head) {
157
+ if (cur) out.push(cur);
158
+ cur = { id: head[1], label: head[2] || head[1], width: 0, height: 0, scale: 1, originX: 0, originY: 0, primary: out.length === 0 };
159
+ continue;
160
+ }
161
+ if (!cur) continue;
162
+ const pos = line.match(/Position:\s*(-?\d+),(-?\d+)/);
163
+ if (pos) { cur.originX = Number(pos[1]); cur.originY = Number(pos[2]); }
164
+ const scale = line.match(/Scale:\s*([\d.]+)/);
165
+ if (scale) cur.scale = Number(scale[1]) || 1;
166
+ const mode = line.match(/(\d+)x(\d+)\s+px.*current/);
167
+ if (mode) { cur.width = Number(mode[1]); cur.height = Number(mode[2]); }
168
+ }
169
+ if (cur) out.push(cur);
170
+ return out.filter((d) => d.width > 0 && d.height > 0);
171
+ }
172
+
173
+ function displays() {
174
+ return isWayland ? waylandDisplays() : x11Displays();
175
+ }
176
+
177
+ function findDisplay(id) {
178
+ const all = displays();
179
+ if (!id) return all.find((d) => d.primary) ?? all[0];
180
+ return all.find((d) => d.id === id);
181
+ }
182
+
183
+ // ─── Grants ──────────────────────────────────────────────────────────────────
184
+ //
185
+ // Linux has no TCC. "Granted" here means the tools that do the work are actually
186
+ // present — which is the same question from the user's point of view (can it act?) and
187
+ // the only one with an answer we can give honestly.
188
+
189
+ function screenTool() {
190
+ if (isWayland) return have('grim') ? 'grim' : null;
191
+ if (have('import')) return 'import';
192
+ if (have('maim')) return 'maim';
193
+ if (have('ffmpeg')) return 'ffmpeg';
194
+ return null;
195
+ }
196
+
197
+ function inputTool() {
198
+ if (isWayland) return have('ydotool') ? 'ydotool' : null;
199
+ return have('xdotool') ? 'xdotool' : null;
200
+ }
201
+
202
+ function grants() {
203
+ return {
204
+ screen: !!screenTool() && !!magick(),
205
+ input: !!inputTool(),
206
+ // No Linux equivalent of macOS secure input: nothing tells a client that a password
207
+ // field has focus. Reported false rather than guessed.
208
+ secureInput: false,
209
+ frontmostPid: frontmostPid(),
210
+ };
211
+ }
212
+
213
+ /**
214
+ * The pid of the focused window's process, for the caller's refuse-to-click-our-own-
215
+ * window interlock. X11 only — Wayland gives a client no way to ask what else is
216
+ * focused, which is the point of Wayland. Absent rather than wrong.
217
+ */
218
+ function frontmostPid() {
219
+ if (isWayland || !have('xdotool')) return undefined;
220
+ const r = run('xdotool', ['getactivewindow', 'getwindowpid']);
221
+ if (r.status !== 0) return undefined;
222
+ const pid = Number(String(r.stdout).trim());
223
+ return Number.isFinite(pid) && pid > 0 ? pid : undefined;
224
+ }
225
+
226
+ /** Why screen control cannot work here, as a sentence naming the fix. */
227
+ function missingReason() {
228
+ if (isWayland && !have('grim')) {
229
+ return (
230
+ 'This is a Wayland session with no grim. Screen control on Wayland is supported on ' +
231
+ 'wlroots compositors (Sway, Hyprland, river) — install grim, wlr-randr and ydotool. ' +
232
+ 'GNOME and KDE on Wayland expose no screenshot or input command at all (everything ' +
233
+ 'goes through xdg-desktop-portal, which asks the user per request), so an X11 ' +
234
+ 'session is the only option there.'
235
+ );
236
+ }
237
+ if (!screenTool()) return 'No screen capture tool. Install ImageMagick, maim or ffmpeg.';
238
+ if (!magick()) return 'No image resizer. Install ImageMagick (it provides `magick`, or `convert` on v6).';
239
+ if (!inputTool()) {
240
+ return isWayland
241
+ ? 'No ydotool, so the mouse and keyboard cannot be driven. Install ydotool and start ydotoold.'
242
+ : 'No xdotool, so the mouse and keyboard cannot be driven. Install xdotool.';
243
+ }
244
+ return null;
245
+ }
246
+
247
+ // ─── Capture ─────────────────────────────────────────────────────────────────
248
+
249
+ /** The monitor's pixels as a PNG, at native size, on stdout. */
250
+ function captureRaw(display) {
251
+ const geom = `${display.width}x${display.height}+${display.originX}+${display.originY}`;
252
+ if (isWayland) {
253
+ const r = run('grim', ['-o', display.id, '-']);
254
+ return r.status === 0 ? r.stdout : null;
255
+ }
256
+ const tool = screenTool();
257
+ if (tool === 'import') {
258
+ // +repage discards the crop's virtual canvas offset. Without it the resize below
259
+ // sees a canvas the size of the whole desktop with the crop placed inside it, and
260
+ // pads the output — so the picture would be right and its geometry wrong.
261
+ const r = run('import', ['-window', 'root', '-crop', geom, '+repage', 'png:-']);
262
+ return r.status === 0 ? r.stdout : null;
263
+ }
264
+ if (tool === 'maim') {
265
+ const r = run('maim', ['-g', geom, '-f', 'png', '/dev/stdout']);
266
+ return r.status === 0 ? r.stdout : null;
267
+ }
268
+ if (tool === 'ffmpeg') {
269
+ const r = run('ffmpeg', [
270
+ '-loglevel', 'error', '-f', 'x11grab',
271
+ '-video_size', `${display.width}x${display.height}`,
272
+ '-i', `${process.env.DISPLAY || ':0'}+${display.originX},${display.originY}`,
273
+ '-frames:v', '1', '-f', 'image2pipe', '-vcodec', 'png', '-',
274
+ ]);
275
+ return r.status === 0 ? r.stdout : null;
276
+ }
277
+ return null;
278
+ }
279
+
280
+ /**
281
+ * Resize to EXACTLY w x h. The `!` suffix is what makes it exact — without it
282
+ * ImageMagick preserves the aspect ratio and returns something a pixel or two off,
283
+ * which is precisely the silent disagreement the size contract exists to prevent.
284
+ * (The plan's aspect already matches the display's, so nothing is distorted; `!` only
285
+ * removes ImageMagick's rounding.)
286
+ */
287
+ function resizeTo(png, w, h) {
288
+ const mk = magick();
289
+ if (!mk) return null;
290
+ const r = run(mk[0], [...mk.slice(1), 'png:-', '-resize', `${w}x${h}!`, 'png:-'], { input: png });
291
+ return r.status === 0 ? r.stdout : null;
292
+ }
293
+
294
+ // ─── Input ───────────────────────────────────────────────────────────────────
295
+ //
296
+ // Display-local pixels → the global desktop, the one conversion this file makes. On X11
297
+ // that global space is what xdotool takes directly. ydotool likewise works in absolute
298
+ // desktop coordinates.
299
+
300
+ const BUTTON = { left: 1, middle: 2, right: 3 };
301
+
302
+ function xdo(args) {
303
+ const r = run('xdotool', args);
304
+ if (r.status !== 0) throw new Error(String(r.stderr || 'xdotool failed').trim());
305
+ }
306
+
307
+ function ydo(args) {
308
+ const r = run('ydotool', args);
309
+ if (r.status !== 0) {
310
+ const err = String(r.stderr || '').trim();
311
+ // The overwhelmingly common failure, and one whose bare message ("failed to open
312
+ // /dev/uinput") sends people to the wrong place.
313
+ if (/uinput|permission|socket/i.test(err)) {
314
+ throw new Error('ydotool cannot reach its daemon — start ydotoold and make sure your user can use /dev/uinput.');
315
+ }
316
+ throw new Error(err || 'ydotool failed');
317
+ }
318
+ }
319
+
320
+ function pointer(req, display) {
321
+ const gx = display.originX + Number(req.x || 0);
322
+ const gy = display.originY + Number(req.y || 0);
323
+ const button = BUTTON[req.button] || 1;
324
+
325
+ if (isWayland) {
326
+ ydo(['mousemove', '--absolute', '-x', String(gx), '-y', String(gy)]);
327
+ switch (req.action) {
328
+ case 'move': return;
329
+ // ydotool's click codes are a bitfield: 0x00-0x02 select the button, 0x40 is
330
+ // press and 0x80 is release, so 0xC0|n is a full click.
331
+ case 'click': return ydo(['click', `0x${(0xc0 | (button - 1)).toString(16)}`]);
332
+ case 'double_click':
333
+ ydo(['click', `0x${(0xc0 | (button - 1)).toString(16)}`]);
334
+ return ydo(['click', `0x${(0xc0 | (button - 1)).toString(16)}`]);
335
+ case 'drag': {
336
+ ydo(['click', `0x${(0x40 | (button - 1)).toString(16)}`]);
337
+ const tx = display.originX + Number(req.toX || 0);
338
+ const ty = display.originY + Number(req.toY || 0);
339
+ for (let i = 1; i <= 12; i++) {
340
+ const t = i / 12;
341
+ ydo(['mousemove', '--absolute', '-x', String(Math.round(gx + (tx - gx) * t)), '-y', String(Math.round(gy + (ty - gy) * t))]);
342
+ }
343
+ return ydo(['click', `0x${(0x80 | (button - 1)).toString(16)}`]);
344
+ }
345
+ case 'scroll': {
346
+ const dy = Number(req.scrollY || 0);
347
+ const dx = Number(req.scrollX || 0);
348
+ // ydotool's wheel is positive-up, and the tool's contract is negative-up.
349
+ if (dy) ydo(['mousemove', '--wheel', '-y', String(-dy)]);
350
+ if (dx) ydo(['mousemove', '--wheel', '-x', String(dx)]);
351
+ return;
352
+ }
353
+ default: throw new Error(`unknown pointer action "${req.action}"`);
354
+ }
355
+ }
356
+
357
+ xdo(['mousemove', String(gx), String(gy)]);
358
+ switch (req.action) {
359
+ case 'move': return;
360
+ case 'click': return xdo(['click', String(button)]);
361
+ case 'double_click': return xdo(['click', '--repeat', '2', String(button)]);
362
+ case 'drag': {
363
+ const tx = display.originX + Number(req.toX || 0);
364
+ const ty = display.originY + Number(req.toY || 0);
365
+ xdo(['mousedown', String(button)]);
366
+ // Interpolated for the same reason as the other two helpers: a press and release
367
+ // at the destination is ignored by anything that starts its gesture on the first
368
+ // motion event — drag-and-drop, sliders, text selection.
369
+ for (let i = 1; i <= 12; i++) {
370
+ const t = i / 12;
371
+ xdo(['mousemove', String(Math.round(gx + (tx - gx) * t)), String(Math.round(gy + (ty - gy) * t))]);
372
+ }
373
+ return xdo(['mouseup', String(button)]);
374
+ }
375
+ case 'scroll': {
376
+ const dy = Number(req.scrollY || 0);
377
+ const dx = Number(req.scrollX || 0);
378
+ // X11 wheel buttons: 4 up, 5 down, 6 left, 7 right. Negative scrolls UP.
379
+ if (dy) xdo(['click', '--repeat', String(Math.abs(dy)), dy > 0 ? '5' : '4']);
380
+ if (dx) xdo(['click', '--repeat', String(Math.abs(dx)), dx > 0 ? '7' : '6']);
381
+ return;
382
+ }
383
+ default: throw new Error(`unknown pointer action "${req.action}"`);
384
+ }
385
+ }
386
+
387
+ /** Chord names → the spellings xdotool's `key` understands. */
388
+ const XDO_KEYS = {
389
+ return: 'Return', enter: 'Return', tab: 'Tab', space: 'space',
390
+ backspace: 'BackSpace', delete: 'Delete', forwarddelete: 'Delete',
391
+ escape: 'Escape', esc: 'Escape',
392
+ left: 'Left', right: 'Right', up: 'Up', down: 'Down',
393
+ home: 'Home', end: 'End', pageup: 'Prior', pagedown: 'Next',
394
+ };
395
+ const XDO_MODS = {
396
+ ctrl: 'ctrl', control: 'ctrl',
397
+ alt: 'alt', option: 'alt', opt: 'alt',
398
+ shift: 'shift',
399
+ // super is the Linux equivalent of cmd; accepted so a model that learned "cmd+s"
400
+ // elsewhere does not silently send nothing.
401
+ cmd: 'super', command: 'super', meta: 'super', super: 'super', win: 'super',
402
+ };
403
+
404
+ function keyAction(req) {
405
+ if (req.action === 'type') {
406
+ const text = String(req.text ?? '');
407
+ if (!text) return;
408
+ if (isWayland) return ydo(['type', text]);
409
+ // --clearmodifiers so a modifier the user is physically holding does not turn the
410
+ // typed text into a stream of shortcuts.
411
+ return xdo(['type', '--clearmodifiers', '--delay', '6', text]);
412
+ }
413
+ if (req.action !== 'press') throw new Error('unknown key action');
414
+
415
+ const spec = String(req.keys ?? '').toLowerCase().trim();
416
+ if (!spec) throw new Error('empty key combination');
417
+ const parts = spec.split('+');
418
+ const last = parts[parts.length - 1];
419
+ const mods = [];
420
+ for (const p of parts.slice(0, -1)) {
421
+ if (!XDO_MODS[p]) throw new Error(`unknown modifier "${p}" in "${spec}"`);
422
+ mods.push(XDO_MODS[p]);
423
+ }
424
+ let key = XDO_KEYS[last];
425
+ if (!key && /^f\d{1,2}$/.test(last)) key = last.toUpperCase();
426
+ if (!key && last.length === 1) key = last;
427
+ if (!key) throw new Error(`unknown key "${last}" in "${spec}"`);
428
+
429
+ const combo = [...mods, key].join('+');
430
+ if (isWayland) return ydo(['key', combo]);
431
+ return xdo(['key', '--clearmodifiers', combo]);
432
+ }
433
+
434
+ // ─── Dispatch ────────────────────────────────────────────────────────────────
435
+
436
+ function handle(req) {
437
+ const id = req.id;
438
+ switch (req.op) {
439
+ case 'displays':
440
+ return ok(id, { displays: displays() });
441
+
442
+ case 'grants':
443
+ return ok(id, { grants: grants() });
444
+
445
+ case 'capture': {
446
+ const reason = missingReason();
447
+ if (reason && !screenTool()) return fail(id, reason);
448
+ const display = findDisplay(req.display);
449
+ if (!display) return fail(id, 'no such display');
450
+ if (!magick()) return fail(id, 'No image resizer. Install ImageMagick (it provides `magick`, or `convert` on v6).');
451
+ const raw = captureRaw(display);
452
+ if (!raw || !raw.length) return fail(id, 'the display could not be captured');
453
+ const w = Number(req.targetWidth) || display.width;
454
+ const h = Number(req.targetHeight) || display.height;
455
+ const scaled = resizeTo(raw, w, h);
456
+ // Refused rather than answered with the native frame — see the header.
457
+ if (!scaled || !scaled.length) return fail(id, 'the capture could not be resized to the requested size');
458
+
459
+ // A second, much smaller copy of the SAME grab for the permission dialog. Same
460
+ // grab deliberately: a separate capture would be a different moment, so someone
461
+ // could be approving a click against a screen the model never saw. Best-effort —
462
+ // the model's frame is already good and the dialog falls back to text.
463
+ const out = { mimeType: 'image/png', data: scaled.toString('base64'), width: w, height: h };
464
+ const pw = Number(req.previewWidth) || 0;
465
+ const ph = Number(req.previewHeight) || 0;
466
+ if (pw > 0 && ph > 0) {
467
+ const small = resizeTo(raw, pw, ph);
468
+ if (small && small.length) out.preview = small.toString('base64');
469
+ }
470
+ return ok(id, out);
471
+ }
472
+
473
+ case 'pointer': {
474
+ if (!inputTool()) return fail(id, missingReason() ?? 'no input tool');
475
+ const display = findDisplay(req.display);
476
+ if (!display) return fail(id, 'no such display');
477
+ pointer(req, display);
478
+ return ok(id);
479
+ }
480
+
481
+ case 'key': {
482
+ if (!inputTool()) return fail(id, missingReason() ?? 'no input tool');
483
+ keyAction(req);
484
+ return ok(id);
485
+ }
486
+
487
+ default:
488
+ return fail(id, `unknown op "${req.op}"`);
489
+ }
490
+ }
491
+
492
+ // ─── Entry ───────────────────────────────────────────────────────────────────
493
+
494
+ const argv = process.argv.slice(2);
495
+
496
+ if (argv.includes('--version')) {
497
+ process.stdout.write('privateer-computer 1\n');
498
+ process.exit(0);
499
+ }
500
+
501
+ if (argv.includes('--grants')) {
502
+ const reason = missingReason();
503
+ emit({ ok: true, grants: grants(), displays: displays(), ...(reason ? { reason } : {}) });
504
+ process.exit(0);
505
+ }
506
+
507
+ if (!argv.includes('--serve')) {
508
+ process.stderr.write('usage: privateer-computer.mjs --serve | --grants | --version\n');
509
+ process.exit(2);
510
+ }
511
+
512
+ let buffer = '';
513
+ process.stdin.setEncoding('utf8');
514
+ process.stdin.on('data', (chunk) => {
515
+ buffer += chunk;
516
+ let nl;
517
+ while ((nl = buffer.indexOf('\n')) >= 0) {
518
+ const line = buffer.slice(0, nl).trim();
519
+ buffer = buffer.slice(nl + 1);
520
+ if (!line) continue;
521
+ let req;
522
+ try {
523
+ req = JSON.parse(line);
524
+ } catch {
525
+ fail(null, 'malformed request');
526
+ continue;
527
+ }
528
+ try {
529
+ handle(req);
530
+ } catch (err) {
531
+ // One bad frame must not take the session's screen control with it.
532
+ fail(req?.id, err?.message ? String(err.message) : String(err));
533
+ }
534
+ }
535
+ });
536
+ process.stdin.on('end', () => process.exit(0));