@lardexx/arenacode 2.9.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -170,8 +170,15 @@ screenshots are `XGetImage` + `XGetPixel` encoded to PNG by the client itself (z
170
170
  and the X events the server delivered are compared with the requested keycodes, chords, buttons and pixels. On Wayland
171
171
  the XWayland display is used. `ui app launch` still starts the program's own `.desktop` command; nothing else is spawned.
172
172
 
173
- **macOS**: grant Accessibility to your terminal, and `brew install cliclick` for the mouse. On macOS/Linux the
174
- accessibility tree is not read: `ui state` returns windows and geometry as elements.
173
+ **macOS** is native too, again with no helper binaries: mouse with `CGEventCreateMouseEvent` + `CGEventPost`
174
+ (left/right/middle, double and triple click through the click-state field, drag interpolated), keyboard with
175
+ `CGEventCreateKeyboardEvent` plus modifier *flags* (`cmd`, `ctrl`, `alt`, `shift`) and Apple's fixed `kVK_*`
176
+ keycodes, typing through `CGEventKeyboardSetUnicodeString` so any character or emoji goes in without a layout,
177
+ windows from `CGWindowListCopyWindowInfo` (number, bounds, owner pid), screenshots from `CGDisplayCreateImage`
178
+ converted from BGRA to PNG by the client. `brew install cliclick` is gone. Only what CoreGraphics cannot do —
179
+ launch an app, bring a window to the front, resize it — still uses `osascript`, which ships with macOS. Grant the
180
+ terminal **Accessibility** (input) and **Screen Recording** (pixels) in System Settings → Privacy & Security.
181
+ On macOS/Linux the accessibility tree is not read: `ui state` returns windows and geometry as elements.
175
182
 
176
183
  **Ask and Proceed / Proceed.** The desktop's Settings switch, `ctrl+x a` here (also `/ask`, `/proceed`,
177
184
  `--proceed`, and `arenacode config set approval proceed`). The default, **Ask and Proceed**, asks before commands,
@@ -254,7 +261,7 @@ The agent never touches your PC directly: it asks, and this client shows a card.
254
261
 
255
262
  | Card | Source | Executed here |
256
263
  |---|---|---|
