@awak-app/simy-cli 0.7.34 → 0.7.36

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
@@ -276,16 +276,18 @@ Closing the window keeps the agent running. Open the app again or choose
276
276
  interrupting active tasks. The normal `simy` command remains available.
277
277
 
278
278
  The macOS **Automatic updates** switch saves this Mac's choice across restarts.
279
- Compatible signed installations enable it by default. Patch/minor updates wait
280
- for idle and verify the new runtime before handoff; failed startup restores the
281
- previous installed version. The app then switches to the verified new interface
282
- when no unfinished work or confirmation is pending. Turning the switch off keeps
279
+ Compatible signed installations enable it by default. Patch/minor updates prepare
280
+ and verify the new runtime before a safe handoff; failed startup restores the
281
+ previous installed version. Choose **Update and restart** to open the verified
282
+ new interface, or **Later** to keep using the current window. The window never
283
+ restarts automatically. Turning the switch off keeps
283
284
  manual checks and **Download DMG** available. An explicit `SIMY_AUTO_UPDATE=0`
284
285
  remains authoritative, and major updates continue to use the DMG.
285
286
  **Stop task…** in a task
286
287
  detail asks for confirmation and terminates its local Provider process, keeping
287
- existing output and history. An explicit update can likewise stop active work
288
- after confirmation; automatic updates still wait for idle. **⌘Q** exits SIMY and
288
+ existing output and history. Window updates preserve the same execution owner,
289
+ pending approvals and inputs, and scoped drafts. Older services without this
290
+ capability keep running and wait for a safe update. **⌘Q** exits SIMY and
289
291
  its background services directly, including when work is running. The window shows
290
292
  the app version, and normal app launches select the newer installed interface.
291
293
 
@@ -664,10 +666,12 @@ original argv once. A process-wide nonce/version guard prevents update loops.
664
666
  Major releases are never installed at startup: `simy update` asks for explicit
665
667
  confirmation (`simy update --yes` is available for non-interactive automation).
666
668
 
667
- The macOS desktop app runs from `/Applications/SIMY.app`. Its signed updater
668
- keeps downloaded releases in the user cache, then replaces the desktop bundle
669
- after the app and its executor have stopped. The previous bundle is retained
670
- for recovery. If permissions or verification prevent replacement, SIMY shows
669
+ The macOS desktop app starts from `/Applications/SIMY.app`. Its signed updater
670
+ keeps downloaded releases in the user cache. An explicit window restart opens
671
+ the validated immutable release while retaining its execution owner. Whole-bundle
672
+ replacement waits for processes using the bundle to exit; a busy bundle is left
673
+ in place and the selected release opens directly. The previous bundle is retained
674
+ for recovery. If permissions or verification prevent installation, SIMY shows
671
675
  an installer recovery action instead of reporting the desktop app as current.
672
676
  Concurrent handoffs share one exclusive installation lock. Packages before
673
677
  0.5.29 may require one system-authorized installer update because their bundle
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.7.34",
3
+ "version": "0.7.36",
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.34";
17
+ export const CLI_VERSION = "0.7.36";
18
18
  export const CLI_API_CONTRACT_VERSION = 10;
19
19
 
