@jian-ai/remote-control 0.7.2 → 0.8.1

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
@@ -43,4 +43,6 @@ jian-remote configure --gateway https://gateway.example --token WORKER_TOKEN
43
43
 
44
44
  Worker and gateway must share the same major.minor. See [docs/remote.md](../../docs/remote.md).
45
45
 
46
- Requires Node.js 24+.
46
+ Requires Node.js 24+. Published builds include a prebuilt macOS `computer-helper` for Computer
47
+ Use (no Xcode on the worker Mac). From source, run `macos/build-helper.sh` on a Mac, or let the
48
+ worker compile with `swiftc` when Command Line Tools are installed.
@@ -0,0 +1,545 @@
1
+ /**
2
+ * macOS computer-use: primary-display screenshots, coord mapping, Jian overlay helper.
3
+ * English-only messages. Imported by jian-remote.mjs on darwin.
4
+ */
5
+ import { spawn, spawnSync } from 'node:child_process';
6
+ import { createHash } from 'node:crypto';
7
+ import { existsSync } from 'node:fs';
8
+ import { chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
9
+ import { homedir } from 'node:os';
10
+ import { dirname, join } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ export const COMPUTER_CAPABILITY = 'computer-macos-v1';
14
+ const IDLE_MS = 30_000;
15
+ const HELPER_NAME = 'computer-helper';
16
+
17
+ /** Map screenshot image pixels → display points (primary). Half-pixel centers the click. */
18
+ export function imagePixelToPoint(x, y, geometry) {
19
+ const { originX, originY, pointWidth, pointHeight, widthPx, heightPx } = geometry;
20
+ if (!(widthPx > 0 && heightPx > 0 && pointWidth > 0 && pointHeight > 0)) {
21
+ throw new Error('Invalid screenshot geometry for coordinate mapping');
22
+ }
23
+ return {
24
+ pointX: originX + (x + 0.5) * (pointWidth / widthPx),
25
+ pointY: originY + (y + 0.5) * (pointHeight / heightPx),
26
+ };
27
+ }
28
+
29
+ export function scaleFromGeometry(geometry) {
30
+ return {
31
+ imageToPointX: geometry.pointWidth / geometry.widthPx,
32
+ imageToPointY: geometry.pointHeight / geometry.heightPx,
33
+ };
34
+ }
35
+
36
+ /** Capture primary display and compress like the gateway’s former screenshotCommand. */
37
+ export function capturePrimaryScreenshotBase64() {
38
+ const script = [
39
+ 'tmp=$(mktemp /tmp/jian-shot.XXXXXX.jpg);',
40
+ 'screencapture -x -t jpg -D 1 "$tmp" || { rm -f "$tmp"; exit 1; };',
41
+ 'sips -Z 1920 -s format jpeg -s formatOptions 50 "$tmp" >/dev/null 2>&1 || true;',
42
+ 'size=$(wc -c < "$tmp" | tr -d " ");',
43
+ 'if [ "$size" -gt 280000 ]; then sips -Z 1440 -s format jpeg -s formatOptions 40 "$tmp" >/dev/null 2>&1 || true; fi;',
44
+ 'size=$(wc -c < "$tmp" | tr -d " ");',
45
+ 'if [ "$size" -gt 280000 ]; then sips -Z 1280 -s format jpeg -s formatOptions 35 "$tmp" >/dev/null 2>&1 || true; fi;',
46
+ 'w=$(sips -g pixelWidth "$tmp" 2>/dev/null | awk "/pixelWidth/{print \\$2}");',
47
+ 'h=$(sips -g pixelHeight "$tmp" 2>/dev/null | awk "/pixelHeight/{print \\$2}");',
48
+ 'echo "$w $h";',
49
+ 'base64 < "$tmp";',
50
+ 'rm -f "$tmp"',
51
+ ].join(' ');
52
+ const result = spawnSync('/bin/sh', ['-lc', script], {
53
+ encoding: 'utf8',
54
+ maxBuffer: 600_000,
55
+ });
56
+ if (result.status !== 0) {
57
+ const err = (result.stderr || result.stdout || 'Screenshot failed').trim();
58
+ throw new Error(
59
+ /not authorized|capture/i.test(err)
60
+ ? 'Jian Remote needs Screen Recording permission in System Settings.'
61
+ : err || 'Screenshot failed',
62
+ );
63
+ }
64
+ const out = result.stdout || '';
65
+ const nl = out.indexOf('\n');
66
+ if (nl < 0) throw new Error('Screenshot produced no image data.');
67
+ const [wRaw, hRaw] = out.slice(0, nl).trim().split(/\s+/);
68
+ const widthPx = Number(wRaw);
69
+ const heightPx = Number(hRaw);
70
+ const base64 = out.slice(nl + 1).replace(/\s+/g, '');
71
+ if (!Number.isFinite(widthPx) || !Number.isFinite(heightPx) || widthPx < 1 || heightPx < 1) {
72
+ throw new Error('Could not read screenshot dimensions.');
73
+ }
74
+ if (!base64) throw new Error('Screenshot produced no image data.');
75
+ return { base64, widthPx, heightPx };
76
+ }
77
+
78
+ function helperPaths(home = homedir()) {
79
+ const support = join(home, 'Library', 'Application Support', 'Jian Remote');
80
+ return {
81
+ support,
82
+ source: join(support, `${HELPER_NAME}.swift`),
83
+ binary: join(support, HELPER_NAME),
84
+ stamp: join(support, `${HELPER_NAME}.sha256`),
85
+ };
86
+ }
87
+
88
+ /** Paths inside the installed npm package (or a checkout). */
89
+ export function packageHelperPaths(
90
+ packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..'),
91
+ ) {
92
+ return {
93
+ swift: join(packageRoot, 'macos', `${HELPER_NAME}.swift`),
94
+ binary: join(packageRoot, 'macos', HELPER_NAME),
95
+ };
96
+ }
97
+
98
+ function packageSwiftPath() {
99
+ return packageHelperPaths().swift;
100
+ }
101
+
102
+ function packageBinaryPath() {
103
+ return packageHelperPaths().binary;
104
+ }
105
+
106
+ function stampForBytes(bytes) {
107
+ return createHash('sha256').update(bytes).digest('hex').slice(0, 16);
108
+ }
109
+
110
+ /** Drop download quarantine so a helper from npm can exec under the LaunchAgent. */
111
+ function clearQuarantine(path) {
112
+ spawnSync('xattr', ['-d', 'com.apple.quarantine', path], { encoding: 'utf8' });
113
+ }
114
+
115
+ /**
116
+ * Prefer a Publish-built universal binary from the npm package; otherwise compile with swiftc.
117
+ * Stamp is over the installed artifact bytes so updates replace a stale helper.
118
+ */
119
+ async function ensureHelperBinary() {
120
+ const paths = helperPaths();
121
+ await mkdir(paths.support, { recursive: true, mode: 0o755 });
122
+
123
+ let previous = '';
124
+ try {
125
+ previous = (await readFile(paths.stamp, 'utf8')).trim();
126
+ } catch {
127
+ previous = '';
128
+ }
129
+
130
+ const packagedBinary = packageBinaryPath();
131
+ if (existsSync(packagedBinary)) {
132
+ const bytes = await readFile(packagedBinary);
133
+ const hash = `bin:${stampForBytes(bytes)}`;
134
+ if (previous !== hash || !existsSync(paths.binary)) {
135
+ await copyFile(packagedBinary, paths.binary);
136
+ await chmod(paths.binary, 0o755);
137
+ clearQuarantine(paths.binary);
138
+ await writeFile(paths.stamp, `${hash}\n`, { mode: 0o644 });
139
+ }
140
+ // Keep source nearby for debugging / swiftc fallback after a partial install.
141
+ try {
142
+ const swift = packageSwiftPath();
143
+ if (existsSync(swift)) await copyFile(swift, paths.source);
144
+ } catch {
145
+ // optional
146
+ }
147
+ return paths.binary;
148
+ }
149
+
150
+ let sourceText;
151
+ try {
152
+ sourceText = await readFile(packageSwiftPath(), 'utf8');
153
+ } catch {
154
+ try {
155
+ sourceText = await readFile(paths.source, 'utf8');
156
+ } catch {
157
+ throw new Error(
158
+ 'Computer-use helper is missing from this install. Reinstall or update @jian-ai/remote-control.',
159
+ );
160
+ }
161
+ }
162
+ const hash = `src:${stampForBytes(Buffer.from(sourceText, 'utf8'))}`;
163
+ const needsBuild = previous !== hash || !existsSync(paths.binary);
164
+ await writeFile(paths.source, sourceText, { mode: 0o644 });
165
+ if (needsBuild) {
166
+ const compiled = spawnSync(
167
+ 'swiftc',
168
+ ['-O', '-framework', 'AppKit', '-framework', 'Carbon', '-o', paths.binary, paths.source],
169
+ { encoding: 'utf8' },
170
+ );
171
+ if (compiled.status !== 0) {
172
+ throw new Error(
173
+ (compiled.stderr || compiled.stdout || 'swiftc failed').trim() ||
174
+ 'Could not build the Jian computer-use helper. Published packages ship a prebuilt helper; for source builds install Xcode Command Line Tools, or reinstall @jian-ai/remote-control.',
175
+ );
176
+ }
177
+ await chmod(paths.binary, 0o755);
178
+ clearQuarantine(paths.binary);
179
+ await writeFile(paths.stamp, `${hash}\n`, { mode: 0o644 });
180
+ }
181
+ return paths.binary;
182
+ }
183
+
184
+ /** Copy Swift source and any prebuilt helper next to the installed worker. */
185
+ export async function materializeComputerHelperSources(configDir) {
186
+ if (process.platform !== 'darwin') return;
187
+ try {
188
+ const pkg = packageHelperPaths();
189
+ if (!existsSync(pkg.swift) && !existsSync(pkg.binary)) return;
190
+ const support = join(homedir(), 'Library', 'Application Support', 'Jian Remote');
191
+ await mkdir(support, { recursive: true, mode: 0o755 });
192
+ const localMacos = join(configDir, 'macos');
193
+ await mkdir(localMacos, { recursive: true, mode: 0o755 });
194
+ if (existsSync(pkg.swift)) {
195
+ await copyFile(pkg.swift, join(support, `${HELPER_NAME}.swift`));
196
+ await copyFile(pkg.swift, join(localMacos, `${HELPER_NAME}.swift`));
197
+ }
198
+ if (existsSync(pkg.binary)) {
199
+ const destSupport = join(support, HELPER_NAME);
200
+ const destLocal = join(localMacos, HELPER_NAME);
201
+ await copyFile(pkg.binary, destSupport);
202
+ await copyFile(pkg.binary, destLocal);
203
+ await chmod(destSupport, 0o755);
204
+ await chmod(destLocal, 0o755);
205
+ clearQuarantine(destSupport);
206
+ clearQuarantine(destLocal);
207
+ const bytes = await readFile(pkg.binary);
208
+ await writeFile(join(support, `${HELPER_NAME}.sha256`), `bin:${stampForBytes(bytes)}\n`, {
209
+ mode: 0o644,
210
+ });
211
+ }
212
+ } catch {
213
+ // Best-effort on install; computer session will surface a clear error.
214
+ }
215
+ }
216
+
217
+ export class MacComputerSession {
218
+ constructor() {
219
+ this.child = null;
220
+ this.buffer = '';
221
+ this.pending = [];
222
+ this.geometry = null;
223
+ this.shotGeometry = null;
224
+ this.idleTimer = null;
225
+ this.active = false;
226
+ }
227
+
228
+ async ensure() {
229
+ if (this.child && !this.child.killed) {
230
+ await this.send({ cmd: 'ensureSession' });
231
+ this.active = true;
232
+ this.bumpIdle();
233
+ return;
234
+ }
235
+ const binary = await ensureHelperBinary();
236
+ this.child = spawn(binary, [], {
237
+ stdio: ['pipe', 'pipe', 'pipe'],
238
+ });
239
+ this.buffer = '';
240
+ this.child.stdout.setEncoding('utf8');
241
+ this.child.stdout.on('data', (chunk) => this.onData(chunk));
242
+ this.child.stderr.on('data', (chunk) => {
243
+ const text = chunk.toString('utf8').trim();
244
+ if (text) console.error(text);
245
+ });
246
+ this.child.on('exit', () => {
247
+ this.failAll('Computer-use helper exited unexpectedly.');
248
+ this.child = null;
249
+ this.active = false;
250
+ });
251
+ const ping = await this.send({ cmd: 'ping' });
252
+ if (!ping.ok) throw new Error(ping.error || 'Computer-use helper did not respond.');
253
+ const geo = await this.send({ cmd: 'geometry' });
254
+ if (!geo.ok) throw new Error(geo.error || 'Could not read display geometry.');
255
+ this.geometry = {
256
+ originX: Number(geo.originX) || 0,
257
+ originY: Number(geo.originY) || 0,
258
+ pointWidth: Number(geo.pointWidth),
259
+ pointHeight: Number(geo.pointHeight),
260
+ pixelWidth: Number(geo.pixelWidth),
261
+ pixelHeight: Number(geo.pixelHeight),
262
+ scaleFactor: Number(geo.scaleFactor) || 1,
263
+ };
264
+ await this.send({ cmd: 'ensureSession' });
265
+ this.active = true;
266
+ this.bumpIdle();
267
+ }
268
+
269
+ bumpIdle() {
270
+ if (this.idleTimer) clearTimeout(this.idleTimer);
271
+ this.idleTimer = setTimeout(() => {
272
+ if (!this.active || !this.child) return;
273
+ void this.send({ cmd: 'setIdle' }).catch(() => {});
274
+ }, IDLE_MS);
275
+ this.idleTimer.unref?.();
276
+ }
277
+
278
+ onData(chunk) {
279
+ this.buffer += chunk;
280
+ for (;;) {
281
+ const idx = this.buffer.indexOf('\n');
282
+ if (idx < 0) break;
283
+ const line = this.buffer.slice(0, idx).trim();
284
+ this.buffer = this.buffer.slice(idx + 1);
285
+ if (!line) continue;
286
+ let parsed;
287
+ try {
288
+ parsed = JSON.parse(line);
289
+ } catch {
290
+ this.failNext('Computer-use helper returned invalid JSON.');
291
+ continue;
292
+ }
293
+ const next = this.pending.shift();
294
+ if (next) next.resolve(parsed);
295
+ }
296
+ }
297
+
298
+ failNext(message) {
299
+ const next = this.pending.shift();
300
+ if (next) next.reject(new Error(message));
301
+ }
302
+
303
+ failAll(message) {
304
+ while (this.pending.length) {
305
+ this.pending.shift().reject(new Error(message));
306
+ }
307
+ }
308
+
309
+ send(payload) {
310
+ return new Promise((resolve, reject) => {
311
+ if (!this.child?.stdin) {
312
+ reject(new Error('Computer-use helper is not running.'));
313
+ return;
314
+ }
315
+ this.pending.push({ resolve, reject });
316
+ this.child.stdin.write(`${JSON.stringify(payload)}\n`, (error) => {
317
+ if (error) {
318
+ this.pending.pop();
319
+ reject(error);
320
+ }
321
+ });
322
+ });
323
+ }
324
+
325
+ requireShotGeometry() {
326
+ if (!this.shotGeometry) {
327
+ throw new Error('Take a screenshot before using screen coordinates.');
328
+ }
329
+ return this.shotGeometry;
330
+ }
331
+
332
+ mapCoordinate(coordinate) {
333
+ const [x, y] = coordinate;
334
+ const shot = this.requireShotGeometry();
335
+ if (x < 0 || y < 0 || x >= shot.widthPx || y >= shot.heightPx) {
336
+ throw new Error(
337
+ `Coordinate (${x}, ${y}) is outside the last screenshot (${shot.widthPx}×${shot.heightPx}).`,
338
+ );
339
+ }
340
+ return imagePixelToPoint(x, y, shot);
341
+ }
342
+
343
+ async end() {
344
+ if (this.idleTimer) clearTimeout(this.idleTimer);
345
+ this.idleTimer = null;
346
+ if (this.child) {
347
+ try {
348
+ await this.send({ cmd: 'endSession' });
349
+ } catch {
350
+ // ignore
351
+ }
352
+ this.child.kill('SIGTERM');
353
+ this.child = null;
354
+ }
355
+ this.active = false;
356
+ this.shotGeometry = null;
357
+ this.geometry = null;
358
+ }
359
+
360
+ async runAction(computer) {
361
+ const { action } = computer;
362
+ if (action === 'end_session') {
363
+ await this.end();
364
+ return {
365
+ exitCode: 0,
366
+ stdout: '',
367
+ stderr: '',
368
+ timedOut: false,
369
+ computer: { ok: true },
370
+ };
371
+ }
372
+
373
+ if (action === 'wait') {
374
+ const ms = Math.round((computer.duration ?? 0.5) * 1000);
375
+ await new Promise((r) => setTimeout(r, ms));
376
+ if (this.active) this.bumpIdle();
377
+ return {
378
+ exitCode: 0,
379
+ stdout: '',
380
+ stderr: '',
381
+ timedOut: false,
382
+ computer: { ok: true },
383
+ };
384
+ }
385
+
386
+ if (action === 'screenshot') {
387
+ await this.ensure();
388
+ const shot = capturePrimaryScreenshotBase64();
389
+ if (!this.geometry) throw new Error('Display geometry unavailable.');
390
+ this.shotGeometry = {
391
+ ...this.geometry,
392
+ widthPx: shot.widthPx,
393
+ heightPx: shot.heightPx,
394
+ };
395
+ this.bumpIdle();
396
+ return {
397
+ exitCode: 0,
398
+ stdout: shot.base64,
399
+ stderr: '',
400
+ timedOut: false,
401
+ computer: {
402
+ ok: true,
403
+ widthPx: shot.widthPx,
404
+ heightPx: shot.heightPx,
405
+ scale: scaleFromGeometry(this.shotGeometry),
406
+ },
407
+ };
408
+ }
409
+
410
+ await this.ensure();
411
+
412
+ try {
413
+ if (
414
+ action === 'mouse_move' ||
415
+ action === 'left_click' ||
416
+ action === 'right_click' ||
417
+ action === 'double_click'
418
+ ) {
419
+ if (!computer.coordinate) throw new Error('coordinate is required for this action.');
420
+ const { pointX, pointY } = this.mapCoordinate(computer.coordinate);
421
+ const moved = await this.send({ cmd: 'move', pointX, pointY });
422
+ if (!moved.ok) throw new Error(moved.error || 'Move failed.');
423
+ if (action !== 'mouse_move') {
424
+ const button = action === 'right_click' ? 'right' : 'left';
425
+ const count = action === 'double_click' ? 2 : 1;
426
+ const clicked = await this.send({ cmd: 'click', button, count });
427
+ if (!clicked.ok) throw new Error(clicked.error || 'Click failed.');
428
+ }
429
+ } else if (action === 'left_click_drag') {
430
+ if (!computer.startCoordinate || !computer.coordinate) {
431
+ throw new Error('startCoordinate and coordinate are required for drag.');
432
+ }
433
+ const from = this.mapCoordinate(computer.startCoordinate);
434
+ const to = this.mapCoordinate(computer.coordinate);
435
+ const dragged = await this.send({
436
+ cmd: 'drag',
437
+ fromX: from.pointX,
438
+ fromY: from.pointY,
439
+ toX: to.pointX,
440
+ toY: to.pointY,
441
+ });
442
+ if (!dragged.ok) throw new Error(dragged.error || 'Drag failed.');
443
+ } else if (action === 'type') {
444
+ if (!computer.text) throw new Error('text is required for type.');
445
+ const typed = await this.send({ cmd: 'type', text: computer.text });
446
+ if (!typed.ok) throw new Error(typed.error || 'Type failed.');
447
+ } else if (action === 'key') {
448
+ if (!computer.key) throw new Error('key is required for key.');
449
+ const keyed = await this.send({ cmd: 'key', key: computer.key });
450
+ if (!keyed.ok) throw new Error(keyed.error || 'Key failed.');
451
+ } else if (action === 'scroll') {
452
+ if (!computer.coordinate) throw new Error('coordinate is required for scroll.');
453
+ if (!computer.scrollDirection) throw new Error('scrollDirection is required for scroll.');
454
+ const { pointX, pointY } = this.mapCoordinate(computer.coordinate);
455
+ const scrolled = await this.send({
456
+ cmd: 'scroll',
457
+ pointX,
458
+ pointY,
459
+ direction: computer.scrollDirection,
460
+ amount: computer.scrollAmount ?? 3,
461
+ });
462
+ if (!scrolled.ok) throw new Error(scrolled.error || 'Scroll failed.');
463
+ } else {
464
+ throw new Error(`Unsupported computer action: ${action}`);
465
+ }
466
+ } catch (error) {
467
+ const message = error instanceof Error ? error.message : String(error);
468
+ const tcc = /accessib|not permitted|not authorized/i.test(message)
469
+ ? ' Jian Remote needs Accessibility (and Screen Recording for screenshots) in System Settings.'
470
+ : '';
471
+ return {
472
+ exitCode: 1,
473
+ stdout: '',
474
+ stderr: `${message}${tcc}`,
475
+ timedOut: false,
476
+ computer: { ok: false, error: `${message}${tcc}`.trim() },
477
+ };
478
+ }
479
+
480
+ this.bumpIdle();
481
+ return {
482
+ exitCode: 0,
483
+ stdout: '',
484
+ stderr: '',
485
+ timedOut: false,
486
+ computer: { ok: true },
487
+ };
488
+ }
489
+ }
490
+
491
+ let sharedSession = null;
492
+
493
+ export function computerSession() {
494
+ if (!sharedSession) sharedSession = new MacComputerSession();
495
+ return sharedSession;
496
+ }
497
+
498
+ export async function executeComputerTask(task) {
499
+ const session = computerSession();
500
+ const computer = task.computer;
501
+ if (!computer?.action) {
502
+ return {
503
+ taskId: task.id,
504
+ exitCode: 1,
505
+ stdout: '',
506
+ stderr: 'Computer task missing action.',
507
+ timedOut: false,
508
+ computer: { ok: false, error: 'Computer task missing action.' },
509
+ };
510
+ }
511
+ const timeoutMs = task.timeoutMs || 30_000;
512
+ let timedOut = false;
513
+ const result = await Promise.race([
514
+ session.runAction(computer),
515
+ new Promise((resolve) => {
516
+ setTimeout(() => {
517
+ timedOut = true;
518
+ resolve({
519
+ exitCode: null,
520
+ stdout: '',
521
+ stderr: 'Computer action timed out',
522
+ timedOut: true,
523
+ computer: { ok: false, error: 'Computer action timed out' },
524
+ });
525
+ }, timeoutMs);
526
+ }),
527
+ ]);
528
+ return { taskId: task.id, ...result, timedOut: timedOut || Boolean(result.timedOut) };
529
+ }
530
+
531
+ /** Probe whether this Mac can advertise computer-use (helper builds). */
532
+ export async function probeComputerCapability() {
533
+ if (process.platform !== 'darwin') return false;
534
+ try {
535
+ await ensureHelperBinary();
536
+ return true;
537
+ } catch {
538
+ // Still advertise: screenshot-only path can work; input fails with TCC guidance.
539
+ // Prefer advertising when swiftc exists so gateway can send jobs after first grant.
540
+ return (
541
+ Boolean(spawnSync('which', ['swiftc'], { encoding: 'utf8' }).stdout?.trim()) ||
542
+ existsSync(helperPaths().binary)
543
+ );
544
+ }
545
+ }
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn, spawnSync } from 'node:child_process';
3
3
  import { createHash } from 'node:crypto';
