privateer-agent 0.12.30 → 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.
@@ -0,0 +1,502 @@
1
+ <#
2
+ PrivateerComputer.ps1 — the Windows screen-control helper.
3
+
4
+ One long-lived process, spoken to in newline-delimited JSON on stdin/stdout. The client
5
+ is src/computer/helper.ts; the coordinate contract is src/computer/space.ts. Read that
6
+ file before changing anything here — the two halves must agree exactly or every click
7
+ lands wrong in a way nothing reports.
8
+
9
+ ── Why PowerShell and not a compiled .exe ───────────────────────────────────
10
+
11
+ Windows PowerShell 5.1 ships with every supported version of Windows, and `Add-Type`
12
+ compiles the P/Invoke surface below at startup. So this is a real Win32 helper with no
13
+ build step, no toolchain, no native npm module, and nothing to sign separately from the
14
+ app. The cost is ~300ms of one-time JIT on first launch, which the long-lived process
15
+ pays once rather than per action.
16
+
17
+ ── DPI IS THE WHOLE BALLGAME ON WINDOWS ─────────────────────────────────────
18
+
19
+ A process that has not declared DPI awareness is LIED TO by the operating system.
20
+ GetMonitorInfo returns virtualised 96-DPI coordinates, and CopyFromScreen returns a
21
+ stretched, blurry image of a smaller desktop. On a 150% display that is a 1.5x error in
22
+ every direction — and nothing reports it, because from inside the process the numbers
23
+ are self-consistent. So awareness is declared FIRST, before any screen call, and with a
24
+ three-step fallback because the good API is recent:
25
+
26
+ SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2) Win10 1703+ — per-monitor, correct
27
+ SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE) Win8.1+ — per-monitor
28
+ SetProcessDPIAware() Vista+ — system-wide only
29
+
30
+ Once per-monitor-aware, every rectangle Windows hands us is in PHYSICAL PIXELS, which is
31
+ exactly the space the agent's display-local device pixels live in. `scale` is then
32
+ reported for information only (the agent never converts through it on Windows).
33
+
34
+ ── UIPI, and what "input: true" does not promise ────────────────────────────
35
+
36
+ Windows has no consent gate for screen capture or synthetic input, so both grants are
37
+ reported true. But User Interface Privilege Isolation silently DISCARDS input sent from a
38
+ normal-integrity process to an elevated window — so a UAC prompt, or an app launched as
39
+ administrator, cannot be driven from here and the events vanish with no error. There is
40
+ no reliable way to detect that before the fact (querying the foreground process's token
41
+ usually fails with access denied, which is itself ambiguous), so it is not reported as a
42
+ grant. It is documented in the tool description instead, which is the honest place for a
43
+ limitation we cannot measure.
44
+ #>
45
+
46
+ param(
47
+ [switch]$Serve,
48
+ [switch]$Grants,
49
+ [switch]$Prompt,
50
+ [switch]$Version
51
+ )
52
+
53
+ $ErrorActionPreference = 'Stop'
54
+
55
+ Add-Type -ReferencedAssemblies System.Drawing -TypeDefinition @'
56
+ using System;
57
+ using System.Drawing;
58
+ using System.Drawing.Drawing2D;
59
+ using System.Drawing.Imaging;
60
+ using System.IO;
61
+ using System.Collections.Generic;
62
+ using System.Runtime.InteropServices;
63
+
64
+ public class PComp {
65
+ // ── DPI ──────────────────────────────────────────────────────────────
66
+ [DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
67
+ [DllImport("user32.dll")] public static extern IntPtr SetProcessDpiAwarenessContext(IntPtr value);
68
+ [DllImport("shcore.dll")] public static extern int SetProcessDpiAwareness(int value);
69
+ [DllImport("shcore.dll")] public static extern int GetDpiForMonitor(IntPtr hmon, int dpiType, out uint dpiX, out uint dpiY);
70
+
71
+ public static void DeclareDpiAwareness() {
72
+ // -4 is DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2. Each call throws
73
+ // EntryPointNotFoundException on an OS too old to have it, so they are tried in
74
+ // order of preference — see the header on why being wrong here is invisible.
75
+ try { SetProcessDpiAwarenessContext(new IntPtr(-4)); return; } catch {}
76
+ try { SetProcessDpiAwareness(2); return; } catch {}
77
+ try { SetProcessDPIAware(); } catch {}
78
+ }
79
+
80
+ // ── Monitors ─────────────────────────────────────────────────────────
81
+ [StructLayout(LayoutKind.Sequential)]
82
+ public struct RECT { public int Left, Top, Right, Bottom; }
83
+
84
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
85
+ public struct MONITORINFOEX {
86
+ public int cbSize;
87
+ public RECT rcMonitor;
88
+ public RECT rcWork;
89
+ public uint dwFlags;
90
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string szDevice;
91
+ }
92
+
93
+ public delegate bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdc, ref RECT rect, IntPtr data);
94
+ [DllImport("user32.dll")] public static extern bool EnumDisplayMonitors(IntPtr hdc, IntPtr clip, MonitorEnumProc cb, IntPtr data);
95
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFOEX info);
96
+ [DllImport("user32.dll")] public static extern int GetSystemMetrics(int index);
97
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
98
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
99
+
100
+ public class Mon {
101
+ public string Id; public string Label; public int X, Y, W, H; public double Scale; public bool Primary;
102
+ }
103
+
104
+ public static List<Mon> Monitors() {
105
+ var found = new List<Mon>();
106
+ MonitorEnumProc cb = delegate(IntPtr h, IntPtr hdc, ref RECT r, IntPtr d) {
107
+ var info = new MONITORINFOEX();
108
+ info.cbSize = Marshal.SizeOf(typeof(MONITORINFOEX));
109
+ if (!GetMonitorInfo(h, ref info)) return true;
110
+ double scale = 1.0;
111
+ try {
112
+ uint dx, dy;
113
+ // 0 = MDT_EFFECTIVE_DPI. 96 is the unscaled baseline.
114
+ if (GetDpiForMonitor(h, 0, out dx, out dy) == 0 && dx > 0) scale = dx / 96.0;
115
+ } catch {}
116
+ found.Add(new Mon {
117
+ Id = info.szDevice,
118
+ Label = info.szDevice,
119
+ X = info.rcMonitor.Left,
120
+ Y = info.rcMonitor.Top,
121
+ W = info.rcMonitor.Right - info.rcMonitor.Left,
122
+ H = info.rcMonitor.Bottom - info.rcMonitor.Top,
123
+ Scale = scale,
124
+ // MONITORINFOF_PRIMARY
125
+ Primary = (info.dwFlags & 1) != 0
126
+ });
127
+ return true;
128
+ };
129
+ EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, cb, IntPtr.Zero);
130
+ return found;
131
+ }
132
+
133
+ public static int ForegroundPid() {
134
+ try { uint pid; GetWindowThreadProcessId(GetForegroundWindow(), out pid); return (int)pid; }
135
+ catch { return 0; }
136
+ }
137
+
138
+ // ── Capture ──────────────────────────────────────────────────────────
139
+ //
140
+ // Two steps in one call: grab the monitor's physical pixels, then resize to EXACTLY
141
+ // the requested size. Exactly, because the agent's coordinate space is defined by
142
+ // that size — a resize that rounded, or that returned the native frame, would put
143
+ // agent space and the picture into disagreement and every click would be off by the
144
+ // reduction factor.
145
+ //
146
+ // HighQualityBicubic for the same reason macOS uses high interpolation: a screenshot
147
+ // is nearly always being REDUCED, and point-sampling text does not look soft, it
148
+ // aliases — strokes break up and the model misreads them.
149
+ static byte[] Encode(Bitmap source, int w, int h) {
150
+ using (var scaled = new Bitmap(w, h, PixelFormat.Format32bppArgb)) {
151
+ using (var g = Graphics.FromImage(scaled)) {
152
+ g.InterpolationMode = InterpolationMode.HighQualityBicubic;
153
+ g.PixelOffsetMode = PixelOffsetMode.HighQuality;
154
+ g.DrawImage(source, new Rectangle(0, 0, w, h));
155
+ }
156
+ using (var ms = new MemoryStream()) {
157
+ // PNG, not JPEG: UI is flat colour and hard edges, and JPEG ringing on
158
+ // small text is exactly the detail being read.
159
+ scaled.Save(ms, ImageFormat.Png);
160
+ return ms.ToArray();
161
+ }
162
+ }
163
+ }
164
+
165
+ /// Returns { model-sized frame, dialog-sized preview }; the preview is null when it
166
+ /// was not asked for. ONE CopyFromScreen feeding both, deliberately — grabbing twice
167
+ /// would be two moments in time, so a person could approve a click against a screen
168
+ /// the model never saw.
169
+ public static byte[][] Capture(int x, int y, int w, int h, int tw, int th, int pw, int ph) {
170
+ using (var shot = new Bitmap(w, h, PixelFormat.Format32bppArgb)) {
171
+ using (var g = Graphics.FromImage(shot)) {
172
+ g.CopyFromScreen(x, y, 0, 0, new Size(w, h), CopyPixelOperation.SourceCopy);
173
+ }
174
+ var main = Encode(shot, tw, th);
175
+ byte[] preview = null;
176
+ if (pw > 0 && ph > 0) {
177
+ // Best-effort: the model's frame is already in hand, and a dialog that
178
+ // falls back to text is far better than a failed capture.
179
+ try { preview = Encode(shot, pw, ph); } catch {}
180
+ }
181
+ return new byte[][] { main, preview };
182
+ }
183
+ }
184
+
185
+ // ── Input ────────────────────────────────────────────────────────────
186
+ [StructLayout(LayoutKind.Sequential)]
187
+ public struct MOUSEINPUT { public int dx, dy; public uint mouseData, dwFlags, time; public IntPtr dwExtraInfo; }
188
+ [StructLayout(LayoutKind.Sequential)]
189
+ public struct KEYBDINPUT { public ushort wVk, wScan; public uint dwFlags, time; public IntPtr dwExtraInfo; }
190
+ [StructLayout(LayoutKind.Explicit)]
191
+ public struct INPUTUNION { [FieldOffset(0)] public MOUSEINPUT mi; [FieldOffset(0)] public KEYBDINPUT ki; }
192
+ [StructLayout(LayoutKind.Sequential)]
193
+ public struct INPUT { public uint type; public INPUTUNION u; }
194
+
195
+ [DllImport("user32.dll", SetLastError = true)] public static extern uint SendInput(uint n, INPUT[] inputs, int cbSize);
196
+
197
+ const uint MOUSEEVENTF_MOVE = 0x0001, MOUSEEVENTF_ABSOLUTE = 0x8000, MOUSEEVENTF_VIRTUALDESK = 0x4000;
198
+ const uint MOUSEEVENTF_WHEEL = 0x0800, MOUSEEVENTF_HWHEEL = 0x1000;
199
+ const uint KEYEVENTF_KEYUP = 0x0002, KEYEVENTF_UNICODE = 0x0004;
200
+
201
+ static void Send(INPUT[] inputs) {
202
+ SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(INPUT)));
203
+ }
204
+
205
+ /// Absolute mouse coordinates are NORMALISED to 0..65535 across the whole virtual
206
+ /// desktop, not given in pixels — the single most common way Windows mouse
207
+ /// automation ends up clicking the wrong place. VIRTUALDESK makes the normalisation
208
+ /// span every monitor rather than only the primary, which is what makes a second
209
+ /// display reachable at all.
210
+ static INPUT MouseAt(int gx, int gy, uint extraFlags, uint mouseData) {
211
+ int vx = GetSystemMetrics(76), vy = GetSystemMetrics(77);
212
+ int vw = GetSystemMetrics(78), vh = GetSystemMetrics(79);
213
+ if (vw < 2) vw = 2;
214
+ if (vh < 2) vh = 2;
215
+ var i = new INPUT();
216
+ i.type = 0; // INPUT_MOUSE
217
+ i.u.mi.dx = (int)Math.Round((gx - vx) * 65535.0 / (vw - 1));
218
+ i.u.mi.dy = (int)Math.Round((gy - vy) * 65535.0 / (vh - 1));
219
+ i.u.mi.mouseData = mouseData;
220
+ i.u.mi.dwFlags = MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK | extraFlags;
221
+ return i;
222
+ }
223
+
224
+ static void ButtonCodes(string button, out uint down, out uint up) {
225
+ if (button == "right") { down = 0x0008; up = 0x0010; }
226
+ else if (button == "middle") { down = 0x0020; up = 0x0040; }
227
+ else { down = 0x0002; up = 0x0004; }
228
+ }
229
+
230
+ public static void Move(int gx, int gy) { Send(new INPUT[] { MouseAt(gx, gy, 0, 0) }); }
231
+
232
+ public static void Click(int gx, int gy, string button, int clicks) {
233
+ uint down, up;
234
+ ButtonCodes(button, out down, out up);
235
+ // The pointer moves first and as its own event: applications track hover from
236
+ // the move, and a click arriving where the pointer has never been is missed by
237
+ // menus and anything with a hover-to-open affordance.
238
+ Send(new INPUT[] { MouseAt(gx, gy, 0, 0) });
239
+ for (int n = 0; n < Math.Max(1, clicks); n++) {
240
+ Send(new INPUT[] { MouseAt(gx, gy, down, 0), MouseAt(gx, gy, up, 0) });
241
+ // Windows decides double-click by TIMING, not by a click count, so the pairs
242
+ // must arrive inside the system double-click interval — hence no sleep here.
243
+ }
244
+ }
245
+
246
+ public static void Drag(int gx, int gy, int tx, int ty, string button) {
247
+ uint down, up;
248
+ ButtonCodes(button, out down, out up);
249
+ Send(new INPUT[] { MouseAt(gx, gy, 0, 0), MouseAt(gx, gy, down, 0) });
250
+ // Interpolated, for the same reason as macOS: a drag delivered as down-then-up
251
+ // at the destination is ignored by anything that starts its gesture on the first
252
+ // move — most drag-and-drop, sliders, and every text selection.
253
+ const int steps = 12;
254
+ for (int i = 1; i <= steps; i++) {
255
+ double t = (double)i / steps;
256
+ Send(new INPUT[] { MouseAt((int)Math.Round(gx + (tx - gx) * t), (int)Math.Round(gy + (ty - gy) * t), 0, 0) });
257
+ System.Threading.Thread.Sleep(8);
258
+ }
259
+ Send(new INPUT[] { MouseAt(tx, ty, up, 0) });
260
+ }
261
+
262
+ public static void Scroll(int gx, int gy, int dx, int dy) {
263
+ Send(new INPUT[] { MouseAt(gx, gy, 0, 0) });
264
+ // WHEEL_DELTA is 120 per notch. Windows scrolls DOWN on a negative delta and the
265
+ // tool's contract is that negative scrolls UP, so the sign flips exactly once.
266
+ if (dy != 0) Send(new INPUT[] { MouseAt(gx, gy, MOUSEEVENTF_WHEEL, unchecked((uint)(-dy * 120))) });
267
+ if (dx != 0) Send(new INPUT[] { MouseAt(gx, gy, MOUSEEVENTF_HWHEEL, unchecked((uint)(dx * 120))) });
268
+ }
269
+
270
+ static INPUT Key(ushort vk, ushort scan, uint flags) {
271
+ var i = new INPUT();
272
+ i.type = 1; // INPUT_KEYBOARD
273
+ i.u.ki.wVk = vk;
274
+ i.u.ki.wScan = scan;
275
+ i.u.ki.dwFlags = flags;
276
+ return i;
277
+ }
278
+
279
+ /// Type literal text as UNICODE scan codes (wVk = 0), the exact counterpart of
280
+ /// macOS's keyboardSetUnicodeString: it delivers the characters themselves, so it is
281
+ /// correct on every keyboard layout and handles accents, CJK and emoji, none of which
282
+ /// have a virtual-key code to look up. Surrogate pairs work because the string is
283
+ /// walked as UTF-16 code units and each is sent in turn.
284
+ public static void TypeText(string text) {
285
+ foreach (char c in text) {
286
+ Send(new INPUT[] {
287
+ Key(0, c, KEYEVENTF_UNICODE),
288
+ Key(0, c, KEYEVENTF_UNICODE | KEYEVENTF_KEYUP)
289
+ });
290
+ // Applications with their own input handling (Electron, Java, terminals)
291
+ // drop events delivered faster than a person could type.
292
+ System.Threading.Thread.Sleep(6);
293
+ }
294
+ }
295
+
296
+ public static void PressKeys(ushort[] modifiers, ushort vk) {
297
+ var down = new List<INPUT>();
298
+ foreach (var m in modifiers) down.Add(Key(m, 0, 0));
299
+ down.Add(Key(vk, 0, 0));
300
+ Send(down.ToArray());
301
+ var up = new List<INPUT>();
302
+ up.Add(Key(vk, 0, KEYEVENTF_KEYUP));
303
+ // Modifiers release in REVERSE order, the way a hand leaves a chord. Releasing
304
+ // ctrl before the key it modified leaves applications seeing a bare keypress.
305
+ for (int i = modifiers.Length - 1; i >= 0; i--) up.Add(Key(modifiers[i], 0, KEYEVENTF_KEYUP));
306
+ Send(up.ToArray());
307
+ }
308
+ }
309
+ '@
310
+
311
+ # BEFORE any screen call. See the header — getting this wrong is invisible from inside.
312
+ [PComp]::DeclareDpiAwareness()
313
+
314
+ # ── Named keys ───────────────────────────────────────────────────────────────
315
+ # Only keys whose position is fixed. Letters and digits are here for CHORDS only
316
+ # (ctrl+s), where every application's shortcut table is written against the US
317
+ # position; literal characters go through TypeText, which is layout-independent.
318
+ $VK = @{
319
+ 'return' = 0x0D; 'enter' = 0x0D; 'tab' = 0x09; 'space' = 0x20
320
+ 'backspace' = 0x08; 'delete' = 0x2E; 'forwarddelete' = 0x2E
321
+ 'escape' = 0x1B; 'esc' = 0x1B
322
+ 'left' = 0x25; 'up' = 0x26; 'right' = 0x27; 'down' = 0x28
323
+ 'home' = 0x24; 'end' = 0x23; 'pageup' = 0x21; 'pagedown' = 0x22
324
+ 'f1' = 0x70; 'f2' = 0x71; 'f3' = 0x72; 'f4' = 0x73; 'f5' = 0x74; 'f6' = 0x75
325
+ 'f7' = 0x76; 'f8' = 0x77; 'f9' = 0x78; 'f10' = 0x79; 'f11' = 0x7A; 'f12' = 0x7B
326
+ }
327
+ $MODS = @{
328
+ 'ctrl' = 0x11; 'control' = 0x11
329
+ 'alt' = 0x12; 'option' = 0x12; 'opt' = 0x12
330
+ 'shift' = 0x10
331
+ # cmd/meta/super map to the Windows key. Accepted so a model that learned "cmd+s"
332
+ # on macOS does not silently send nothing here — though on Windows the useful
333
+ # shortcut is almost always ctrl, which the tool description says.
334
+ 'cmd' = 0x5B; 'command' = 0x5B; 'meta' = 0x5B; 'super' = 0x5B; 'win' = 0x5B
335
+ }
336
+
337
+ function Get-Monitors {
338
+ $out = @()
339
+ foreach ($m in [PComp]::Monitors()) {
340
+ $out += [ordered]@{
341
+ id = $m.Id; label = $m.Label
342
+ width = $m.W; height = $m.H
343
+ scale = $m.Scale
344
+ # Reported in the SAME physical pixels as width/height, because a
345
+ # per-monitor-aware process is given physical pixels throughout. Still
346
+ # informational only — the agent never converts through it.
347
+ originX = $m.X; originY = $m.Y
348
+ primary = $m.Primary
349
+ }
350
+ }
351
+ return $out
352
+ }
353
+
354
+ function Get-Grants {
355
+ # Windows gates neither screen capture nor synthetic input, so both are true. UIPI
356
+ # can still swallow input aimed at an elevated window and cannot be detected
357
+ # reliably — see the header; it is documented, not reported as a grant.
358
+ return [ordered]@{
359
+ screen = $true
360
+ input = $true
361
+ secureInput = $false
362
+ frontmostPid = [PComp]::ForegroundPid()
363
+ }
364
+ }
365
+
366
+ function Write-Frame($obj) {
367
+ # -Compress keeps it to ONE line (the client reads newline-delimited frames) and
368
+ # -Depth 10 is required because ConvertTo-Json truncates at depth 2 by default,
369
+ # which would silently empty the displays array.
370
+ [Console]::Out.WriteLine(($obj | ConvertTo-Json -Depth 10 -Compress))
371
+ [Console]::Out.Flush()
372
+ }
373
+
374
+ function Send-Ok($id, $fields) {
375
+ $out = [ordered]@{ id = $id; ok = $true }
376
+ if ($fields) { foreach ($k in $fields.Keys) { $out[$k] = $fields[$k] } }
377
+ Write-Frame $out
378
+ }
379
+
380
+ function Send-Fail($id, $message) {
381
+ Write-Frame ([ordered]@{ id = $id; ok = $false; error = $message })
382
+ }
383
+
384
+ if ($Version) { Write-Output 'privateer-computer 1'; exit 0 }
385
+
386
+ if ($Grants) {
387
+ Write-Frame ([ordered]@{ ok = $true; grants = (Get-Grants); displays = (Get-Monitors) })
388
+ exit 0
389
+ }
390
+
391
+ if (-not $Serve) {
392
+ [Console]::Error.WriteLine('usage: PrivateerComputer.ps1 -Serve | -Grants | -Version')
393
+ exit 2
394
+ }
395
+
396
+ function Find-Monitor($id) {
397
+ $all = [PComp]::Monitors()
398
+ if ($null -eq $id -or $id -eq '') {
399
+ $p = $all | Where-Object { $_.Primary } | Select-Object -First 1
400
+ if ($p) { return $p }
401
+ return ($all | Select-Object -First 1)
402
+ }
403
+ return ($all | Where-Object { $_.Id -eq $id } | Select-Object -First 1)
404
+ }
405
+
406
+ while ($null -ne ($line = [Console]::In.ReadLine())) {
407
+ $line = $line.Trim()
408
+ if ($line -eq '') { continue }
409
+
410
+ $req = $null
411
+ try { $req = $line | ConvertFrom-Json } catch { Send-Fail $null 'malformed request'; continue }
412
+ $id = $req.id
413
+ $op = [string]$req.op
414
+
415
+ try {
416
+ switch ($op) {
417
+ 'displays' { Send-Ok $id @{ displays = (Get-Monitors) } }
418
+
419
+ 'grants' { Send-Ok $id @{ grants = (Get-Grants) } }
420
+
421
+ 'capture' {
422
+ $m = Find-Monitor $req.display
423
+ if (-not $m) { Send-Fail $id 'no such display'; continue }
424
+ $tw = if ($req.targetWidth) { [int]$req.targetWidth } else { $m.W }
425
+ $th = if ($req.targetHeight) { [int]$req.targetHeight } else { $m.H }
426
+ $pw = if ($req.previewWidth) { [int]$req.previewWidth } else { 0 }
427
+ $ph = if ($req.previewHeight) { [int]$req.previewHeight } else { 0 }
428
+ $shots = [PComp]::Capture($m.X, $m.Y, $m.W, $m.H, $tw, $th, $pw, $ph)
429
+ $fields = [ordered]@{
430
+ mimeType = 'image/png'
431
+ data = [Convert]::ToBase64String($shots[0])
432
+ width = $tw
433
+ height = $th
434
+ }
435
+ if ($shots[1]) { $fields['preview'] = [Convert]::ToBase64String($shots[1]) }
436
+ Send-Ok $id $fields
437
+ }
438
+
439
+ 'pointer' {
440
+ $m = Find-Monitor $req.display
441
+ if (-not $m) { Send-Fail $id 'no such display'; continue }
442
+ # Display-local physical pixels → global physical pixels. The ONLY
443
+ # conversion, mirroring the macOS helper's globalPoint.
444
+ $gx = $m.X + [int]$req.x
445
+ $gy = $m.Y + [int]$req.y
446
+ $button = if ($req.button) { [string]$req.button } else { 'left' }
447
+ switch ([string]$req.action) {
448
+ 'move' { [PComp]::Move($gx, $gy) }
449
+ 'click' { [PComp]::Click($gx, $gy, $button, 1) }
450
+ 'double_click' { [PComp]::Click($gx, $gy, $button, 2) }
451
+ 'drag' {
452
+ $tx = $m.X + [int]$req.toX
453
+ $ty = $m.Y + [int]$req.toY
454
+ [PComp]::Drag($gx, $gy, $tx, $ty, $button)
455
+ }
456
+ 'scroll' {
457
+ $dx = if ($req.scrollX) { [int]$req.scrollX } else { 0 }
458
+ $dy = if ($req.scrollY) { [int]$req.scrollY } else { 0 }
459
+ [PComp]::Scroll($gx, $gy, $dx, $dy)
460
+ }
461
+ default { Send-Fail $id ("unknown pointer action `"" + $req.action + "`""); continue }
462
+ }
463
+ Send-Ok $id $null
464
+ }
465
+
466
+ 'key' {
467
+ switch ([string]$req.action) {
468
+ 'type' { [PComp]::TypeText([string]$req.text); Send-Ok $id $null }
469
+ 'press' {
470
+ $spec = ([string]$req.keys).ToLower().Trim()
471
+ if ($spec -eq '') { Send-Fail $id 'empty key combination'; continue }
472
+ $parts = $spec.Split('+')
473
+ $last = $parts[-1]
474
+ $mods = @()
475
+ $bad = $null
476
+ for ($i = 0; $i -lt $parts.Length - 1; $i++) {
477
+ $p = $parts[$i]
478
+ if ($MODS.ContainsKey($p)) { $mods += [uint16]$MODS[$p] }
479
+ else { $bad = $p; break }
480
+ }
481
+ if ($bad) { Send-Fail $id ("unknown modifier `"" + $bad + "`" in `"" + $spec + "`""); continue }
482
+ $vk = $null
483
+ if ($VK.ContainsKey($last)) { $vk = $VK[$last] }
484
+ elseif ($last.Length -eq 1 -and $last -match '[a-z0-9]') {
485
+ $vk = [int][char]([string]$last).ToUpper()
486
+ }
487
+ if ($null -eq $vk) { Send-Fail $id ("unknown key `"" + $last + "`" in `"" + $spec + "`""); continue }
488
+ [PComp]::PressKeys([uint16[]]$mods, [uint16]$vk)
489
+ Send-Ok $id $null
490
+ }
491
+ default { Send-Fail $id 'unknown key action' }
492
+ }
493
+ }
494
+
495
+ default { Send-Fail $id ("unknown op `"" + $op + "`"") }
496
+ }
497
+ } catch {
498
+ # A helper that dies on one bad frame takes the whole session's screen control
499
+ # with it. Report and keep serving.
500
+ Send-Fail $id $_.Exception.Message
501
+ }
502
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.30",
3
+ "version": "0.12.31",
4
4
  "description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -49,6 +49,7 @@
49
49
  "files": [
50
50
  "bin",
51
51
  "src",
52
+ "native",
52
53
  "extensions",
53
54
  "skills",
54
55
  "patches",
package/src/acp/run.ts CHANGED
@@ -92,7 +92,7 @@ export async function runAcp(): Promise<void> {
92
92
  const { modelRegistryOf, piAuthStore } = await import("../providers/piAuthStore.ts");
93
93
  const { hasCredentials, acquireAccountCredential, revokeAccountSession } = await import("../auth/privateer.ts");
94
94
  const { webEnabled, mediaEnabled } = await import("../config/hosted.ts");
95
- const { resolveDefaultModel } = await import("../providers/defaultModel.ts");
95
+ const { resolveDefaultModel, writePiDefaultModel } = await import("../providers/defaultModel.ts");
96
96
  const { agentDir, configPath } = await import("../config/paths.ts");
97
97
  const { PrivateerAcpAgent, askOverAcp } = await import("./server.ts");
98
98
  type AcpSession = import("./server.ts").AcpSession;
@@ -333,6 +333,11 @@ export async function runAcp(): Promise<void> {
333
333
  await ensureAccountArmed(next.provider);
334
334
  await session.setModel(next);
335
335
  currentProvider = next.provider;
336
+ // The host's model dropdown (session/set_model) is the user choosing, so it
337
+ // becomes the default the next launch resolves — the desktop app and a fresh
338
+ // terminal are meant to agree on which model you're on. Pi won't write it for
339
+ // us: nothing here passes `persist`. See writePiDefaultModel.
340
+ writePiDefaultModel(`${next.provider}/${next.id}`);
336
341
  },
337
342
  async prompt(text, events, signal) {
338
343
  holder.events = events;
package/src/cli/chat.ts CHANGED
@@ -53,7 +53,8 @@ async function main() {
53
53
  const { modelRegistryOf } = await import("../providers/piAuthStore.ts");
54
54
  const { agentVersion } = await import("../config/version.ts");
55
55
  const { pickerCatalog, hiddenAccountNotice, hiddenAccountTitleSuffix } = await import("../providers/modelCatalog.ts");
56
- const { resolveDefaultModel, resolveSignedInModel, savedPiDefaultSpec } = await import("../providers/defaultModel.ts");
56
+ const { resolveDefaultModel, resolveSignedInModel, savedPiDefaultSpec, writePiDefaultModel } =
57
+ await import("../providers/defaultModel.ts");
57
58
  const { postOutbox } = await import("../outbox/cloudOutbox.ts");
58
59
  const { addPendingCloud } = await import("../routines/store.ts");
59
60
 
@@ -715,6 +716,11 @@ async function main() {
715
716
  try {
716
717
  await session.setModel(model);
717
718
  currentSpec = sp;
719
+ // Typed /model is a deliberate pick, and Pi persists a switch only for a caller
720
+ // that asks (AgentSession.setModel's `options.persist`, which we don't pass and
721
+ // its own /model doesn't either). Without this the pick died with the process and
722
+ // the next terminal came back on the old model. See writePiDefaultModel.
723
+ writePiDefaultModel(sp);
718
724
  const m = `model → ${sp}`;
719
725
  console.log(`${DIM}${m}${RESET}`);
720
726
  relay?.sendContext({ model: currentSpec, cwd, version: agentVersion() }); // banner follows the switch