20
20
  export function narrativeImageCapabilityMetadata() {
@@ -7,7 +7,7 @@ import { execFile } from 'node:child_process';
7
7
  import { promisify } from 'node:util';
8
8
  import { webApiHeaders, webApiUrl } from './web-api.js';
9
9
  // Keep this leaf module package-safe: hooks load it before the full CLI contract.
10
- const CLI_VERSION = '0.7.34';
10
+ const CLI_VERSION = '0.7.36';
11
11
 
12
12
  const STORE = 'cli-observability-state.json';
13
13
  const LEGACY_CONFIG = 'cli-observability.json';
@@ -35,6 +35,7 @@ import { repairProviderPlugin } from './sqm/provider-plugin-repair.js';
35
35
  import { openCodexAppServer, verifyCodexArchiveState } from './session-supervision/codex-app-server.js';
36
36
  import { conversationSqmChecks, conversationSqmResult } from './session-supervision/sqm-presentation.js';
37
37
  import { stopOwnedDesktopHost } from "./desktop-force-quit.js";
38
+ import { relayToOwner } from './session-supervision/owner-transport.js';
38
39
  import { stopDesktopWorkForUpdate } from './desktop-update-stop.js';
39
40
  import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
40
41
  import { createActionDeadlineService } from './session-supervision/action-deadline.js';
@@ -82,9 +83,9 @@ import {
82
83
  } from "./desktop-process-lock.js";
83
84
 
84
85
  const silentLogger = { log() {}, error() {} };
85
- // Match the native DesktopStatusFrames limit: 200 conversation summaries can
86
- // exceed 128 KiB, including after a signed runtime handoff.
87
- const MAX_DESKTOP_FRAME_BYTES = 8 * 1024 * 1024;
86
+ // Match the native DesktopStatusFrames limit for full retained histories,
87
+ // including after a signed runtime handoff. Native delivery remains bounded.
88
+ const MAX_DESKTOP_FRAME_BYTES = 64 * 1024 * 1024;
88
89
 
89
90
  async function withReadCancellation(signal, work) {
90
91
  signal.throwIfAborted();
@@ -159,7 +160,23 @@ export async function startDesktopHost({ input = process.stdin, output = process
159
160
  const frames = createDesktopFrameWriter(output);
160
161
  const pendingRemoteReceipts = new Map();
161
162
  let operationJournal, ownerTransport;
162
- const writeEvent = event => { ownerTransport?.publish(event); frames.write(`${JSON.stringify(event)}\n`, event.type === 'status'); };
163
+ // An explicitly requested window update releases only its viewer. The same
164
+ // authenticated owner, tasks and pending permissions remain alive on IPC.
165
+ let windowUpdatePrepared = false, viewerDetached = false, relayStatus = null;
166
+ const detachViewer = () => { viewerDetached = true; frames.close(); };
167
+ output.on('error', error => {
168
+ if (windowUpdatePrepared && ['EPIPE', 'ERR_STREAM_DESTROYED'].includes(error.code)) detachViewer();
169
+ else if (!closed) void close().then(() => exit(1));
170
+ });
171
+ const writeEvent = event => {
172
+ if (event.type === 'status' && event.status) {
173
+ relayStatus = { value: event.status, at: Date.now() };
174
+ if (ownerTransport) event = { ...event, status: { ...event.status, windowUpdate: { version: 1, preservesWork: Boolean(ownerTransport), prepared: windowUpdatePrepared } } };
175
+ }
176
+ ownerTransport?.publish(event);
177
+ if (event.type === 'status') ownerTransport?.publishDesktop?.(event);
178
+ if (!viewerDetached) frames.write(`${JSON.stringify(event)}\n`, event.type === 'status');
179
+ };
163
180
  const emit = (event) => {
164
181
  const pending = event.type === 'result' ? pendingRemoteReceipts.get(event.id) : null;
165
182
  if (pending) {
@@ -181,6 +198,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
181
198
  let remoteHosts;
182
199
  const emitStatus = status => {
183
200
  status = projectCatalogFrame(status);
201
+ status = { ...status, windowUpdate: { version: 1, preservesWork: Boolean(ownerTransport), prepared: windowUpdatePrepared } };
184
202
  const updateSettings = updater?.snapshot?.() || {};
185
203
  status = { ...status, update: { ...status.update,
186
204
  auto_update_supported: updateSettings.auto_update_supported === true,
@@ -205,13 +223,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
205
223
  void controllerPreferences.change(catalogScope, state => { state.catalog = catalog; }).catch(() => {});
206
224
  }
207
225
  if (updateProgress) status = { ...status, update: { ...status.update, ...updateProgress } };
226
+ ownerTransport?.publishDesktop?.({ type: 'status', status });
208
227
  const { sampledAt, ...content } = status;
209
228
  if (!lastStatus || !isDeepStrictEqual(content, lastStatus)) {
210
229
  encodedStatus = JSON.stringify(content).slice(1, -1);
211
230
  lastStatus = structuredClone(content);
212
231
  }
213
232
  const observation = sampledAt === undefined ? '' : `"sampledAt":${JSON.stringify(sampledAt)}${encodedStatus ? ',' : ''}`;
214
- frames.write(`{"type":"status","status":{${observation}${encodedStatus}}}\n`, true);
233
+ if (!viewerDetached) frames.write(`{"type":"status","status":{${observation}${encodedStatus}}}\n`, true);
215
234
  resolveFirstStatus?.(); resolveFirstStatus = null;
216
235
  };
217
236
  const root = env.SIMY_HOME?.trim() || join(homedir(), ".simy");
@@ -953,6 +972,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
953
972
  scope: accessScope(status.access), controllerScope: controllerScope(status), deviceSession: reportDeviceSession(),
954
973
  }) && nativeDevelopmentReportsScope === accessScope(currentAccess)
955
974
  ? { ...structuredClone(cached?.nativeDevelopmentReports || {}), ...structuredClone(nativeDevelopmentReports) } : { state: 'loading', enrollments: [] };
975
+ // Local consent is durable independently of the optional server read or
976
+ // conversation enrichment. Include it even when that full projection is
977
+ // blocked; otherwise the native confirmation timer reports a false error.
978
+ const preferenceState = preferenceScope && desktopAccessReady(status.access)
979
+ ? controllerPreferences.peek(preferenceScope) : null;
980
+ const emailPreferences = Object.values(preferenceState?.conversations || {})
981
+ .flatMap(row => row.emailPreference ? [{ ...row.emailPreference.request, sync: row.emailPreference.state }] : []);
982
+ if (emailPreferences.length || status.nativeDevelopmentReports.preferences)
983
+ status.nativeDevelopmentReports.preferences = emailPreferences;
956
984
  // Cached enrichment must follow the current connection preference even
957
985
  // while a full projection is waiting on records or remote metadata.
958
986
  status.deliveryHooks.runs = linkedSessions(status.deliveryHooks.runs || []);
@@ -1439,6 +1467,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
1439
1467
  let command; try { command = JSON.parse(line); } catch { return; }
1440
1468
  if (typeof command.id !== "string" || command.id.length > 128) return;
1441
1469
  if (relay) {
1470
+ if (['prepare_window_update', 'cancel_window_update'].includes(command.action)) {
1471
+ const authorized = Boolean(ownerTransport) && relayStatus && Date.now() - relayStatus.at < 10_000 && desktopAccessReady(relayStatus.value.access);
1472
+ if (authorized) windowUpdatePrepared = command.action === 'prepare_window_update';
1473
+ emit({ type: 'result', id: command.id, ok: Boolean(authorized) }); return;
1474
+ }
1442
1475
  if (command.action === 'session_renew_claude_owner') {
1443
1476
  emit({ type: 'result', id: command.id, ok: false, message: 'Use the verified local conversation host for connection recovery.' }); return;
1444
1477
  }
@@ -1479,6 +1512,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
1479
1512
  emit({type:'result',id:command.id,ok:coordinator.snapshot().state==='connected' && (action!=='detach_update' || updater.snapshot().state==='updated')});
1480
1513
  await publish();return;
1481
1514
  }
1515
+ if (action === 'prepare_window_update' || action === 'cancel_window_update') {
1516
+ requireDesktopAccess(await accessSnapshot());
1517
+ if (action === 'prepare_window_update' && (!ownerTransport || closed || stoppingForUpdate))
1518
+ throw new Error('Keep using SIMY; the update will be available when this work finishes.');
1519
+ windowUpdatePrepared = action === 'prepare_window_update';
1520
+ emit({ type: 'result', id: command.id, ok: true });
1521
+ await publish(); return;
1522
+ }
1482
1523
  if (action === "quit") {
1483
1524
  if (command.forUpdate === true) {
1484
1525
  if (command.force !== true) throw new Error('Confirm before stopping unfinished tasks.');
@@ -2289,7 +2330,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
2289
2330
  }
2290
2331
  }
2291
2332
  };
2292
- if (command.action === "quit" || command.action === "prepare_update_restart" || command.action === "update" && (command.force === true || stoppingForUpdate)) {
2333
+ if (["prepare_window_update", "cancel_window_update"].includes(command.action) || command.action === "quit" || command.action === "prepare_update_restart" || command.action === "update" && (command.force === true || stoppingForUpdate)) {
2293
2334
  void execute();
2294
2335
  } else if (["login", "organization_select", "organization_cancel", "authorization_open", "authorization_copy"].includes(command.action)) {
2295
2336
  authorizationQueue = authorizationQueue.then(execute).catch(() => {});
@@ -2316,9 +2357,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
2316
2357
  }).catch(() => {});
2317
2358
  }
2318
2359
  });
2319
- commands.on("close", () => { if (!closed) void close().then(() => exit(0)); });
2320
- if (lock && !dependencies.startAgent) {
2321
- ownerTransport = await createOwnerTransport({ root, nonce: lock.nonce, allowedActions, allowControllerArchive: env.SIMY_REMOTE_CHILD !== '1', dispatch: command => commands.emit('line', JSON.stringify(command)) });
2360
+ commands.on("close", () => { if (windowUpdatePrepared && ownerTransport && !closed) { detachViewer(); return; } if (!closed) void close().then(() => exit(0)); });
2361
+ if (lock && (!dependencies.startAgent || dependencies.createOwnerTransport)) {
2362
+ ownerTransport = await (dependencies.createOwnerTransport || createOwnerTransport)({ root, nonce: lock.nonce, allowedActions, allowControllerArchive: env.SIMY_REMOTE_CHILD !== '1', allowDesktopClient: env.SIMY_REMOTE_CHILD !== '1', dispatch: command => commands.emit('line', JSON.stringify(command)) });
2322
2363
  await publish();
2323
2364
  }
2324
2365
  // Register controls before awaiting initial presentation. A fast, authorized
@@ -2407,6 +2448,10 @@ export function isDesktopHostEntry(entry = process.argv[1]) {
2407
2448
 
2408
2449
  function desktopStartupError(error, fallbackMessage = "SIMY could not start. Check whether another SIMY window is already running, then reconnect.") {
2409
2450
  const conflict = error?.code === PROCESS_CONFLICT_CODE;
2451
+ if (error?.code === 'desktop_owner_update_required') return {
2452
+ type: 'error', code: 'desktop_owner_update_required',
2453
+ message: 'The existing SIMY service cannot hand off its window yet. Keep using the original window. After its work finishes, quit SIMY there and reopen it to update; the running work was left untouched.',
2454
+ };
2410
2455
  return {
2411
2456
  type: "error",
2412
2457
  ...(conflict ? { code: PROCESS_CONFLICT_CODE, conflicts: error.conflicts || [] } : {}),
@@ -2425,6 +2470,7 @@ export function startDesktopEntry(options = {}) {
2425
2470
  const diagnostics = createRuntimeDiagnostics(process.env.SIMY_HOME?.trim() || join(homedir(), ".simy"), { name: "desktop-host" });
2426
2471
  // stdout is the native JSONL channel; stderr is the only unstructured output
2427
2472
  // and the native window does not display it, so keep it for support.
2473
+ process.stderr.on('error', error => { if (error.code !== 'EPIPE') diagnostics.write('stderr closed: ' + error.code); });
2428
2474
  captureRuntimeOutput(diagnostics, { stderr: process.stderr });
2429
2475
  applyLoginShellPath({ diagnostics }).catch(() => {}).then(() => startDesktopHost(options)).then((value) => { host = value; }).catch((error) => {
2430
2476
  diagnostics.write(`startup failed: ${error?.stack || error}`);
@@ -2436,6 +2482,7 @@ export function startDesktopEntry(options = {}) {
2436
2482
  if (isDesktopHostEntry()) {
2437
2483
  try {
2438
2484
  const root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy');
2485
+ if (process.env.SIMY_DESKTOP_ATTACH_OWNER === '1' && await relayToOwner({ root, webOrigin: resolveWebOrigin(null, process.env), desktopClient: true })) process.exit(0);
2439
2486
  try { await adoptInstalledRuntime({ root }); }
2440
2487
  catch (error) {
2441
2488
  if (error.code === PROCESS_CONFLICT_CODE) throw error;
@@ -12,23 +12,26 @@ export const ownerSocketPath = (root, platform = process.platform) => platform =
12
12
  const socketPath = ownerSocketPath;
13
13
  // The socket and nonce are accessible only to the local OS account. Application
14
14
  // account/provider checks still run in the owner's ordinary command handler.
15
- export async function createOwnerTransport({ root, nonce, allowedActions, dispatch, allowControllerArchive = false }) {
15
+ export async function createOwnerTransport({ root, nonce, allowedActions, dispatch, allowControllerArchive = false, allowDesktopClient = false }) {
16
16
  const writers = new WeakMap();
17
- const peers = new Set(), subscribers = new Set(), requests = new Map();
18
- let latest;
17
+ const peers = new Set(), subscribers = new Set(), desktopSubscribers = new Set(), requests = new Map();
18
+ let latest, latestDesktop;
19
19
  const send = (peer, frame) => {
20
20
  if (peer.destroyed) return;
21
- if (frame.type !== 'status' && peer.writableLength > 8 * 1024 * 1024) { peer.destroy(); return; }
21
+ // A valid full-history status can already occupy up to 64 MiB of the
22
+ // socket queue. Preserve room for bounded control receipts rather than
23
+ // disconnecting the viewer as soon as it requests a result behind it.
24
+ if (frame.type !== 'status' && peer.writableLength > (64 + 8) * 1024 * 1024) { peer.destroy(); return; }
22
25
  writers.get(peer).write(JSON.stringify(frame) + '\n', frame.type === 'status');
23
26
  };
24
27
  const server = createServer(peer => {
25
28
  peer.setEncoding('utf8');
26
29
  const writer = createDesktopFrameWriter(peer);
27
30
  writers.set(peer, writer);
28
- let authenticated = false, controllerArchive = false, buffer = '';
31
+ let authenticated = false, controllerArchive = false, desktopClient = false, buffer = '';
29
32
  const timeout = setTimeout(() => peer.destroy(), 5000);
30
33
  peer.on('error', () => {});
31
- peer.on('close', () => { writer.close(); clearTimeout(timeout); peers.delete(peer); subscribers.delete(peer); for (const [id, item] of requests) if (item.peer === peer) requests.delete(id); });
34
+ peer.on('close', () => { writer.close(); clearTimeout(timeout); peers.delete(peer); subscribers.delete(peer); desktopSubscribers.delete(peer); for (const [id, item] of requests) if (item.peer === peer) requests.delete(id); });
32
35
  peer.on('data', chunk => {
33
36
  buffer += chunk.toString();
34
37
  if (buffer.length > 16384) { peer.destroy(); return; }
@@ -39,16 +42,18 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
39
42
  if (!command || typeof command !== 'object' || Array.isArray(command)) { peer.destroy(); return; }
40
43
  if (!authenticated) {
41
44
  if (command.nonce !== nonce) { peer.destroy(); return; }
42
- authenticated = true; controllerArchive = allowControllerArchive && command.client === 'archive-controller'; clearTimeout(timeout); peers.add(peer);
45
+ 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 });
43
47
  // One-shot controls need their result, not multi-megabyte broadcasts
44
48
  // that can fill the write queue before the result is sent.
45
49
  if (command.subscribeStatus !== false) {
46
- subscribers.add(peer);
47
- if (latest) send(peer, latest);
50
+ (desktopClient ? desktopSubscribers : subscribers).add(peer);
51
+ const snapshot = desktopClient ? latestDesktop : latest;
52
+ if (snapshot) send(peer, snapshot);
48
53
  }
49
54
  continue;
50
55
  }
51
- if (!allowedActions.has(command.action) || typeof command.id !== 'string' || command.id.length > 128 || command.hostId && command.hostId !== 'local' && !(controllerArchive && ['session_archive', 'session_restore', 'session_provider_setup'].includes(command.action))) {
56
+ 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))) {
52
57
  send(peer, { type: 'result', id: command.id, ok: false, code: 'unsupported_remote_action' }); continue;
53
58
  }
54
59
  if (requests.size >= 256) { peer.destroy(); return; }
@@ -70,15 +75,23 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
70
75
  if (item) { requests.delete(frame.id); send(item.peer, { ...frame, id: item.id }); }
71
76
  } else if (frame.type === 'status' || frame.type === 'notify' && (!frame.hostId || frame.hostId === 'local')) {
72
77
  for (const peer of subscribers) send(peer, frame);
78
+ if (frame.type === 'notify') for (const peer of desktopSubscribers) send(peer, frame);
73
79
  }
74
80
  },
81
+ // Local native viewers need the same complete, scoped projection as the
82
+ // original window. Remote observers retain their local-only catalog.
83
+ publishDesktop(frame) {
84
+ if (frame.type !== 'status') return;
85
+ latestDesktop = frame;
86
+ for (const peer of desktopSubscribers) send(peer, frame);
87
+ },
75
88
  async close() { for (const peer of peers) peer.destroy(); await new Promise(resolve => server.close(resolve)); },
76
89
  };
77
90
  }
78
91
 
79
92
  // Returns false only when no compatible owner endpoint exists. Never starts a
80
93
  // second owner, kills a process, or converts a disconnect into an execution retry.
81
- export async function relayToOwner({ root, webOrigin, input = process.stdin, output = process.stdout }) {
94
+ export async function relayToOwner({ root, webOrigin, input = process.stdin, output = process.stdout, desktopClient = false }) {
82
95
  let owner;
83
96
  try { owner = JSON.parse(await readFile(join(root, 'desktop-app.lock', 'owner.json'), 'utf8')); }
84
97
  catch (error) { if (error.code === 'ENOENT') return false; throw error; }
@@ -94,7 +107,11 @@ export async function relayToOwner({ root, webOrigin, input = process.stdin, out
94
107
  if (webOrigin && (!owner.webOrigin || normalizeWebOrigin(owner.webOrigin) !== normalizeWebOrigin(webOrigin)))
95
108
  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' });
96
109
  } catch (error) { peer.destroy(); throw error; }
97
- peer.write(JSON.stringify({ nonce: owner.nonce }) + '\n');
110
+ peer.write(JSON.stringify({ nonce: owner.nonce, ...(desktopClient ? { client: 'desktop', desktopProtocol: 1 } : {}) }) + '\n');
111
+ // Old owners ignore unknown hello fields and expose only the restricted SSH
112
+ // projection. Never attach a native window or forward its controls as though
113
+ // that were a complete desktop endpoint. Keep the original owner untouched.
114
+ if (desktopClient) await confirmDesktopOwner(peer);
98
115
  peer.pipe(output, { end: false });
99
116
  const lines = createInterface({ input, crlfDelay: Infinity });
100
117
  lines.on('line', line => { if (line.length <= 16384 && !peer.destroyed) peer.write(line + '\n'); });
@@ -104,6 +121,34 @@ export async function relayToOwner({ root, webOrigin, input = process.stdin, out
104
121
  return true;
105
122
  }
106
123
 
124
+ function confirmDesktopOwner(peer) {
125
+ return new Promise((resolve, reject) => {
126
+ const incompatible = () => Object.assign(new Error('The running SIMY owner does not support desktop window handoff.'), { code: 'desktop_owner_update_required' });
127
+ let buffer = Buffer.alloc(0);
128
+ const finish = error => {
129
+ clearTimeout(timer);
130
+ peer.removeListener('data', data); peer.removeListener('close', closed); peer.removeListener('error', failed);
131
+ if (error) { peer.destroy(); reject(error); } else resolve();
132
+ };
133
+ const closed = () => finish(incompatible());
134
+ const failed = error => finish(error);
135
+ const data = chunk => {
136
+ const newline = chunk.indexOf(10);
137
+ const line = newline < 0 ? chunk : chunk.subarray(0, newline);
138
+ if (buffer.length + line.length > 16384) { finish(incompatible()); return; }
139
+ buffer = Buffer.concat([buffer, line]);
140
+ if (newline < 0) return;
141
+ 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; }
143
+ peer.pause();
144
+ if (newline + 1 < chunk.length) peer.unshift(chunk.subarray(newline + 1));
145
+ finish();
146
+ };
147
+ const timer = setTimeout(closed, 5000);
148
+ peer.on('data', data); peer.once('close', closed); peer.once('error', failed);
149
+ });
150
+ }
151
+
107
152
  // A terminal command attaches to the existing execution owner. It never starts
108
153
  // another supervisor or retries a mutation after a lost response.
109
154
  export async function requestOwnerArchive({ root, command, signal, timeoutMs = 55_000 }) {
@@ -9,7 +9,7 @@ import { compareSessionWork } from './work-state.js';
9
9
  import { manualAcceptanceCommand, matchesManualAcceptanceControls, manualAcceptanceContinuation } from './manual-acceptance-command.js';
10
10
  import { remoteHostRecovery } from './remote-host-recovery.js';
11
11
 
12
- const MAX_FRAME = 8 * 1024 * 1024;
12
+ const MAX_FRAME = 64 * 1024 * 1024;
13
13
  const MAX_HOSTS = 8;
14
14
  const aliasPattern = /^[a-zA-Z0-9][a-zA-Z0-9._@-]{0,127}$/;
15
15
  // Resolve the saved alias on every connection. A user's multiplexed SSH master
@@ -194,10 +194,18 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
194
194
  if (status.controllerEventsVersion === 1) void pullEvents(connection).catch(() => {});
195
195
  else for (const event of status.controllerEvents || []) void receiveNotification(connection, event)?.catch(() => {});
196
196
  const expectedScope = preferenceScope;
197
- void preferences.change(expectedScope, state => {
197
+ connection.statusPersistence = preferences.change(expectedScope, state => {
198
198
  for (const row of status.sessionSupervision.sessions) {
199
199
  const key = JSON.stringify([connection.id, row.provider, row.session]);
200
200
  const saved = state.conversations[key] ||= {};
201
+ // A lost command response is not proof of a failed save. Only
202
+ // this newly received, authorized host frame may resolve it;
203
+ // cached/offline projections and a requested value cannot.
204
+ const auto = saved.policy?.session_set_auto_continue;
205
+ if (['unknown', 'confirming'].includes(auto?.state) &&
206
+ typeof row.autoContinue === 'boolean' && row.autoContinue === auto.command.enabled) {
207
+ auto.state = 'applied'; delete auto.code;
208
+ }
201
209
  if (['ready','partial'].includes(row.statistics?.state)) saved.statistics = { ...row.statistics, observedAt: row.statistics.observedAt || now() };
202
210
  if (!saved.importedRemoteDisplay) {
203
211
  saved.pinned ??= row.listPinned === true;
@@ -209,7 +217,7 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
209
217
  state.hosts ||= {};
210
218
  state.hosts[connection.id] = { sessionSupervision: status.sessionSupervision, deliveryHooks: status.deliveryHooks,
211
219
  controllerCompletions: [...new Map([...(state.hosts[connection.id]?.controllerCompletions || []), ...(status.controllerCompletions || [])].map(item => [JSON.stringify([item.provider,item.provider_session_id,item.task_id]),item])).values()], tasks: status.tasks, sqmTasks: status.sqmTasks };
212
- }).catch(() => {});
220
+ }).then(changed).catch(() => {});
213
221
  }
214
222
  changed();
215
223
  } else if (frame.type === 'notify' && connection.ready && local && typeof frame.id === 'string' && ['finished','failed','paused','needs_decision'].includes(frame.kind)) {
@@ -508,6 +516,7 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
508
516
  connection.flushing = true;
509
517
  const expectedScope = preferenceScope;
510
518
  try {
519
+ await connection.statusPersistence;
511
520
  const state = await preferences.load(expectedScope);
512
521
  const pending = Object.entries(state.conversations).flatMap(([key, row]) => {
513
522
  if (JSON.parse(key)[0] !== connection.id) return [];
@@ -536,7 +545,9 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
536
545
  await preferences.change(expectedScope, data => {
537
546
  const current = data.conversations[key]?.policy?.[item.command.action];
538
547
  if (current?.command.id === item.command.id) {
539
- current.state = result.nextState || (result.ok ? 'applied' : result.code === 'remote_operation_unknown' ? 'unknown' : 'failed');
548
+ current.state = result.nextState || (result.ok
549
+ ? item.command.action === 'session_set_auto_continue' ? 'confirming' : 'applied'
550
+ : result.code === 'remote_operation_unknown' ? 'unknown' : 'failed');
540
551
  current.code = result.code;
541
552
  }
542
553
  });