257
- | **terminal** | `POST /task/<id>/local-exec` | PowerShell / `cmd` / WSL on Windows, `/bin/sh` elsewhere — asked before running unless `--yes` |
264
+ | **terminal** | `POST /task/<id>/local-exec` | your shell — zsh/bash/sh/fish on macOS and Linux, PowerShell/pwsh/cmd/WSL/Git Bash on Windows (see [Shells](#shells)) — asked before running unless `--yes` |
258
265
  | **files** | `local-fs` | `ls`, `stat`, `read`, `write`, `replace`, `find`, `mkdir`, `mv`, `rm`, `pull`, `git_status`, `git_log`, `git_diff`, `git_commit`, `git_revert`, `git_publish` |
259
266
  | **plan** | `local-ui` `plan` | approve / reject / ask for a revision, right in the terminal |
260
267
  | **file from the agent** | `local_file` | downloaded from `GET /file?raw=1` into the open folder |
@@ -264,17 +271,30 @@ Every file path is resolved **inside the open folder**; paths that escape it are
264
271
  the folder itself. Local operations run with your user's permissions — `--confirm-writes` adds a confirmation for
265
272
  mutating file operations, `--yes` skips confirmations for terminal commands.
266
273
 
267
- ### Not available in a terminal
274
+ ### Shells
268
275
 
269
- The desktop client can drive the whole PC (mouse, keyboard, screenshots, app launching — `local-ui`
270
- `control` / `type` / `keystroke` / `screenshot` / `app` / `state` / `apps` / `multi_*`). A terminal cannot, so
271
- those cards are answered with an explicit error instead of silently doing nothing:
276
+ Terminal cards run in a real shell, and which one is not fixed:
272
277
 
273
- ```json
274
- { "ok": false, "error": "operation \"screenshot\" is not available in the terminal client: the ArenaCode desktop client is required" }
278
+ | Platform | Shells |
279
+ |---|---|
280
+ | **Windows** | `powershell` (default), `pwsh`, `cmd`, `wsl`, `bash` (Git Bash when installed, otherwise the WSL bash) |
281
+ | **macOS / Linux** | `zsh`, `bash`, `sh`, `fish`, `dash`, `ksh` — or any absolute path |
282
+
283
+ The order is: the shell the **bridge** names in the `local-exec` request → the shell **you pinned** →
284
+ your **login shell** (`$SHELL`, e.g. `zsh` on macOS, `bash` on most Linux boxes; PowerShell on Windows).
285
+ Pin it with any of:
286
+
287
+ ```
288
+ arenacode --shell zsh
289
+ arenacode config set shell zsh
290
+ /shell # in the TUI: pick from the shells installed on this machine
275
291
  ```
276
292
 
277
- Everything else in the protocol is implemented.
293
+ `sysinfo` reports `shell` and the full `shells` list, and the `!command` prompt line in the TUI runs in the
294
+ same shell as the agent's cards. Commands are passed as a single argv entry (`zsh -lc '<command>'`,
295
+ `cmd.exe /d /s /c '<command>'`, `powershell.exe -NoProfile -NonInteractive -Command '<command>'`), so quotes,
296
+ `$variables` and pipes are never re-quoted on the way through. `test/shell.js` checks every mapping and runs
297
+ real commands through `sh`/`bash`.
278
298
 
279
299
  ## How it talks to the bridge
280
300
 
@@ -319,7 +339,8 @@ release it on the bridge with `keys unbind <ID>`).
319
339
  "pc_access": true,
320
340
  "device": "",
321
341
  "vision": false,
322
- "idle_timeout": 180
342
+ "idle_timeout": 180,
343
+ "shell": ""
323
344
  }
324
345
  ```
325
346
 
@@ -328,7 +349,7 @@ Node client cannot, so the key sits in that file — keep it readable only by yo
328
349
  (`arenacode keys` on the bridge side: `python3 bridge_server.py keys create "laptop CLI"`).
329
350
 
330
351
  Environment variables override the file: `ARENACODE_URL`, `ARENACODE_KEY`, `ARENACODE_FOLDER`,
331
- `ARENACODE_CONFIG`.
352
+ `ARENACODE_SHELL`, `ARENACODE_CONFIG`.
332
353
 
333
354
  ## Troubleshooting
334
355
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lardexx/arenacode",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "description": "Terminal client for ArenaCode: talks to an arena-bridge, streams the Agent Mode timeline, and runs local commands, file operations and computer automation on your PC (windows-use.dll on Windows, Xlib/XTest on Linux, osascript on macOS). Node port of the ArenaCode desktop client.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -20,7 +20,7 @@
20
20
  "vendor/"
21
21
  ],
22
22
  "scripts": {
23
- "test": "node test/integration.js && node test/parity.js && node test/automation.js && node test/native.js"
23
+ "test": "node test/shell.js && node test/integration.js && node test/parity.js && node test/automation.js && node test/native.js"
24
24
  },
25
25
  "keywords": [
26
26
  "arenacode",
@@ -7,9 +7,9 @@
7
7
  const fs = require('node:fs');
8
8
  const os = require('node:os');
9
9
  const path = require('node:path');
10
- const zlib = require('node:zlib');
11
10
  const { spawn } = require('node:child_process');
12
11
  const { lib, fn } = require('./ffi');
12
+ const { encodePng } = require('./png');
13
13
 
14
14
  const DISPLAY = () => process.env.DISPLAY || ':0';
15
15
 
@@ -356,8 +356,7 @@ function pressKeys(combo) {
356
356
  requireX();
357
357
  const parts = String(combo || '')
358
358
  .toLowerCase()
359
- .split('+')
360
- .map((p) => p.trim())
359
+ .split(/[\s+]+/)
361
360
  .filter(Boolean);
362
361
  if (!parts.length) return { ok: false, error: `unreadable key combination: ${combo}` };
363
362
  let repeat = 1;
@@ -553,43 +552,6 @@ function resizeWindow(handle, x, y, width, height) {
553
552
 
554
553
  // ---------------------------------------------------------------- screenshot
555
554
 
556
- function crc32(buf) {
557
- let c = 0xffffffff;
558
- for (let i = 0; i < buf.length; i += 1) {
559
- c ^= buf[i];
560
- for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
561
- }
562
- return (c ^ 0xffffffff) >>> 0;
563
- }
564
-
565
- function pngChunk(type, data) {
566
- const len = Buffer.alloc(4);
567
- len.writeUInt32BE(data.length);
568
- const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
569
- const crc = Buffer.alloc(4);
570
- crc.writeUInt32BE(crc32(body));
571
- return Buffer.concat([len, body, crc]);
572
- }
573
-
574
- function encodePng(rgb, width, height) {
575
- const raw = Buffer.alloc((width * 3 + 1) * height);
576
- for (let y = 0; y < height; y += 1) {
577
- raw[y * (width * 3 + 1)] = 0;
578
- rgb.copy(raw, y * (width * 3 + 1) + 1, y * width * 3, (y + 1) * width * 3);
579
- }
580
- const ihdr = Buffer.alloc(13);
581
- ihdr.writeUInt32BE(width, 0);
582
- ihdr.writeUInt32BE(height, 4);
583
- ihdr[8] = 8;
584
- ihdr[9] = 2;
585
- return Buffer.concat([
586
- Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
587
- pngChunk('IHDR', ihdr),
588
- pngChunk('IDAT', zlib.deflateSync(raw, { level: 6 })),
589
- pngChunk('IEND', Buffer.alloc(0)),
590
- ]);
591
- }
592
-
593
555
  function screenshot(dest) {
594
556
  openDisplay();
595
557
  const XGetImage = fn(
@@ -0,0 +1,657 @@
1
+ 'use strict';
2
+
3
+ // macOS backend: CoreGraphics through koffi. Same idea as the Linux one — no helper
4
+ // binaries, no `brew install cliclick`: the mouse is CGEventCreateMouseEvent + CGEventPost,
5
+ // the keyboard is CGEventCreateKeyboardEvent (chords) and CGEventKeyboardSetUnicodeString
6
+ // (any character, emoji included), windows come from CGWindowListCopyWindowInfo and the
7
+ // screenshot from CGDisplayCreateImage. Only what CoreGraphics cannot do at all —
8
+ // launching an app, bringing a window to the front, resizing it — still goes through
9
+ // `osascript`, which ships with macOS.
10
+ //
11
+ // Permissions: posting events needs Accessibility for the terminal (System Settings →
12
+ // Privacy & Security → Accessibility); window titles and pixels need Screen Recording.
13
+
14
+ const fs = require('node:fs');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+ const { spawnSync } = require('node:child_process');
18
+ const { fn, lib } = require('./ffi');
19
+ const { encodePng } = require('./png');
20
+
21
+ const CG = '/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics';
22
+ const CF = '/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation';
23
+
24
+ const K_CG_HID_EVENT_TAP = 0;
25
+ const K_CG_SESSION_EVENT_TAP = 1;
26
+
27
+ const EVENT = {
28
+ leftDown: 1,
29
+ leftUp: 2,
30
+ rightDown: 3,
31
+ rightUp: 4,
32
+ mouseMoved: 5,
33
+ leftDragged: 6,
34
+ rightDragged: 7,
35
+ keyDown: 10,
36
+ keyUp: 11,
37
+ scrollWheel: 22,
38
+ };
39
+ const FIELD_CLICK_STATE = 1;
40
+
41
+ const FLAG = {
42
+ shift: 0x20000,
43
+ control: 0x40000,
44
+ option: 0x80000,
45
+ command: 0x100000,
46
+ };
47
+
48
+ // kVK_* — fixed virtual keycodes; they do not depend on the keyboard layout.
49
+ const VK = {
50
+ a: 0, s: 1, d: 2, f: 3, h: 4, g: 5, z: 6, x: 7, c: 8, v: 9,
51
+ b: 11, q: 12, w: 13, e: 14, r: 15, y: 16, t: 17,
52
+ 1: 18, 2: 19, 3: 20, 4: 21, 6: 22, 5: 23, equal: 24, 9: 25, 7: 26, minus: 27,
53
+ 8: 28, 0: 29, bracketright: 30, o: 31, u: 32, bracketleft: 33, i: 34, p: 35,
54
+ return: 36, l: 37, j: 38, quote: 39, k: 40, semicolon: 41, backslash: 42,
55
+ comma: 43, slash: 44, n: 45, m: 46, period: 47, tab: 48, space: 49, grave: 50,
56
+ backspace: 51, escape: 53, command: 55, shift: 56, capslock: 57, option: 58,
57
+ control: 59, rightshift: 60, rightoption: 61, rightcontrol: 62,
58
+ help: 114, home: 115, pageup: 116, delete: 117, end: 119, pagedown: 121,
59
+ left: 123, right: 124, down: 125, up: 126,
60
+ f1: 122, f2: 120, f3: 99, f4: 118, f5: 96, f6: 97, f7: 98, f8: 100,
61
+ f9: 101, f10: 109, f11: 103, f12: 111,
62
+ enter: 76,
63
+ };
64
+
65
+ const MODIFIERS = {
66
+ cmd: FLAG.command, command: FLAG.command, meta: FLAG.command, win: FLAG.command, super: FLAG.command,
67
+ ctrl: FLAG.control, control: FLAG.control,
68
+ alt: FLAG.option, option: FLAG.option, opt: FLAG.option,
69
+ shift: FLAG.shift,
70
+ };
71
+
72
+ // Names that AppleScript/linux-style tasks use, mapped to the kVK_* table.
73
+ const ALIASES = {
74
+ esc: 'escape', delete: 'backspace', backspace: 'backspace', del: 'delete',
75
+ arrowup: 'up', arrowdown: 'down', arrowleft: 'left', arrowright: 'right',
76
+ up: 'up', down: 'down', left: 'left', right: 'right',
77
+ pgup: 'pageup', pgdn: 'pagedown', page_up: 'pageup', page_down: 'pagedown',
78
+ home: 'home', end: 'end', space: 'space', spacebar: 'space',
79
+ return: 'return', enter: 'return', tab: 'tab', capslock: 'capslock',
80
+ '[': 'bracketleft', ']': 'bracketright', ';': 'semicolon', '=': 'equal',
81
+ '-': 'minus', '`': 'grave', '\\': 'backslash', ',': 'comma', '.': 'period',
82
+ '/': 'slash', "'": 'quote',
83
+ };
84
+
85
+ let bound = null;
86
+
87
+ function bindings() {
88
+ if (bound) return bound;
89
+ if (!lib(CG) || !lib(CF)) throw new Error('CoreGraphics not available (not macOS?)');
90
+ bound = {
91
+ CGMainDisplayID: fn(CG, 'CGMainDisplayID', 'unsigned int CGMainDisplayID(void)'),
92
+ CGDisplayPixelsWide: fn(CG, 'CGDisplayPixelsWide', 'unsigned long CGDisplayPixelsWide(unsigned int display)'),
93
+ CGDisplayPixelsHigh: fn(CG, 'CGDisplayPixelsHigh', 'unsigned long CGDisplayPixelsHigh(unsigned int display)'),
94
+ CGEventCreateMouseEvent: fn(
95
+ CG,
96
+ 'CGEventCreateMouseEvent',
97
+ 'void* CGEventCreateMouseEvent(void* source, int mouseType, double x, double y, int mouseButton)'
98
+ ),
99
+ CGEventCreateKeyboardEvent: fn(
100
+ CG,
101
+ 'CGEventCreateKeyboardEvent',
102
+ 'void* CGEventCreateKeyboardEvent(void* source, unsigned short virtualKey, bool keyDown)'
103
+ ),
104
+ CGEventCreateScrollWheelEvent2: fn(
105
+ CG,
106
+ 'CGEventCreateScrollWheelEvent2',
107
+ 'void* CGEventCreateScrollWheelEvent2(void* source, int units, unsigned int wheelCount, int wheel1, int wheel2, int wheel3)'
108
+ ),
109
+ CGEventPost: fn(CG, 'CGEventPost', 'void CGEventPost(int tap, void* event)'),
110
+ CGEventSetFlags: fn(CG, 'CGEventSetFlags', 'void CGEventSetFlags(void* event, unsigned long long flags)'),
111
+ CGEventSetIntegerValueField: fn(
112
+ CG,
113
+ 'CGEventSetIntegerValueField',
114
+ 'void CGEventSetIntegerValueField(void* event, int field, long long value)'
115
+ ),
116
+ CGEventKeyboardSetUnicodeString: fn(
117
+ CG,
118
+ 'CGEventKeyboardSetUnicodeString',
119
+ 'void CGEventKeyboardSetUnicodeString(void* event, unsigned long length, void* unicodeString)'
120
+ ),
121
+ CGWindowListCopyWindowInfo: fn(
122
+ CG,
123
+ 'CGWindowListCopyWindowInfo',
124
+ 'void* CGWindowListCopyWindowInfo(unsigned int option, unsigned int relativeToWindow)'
125
+ ),
126
+ CGDisplayCreateImage: fn(CG, 'CGDisplayCreateImage', 'void* CGDisplayCreateImage(unsigned int display)'),
127
+ CGImageGetWidth: fn(CG, 'CGImageGetWidth', 'unsigned long CGImageGetWidth(void* image)'),
128
+ CGImageGetHeight: fn(CG, 'CGImageGetHeight', 'unsigned long CGImageGetHeight(void* image)'),
129
+ CGImageGetBytesPerRow: fn(CG, 'CGImageGetBytesPerRow', 'unsigned long CGImageGetBytesPerRow(void* image)'),
130
+ CGImageGetBitsPerPixel: fn(CG, 'CGImageGetBitsPerPixel', 'unsigned long CGImageGetBitsPerPixel(void* image)'),
131
+ CGImageGetDataProvider: fn(CG, 'CGImageGetDataProvider', 'void* CGImageGetDataProvider(void* image)'),
132
+ CFDataGetBytePtr: fn(CF, 'CFDataGetBytePtr', 'const unsigned char* CFDataGetBytePtr(void* data)'),
133
+ CFDataGetLength: fn(CF, 'CFDataGetLength', 'long CFDataGetLength(void* data)'),
134
+ CFDataGetBytes: fn(CF, 'CFDataGetBytes', 'void CFDataGetBytes(void* data, void* buffer, unsigned long length)'),
135
+ CGDataProviderCopyData: fn(CF, 'CGDataProviderCopyData', 'void* CGDataProviderCopyData(void* provider)'),
136
+ CFRelease: fn(CF, 'CFRelease', 'void CFRelease(void* ref)'),
137
+ // CF containers are only aliases of the CoreFoundation types we read windows with
138
+ CFArrayGetCount: fn(CF, 'CFArrayGetCount', 'long CFArrayGetCount(void* array)'),
139
+ CFArrayGetValueAtIndex: fn(CF, 'CFArrayGetValueAtIndex', 'void* CFArrayGetValueAtIndex(void* array, long index)'),
140
+ CFDictionaryGetValue: fn(CF, 'CFDictionaryGetValue', 'void* CFDictionaryGetValue(void* dict, void* key)'),
141
+ CFStringCreateWithCString: fn(
142
+ CF,
143
+ 'CFStringCreateWithCString',
144
+ 'void* CFStringCreateWithCString(void* allocator, const char* cString, unsigned int encoding)'
145
+ ),
146
+ CFStringGetCString: fn(
147
+ CF,
148
+ 'CFStringGetCString',
149
+ 'bool CFStringGetCString(void* string, void* buffer, long bufferSize, unsigned int encoding)'
150
+ ),
151
+ CFStringGetLength: fn(CF, 'CFStringGetLength', 'long CFStringGetLength(void* string)'),
152
+ CFNumberGetValue: fn(CF, 'CFNumberGetValue', 'bool CFNumberGetValue(void* number, int type, void* valuePtr)'),
153
+ CFBooleanGetValue: fn(CF, 'CFBooleanGetValue', 'bool CFBooleanGetValue(void* boolean)'),
154
+ };
155
+ return bound;
156
+ }
157
+
158
+ function requireCG() {
159
+ if (process.platform !== 'darwin') throw new Error('the macOS backend runs on macOS only');
160
+ return bindings();
161
+ }
162
+
163
+ const K_CF_STRING_ENCODING_UTF8 = 0x08000100;
164
+
165
+ function cfString(text) {
166
+ const b = bindings();
167
+ return b.CFStringCreateWithCString(null, String(text), K_CF_STRING_ENCODING_UTF8);
168
+ }
169
+
170
+ function cfToString(ref) {
171
+ if (!ref) return '';
172
+ const koffi = require('koffi');
173
+ const b = bindings();
174
+ const length = Number(b.CFStringGetLength(ref));
175
+ if (!Number.isFinite(length) || length < 0) return '';
176
+ const size = Math.max(16, length * 4 + 1);
177
+ const buffer = koffi.alloc('uint8_t', size);
178
+ if (!b.CFStringGetCString(ref, buffer, size, K_CF_STRING_ENCODING_UTF8)) return '';
179
+ return String(koffi.decode(buffer, 'uint8_t', size)).split('\0')[0];
180
+ }
181
+
182
+ function cfNumber(ref) {
183
+ if (!ref) return 0;
184
+ const koffi = require('koffi');
185
+ const out = koffi.alloc('double', 1);
186
+ const b = bindings();
187
+ // kCFNumberDoubleType = 13, kCFNumberSInt64Type = 4
188
+ if (b.CFNumberGetValue(ref, 13, out)) return Number(koffi.decode(out, 'double', 1));
189
+ return 0;
190
+ }
191
+
192
+ /** Reads a key out of the CGWindowList dictionaries. */
193
+ function dictValue(dict, key, stringKey) {
194
+ const b = bindings();
195
+ const name = cfString(key);
196
+ let raw = null;
197
+ try {
198
+ raw = b.CFDictionaryGetValue(dict, name);
199
+ if (!raw) return stringKey ? '' : 0;
200
+ return stringKey ? cfToString(raw) : cfNumber(raw);
201
+ } finally {
202
+ if (name) b.CFRelease(name);
203
+ }
204
+ }
205
+
206
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
207
+
208
+ // ------------------------------------------------------------------- input --
209
+
210
+ function post(event, tap = K_CG_HID_EVENT_TAP) {
211
+ const b = bindings();
212
+ b.CGEventPost(tap, event);
213
+ if (event) b.CFRelease(event);
214
+ // let the window server digest the event before the next one
215
+ sleep(2);
216
+ }
217
+
218
+ function moveMouse(x, y) {
219
+ const b = requireCG();
220
+ const event = b.CGEventCreateMouseEvent(null, EVENT.mouseMoved, Number(x), Number(y), 0);
221
+ if (!event) return { ok: false, error: 'CGEventCreateMouseEvent failed' };
222
+ post(event);
223
+ return { ok: true, x, y };
224
+ }
225
+
226
+ function buttonEvent(type, x, y, button = 'left') {
227
+ const b = bindings();
228
+ const which = button === 'right' ? 1 : button === 'middle' ? 2 : 0;
229
+ return b.CGEventCreateMouseEvent(null, type, Number(x), Number(y), which);
230
+ }
231
+
232
+ function click(x, y, { button = 'left', count = 1 } = {}) {
233
+ const b = requireCG();
234
+ const clicks = Math.max(1, Math.trunc(count) || 1);
235
+ const down = button === 'right' ? EVENT.rightDown : EVENT.leftDown;
236
+ const up = button === 'right' ? EVENT.rightUp : EVENT.leftUp;
237
+ moveMouse(x, y);
238
+ for (let i = 1; i <= Math.min(3, clicks); i += 1) {
239
+ const press = buttonEvent(down, x, y, button);
240
+ if (press) {
241
+ b.CGEventSetIntegerValueField(press, FIELD_CLICK_STATE, i);
242
+ post(press);
243
+ }
244
+ const release = buttonEvent(up, x, y, button);
245
+ if (release) {
246
+ b.CGEventSetIntegerValueField(release, FIELD_CLICK_STATE, i);
247
+ post(release);
248
+ }
249
+ }
250
+ return { ok: true, x, y, button, count: clicks };
251
+ }
252
+
253
+ function mouseButton(x, y, button, down) {
254
+ const b = requireCG();
255
+ moveMouse(x, y);
256
+ const type = button === 'right' ? (down ? EVENT.rightDown : EVENT.rightUp) : down ? EVENT.leftDown : EVENT.leftUp;
257
+ const event = buttonEvent(type, x, y, button);
258
+ if (!event) return { ok: false, error: 'CGEventCreateMouseEvent failed' };
259
+ post(event);
260
+ return { ok: true, x, y, button };
261
+ }
262
+
263
+ function drag(x, y, x2, y2) {
264
+ const b = requireCG();
265
+ const downEvent = buttonEvent(EVENT.leftDown, x, y, 'left');
266
+ if (!downEvent) return { ok: false, error: 'CGEventCreateMouseEvent failed' };
267
+ post(downEvent);
268
+ const steps = 12;
269
+ for (let i = 1; i <= steps; i += 1) {
270
+ const px = Math.round(x + ((x2 - x) * i) / steps);
271
+ const py = Math.round(y + ((y2 - y) * i) / steps);
272
+ const moved = b.CGEventCreateMouseEvent(null, EVENT.leftDragged, px, py, 0);
273
+ if (moved) post(moved);
274
+ }
275
+ const upEvent = buttonEvent(EVENT.leftUp, x2, y2, 'left');
276
+ if (upEvent) post(upEvent);
277
+ const released = b.CGEventCreateMouseEvent(null, EVENT.mouseMoved, x2, y2, 0);
278
+ if (released) post(released);
279
+ return { ok: true, from: [x, y], to: [x2, y2] };
280
+ }
281
+
282
+ function scroll(amount, x = null, y = null) {
283
+ const b = requireCG();
284
+ if (x !== null && y !== null) moveMouse(x, y);
285
+ const lines = Math.max(1, Math.abs(Math.trunc(amount) || 1));
286
+ const signed = (Math.trunc(amount) || 1) > 0 ? lines : -lines;
287
+ const event = b.CGEventCreateScrollWheelEvent2(null, 1 /* line */, 1, signed, 0, 0);
288
+ if (!event) return { ok: false, error: 'CGEventCreateScrollWheelEvent2 failed' };
289
+ post(event);
290
+ return { ok: true, amount };
291
+ }
292
+
293
+ /** Types text as unicode: layout-independent, works for é, ü, 中文, emoji. */
294
+ function typeText(text, x = null, y = null) {
295
+ const b = requireCG();
296
+ if (x !== null && y !== null) click(x, y);
297
+ const value = String(text || '');
298
+ let typed = 0;
299
+ for (const ch of value) {
300
+ const utf16 = Buffer.from(ch, 'utf16le');
301
+ const length = utf16.length / 2;
302
+ const down = b.CGEventCreateKeyboardEvent(null, 0, true);
303
+ const up = b.CGEventCreateKeyboardEvent(null, 0, false);
304
+ if (!down || !up) return { ok: false, error: 'CGEventCreateKeyboardEvent failed' };
305
+ const koffi = require('koffi');
306
+ const buffer = koffi.alloc('uint16_t', length);
307
+ koffi.encode(buffer, koffi.array('uint16_t', length), [...Array(length).keys()].map((i) => utf16.readUInt16LE(i * 2)));
308
+ b.CGEventKeyboardSetUnicodeString(down, length, buffer);
309
+ b.CGEventKeyboardSetUnicodeString(up, length, buffer);
310
+ post(down);
311
+ post(up);
312
+ typed += 1;
313
+ }
314
+ return { ok: true, chars: value.length, typed };
315
+ }
316
+
317
+ function keycodeFor(name) {
318
+ const key = String(name || '').toLowerCase();
319
+ const alias = ALIASES[key] || key;
320
+ if (alias in VK) return VK[alias];
321
+ if (key.length === 1) {
322
+ const lower = key.toLowerCase();
323
+ if (lower in VK) return VK[lower];
324
+ }
325
+ return null;
326
+ }
327
+
328
+ /**
329
+ * `ctrl+shift+t`, `cmd+space`, `alt+f4 x2` → { flags, keys, repeat }. Pure, so it is
330
+ * checked on every platform by test/native.js.
331
+ */
332
+ function parseChord(combo) {
333
+ const parts = String(combo || '')
334
+ .toLowerCase()
335
+ .split(/[\s+]+/)
336
+ .filter(Boolean);
337
+ if (!parts.length) return { ok: false, error: `unreadable key combination: ${combo}` };
338
+ let repeat = 1;
339
+ let flags = 0;
340
+ const keys = [];
341
+ const modifiers = [];
342
+ for (const part of parts) {
343
+ if (part === 'repeat') {
344
+ repeat = 2;
345
+ continue;
346
+ }
347
+ if (/^x\d+$/.test(part)) {
348
+ repeat = Number(part.slice(1));
349
+ continue;
350
+ }
351
+ if (MODIFIERS[part] !== undefined) {
352
+ flags |= MODIFIERS[part];
353
+ modifiers.push(part);
354
+ continue;
355
+ }
356
+ const code = keycodeFor(part);
357
+ if (code !== null) keys.push(code);
358
+ }
359
+ if (!keys.length && !modifiers.length) return { ok: false, error: `unreadable key combination: ${combo}` };
360
+ if (!keys.length) keys.push(VK.space); // a bare modifier: press it
361
+ return { ok: true, flags, keys, modifiers, repeat: Math.min(50, Math.max(1, repeat)) };
362
+ }
363
+
364
+ function pressKeys(combo) {
365
+ const b = requireCG();
366
+ const chord = parseChord(combo);
367
+ if (!chord.ok) return chord;
368
+ const { flags, keys, repeat } = chord;
369
+ for (let i = 0; i < repeat; i += 1) {
370
+ for (const code of keys) {
371
+ const down = b.CGEventCreateKeyboardEvent(null, code, true);
372
+ const up = b.CGEventCreateKeyboardEvent(null, code, false);
373
+ if (!down || !up) return { ok: false, error: 'CGEventCreateKeyboardEvent failed' };
374
+ if (flags) {
375
+ b.CGEventSetFlags(down, flags);
376
+ b.CGEventSetFlags(up, flags);
377
+ }
378
+ post(down);
379
+ post(up);
380
+ }
381
+ }
382
+ return { ok: true, keys: combo };
383
+ }
384
+
385
+ // ------------------------------------------------------------------ screen --
386
+
387
+ function screenSize() {
388
+ const b = requireCG();
389
+ const display = b.CGMainDisplayID();
390
+ return { ok: true, width: Number(b.CGDisplayPixelsWide(display)), height: Number(b.CGDisplayPixelsHigh(display)) };
391
+ }
392
+
393
+ function windows() {
394
+ const b = requireCG();
395
+ const options = 1 | 16; // kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements
396
+ const list = b.CGWindowListCopyWindowInfo(options, 0);
397
+ if (!list) return { ok: true, windows: [] };
398
+ const out = [];
399
+ try {
400
+ const count = Number(b.CFArrayGetCount(list));
401
+ for (let i = 0; i < count; i += 1) {
402
+ const dict = b.CFArrayGetValueAtIndex(list, i);
403
+ if (!dict) continue;
404
+ const name = dictValue(dict, 'kCGWindowName', true) || dictValue(dict, 'kCGWindowOwnerName', true);
405
+ if (!name) continue;
406
+ const layer = dictValue(dict, 'kCGWindowLayer', false);
407
+ if (layer !== 0) continue; // menu bar, dock, overlays
408
+ // kCGWindowBounds is a nested dictionary with X/Y/Width/Height numbers
409
+ const boundsRef = (() => {
410
+ const key = cfString('kCGWindowBounds');
411
+ try {
412
+ return b.CFDictionaryGetValue(dict, key);
413
+ } finally {
414
+ if (key) b.CFRelease(key);
415
+ }
416
+ })();
417
+ const x = boundsRef ? dictValue(boundsRef, 'X', false) : 0;
418
+ const y = boundsRef ? dictValue(boundsRef, 'Y', false) : 0;
419
+ const width = boundsRef ? dictValue(boundsRef, 'Width', false) : 0;
420
+ const height = boundsRef ? dictValue(boundsRef, 'Height', false) : 0;
421
+ if (!width || !height) continue;
422
+ out.push({
423
+ name,
424
+ handle: dictValue(dict, 'kCGWindowNumber', false),
425
+ hwnd: dictValue(dict, 'kCGWindowNumber', false),
426
+ x: Math.round(x),
427
+ y: Math.round(y),
428
+ width: Math.round(width),
429
+ height: Math.round(height),
430
+ process_id: dictValue(dict, 'kCGWindowOwnerPID', false),
431
+ status: 'normal',
432
+ });
433
+ }
434
+ } finally {
435
+ b.CFRelease(list);
436
+ }
437
+ return { ok: true, windows: out };
438
+ }
439
+
440
+ function activeWindow() {
441
+ const res = osascript('tell application "System Events" to get name of first application process whose frontmost is true');
442
+ if (!res.ok || !res.out) return { ok: true, name: '', handle: null };
443
+ const hit = windows().windows.find((w) => w.name === res.out) || null;
444
+ return { ok: true, name: res.out, handle: hit ? hit.handle : null };
445
+ }
446
+
447
+ /** CoreGraphics hands out BGRA (little endian, alpha first) with a padded row stride. */
448
+ function bgraToRgb(raw, width, height, bytesPerRow, bitsPerPixel = 32) {
449
+ const rgb = Buffer.alloc(width * height * 3);
450
+ const bpp = Math.max(4, Math.floor(bitsPerPixel / 8));
451
+ for (let y = 0; y < height; y += 1) {
452
+ for (let x = 0; x < width; x += 1) {
453
+ const src = y * bytesPerRow + x * bpp;
454
+ const dst = (y * width + x) * 3;
455
+ rgb[dst] = raw[src + 2];
456
+ rgb[dst + 1] = raw[src + 1];
457
+ rgb[dst + 2] = raw[src];
458
+ }
459
+ }
460
+ return rgb;
461
+ }
462
+
463
+ function screenshot(dest) {
464
+ const b = requireCG();
465
+ const display = b.CGMainDisplayID();
466
+ const image = b.CGDisplayCreateImage(display);
467
+ if (!image) return { ok: false, error: 'CGDisplayCreateImage failed (grant Screen Recording to the terminal)' };
468
+ try {
469
+ const width = Number(b.CGImageGetWidth(image));
470
+ const height = Number(b.CGImageGetHeight(image));
471
+ const bytesPerRow = Number(b.CGImageGetBytesPerRow(image));
472
+ const bits = Number(b.CGImageGetBitsPerPixel(image));
473
+ const provider = b.CGImageGetDataProvider(image);
474
+ const data = provider ? b.CGDataProviderCopyData(provider) : null;
475
+ if (!data) return { ok: false, error: 'CGDataProviderCopyData failed' };
476
+ const koffi = require('koffi');
477
+ try {
478
+ const length = Number(b.CFDataGetLength(data));
479
+ const ptr = b.CFDataGetBytePtr(data);
480
+ if (!ptr) return { ok: false, error: 'CFDataGetBytePtr failed' };
481
+ const raw = koffi.decode(ptr, 'uint8_t', length);
482
+ const rgb = bgraToRgb(raw, width, height, bytesPerRow, bits);
483
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
484
+ fs.writeFileSync(dest, encodePng(rgb, width, height));
485
+ return { ok: true, file: dest, width, height, mime: 'image/png' };
486
+ } finally {
487
+ b.CFRelease(data);
488
+ }
489
+ } finally {
490
+ b.CFRelease(image);
491
+ }
492
+ }
493
+
494
+ // ----------------------------------------------------- what CoreGraphics cannot do --
495
+
496
+ function osascript(script) {
497
+ const res = spawnSync('osascript', ['-e', script], { encoding: 'utf8', timeout: 20000 });
498
+ return { ok: res.status === 0, out: String(res.stdout || '').trim(), error: String(res.stderr || '').trim() };
499
+ }
500
+
501
+ function launchApp(name) {
502
+ const res = spawnSync('open', ['-a', String(name)], { encoding: 'utf8', timeout: 30000 });
503
+ return res.status === 0 ? { ok: true, app: name } : { ok: false, error: String(res.stderr || 'open -a failed').trim() };
504
+ }
505
+
506
+ function focusWindow(name) {
507
+ const res = osascript(
508
+ `tell application "System Events" to set frontmost of (first application process whose name is ${JSON.stringify(
509
+ String(name)
510
+ )}) to true`
511
+ );
512
+ return res.ok ? { ok: true, name } : { ok: false, error: res.error || `window not found: ${name}` };
513
+ }
514
+
515
+ function resizeWindow(name, x, y, width, height) {
516
+ const res = osascript(
517
+ `tell application "System Events" to tell (first application process whose name is ${JSON.stringify(
518
+ String(name)
519
+ )}) to set {position, size} of window 1 to {{${x}, ${y}}, {${width}, ${height}}}`
520
+ );
521
+ return res.ok ? { ok: true, name, x, y, width, height } : { ok: false, error: res.error || 'resize failed' };
522
+ }
523
+
524
+ function listApps() {
525
+ const out = [];
526
+ for (const dir of ['/Applications', '/System/Applications', path.join(os.homedir(), 'Applications')]) {
527
+ let entries = [];
528
+ try {
529
+ entries = fs.readdirSync(dir);
530
+ } catch {
531
+ continue;
532
+ }
533
+ for (const entry of entries) {
534
+ if (!entry.endsWith('.app')) continue;
535
+ out.push({ name: entry.replace(/\.app$/, ''), app_id: path.join(dir, entry) });
536
+ }
537
+ }
538
+ return { ok: true, apps: out };
539
+ }
540
+
541
+ function state(handle = null) {
542
+ const wins = windows();
543
+ const size = screenSize();
544
+ const active = activeWindow();
545
+ const list = wins.ok ? wins.windows : [];
546
+ const focused = handle || active.handle;
547
+ const elements = list
548
+ .filter((w) => !focused || w.handle === focused)
549
+ .map((w) => ({
550
+ name: w.name,
551
+ control_type: 'Window',
552
+ kind: 'Window',
553
+ bounding_box: { left: w.x, top: w.y, right: w.x + w.width, bottom: w.y + w.height },
554
+ center: { x: w.x + Math.floor(w.width / 2), y: w.y + Math.floor(w.height / 2) },
555
+ value: '',
556
+ enabled: true,
557
+ automation_id: '',
558
+ }));
559
+ return {
560
+ ok: true,
561
+ windows: list,
562
+ active_window: active.handle ? list.find((w) => w.handle === active.handle) || { name: active.name, handle: active.handle } : null,
563
+ interactive_elements: elements,
564
+ scrollable_elements: [],
565
+ interactive_elements_text: elements
566
+ .map((e, i) => `${i + 1}. ${e.control_type} '${e.name}' at (${e.center.x}, ${e.center.y})`)
567
+ .join('\n'),
568
+ scrollable_elements_text: '',
569
+ tree_status: 'coregraphics',
570
+ system: {
571
+ windows_version: `darwin ${os.release()}`,
572
+ os_version: os.release(),
573
+ screen_size: { width: size.width, height: size.height },
574
+ user_account_type: 'Local Account',
575
+ default_language: process.env.LANG || 'en',
576
+ dpi_scaling: 1,
577
+ },
578
+ metadata: { depth: 'top-level', kind: 'windows-list', all_desktops: false },
579
+ };
580
+ }
581
+
582
+ function available() {
583
+ if (process.platform !== 'darwin') return { ok: false, backend: 'macos', error: 'macOS only' };
584
+ try {
585
+ const size = screenSize();
586
+ if (!size.width || !size.height) throw new Error('no display');
587
+ return { ok: true, backend: 'macos', detail: 'CoreGraphics via FFI (no external tools)', screen: size };
588
+ } catch (err) {
589
+ return { ok: false, backend: 'macos', error: err.message };
590
+ }
591
+ }
592
+
593
+ function sysinfo() {
594
+ const size = screenSize();
595
+ return {
596
+ ok: true,
597
+ system: {
598
+ windows_version: `darwin ${os.release()}`,
599
+ os_version: os.release(),
600
+ screen_size: { width: size.width, height: size.height },
601
+ user_account_type: 'Local Account',
602
+ default_language: process.env.LANG || 'en',
603
+ dpi_scaling: 1,
604
+ },
605
+ };
606
+ }
607
+
608
+ const backend = {
609
+ name: 'coregraphics-ffi',
610
+ available,
611
+ screenSize,
612
+ windows,
613
+ activeWindow,
614
+ state,
615
+ moveMouse,
616
+ click,
617
+ mouseButton,
618
+ drag,
619
+ scroll,
620
+ typeText,
621
+ pressKeys,
622
+ focusWindow,
623
+ resizeWindow,
624
+ screenshot,
625
+ launchApp,
626
+ listApps,
627
+ sysinfo,
628
+ };
629
+
630
+ module.exports = {
631
+ backend,
632
+ available,
633
+ screenSize,
634
+ windows,
635
+ activeWindow,
636
+ state,
637
+ moveMouse,
638
+ click,
639
+ mouseButton,
640
+ drag,
641
+ scroll,
642
+ typeText,
643
+ pressKeys,
644
+ focusWindow,
645
+ resizeWindow,
646
+ screenshot,
647
+ launchApp,
648
+ listApps,
649
+ sysinfo,
650
+ VK,
651
+ FLAG,
652
+ MODIFIERS,
653
+ EVENT,
654
+ keycodeFor,
655
+ parseChord,
656
+ bgraToRgb,
657
+ };
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ // PNG encoding without dependencies: the screenshots of the Linux and macOS backends
4
+ // are RGB rasters, wrapped here in IHDR/IDAT/IEND with zlib.
5
+
6
+ const zlib = require('node:zlib');
7
+
8
+ function crc32(buf) {
9
+ let c = 0xffffffff;
10
+ for (let i = 0; i < buf.length; i += 1) {
11
+ c ^= buf[i];
12
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
13
+ }
14
+ return (c ^ 0xffffffff) >>> 0;
15
+ }
16
+
17
+ function pngChunk(type, data) {
18
+ const len = Buffer.alloc(4);
19
+ len.writeUInt32BE(data.length);
20
+ const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
21
+ const crc = Buffer.alloc(4);
22
+ crc.writeUInt32BE(crc32(body));
23
+ return Buffer.concat([len, body, crc]);
24
+ }
25
+
26
+ /** rgb: width*height*3 bytes, top-down, no padding. */
27
+ function encodePng(rgb, width, height) {
28
+ const stride = width * 3;
29
+ const raw = Buffer.alloc((stride + 1) * height);
30
+ for (let y = 0; y < height; y += 1) {
31
+ raw[y * (stride + 1)] = 0; // filter: none
32
+ rgb.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
33
+ }
34
+ const ihdr = Buffer.alloc(13);
35
+ ihdr.writeUInt32BE(width, 0);
36
+ ihdr.writeUInt32BE(height, 4);
37
+ ihdr[8] = 8; // bit depth
38
+ ihdr[9] = 2; // truecolour
39
+ return Buffer.concat([
40
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
41
+ pngChunk('IHDR', ihdr),
42
+ pngChunk('IDAT', zlib.deflateSync(raw, { level: 6 })),
43
+ pngChunk('IEND', Buffer.alloc(0)),
44
+ ]);
45
+ }
46
+
47
+ module.exports = { encodePng, crc32 };
package/src/automation.js CHANGED
@@ -1003,11 +1003,24 @@ let nativeKind = null;
1003
1003
 
1004
1004
  try {
1005
1005
  if (isLinux) {
1006
+ // Linux has no fallback any more: the FFI backend *is* the backend (the old one
1007
+ // shelled out to xdotool, which is exactly what we do not want).
1006
1008
  nativeBackend = require('./automation/linux').backend;
1007
1009
  nativeKind = 'x11';
1008
1010
  } else if (isWin) {
1009
- nativeBackend = require('./automation/win-dll').backend;
1010
- nativeKind = 'dll';
1011
+ // The DLL ships in the package; PowerShell stays only as a safety net for the case
1012
+ // where it cannot be loaded at all.
1013
+ const dll = require('./automation/win-dll');
1014
+ if (dll.load()) {
1015
+ nativeBackend = dll.backend;
1016
+ nativeKind = 'dll';
1017
+ }
1018
+ } else if (isMac) {
1019
+ const mac = require('./automation/mac');
1020
+ if (mac.available().ok) {
1021
+ nativeBackend = mac.backend;
1022
+ nativeKind = 'mac';
1023
+ }
1011
1024
  }
1012
1025
  } catch (err) {
1013
1026
  nativeBackend = null;
@@ -1023,7 +1036,11 @@ if (nativeBackend) {
1023
1036
  };
1024
1037
 
1025
1038
  Object.assign(automation, {
1026
- native: { kind: nativeKind, name: nativeBackend.name, dll: nativeKind === 'dll' ? require('./automation/win-dll').locate() : null },
1039
+ native: {
1040
+ kind: nativeKind,
1041
+ name: nativeBackend.name,
1042
+ dll: nativeKind === 'dll' ? require('./automation/win-dll').locate() : null,
1043
+ },
1027
1044
  available: () => nativeBackend.available(),
1028
1045
  screenSize: () => nativeBackend.screenSize(),
1029
1046
  windows: () => nativeBackend.windows(),
package/src/cli.js CHANGED
@@ -103,6 +103,7 @@ function context(flags) {
103
103
  json: Boolean(flags.json),
104
104
  confirmWrites: Boolean(flags['confirm-writes']),
105
105
  approval: normaliseApproval(flags.proceed ? 'proceed' : flags.ask ? 'ask' : cfg.approval),
106
+ shell: String(flags.shell || process.env.ARENACODE_SHELL || cfg.shell || '').trim(),
106
107
  idleTimeout: Number(flags['idle-timeout'] || cfg.idle_timeout || 180),
107
108
  bridge: new Bridge(url, key, device),
108
109
  };
@@ -194,7 +195,7 @@ function cmdConfig(positional, flags) {
194
195
  const name = key;
195
196
  const val = rest.join(' ');
196
197
  if (!name || val === '') {
197
- write(`usage: ${SELF} config set <url|key|lang|mode|approval|pc_access> <value>`);
198
+ write(`usage: ${SELF} config set <url|key|lang|mode|approval|pc_access|shell> <value>`);
198
199
  return 2;
199
200
  }
200
201
  if (name === 'approval' && !['ask', 'proceed'].includes(String(val))) {
@@ -337,6 +338,7 @@ async function runInstruction(ctx, text, { attachments = [] } = {}) {
337
338
  const result = await followTask(ctx.bridge, taskId, {
338
339
  folder: ctx.folder,
339
340
  lang: ctx.lang,
341
+ shell: ctx.shell,
340
342
  yes: ctx.yes,
341
343
  approval: ctx.approval,
342
344
  confirmWrites: ctx.confirmWrites,
@@ -400,6 +402,7 @@ async function cmdUi(flags) {
400
402
  yes: ctx.yes,
401
403
  approval: ctx.approval,
402
404
  confirmWrites: ctx.confirmWrites,
405
+ shell: ctx.shell,
403
406
  });
404
407
  await app.start();
405
408
  return 0;
@@ -688,7 +691,7 @@ async function cmdHandshake(flags) {
688
691
  const USAGE = {
689
692
  init: [['init', 'configure bridge URL + API key, then test the connection']],
690
693
  config: [['config [show|path]', 'read the saved configuration'],
691
- ['config set <key> <value>', 'url · key · lang · mode · pc_access']],
694
+ ['config set <key> <value>', 'url · key · lang · mode · pc_access · shell']],
692
695
  ping: [['ping', 'bridge status and API key check']],
693
696
  doctor: [['doctor', 'local checks: node, platform, folder, git, bridge']],
694
697
  ask: [['ask "instruction"', 'run one task and stream the timeline']],
package/src/config.js CHANGED
@@ -34,6 +34,7 @@ const DEFAULTS = {
34
34
  device: '',
35
35
  vision: false,
36
36
  idle_timeout: 180,
37
+ shell: '',
37
38
  };
38
39
 
39
40
  function readConfig() {
package/src/local.js CHANGED
@@ -48,22 +48,110 @@ function relativize(root, abs) {
48
48
  // Local terminal (local-exec)
49
49
  // --------------------------------------------------------------------------- //
50
50
 
51
- function shellCommand(shell, command) {
52
- if (process.platform === 'win32') {
53
- switch (shell) {
51
+ // Every shell the client can drive. The bridge may ask for one by name (`shell` in the
52
+ // local-exec request), the user can pin one (`arenacode config set shell zsh`, `/shell`),
53
+ // and otherwise the client takes the user's login shell, like a terminal would.
54
+ const UNIX_SHELLS = {
55
+ sh: '/bin/sh',
56
+ bash: '/bin/bash',
57
+ zsh: '/bin/zsh',
58
+ fish: '/usr/bin/fish',
59
+ dash: '/bin/dash',
60
+ ksh: '/bin/ksh',
61
+ };
62
+
63
+ const WINDOWS_SHELLS = ['powershell', 'pwsh', 'cmd', 'wsl', 'bash'];
64
+
65
+ function shellExists(file) {
66
+ try {
67
+ fs.accessSync(file, fs.constants.X_OK);
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ /** sh/bash/zsh/dash/ksh: -l login, -c command. fish spells the same as -l -c. */
75
+ function unixShell(name) {
76
+ const wanted = String(name || '').toLowerCase().replace(/\.exe$/, '');
77
+ const alias = { 'git-bash': 'bash', gitbash: 'bash' }[wanted] || wanted;
78
+ let file = UNIX_SHELLS[alias];
79
+ if (!file && (alias === 'sh' || alias.startsWith('/'))) file = alias === 'sh' ? '/bin/sh' : alias;
80
+ if (!file) file = whichPath(alias);
81
+ if (!file) file = UNIX_SHELLS[alias] || null;
82
+ if (!file) return null;
83
+ const base = path.basename(file);
84
+ const args = base === 'fish' ? ['-l', '-c', null] : ['-lc', null];
85
+ return { file, args };
86
+ }
87
+
88
+ function whichPath(name) {
89
+ for (const dir of ['/bin', '/usr/bin', '/usr/local/bin', '/opt/homebrew/bin']) {
90
+ const file = path.join(dir, name);
91
+ if (shellExists(file)) return file;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /** The shell to use when nobody asked for a specific one. */
97
+ function detectShell(platform = process.platform) {
98
+ if (platform === 'win32') {
99
+ if (shellExists('pwsh.exe') || whichPath('pwsh')) return 'pwsh';
100
+ return 'powershell';
101
+ }
102
+ const login = path.basename(process.env.SHELL || '');
103
+ if (login && (UNIX_SHELLS[login] || whichPath(login))) return login;
104
+ return 'sh';
105
+ }
106
+
107
+ /** What the client can offer on this machine — for `sysinfo`, `/shell` and the help. */
108
+ function availableShells(platform = process.platform) {
109
+ if (platform === 'win32') {
110
+ return WINDOWS_SHELLS.filter((name) => name === 'powershell' || name === 'cmd' || Boolean(whichPath(name)));
111
+ }
112
+ const found = Object.keys(UNIX_SHELLS).filter((name) => shellExists(UNIX_SHELLS[name]) || Boolean(whichPath(name)));
113
+ return found.length ? found : ['sh'];
114
+ }
115
+
116
+ /**
117
+ * Command template for one shell. `command` is passed as its own argv entry, never
118
+ * through a second round of quoting, so quotes and `$` survive.
119
+ */
120
+ function shellCommand(shell, command, platform = process.platform) {
121
+ const name = String(shell || detectShell(platform)).toLowerCase().replace(/\.exe$/, '');
122
+ if (platform === 'win32') {
123
+ switch (name) {
54
124
  case 'cmd':
55
125
  return { file: 'cmd.exe', args: ['/d', '/s', '/c', command] };
56
126
  case 'wsl':
57
127
  return { file: 'wsl.exe', args: ['sh', '-lc', command] };
128
+ case 'bash': {
129
+ // Git Bash when it is installed, otherwise the WSL bash
130
+ const gitBash = [
131
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
132
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
133
+ ].find((file) => {
134
+ try {
135
+ return fs.existsSync(file);
136
+ } catch {
137
+ return false;
138
+ }
139
+ });
140
+ return gitBash
141
+ ? { file: gitBash, args: ['-lc', command] }
142
+ : { file: 'wsl.exe', args: ['bash', '-lc', command] };
143
+ }
144
+ case 'pwsh':
145
+ return { file: 'pwsh.exe', args: ['-NoProfile', '-NonInteractive', '-Command', command] };
58
146
  case 'powershell':
147
+ case 'ps':
59
148
  default:
60
- return {
61
- file: shell === 'pwsh' ? 'pwsh.exe' : 'powershell.exe',
62
- args: ['-NoProfile', '-NonInteractive', '-Command', command],
63
- };
149
+ return { file: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-Command', command] };
64
150
  }
65
151
  }
66
- return { file: '/bin/sh', args: ['-lc', command] };
152
+ const template = unixShell(name) || unixShell('sh');
153
+ const args = template.args.map((arg) => (arg === null ? command : arg));
154
+ return { file: template.file, args };
67
155
  }
68
156
 
69
157
  /** Runs a command and collects stdout+stderr and the exit code. */
@@ -96,9 +184,12 @@ async function handleLocalExec(bridge, taskId, request, opts) {
96
184
  const { rid, command, cwd: reqCwd, shell, admin, sandbox_reason: sandboxReason } = request;
97
185
  const lang = opts.lang;
98
186
  const dir = reqCwd ? resolveInRoot(opts.folder, reqCwd) : opts.folder;
187
+ // the bridge names the shell it wrote the command for; otherwise the user's choice
188
+ // from `arenacode config set shell …` / `/shell`, otherwise the login shell
189
+ const chosenShell = shell || opts.shell || detectShell();
99
190
 
100
191
  write('');
101
- write(` ${c.bold(c.yellow('⌘ terminal'))} ${c.dim('·')} ${c.cyan(shell || 'shell')} ${c.dim('·')} ${c.gray(dir)}`);
192
+ write(` ${c.bold(c.yellow('⌘ terminal'))} ${c.dim('·')} ${c.cyan(chosenShell)} ${c.dim('·')} ${c.gray(dir)}`);
102
193
  write(indentLines(command, ' '));
103
194
  if (admin) write(` ${c.yellow('!')} ${t(lang, 'Richiede i permessi di amministratore: qui il comando gira senza UAC.', 'Needs administrator rights: here it runs without UAC.', 'Necesita permisos de administrador: aquí se ejecuta sin UAC.')}`);
104
195
  if (sandboxReason) write(` ${c.yellow('!')} ${t(lang, 'L’agente chiede di installare sul PC', 'The agent asks to install on the PC', 'El agente pide instalar en el PC')}: ${c.gray(sandboxReason)}`);
@@ -125,7 +216,7 @@ async function handleLocalExec(bridge, taskId, request, opts) {
125
216
  return true;
126
217
  }
127
218
 
128
- const { output, code } = await runShell(command, { cwd: dir, shell });
219
+ const { output, code } = await runShell(command, { cwd: dir, shell: chosenShell });
129
220
  await bridge.localResult(taskId, { rid, output, code });
130
221
  write(` ${code === 0 ? c.green('✓') : c.red('✗')} ${c.gray(`exit ${code}`)}`);
131
222
  return true;
@@ -495,7 +586,8 @@ async function handleLocalUi(bridge, taskId, request, opts) {
495
586
  cpus: os.cpus().length,
496
587
  memory_mb: Math.round(os.totalmem() / 1048576),
497
588
  node: process.version,
498
- shell: require('./meta').which('pwsh') ? 'pwsh' : process.platform === 'win32' ? 'powershell' : 'sh',
589
+ shell: detectShell(),
590
+ shells: availableShells(),
499
591
  };
500
592
  await bridge.localResult(taskId, { rid, output: JSON.stringify(info), code: 0 });
501
593
  write(` ${c.green('✓')} ${c.gray('sysinfo')}`);
@@ -683,6 +775,9 @@ async function handleLocalFile(bridge, taskId, request, opts) {
683
775
 
684
776
  module.exports = {
685
777
  runUiAction,
778
+ shellCommand,
779
+ detectShell,
780
+ availableShells,
686
781
  REFUSED,
687
782
  resolveInRoot,
688
783
  runShell,
package/src/tui/app.js CHANGED
@@ -14,7 +14,7 @@ const { getTheme, themeNames } = require('./theme');
14
14
 
15
15
  const { Bridge } = require('../bridge');
16
16
  const { buildMeta, readProjectContext } = require('../meta');
17
- const { runShell, runFsOp, resolveInRoot } = require('../local');
17
+ const { runShell, runFsOp, resolveInRoot, detectShell, availableShells, shellCommand } = require('../local');
18
18
  const { readConfig, patchConfig, deviceId, resolveUrl, resolveKey, openFolder, VERSION } = require('../config');
19
19
 
20
20
  const { MODE_LINES, modeLabel, nextMode, normaliseMode } = require('../modes');
@@ -49,6 +49,7 @@ class App {
49
49
  this.autoApprove = Boolean(opts.yes);
50
50
  this.confirmWrites = Boolean(opts.confirmWrites);
51
51
  this.approval = normaliseApproval(opts.approval);
52
+ this.shell = String(opts.shell || cfg.shell || '').trim() || detectShell();
52
53
 
53
54
  this.state = {
54
55
  mode: normaliseMode(opts.mode || cfg.mode || 'agent'),
@@ -493,6 +494,7 @@ class App {
493
494
  { slash: '/ask', title: 'Ask and Proceed: ask before commands, files and plans', hint: '', group: 'Session', run: () => this.setApproval('ask') },
494
495
  { slash: '/proceed', title: 'Proceed: approve the plan and run everything without asking', hint: '', group: 'Session', run: () => this.setApproval('proceed') },
495
496
  { slash: '/theme', title: 'Cycle the colour theme', hint: 'ctrl+x t', group: 'Session', run: by('Theme') },
497
+ { slash: '/shell', title: 'Show or change the shell commands run in', hint: '', group: 'Session', run: () => this.pickShell() },
496
498
  { slash: '/status', title: 'Ping the bridge', hint: '', group: 'Session', run: by('Bridge status') },
497
499
  { slash: '/help', title: 'Every keybinding', hint: 'f1', group: 'Session', run: () => this.openHelp() },
498
500
  { slash: '/quit', title: 'Leave arenacode', hint: 'ctrl+x q', group: 'Session', run: () => this.quit() },
@@ -538,6 +540,42 @@ class App {
538
540
  this.toast(`approval: ${next === 'proceed' ? 'Proceed' : 'Ask and Proceed'}`, next === 'proceed' ? 'warn' : 'ok');
539
541
  }
540
542
 
543
+ /** `/shell` (and the double-click on a card's shell line): pick from the shells here. */
544
+ async pickShell(name = '') {
545
+ const shells = availableShells();
546
+ const wanted = String(name || '').trim().toLowerCase();
547
+ if (wanted) {
548
+ if (!shells.includes(wanted)) {
549
+ this.toast(`shell: ${wanted} not found (${shells.join(', ')})`, 'warn');
550
+ return;
551
+ }
552
+ this.setShell(wanted);
553
+ return;
554
+ }
555
+ // a free-text prompt instead of a dialog: esc keeps the current shell
556
+ this.toast(`shell: ${this.shell} (${shells.join(', ')})`);
557
+ const current = shellCommand(this.shell).file;
558
+ const answer = String(await this.prompt(`shell [${current}]`)).trim().toLowerCase();
559
+ if (!answer) return;
560
+ if (!shells.includes(answer)) {
561
+ this.toast(`shell: ${answer} not found (${shells.join(', ')})`, 'warn');
562
+ return;
563
+ }
564
+ this.setShell(answer);
565
+ }
566
+
567
+ setShell(name) {
568
+ this.shell = name;
569
+ try {
570
+ patchConfig({ shell: name });
571
+ } catch {
572
+ /* a read-only HOME keeps the choice in memory only */
573
+ }
574
+ const { file } = shellCommand(name);
575
+ this.push({ kind: 'system', text: `shell: ${name} · ${file}` });
576
+ this.toast(`shell: ${name}`, 'ok');
577
+ }
578
+
541
579
  onOverlayKey(key) {
542
580
  const ov = this.state.overlay;
543
581
  if (key.name === 'escape') {
@@ -672,7 +710,7 @@ class App {
672
710
  const cmd = text.slice(1).trim();
673
711
  this.push({ kind: 'user', text: cmd, shell: true });
674
712
  const item = this.push({ kind: 'tool', icon: 'terminal', label: cmd, state: 'running', detail: '' });
675
- const { output, code } = await runShell(cmd, { cwd: this.folder, shell: undefined, quiet: true });
713
+ const { output, code } = await runShell(cmd, { cwd: this.folder, shell: this.shell, quiet: true });
676
714
  item.state = code === 0 ? 'done' : 'failed';
677
715
  item.detail = output.trim();
678
716
  item.secs = undefined;