@awak-app/simy-cli 0.7.40 → 0.7.42

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.7.40",
3
+ "version": "0.7.42",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,7 @@ import {
14
14
  } from "./durable-local-task-steps-contract.js";
15
15
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
16
16
 
17
- export const CLI_VERSION = "0.7.40";
17
+ export const CLI_VERSION = "0.7.42";
18
18
  export const CLI_API_CONTRACT_VERSION = 10;
19
19
 
20
20
  export function narrativeImageCapabilityMetadata() {
@@ -0,0 +1,85 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+
3
+ const catalogScope = status => JSON.stringify([status.webOrigin, status.auth?.userId, status.auth?.state,
4
+ status.access?.organizationId, status.access?.revision, status.access?.state]);
5
+
6
+ function updateRows(previous, incoming, full, { unchanged = false, immutableInput = false, copyOnWrite = false } = {}) {
7
+ if (full || previous === null || previous.length !== incoming.length) {
8
+ const rows = immutableInput ? incoming : structuredClone(incoming);
9
+ return { rows, payload: { rows } };
10
+ }
11
+ if (unchanged) return { rows: previous, payload: { count: previous.length, changes: [] } };
12
+ const changes = [];
13
+ let rows = previous;
14
+ for (let index = 0; index < incoming.length; index++) {
15
+ if (!isDeepStrictEqual(previous[index], incoming[index])) {
16
+ const row = immutableInput ? incoming[index] : structuredClone(incoming[index]);
17
+ if (!immutableInput) {
18
+ if (copyOnWrite && rows === previous) rows = previous.slice();
19
+ rows[index] = row;
20
+ }
21
+ changes.push([index, row]);
22
+ }
23
+ }
24
+ return { rows: immutableInput ? incoming : rows, payload: { count: incoming.length, changes } };
25
+ }
26
+
27
+ // Detach mutable producer data once. Unchanged catalog rows may be shared only
28
+ // between these owned read-only snapshots; changed rows get detached copies and
29
+ // a new array. Older queued frames and durable writes must remain immutable.
30
+ export function createDesktopStatusSnapshot() {
31
+ let rows = null, candidates = null, scope = null;
32
+ return status => {
33
+ const supervision = status?.sessionSupervision;
34
+ if (!Array.isArray(supervision?.sessions)) {
35
+ rows = candidates = scope = null;
36
+ return structuredClone(status);
37
+ }
38
+ const nextScope = catalogScope(status);
39
+ if (nextScope !== scope) { rows = candidates = null; scope = nextScope; }
40
+ const hasCandidates = Array.isArray(supervision.selectionCandidates);
41
+ const detached = structuredClone({ ...status, sessionSupervision: { ...supervision,
42
+ sessions: [], ...(hasCandidates ? { selectionCandidates: [] } : {}) } });
43
+ rows = updateRows(rows, supervision.sessions, false, { copyOnWrite: true }).rows;
44
+ detached.sessionSupervision.sessions = rows;
45
+ if (hasCandidates) {
46
+ candidates = updateRows(candidates, supervision.selectionCandidates, false, { copyOnWrite: true }).rows;
47
+ detached.sessionSupervision.selectionCandidates = candidates;
48
+ } else candidates = null;
49
+ return detached;
50
+ };
51
+ }
52
+
53
+ // One encoder per ordered output stream. Call only when a frame actually enters
54
+ // that stream, after replaceable/backpressured snapshots have been coalesced.
55
+ // This cache is transport state; it never authorizes work or provider dispatch.
56
+ export function createDesktopCatalogEncoder({ immutableInput = false } = {}) {
57
+ let rows = null, candidates = null, sourceRows = null, sourceCandidates = null, scope = null, revision = 0;
58
+ const reset = () => { rows = null; candidates = null; sourceRows = null; sourceCandidates = null; scope = null; };
59
+ return { reset, encode(frame) {
60
+ if (frame.type !== 'status' || !Array.isArray(frame.status?.sessionSupervision?.sessions)) {
61
+ if (frame.type === 'status') reset();
62
+ return JSON.stringify(frame) + '\n';
63
+ }
64
+ const status = frame.status;
65
+ const incoming = status.sessionSupervision.sessions;
66
+ const identity = catalogScope(status);
67
+ const previous = revision++;
68
+ const full = rows === null || scope !== identity || rows.length !== incoming.length;
69
+ const snapshot = updateRows(rows, incoming, full, { immutableInput, unchanged: immutableInput && sourceRows === incoming });
70
+ rows = snapshot.rows; sourceRows = incoming; scope = identity;
71
+ const catalog = { version: 1, revision, scope, ...(full ? {} : { baseRevision: previous }), ...snapshot.payload };
72
+ const { sessions, ...metadata } = status.sessionSupervision;
73
+ let supervision = metadata;
74
+ if (Array.isArray(metadata.selectionCandidates)) {
75
+ // This is another complete directory, used by the conversation picker.
76
+ // Do not silently retransmit it as supposedly small status metadata.
77
+ const snapshot = updateRows(candidates, metadata.selectionCandidates, full,
78
+ { immutableInput, unchanged: immutableInput && sourceCandidates === metadata.selectionCandidates });
79
+ candidates = snapshot.rows; sourceCandidates = metadata.selectionCandidates; catalog.selectionCandidates = snapshot.payload;
80
+ const { selectionCandidates, ...small } = metadata;
81
+ supervision = small;
82
+ } else { candidates = null; sourceCandidates = null; }
83
+ return JSON.stringify({ ...frame, status: { ...status, sessionSupervision: supervision }, catalog }) + '\n';
84
+ } };
85
+ }
@@ -1,7 +1,8 @@
1
1
  // Status snapshots are replaceable; command receipts and stop notifications are
2
2
  // not. Never enqueue periodic snapshots behind a native reader that has paused.
3
3
  // Control frames retain the stream's FIFO order and never wait for GUI drain.
4
- export function createDesktopFrameWriter(output) {
4
+ export function createDesktopFrameWriter(output, { encode = frame => frame,
5
+ capture = frame => typeof frame === 'string' ? frame : structuredClone(frame) } = {}) {
5
6
  let blocked = false, pendingStatus = null, statusClosed = false;
6
7
  const drain = () => {
7
8
  blocked = false;
@@ -11,11 +12,11 @@ export function createDesktopFrameWriter(output) {
11
12
  };
12
13
  const write = (frame, isStatus = false) => {
13
14
  if (isStatus && statusClosed) return;
14
- if (isStatus && blocked) { pendingStatus = frame; return; }
15
+ if (isStatus && blocked) { pendingStatus = capture(frame); return; }
15
16
  // A control frame must not be followed by a snapshot captured before it.
16
17
  // The next authorized projection will publish fresh state.
17
18
  if (!isStatus) pendingStatus = null;
18
- const accepted = output.write(frame);
19
+ const accepted = output.write(encode(frame));
19
20
  if (accepted === false && !blocked && !statusClosed) {
20
21
  blocked = true;
21
22
  output.once('drain', drain);
@@ -1,4 +1,5 @@
1
1
  import { createDesktopFrameWriter } from './desktop-frame-writer.js';
2
+ import { createDesktopCatalogEncoder, createDesktopStatusSnapshot } from './desktop-catalog-frames.js';
2
3
  import { boundedDiscovery } from './session-supervision/progressive-discovery.js';
3
4
  import { createCatalogRefreshPolicy } from './session-supervision/catalog-refresh.js';
4
5
  import { supervisedSession } from './session-supervision/discovery-window.js';
@@ -159,7 +160,12 @@ export { createDesktopFrameWriter } from './desktop-frame-writer.js';
159
160
 
160
161
  export async function startDesktopHost({ input = process.stdin, output = process.stdout, env = process.env,
161
162
  updatePackageRoot, dependencies = {}, exit = (code) => process.exit(code) } = {}) {
162
- const frames = createDesktopFrameWriter(output);
163
+ let catalogEncoder = env.SIMY_REMOTE_CHILD !== '1' && env.SIMY_DESKTOP_CATALOG_DELTA === '1'
164
+ ? createDesktopCatalogEncoder({ immutableInput: true }) : null;
165
+ // Object frames below own detached snapshots. Queue their references rather
166
+ // than copying multi-MiB history again whenever a native reader is paused.
167
+ const frames = createDesktopFrameWriter(output, { capture: frame => frame,
168
+ encode: frame => typeof frame === 'string' ? frame : catalogEncoder.encode(frame) });
163
169
  const pendingRemoteReceipts = new Map();
164
170
  let operationJournal, ownerTransport;
165
171
  // An explicitly requested window update releases only its viewer. The same
@@ -177,7 +183,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
177
183
  }
178
184
  ownerTransport?.publish(event);
179
185
  if (event.type === 'status') ownerTransport?.publishDesktop?.(event);
180
- if (!viewerDetached) frames.write(`${JSON.stringify(event)}\n`, event.type === 'status');
186
+ if (!viewerDetached) frames.write(catalogEncoder ? structuredClone(event) : `${JSON.stringify(event)}\n`, event.type === 'status');
181
187
  };
182
188
  const emit = (event) => {
183
189
  const pending = event.type === 'result' ? pendingRemoteReceipts.get(event.id) : null;
@@ -186,10 +192,12 @@ export async function startDesktopHost({ input = process.stdin, output = process
186
192
  void operationJournal.finish(pending.scope, pending.command, event).then(() => writeEvent(event)).catch(() => writeEvent({ type: 'result', id: event.id, ok: false, code: 'remote_operation_unknown' }));
187
193
  } else writeEvent(event);
188
194
  };
189
- // A heartbeat remains a complete native frame. Only its observation time is
190
- // volatile; reuse the encoded body after comparing freshly authorized data.
191
- // Detach the comparison value because registries may mutate nested objects.
192
- let encodedStatus = null, lastStatus = null, updateProgress = null;
195
+ // Retain complete logical snapshots for authorization-independent display.
196
+ // Legacy streams reuse the encoded body; opted-in native streams encode row
197
+ // deltas later, after backpressure coalescing. Registries may mutate objects,
198
+ // so the comparison snapshot must remain detached from producer references.
199
+ let encodedStatus = null, lastStatus = null, lastStatusFrame = null, updateProgress = null;
200
+ const snapshotStatus = createDesktopStatusSnapshot();
193
201
  let projectCatalogFrame = status => status;
194
202
  let resolveFirstStatus;
195
203
  const firstStatus = new Promise(resolve => { resolveFirstStatus = resolve; });
@@ -199,6 +207,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
199
207
  let acceptanceReadbackSequence = 0;
200
208
  let remoteHosts;
201
209
  const emitStatus = status => {
210
+ let catalogObservation;
202
211
  status = projectCatalogFrame(status);
203
212
  status = { ...status, windowUpdate: { version: 1, preservesWork: Boolean(ownerTransport), prepared: windowUpdatePrepared } };
204
213
  const updateSettings = updater?.snapshot?.() || {};
@@ -222,18 +231,26 @@ export async function startDesktopHost({ input = process.stdin, output = process
222
231
  if (preferenceScope && desktopAccessReady(status.access) && status.sessionSupervision?.sessions) {
223
232
  const catalogScope = preferenceScope;
224
233
  status = { ...status, sessionSupervision: { ...status.sessionSupervision, sessions: status.sessionSupervision.sessions.filter(row => controllerPreferences.get(catalogScope, row).deleted !== true).map(row => { const saved = controllerPreferences.get(catalogScope, row); return { ...row, controllerPreferences: saved, ...(saved.pinned === undefined ? {} : { listPinned: saved.pinned, listPinScopes: saved.pinScopes }), ...(saved.hidden === undefined ? {} : { localHidden: saved.hidden }) }; }) } };
225
- const catalog = { observedAt: Date.now(), sessions: status.sessionSupervision.sessions, hosts: status.remoteHosts?.items || [], providerLinks };
226
- void controllerPreferences.replaceCatalog(catalogScope, catalog).catch(() => {});
234
+ catalogObservation = { scope: catalogScope, observedAt: Date.now() };
227
235
  }
228
236
  if (updateProgress) status = { ...status, update: { ...status.update, ...updateProgress } };
229
- ownerTransport?.publishDesktop?.({ type: 'status', status });
230
237
  const { sampledAt, ...content } = status;
231
238
  if (!lastStatus || !isDeepStrictEqual(content, lastStatus)) {
232
- encodedStatus = JSON.stringify(content).slice(1, -1);
233
- lastStatus = structuredClone(content);
239
+ if (!catalogEncoder) encodedStatus = JSON.stringify(content).slice(1, -1);
240
+ lastStatus = snapshotStatus(content);
234
241
  }
242
+ const ownedStatus = { ...lastStatus, ...(sampledAt === undefined ? {} : { sampledAt }) };
243
+ if (catalogObservation) {
244
+ const catalog = { observedAt: catalogObservation.observedAt, sessions: ownedStatus.sessionSupervision.sessions,
245
+ hosts: ownedStatus.remoteHosts?.items || [], providerLinks: structuredClone(providerLinks) };
246
+ void controllerPreferences.replaceCatalog(catalogObservation.scope, catalog, { immutableInput: true }).catch(() => {});
247
+ }
248
+ lastStatusFrame = ownedStatus;
249
+ ownerTransport?.publishDesktop?.({ type: 'status', status: ownedStatus });
235
250
  const observation = sampledAt === undefined ? '' : `"sampledAt":${JSON.stringify(sampledAt)}${encodedStatus ? ',' : ''}`;
236
- if (!viewerDetached) frames.write(`{"type":"status","status":{${observation}${encodedStatus}}}\n`, true);
251
+ if (!viewerDetached) frames.write(catalogEncoder
252
+ ? { type: 'status', status: ownedStatus }
253
+ : `{"type":"status","status":{${observation}${encodedStatus}}}\n`, true);
237
254
  resolveFirstStatus?.(); resolveFirstStatus = null;
238
255
  };
239
256
  const root = env.SIMY_HOME?.trim() || join(homedir(), ".simy");
@@ -1314,7 +1331,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
1314
1331
  try { child = (dependencies.spawn || spawn)(installation.executablePath,
1315
1332
  [join(dirname(installation.entryPath), "desktop-host.js")], {
1316
1333
  stdio: ["pipe", "pipe", "pipe"],
1317
- env: { ...updateConnectionEnvironment({ env, root, webOrigin }), SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken, ...(transferToken ? {SIMY_UPDATE_TRANSFER_TOKEN:transferToken} : {}) },
1334
+ // The parent validates complete candidate frames and owns encoding for
1335
+ // its native viewer. Never chain delta baselines through a handoff.
1336
+ env: { ...updateConnectionEnvironment({ env, root, webOrigin }), SIMY_DESKTOP_CATALOG_DELTA: '0', SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken, ...(transferToken ? {SIMY_UPDATE_TRANSFER_TOKEN:transferToken} : {}) },
1318
1337
  }); } catch (error) {
1319
1338
  if(transferToken)await updateRuntime.reclaimFailedHandoff();
1320
1339
  actionAssociations = makeActionAssociations();
@@ -1498,6 +1517,20 @@ export async function startDesktopHost({ input = process.stdin, output = process
1498
1517
  if (line.length > 16384) return;
1499
1518
  let command; try { command = JSON.parse(line); } catch { return; }
1500
1519
  if (typeof command.id !== "string" || command.id.length > 128) return;
1520
+ if (command.action === 'desktop_status_resync') {
1521
+ catalogEncoder?.reset();
1522
+ if (command.catalogDeltas === false && catalogEncoder) {
1523
+ catalogEncoder = null;
1524
+ if (lastStatus) encodedStatus = JSON.stringify(lastStatus).slice(1, -1);
1525
+ }
1526
+ emit({ type: 'result', id: command.id, ok: true });
1527
+ // Send after the receipt: a control may discard a pending snapshot.
1528
+ const snapshot = relay ? relayStatus?.value : lastStatusFrame;
1529
+ if (!viewerDetached && snapshot) frames.write(catalogEncoder
1530
+ ? { type: 'status', status: structuredClone(snapshot) }
1531
+ : JSON.stringify({ type: 'status', status: snapshot }) + '\n', true);
1532
+ return;
1533
+ }
1501
1534
  if (relay) {
1502
1535
  if (['prepare_window_update', 'cancel_window_update'].includes(command.action)) {
1503
1536
  const authorized = Boolean(ownerTransport) && relayStatus && Date.now() - relayStatus.at < 10_000 && desktopAccessReady(relayStatus.value.access);
@@ -2520,7 +2553,8 @@ export function startDesktopEntry(options = {}) {
2520
2553
  if (isDesktopHostEntry()) {
2521
2554
  try {
2522
2555
  const root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy');
2523
- if (process.env.SIMY_DESKTOP_ATTACH_OWNER === '1' && await relayToOwner({ root, webOrigin: resolveWebOrigin(null, process.env), desktopClient: true })) process.exit(0);
2556
+ if (process.env.SIMY_DESKTOP_ATTACH_OWNER === '1' && await relayToOwner({ root, webOrigin: resolveWebOrigin(null, process.env), desktopClient: true,
2557
+ catalogDeltas: process.env.SIMY_DESKTOP_CATALOG_DELTA === '1' })) process.exit(0);
2524
2558
  try { await adoptInstalledRuntime({ root }); }
2525
2559
  catch (error) {
2526
2560
  if (error.code === PROCESS_CONFLICT_CODE) throw error;
@@ -41,11 +41,13 @@ export function createControllerStore(root) {
41
41
  await persist(scope, next); return result;
42
42
  });
43
43
  }
44
- function replaceCatalog(scope, catalog) {
44
+ function replaceCatalog(scope, catalog, { immutableInput = false } = {}) {
45
45
  // Own the incoming snapshot immediately, but do not clone the previous
46
46
  // multi-MiB catalog just to replace it. Preference writes still serialize
47
47
  // on the same queue and commit only after their atomic write succeeds.
48
- const snapshot = structuredClone(catalog);
48
+ // Only the desktop's detached read-only snapshots may be borrowed. Generic
49
+ // callers retain immediate mutation isolation, including while queued.
50
+ const snapshot = immutableInput ? catalog : structuredClone(catalog);
49
51
  return enqueue(async () => {
50
52
  const settings = { ...await load(scope) };
51
53
  delete settings.catalog;
@@ -1,4 +1,5 @@
1
1
  import { createDesktopFrameWriter } from '../desktop-frame-writer.js';
2
+ import { createDesktopCatalogEncoder } from '../desktop-catalog-frames.js';
2
3
  import { createServer, createConnection } from 'node:net';
3
4
  import { chmod, readFile } from 'node:fs/promises';
4
5
  import { join } from 'node:path';
@@ -14,6 +15,7 @@ const socketPath = ownerSocketPath;
14
15
  // account/provider checks still run in the owner's ordinary command handler.
15
16
  export async function createOwnerTransport({ root, nonce, allowedActions, dispatch, allowControllerArchive = false, allowDesktopClient = false }) {
16
17
  const writers = new WeakMap();
18
+ const catalogEncoders = new WeakMap();
17
19
  const peers = new Set(), subscribers = new Set(), desktopSubscribers = new Set(), requests = new Map();
18
20
  let latest, latestDesktop;
19
21
  const send = (peer, frame) => {
@@ -22,11 +24,11 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
22
24
  // socket queue. Preserve room for bounded control receipts rather than
23
25
  // disconnecting the viewer as soon as it requests a result behind it.
24
26
  if (frame.type !== 'status' && peer.writableLength > (64 + 8) * 1024 * 1024) { peer.destroy(); return; }
25
- writers.get(peer).write(JSON.stringify(frame) + '\n', frame.type === 'status');
27
+ writers.get(peer).write(catalogEncoders.has(peer) ? frame : JSON.stringify(frame) + '\n', frame.type === 'status');
26
28
  };
27
29
  const server = createServer(peer => {
28
30
  peer.setEncoding('utf8');
29
- const writer = createDesktopFrameWriter(peer);
31
+ const writer = createDesktopFrameWriter(peer, { encode: frame => typeof frame === 'string' ? frame : catalogEncoders.get(peer).encode(frame) });
30
32
  writers.set(peer, writer);
31
33
  let authenticated = false, controllerArchive = false, desktopClient = false, buffer = '';
32
34
  const timeout = setTimeout(() => peer.destroy(), 5000);
@@ -43,7 +45,8 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
43
45
  if (!authenticated) {
44
46
  if (command.nonce !== nonce) { peer.destroy(); return; }
45
47
  authenticated = true; desktopClient = allowDesktopClient && command.client === 'desktop'; controllerArchive = allowControllerArchive && command.client === 'archive-controller'; clearTimeout(timeout); peers.add(peer);
46
- if (command.desktopProtocol === 1 && desktopClient) send(peer, { type: 'desktop_owner_ready', version: 1 });
48
+ if (desktopClient && (command.desktopProtocol === 2 || command.catalogDeltas === 1)) catalogEncoders.set(peer, createDesktopCatalogEncoder());
49
+ if ([1, 2].includes(command.desktopProtocol) && desktopClient) send(peer, { type: 'desktop_owner_ready', version: catalogEncoders.has(peer) ? 2 : 1 });
47
50
  // One-shot controls need their result, not multi-megabyte broadcasts
48
51
  // that can fill the write queue before the result is sent.
49
52
  if (command.subscribeStatus !== false) {
@@ -53,6 +56,13 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
53
56
  }
54
57
  continue;
55
58
  }
59
+ if (desktopClient && command.action === 'desktop_status_resync' && typeof command.id === 'string' && command.id.length <= 128) {
60
+ catalogEncoders.get(peer)?.reset();
61
+ if (command.catalogDeltas === false) catalogEncoders.delete(peer);
62
+ send(peer, { type: 'result', id: command.id, ok: true });
63
+ if (latestDesktop) send(peer, latestDesktop);
64
+ continue;
65
+ }
56
66
  if ((!desktopClient && !allowedActions.has(command.action)) || typeof command.id !== 'string' || command.id.length > 128 || command.hostId && command.hostId !== 'local' && !desktopClient && !(controllerArchive && ['session_archive', 'session_restore', 'session_provider_setup'].includes(command.action))) {
57
67
  send(peer, { type: 'result', id: command.id, ok: false, code: 'unsupported_remote_action' }); continue;
58
68
  }
@@ -91,7 +101,7 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
91
101
 
92
102
  // Returns false only when no compatible owner endpoint exists. Never starts a
93
103
  // second owner, kills a process, or converts a disconnect into an execution retry.
94
- export async function relayToOwner({ root, webOrigin, input = process.stdin, output = process.stdout, desktopClient = false }) {
104
+ export async function relayToOwner({ root, webOrigin, input = process.stdin, output = process.stdout, desktopClient = false, catalogDeltas = false }) {
95
105
  let owner;
96
106
  try { owner = JSON.parse(await readFile(join(root, 'desktop-app.lock', 'owner.json'), 'utf8')); }
97
107
  catch (error) { if (error.code === 'ENOENT') return false; throw error; }
@@ -107,7 +117,9 @@ export async function relayToOwner({ root, webOrigin, input = process.stdin, out
107
117
  if (webOrigin && (!owner.webOrigin || normalizeWebOrigin(owner.webOrigin) !== normalizeWebOrigin(webOrigin)))
108
118
  throw Object.assign(new Error('The existing SIMY owner belongs to another Web address. Keep its tasks and reconnect using its saved environment.'), { code: 'remote_owner_origin_mismatch' });
109
119
  } catch (error) { peer.destroy(); throw error; }
110
- peer.write(JSON.stringify({ nonce: owner.nonce, ...(desktopClient ? { client: 'desktop', desktopProtocol: 1 } : {}) }) + '\n');
120
+ // A protocol-1 owner replies with complete frames. New native windows accept
121
+ // both versions, so attaching never requires replacing its execution owner.
122
+ peer.write(JSON.stringify({ nonce: owner.nonce, ...(desktopClient ? { client: 'desktop', desktopProtocol: 1, ...(catalogDeltas ? { catalogDeltas: 1 } : {}) } : {}) }) + '\n');
111
123
  // Old owners ignore unknown hello fields and expose only the restricted SSH
112
124
  // projection. Never attach a native window or forward its controls as though
113
125
  // that were a complete desktop endpoint. Keep the original owner untouched.
@@ -139,7 +151,7 @@ function confirmDesktopOwner(peer) {
139
151
  buffer = Buffer.concat([buffer, line]);
140
152
  if (newline < 0) return;
141
153
  let frame; try { frame = JSON.parse(buffer.toString('utf8')); } catch { finish(incompatible()); return; }
142
- if (frame?.type !== 'desktop_owner_ready' || frame.version !== 1) { finish(incompatible()); return; }
154
+ if (frame?.type !== 'desktop_owner_ready' || ![1, 2].includes(frame.version)) { finish(incompatible()); return; }
143
155
  peer.pause();
144
156
  if (newline + 1 < chunk.length) peer.unshift(chunk.subarray(newline + 1));
145
157
  finish();