4
- import { mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs';
4
+ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs';
5
5
  import { chmod, copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
6
6
  import { homedir, tmpdir } from 'node:os';
7
- import { join, resolve } from 'node:path';
7
+ import { dirname, join, resolve } from 'node:path';
8
8
  import { fileURLToPath, pathToFileURL } from 'node:url';
9
9
 
10
10
  const configDir = join(homedir(), '.config', 'jian-remote');
@@ -21,7 +21,7 @@ const option = (name) => {
21
21
  * Stamped to the release tag on npm publish and when the gateway serves this script.
22
22
  * Compatibility with the gateway requires the same major.minor (see versionsCompatible).
23
23
  */
24
- export const PACKAGE_VERSION = '0.7.2';
24
+ export const PACKAGE_VERSION = '0.8.1';
25
25
 
26
26
  /** Soft stdout/stderr caps while capturing a command (chars). Gateway schema matches these. */
27
27
  const STDOUT_CAP = 400_000;
@@ -256,6 +256,7 @@ export function cappedAppend(current, chunk, cap = STDOUT_CAP) {
256
256
  export function fitResult(result, budget = RESULT_BODY_BUDGET) {
257
257
  let stdout = result.stdout ?? '';
258
258
  let stderr = result.stderr ?? '';
259
+ const computer = result.computer;
259
260
  const pack = () =>
260
261
  JSON.stringify({
261
262
  taskId: result.taskId,
@@ -263,10 +264,14 @@ export function fitResult(result, budget = RESULT_BODY_BUDGET) {
263
264
  stdout,
264
265
  stderr,
265
266
  timedOut: Boolean(result.timedOut),
267
+ ...(computer ? { computer } : {}),
266
268
  });
267
269
  let body = pack();
268
270
  if (Buffer.byteLength(body) <= budget) {
269
- return { result: { ...result, stdout, stderr, timedOut: Boolean(result.timedOut) }, body };
271
+ return {
272
+ result: { ...result, stdout, stderr, timedOut: Boolean(result.timedOut), computer },
273
+ body,
274
+ };
270
275
  }
271
276
 
272
277
  const note = '\n… truncated to fit the gateway size limit';
@@ -481,6 +486,22 @@ async function materializeWorkerScript(sourceBytes) {
481
486
  if (source !== installedScript) await copyFile(source, installedScript);
482
487
  }
483
488
  if (process.platform !== 'win32') await chmod(installedScript, 0o700);
489
+ // Companion modules + macOS helper source (computer use). Best-effort beside the worker.
490
+ try {
491
+ const here = dirname(fileURLToPath(import.meta.url));
492
+ const computerSrc = join(here, 'computer-macos.mjs');
493
+ const computerDest = join(configDir, 'computer-macos.mjs');
494
+ if (existsSync(computerSrc) && computerSrc !== computerDest) {
495
+ await copyFile(computerSrc, computerDest);
496
+ if (process.platform !== 'win32') await chmod(computerDest, 0o700);
497
+ }
498
+ if (process.platform === 'darwin') {
499
+ const { materializeComputerHelperSources } = await import('./computer-macos.mjs');
500
+ await materializeComputerHelperSources(configDir);
501
+ }
502
+ } catch {
503
+ // Install still succeeds; computer-use probe will explain if the helper is missing.
504
+ }
484
505
  return installedScript;
485
506
  }
486
507
 
@@ -731,7 +752,7 @@ async function update() {
731
752
  printSuccess('Updated Jian Remote', facts);
732
753
  }
733
754
 
734
- async function execute(task) {
755
+ async function executeShell(task) {
735
756
  return await new Promise((resolveResult) => {
736
757
  const command = process.platform === 'win32' ? 'powershell.exe' : '/bin/sh';
737
758
  const commandArgs =
@@ -793,6 +814,24 @@ async function execute(task) {
793
814
  });
794
815
  }
795
816
 
817
+ async function execute(task) {
818
+ if (task.kind === 'computer') {
819
+ if (process.platform !== 'darwin') {
820
+ return {
821
+ taskId: task.id,
822
+ exitCode: 1,
823
+ stdout: '',
824
+ stderr: 'Computer use is supported on macOS Jian Remote only.',
825
+ timedOut: false,
826
+ computer: { ok: false, error: 'Computer use is supported on macOS Jian Remote only.' },
827
+ };
828
+ }
829
+ const { executeComputerTask } = await import('./computer-macos.mjs');
830
+ return executeComputerTask(task);
831
+ }
832
+ return executeShell(task);
833
+ }
834
+
796
835
  async function updateIfNeeded(base, headers) {
797
836
  // Prefer the installed copy under ~/.config so updates land where the service points.
798
837
  const script = installedScript;
@@ -900,16 +939,26 @@ async function postResult(base, headers, result) {
900
939
 
901
940
  async function run() {
902
941
  const config = await loadConfig();
942
+ const capabilities = [];
943
+ if (process.platform === 'darwin') {
944
+ try {
945
+ const { probeComputerCapability, COMPUTER_CAPABILITY } = await import('./computer-macos.mjs');
946
+ if (await probeComputerCapability()) capabilities.push(COMPUTER_CAPABILITY);
947
+ } catch {
948
+ // Computer use unavailable until helper builds; shell tasks still work.
949
+ }
950
+ }
903
951
  const headers = {
904
952
  authorization: `Bearer ${config.token}`,
905
953
  'x-jian-platform': `${process.platform}-${process.arch}`,
906
954
  'x-jian-remote-version': PACKAGE_VERSION,
955
+ ...(capabilities.length ? { 'x-jian-remote-capabilities': capabilities.join(',') } : {}),
907
956
  };
908
957
  const base = `${config.gateway}/v1/remote/worker/${config.machineId}`;
909
958
  // Fail fast before the poll loop so an incompatible build does not look "online then stuck".
910
959
  const gatewayVersion = await assertCompatibleWithGateway(config.gateway);
911
960
  console.log(
912
- `Connected to Jian Remote at ${config.gateway} (worker ${PACKAGE_VERSION}${gatewayVersion ? `, gateway ${gatewayVersion}` : ''})`,
961
+ `Connected to Jian Remote at ${config.gateway} (worker ${PACKAGE_VERSION}${gatewayVersion ? `, gateway ${gatewayVersion}` : ''}${capabilities.length ? `, capabilities ${capabilities.join(',')}` : ''})`,
913
962
  );
914
963
  let failures = 0;
915
964
  let nextUpdateCheck = 0;