@myagentroam/node 0.13.13 → 0.13.14

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/dist/connector.js CHANGED
@@ -13,7 +13,7 @@ import { NodeModelUsageService } from './service/model-usage-service.js';
13
13
  import { dirname, resolve } from 'node:path';
14
14
  import { homedir } from 'node:os';
15
15
  import WebSocket from 'ws';
16
- import { createEnvelope, directNodeCommandSchema, remoteDesktopDescriptionSchema } from '@myagentroam/protocol';
16
+ import { createEnvelope, directNodeCommandSchema, remoteDesktopDescriptionSchema, remoteDesktopDescribeInputSchema } from '@myagentroam/protocol';
17
17
  import { loadNodeConfig, computerUseRuntimeExecutable, directTransferUdpPortRange, nodeDataDirectory, nodeDatabasePath } from './config.js';
18
18
  import { NodeMetrics, nodeLog } from './operational.js';
19
19
  import { assertLocalSqlitePath } from './storage.js';
@@ -1550,16 +1550,30 @@ export class NodeConnector {
1550
1550
  service: this.agentNetworkService,
1551
1551
  observations: this.agentNetworkObservations
1552
1552
  }));
1553
- this.operationRouter.register('desktop.describe', async () => {
1553
+ this.operationRouter.register('desktop.describe', async (data) => {
1554
+ if (!isPlainRecord(data))
1555
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
1556
+ const { __workspaceWatchSessionHash: _sessionHash, __requestUserId: _userId, __usageGrant: _usageGrant, __marctlCaller: _caller, ...input } = data;
1557
+ const parsed = remoteDesktopDescribeInputSchema.safeParse(input);
1558
+ if (!parsed.success)
1559
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
1560
+ const selection = parsed.data;
1554
1561
  const runtime = this.desktopRuntime;
1555
1562
  if (!runtime)
1556
1563
  throw new RemoteDesktopError('DESKTOP_UNAVAILABLE');
1557
1564
  if (runtime instanceof WindowsRemoteDesktopRuntime ||
1558
1565
  runtime instanceof MacosRemoteDesktopRuntime)
1559
1566
  await runtime.refresh(AbortSignal.timeout(10_000));
1560
- const description = remoteDesktopDescriptionSchema.safeParse(runtime.describe());
1567
+ const description = remoteDesktopDescriptionSchema.safeParse(runtime instanceof MacosRemoteDesktopRuntime
1568
+ ? runtime.describe(selection)
1569
+ : runtime.describe());
1561
1570
  if (!description.success)
1562
1571
  throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
1572
+ if ((selection.desktopGeneration !== undefined &&
1573
+ selection.desktopGeneration !== description.data.desktopGeneration) ||
1574
+ (selection.displayId !== undefined &&
1575
+ !description.data.displays.some((display) => display.displayId === selection.displayId)))
1576
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
1563
1577
  return description.data;
1564
1578
  });
1565
1579
  this.operationRouter.registerAll(this.promptCompletion.operations());
@@ -1 +1 @@
1
- {"type":"module","version":"0.13.13"}
1
+ {"type":"module","version":"0.13.14"}
@@ -55,67 +55,161 @@ async function queryScreens(binary, signal) {
55
55
  throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
56
56
  return parsed.displays;
57
57
  }
58
- /** Owns the current interactive macOS desktop layout and one shared macVNC backend. */
58
+ /** Owns per-display capture identities and backend pools; selecting a display never changes another page. */
59
59
  export class MacosRemoteDesktopRuntime {
60
60
  options;
61
- #pool;
62
- #generation = randomUUID();
63
- #layout;
61
+ #pools = new Set();
62
+ #entries = new Map();
63
+ #screens = [];
64
64
  #signature = '';
65
65
  #revision = 0;
66
66
  #disposed = false;
67
+ #topologyAvailable = false;
68
+ #refreshTail = Promise.resolve();
67
69
  constructor(options) {
68
70
  this.options = options;
69
- this.#pool = new TigerVncBackendPool((signal, onUnexpectedExit) => MacVncManagedProcess.start({ binaryPath: options.binaryPath, onUnexpectedExit }, signal), () => options.fence('DESKTOP_BACKEND_CLOSED'));
71
+ }
72
+ #createPool(displayId) {
73
+ const pool = new TigerVncBackendPool(async (signal, onUnexpectedExit) => {
74
+ const signature = this.#signature;
75
+ const screens = await this.#probeTopology(signal);
76
+ if (JSON.stringify(screens) !== this.#signature)
77
+ this.#invalidateTopology();
78
+ const displayIndex = screens.findIndex((screen) => screen.id === displayId);
79
+ if (this.#disposed ||
80
+ !this.#topologyAvailable ||
81
+ displayIndex < 0 ||
82
+ this.#signature !== signature)
83
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
84
+ const backend = await MacVncManagedProcess.start({ binaryPath: this.options.binaryPath, displayIndex, onUnexpectedExit }, signal);
85
+ // The pool owns cleanup before the post-launch check can fail. A failed stop
86
+ // must retain the backend/reference so its attachment can retry cleanup.
87
+ return {
88
+ stop: () => backend.stop(),
89
+ connect: async (maxPixels, connectSignal) => {
90
+ const current = await this.#probeTopology(connectSignal);
91
+ if (JSON.stringify(current) !== this.#signature)
92
+ this.#invalidateTopology();
93
+ if (this.#disposed || !this.#topologyAvailable || this.#signature !== signature)
94
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
95
+ return backend.connect(maxPixels, connectSignal);
96
+ }
97
+ };
98
+ }, () => this.options.fence('DESKTOP_BACKEND_CLOSED'));
99
+ this.#pools.add(pool);
100
+ return pool;
70
101
  }
71
102
  static async start(options, signal) {
72
103
  const runtime = new MacosRemoteDesktopRuntime(options);
73
104
  await runtime.refresh(signal);
74
105
  return runtime;
75
106
  }
76
- async refresh(signal) {
77
- const screens = (await queryScreens(this.options.topologyBinary, signal)).sort((left, right) => left.id - right.id);
107
+ refresh(signal) {
108
+ const result = this.#refreshTail.then(() => this.#refresh(signal));
109
+ this.#refreshTail = result.catch(() => { });
110
+ return result;
111
+ }
112
+ #invalidateTopology() {
113
+ const wasAvailable = this.#topologyAvailable;
114
+ this.#topologyAvailable = false;
115
+ if (wasAvailable)
116
+ this.options.fence('DESKTOP_TOPOLOGY_UNAVAILABLE');
117
+ }
118
+ async #probeTopology(signal) {
119
+ if (this.#disposed)
120
+ throw new RemoteDesktopError('DESKTOP_BACKEND_CLOSED');
121
+ try {
122
+ return await queryScreens(this.options.topologyBinary, signal);
123
+ }
124
+ catch (error) {
125
+ // Closing one page aborts its probe; it is not evidence of desktop loss.
126
+ if (!signal.aborted)
127
+ this.#invalidateTopology();
128
+ throw error;
129
+ }
130
+ }
131
+ async #refresh(signal) {
132
+ // Keep native enumeration order: macVNC's -display takes an index, not a display ID.
133
+ const screens = await this.#probeTopology(signal);
78
134
  const signature = JSON.stringify(screens);
79
135
  if (this.#disposed)
80
136
  throw new RemoteDesktopError('DESKTOP_BACKEND_CLOSED');
81
- if (signature === this.#signature)
137
+ if (signature === this.#signature && this.#topologyAvailable)
82
138
  return;
83
- const layout = macosCombinedDesktopLayout(screens.filter((screen) => screen.primary), this.#generation, this.#revision + 1);
84
- if (layout.framebuffer.width * layout.framebuffer.height > this.options.maxPixels)
139
+ // Revoke old captures before validating new geometry. A failed probe/layout
140
+ // must not leave the previous grants current or revive them after recovery.
141
+ this.#invalidateTopology();
142
+ const selectableDisplays = screens.map((screen) => ({
143
+ displayId: `display_${screen.id}`,
144
+ name: `Display ${screen.id}`,
145
+ primary: screen.primary
146
+ }));
147
+ const layouts = screens.map((screen) => {
148
+ const displayId = `display_${screen.id}`;
149
+ const layout = remoteDesktopDescriptionSchema.parse({
150
+ ...macosCombinedDesktopLayout([{ ...screen, primary: true }], this.#entries.get(displayId)?.layout.desktopGeneration ?? randomUUID(), this.#revision + 1),
151
+ selectableDisplays
152
+ });
153
+ if (layout.framebuffer.width * layout.framebuffer.height > this.options.maxPixels)
154
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
155
+ return { screen, displayId, layout };
156
+ });
157
+ if (!layouts.length)
85
158
  throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
86
- if (this.#layout)
87
- this.options.fence('DESKTOP_TOPOLOGY_UNAVAILABLE');
159
+ this.#entries = new Map(layouts.map(({ screen, displayId, layout }) => [
160
+ displayId,
161
+ { layout, pool: this.#entries.get(displayId)?.pool ?? this.#createPool(screen.id) }
162
+ ]));
163
+ this.#screens = screens;
88
164
  this.#signature = signature;
89
165
  this.#revision++;
90
- this.#layout = layout;
166
+ this.#topologyAvailable = true;
91
167
  }
92
- describe() {
93
- if (this.#disposed || !this.#layout || !this.#pool.isAvailable())
168
+ describe(selection = {}) {
169
+ const entry = selection.desktopGeneration !== undefined
170
+ ? [...this.#entries.values()].find((candidate) => candidate.layout.desktopGeneration === selection.desktopGeneration)
171
+ : this.#entries.get(selection.displayId ?? `display_${this.#screens.find((screen) => screen.primary)?.id}`);
172
+ if (this.#disposed)
94
173
  throw new RemoteDesktopError('DESKTOP_BACKEND_CLOSED');
95
- return structuredClone(this.#layout);
174
+ if (!this.#topologyAvailable)
175
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
176
+ if (!entry)
177
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
178
+ if (!entry.pool.isAvailable())
179
+ throw new RemoteDesktopError('DESKTOP_BACKEND_CLOSED');
180
+ return structuredClone(entry.layout);
96
181
  }
97
182
  isCurrent = (command) => !this.#disposed &&
98
- this.#pool.isAvailable() &&
99
- !!this.#layout &&
100
- this.#layout.desktopGeneration === command.desktopGeneration &&
101
- this.#layout.topologyRevision === command.topologyRevision;
102
- createAttachment = (command, frontend, callbacks) => new PooledDesktopAttachment(this.#pool, frontend, (backend) => new RemoteDesktopAttachment({
103
- attachmentId: command.attachmentId,
104
- frontend,
105
- backend,
106
- mode: command.mode,
107
- maxPixels: this.options.maxPixels,
108
- isCurrentTopology: (width, height) => this.isCurrent(command) &&
109
- this.#layout?.framebuffer.width === width &&
110
- this.#layout.framebuffer.height === height,
111
- onAttached: callbacks.attached,
112
- onFailure: callbacks.failed
113
- }));
183
+ this.#topologyAvailable &&
184
+ [...this.#entries.values()].some((entry) => entry.pool.isAvailable() &&
185
+ entry.layout.desktopGeneration === command.desktopGeneration &&
186
+ entry.layout.topologyRevision === command.topologyRevision);
187
+ createAttachment = (command, frontend, callbacks) => {
188
+ const entry = [...this.#entries.values()].find((candidate) => candidate.layout.desktopGeneration === command.desktopGeneration);
189
+ if (!entry || !this.isCurrent(command))
190
+ throw new RemoteDesktopError('DESKTOP_TOPOLOGY_UNAVAILABLE');
191
+ return new PooledDesktopAttachment(entry.pool, frontend, (backend) => new RemoteDesktopAttachment({
192
+ attachmentId: command.attachmentId,
193
+ frontend,
194
+ backend,
195
+ mode: command.mode,
196
+ maxPixels: this.options.maxPixels,
197
+ isCurrentTopology: (width, height) => this.isCurrent(command) &&
198
+ entry.layout.framebuffer.width === width &&
199
+ entry.layout.framebuffer.height === height,
200
+ onAttached: callbacks.attached,
201
+ onFailure: callbacks.failed
202
+ }));
203
+ };
114
204
  async dispose() {
115
205
  if (!this.#disposed) {
116
206
  this.#disposed = true;
117
207
  this.options.fence('DESKTOP_SESSION_UNAVAILABLE');
118
208
  }
119
- await this.#pool.dispose();
209
+ const results = await Promise.allSettled([...this.#pools].map((pool) => pool.dispose()));
210
+ for (const result of results)
211
+ if (result.status === 'rejected')
212
+ throw result.reason;
213
+ this.#pools.clear();
120
214
  }
121
215
  }
@@ -47,6 +47,11 @@ export class MacVncManagedProcess {
47
47
  static async start(options, signal) {
48
48
  if (process.platform !== 'darwin' || !isAbsolute(options.binaryPath))
49
49
  throw new RemoteDesktopError('DESKTOP_BACKEND_CONFIG_INVALID');
50
+ if (options.displayIndex !== undefined &&
51
+ (!Number.isInteger(options.displayIndex) ||
52
+ options.displayIndex < 0 ||
53
+ options.displayIndex >= 64))
54
+ throw new RemoteDesktopError('DESKTOP_BACKEND_CONFIG_INVALID');
50
55
  const deadline = AbortSignal.any([signal, AbortSignal.timeout(10_000)]);
51
56
  if (deadline.aborted)
52
57
  throw new RemoteDesktopError('DESKTOP_SETUP_CANCELLED');
@@ -79,7 +84,8 @@ export class MacVncManagedProcess {
79
84
  '127.0.0.1',
80
85
  '-rfbauth',
81
86
  config,
82
- '-alwaysshared'
87
+ '-alwaysshared',
88
+ ...(options.displayIndex === undefined ? [] : ['-display', String(options.displayIndex)])
83
89
  ], {
84
90
  cwd: dirname(options.binaryPath),
85
91
  windowsHide: true,
@@ -47,7 +47,7 @@ export class NodeRequestService {
47
47
  await this.options.authorizeMarctlOperation(marctlCaller, payload.operation, payload.data, authorization.userId);
48
48
  }
49
49
  const usageGrant = payload.userAuthorization.usageGrant;
50
- const data = payload.data !== null && typeof payload.data === 'object'
50
+ const data = payload.data !== null && typeof payload.data === 'object' && !Array.isArray(payload.data)
51
51
  ? (() => {
52
52
  const { __marctlCaller: _untrustedCaller, __usageGrant: _untrustedGrant, __marctlExecutionResources: _untrustedExecutionResources, __requestAbortSignal: _untrustedSignal, ...input } = payload.data;
53
53
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myagentroam/node",
3
- "version": "0.13.13",
3
+ "version": "0.13.14",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/myagentroam/mar.git",
@@ -37,7 +37,7 @@
37
37
  "dependencies": {
38
38
  "@anthropic-ai/claude-agent-sdk": "0.3.233",
39
39
  "@modelcontextprotocol/sdk": "1.32.0",
40
- "@myagentroam/binaries": "0.10.3",
40
+ "@myagentroam/binaries": "0.10.4",
41
41
  "@opencode-ai/sdk": "1.18.20",
42
42
  "minimatch": "^10.2.6",
43
43
  "node-datachannel": "0.33.4",
@@ -49,14 +49,14 @@
49
49
  "turndown-plugin-gfm": "^1.0.2",
50
50
  "ws": "^8.21.3",
51
51
  "zod": "4.4.3",
52
- "@myagentroam/protocol": "0.13.13",
53
- "@myagentroam/agent": "0.13.13"
52
+ "@myagentroam/protocol": "0.13.14",
53
+ "@myagentroam/agent": "0.13.14"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/turndown": "^5.0.6",
57
57
  "@types/ws": "^8.18.1",
58
- "@myagentroam/marctl": "0.13.13",
59
- "@myagentroam/code-navigation": "0.11.1"
58
+ "@myagentroam/code-navigation": "0.11.1",
59
+ "@myagentroam/marctl": "0.13.14"
60
60
  },
61
61
  "exports": {
62
62
  "./config": {
@@ -76,6 +76,7 @@
76
76
  "test:unit": "vitest run --config vitest.config.mts && node --test test/unit/*.test.mjs",
77
77
  "test:e2e:real": "vitest run --config vitest.e2e.config.mts",
78
78
  "test:e2e:computer-use:windows": "node --test test/e2e/computer-use-windows.test.mjs",
79
- "test:e2e:remote-desktop:windows": "node --test test/e2e/remote-desktop-windows.test.mjs"
79
+ "test:e2e:remote-desktop:windows": "node --test test/e2e/remote-desktop-windows.test.mjs",
80
+ "test:e2e:remote-desktop:macos": "node --test test/e2e/remote-desktop-macos.test.mjs"
80
81
  }
81
82
  }