@awak-app/simy-cli 0.7.35 → 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
|
|
280
|
-
|
|
281
|
-
previous installed version.
|
|
282
|
-
|
|
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.
|
|
288
|
-
|
|
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
|
|
668
|
-
keeps downloaded releases in the user cache
|
|
669
|
-
|
|
670
|
-
for
|
|
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
package/src/cli-contract.js
CHANGED
|
@@ -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.
|
|
17
|
+
export const CLI_VERSION = "0.7.36";
|
|
18
18
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
19
19
|
|
|
20
20
|
export function narrativeImageCapabilityMetadata() {
|
package/src/cli-observability.js
CHANGED
|
@@ -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.
|
|
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';
|
package/src/desktop-host.js
CHANGED
|
@@ -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';
|
|
@@ -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
|
-
|
|
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,10 +12,10 @@ 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
21
|
// A valid full-history status can already occupy up to 64 MiB of the
|
|
@@ -28,10 +28,10 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
|
|
|
28
28
|
peer.setEncoding('utf8');
|
|
29
29
|
const writer = createDesktopFrameWriter(peer);
|
|
30
30
|
writers.set(peer, writer);
|
|
31
|
-
let authenticated = false, controllerArchive = false, buffer = '';
|
|
31
|
+
let authenticated = false, controllerArchive = false, desktopClient = false, buffer = '';
|
|
32
32
|
const timeout = setTimeout(() => peer.destroy(), 5000);
|
|
33
33
|
peer.on('error', () => {});
|
|
34
|
-
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); });
|
|
35
35
|
peer.on('data', chunk => {
|
|
36
36
|
buffer += chunk.toString();
|
|
37
37
|
if (buffer.length > 16384) { peer.destroy(); return; }
|
|
@@ -42,16 +42,18 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
|
|
|
42
42
|
if (!command || typeof command !== 'object' || Array.isArray(command)) { peer.destroy(); return; }
|
|
43
43
|
if (!authenticated) {
|
|
44
44
|
if (command.nonce !== nonce) { peer.destroy(); return; }
|
|
45
|
-
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 });
|
|
46
47
|
// One-shot controls need their result, not multi-megabyte broadcasts
|
|
47
48
|
// that can fill the write queue before the result is sent.
|
|
48
49
|
if (command.subscribeStatus !== false) {
|
|
49
|
-
subscribers.add(peer);
|
|
50
|
-
|
|
50
|
+
(desktopClient ? desktopSubscribers : subscribers).add(peer);
|
|
51
|
+
const snapshot = desktopClient ? latestDesktop : latest;
|
|
52
|
+
if (snapshot) send(peer, snapshot);
|
|
51
53
|
}
|
|
52
54
|
continue;
|
|
53
55
|
}
|
|
54
|
-
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))) {
|
|
55
57
|
send(peer, { type: 'result', id: command.id, ok: false, code: 'unsupported_remote_action' }); continue;
|
|
56
58
|
}
|
|
57
59
|
if (requests.size >= 256) { peer.destroy(); return; }
|
|
@@ -73,15 +75,23 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
|
|
|
73
75
|
if (item) { requests.delete(frame.id); send(item.peer, { ...frame, id: item.id }); }
|
|
74
76
|
} else if (frame.type === 'status' || frame.type === 'notify' && (!frame.hostId || frame.hostId === 'local')) {
|
|
75
77
|
for (const peer of subscribers) send(peer, frame);
|
|
78
|
+
if (frame.type === 'notify') for (const peer of desktopSubscribers) send(peer, frame);
|
|
76
79
|
}
|
|
77
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
|
+
},
|
|
78
88
|
async close() { for (const peer of peers) peer.destroy(); await new Promise(resolve => server.close(resolve)); },
|
|
79
89
|
};
|
|
80
90
|
}
|
|
81
91
|
|
|
82
92
|
// Returns false only when no compatible owner endpoint exists. Never starts a
|
|
83
93
|
// second owner, kills a process, or converts a disconnect into an execution retry.
|
|
84
|
-
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 }) {
|
|
85
95
|
let owner;
|
|
86
96
|
try { owner = JSON.parse(await readFile(join(root, 'desktop-app.lock', 'owner.json'), 'utf8')); }
|
|
87
97
|
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
@@ -97,7 +107,11 @@ export async function relayToOwner({ root, webOrigin, input = process.stdin, out
|
|
|
97
107
|
if (webOrigin && (!owner.webOrigin || normalizeWebOrigin(owner.webOrigin) !== normalizeWebOrigin(webOrigin)))
|
|
98
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' });
|
|
99
109
|
} catch (error) { peer.destroy(); throw error; }
|
|
100
|
-
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);
|
|
101
115
|
peer.pipe(output, { end: false });
|
|
102
116
|
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
103
117
|
lines.on('line', line => { if (line.length <= 16384 && !peer.destroyed) peer.write(line + '\n'); });
|
|
@@ -107,6 +121,34 @@ export async function relayToOwner({ root, webOrigin, input = process.stdin, out
|
|
|
107
121
|
return true;
|
|
108
122
|
}
|
|
109
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
|
+
|
|
110
152
|
// A terminal command attaches to the existing execution owner. It never starts
|
|
111
153
|
// another supervisor or retries a mutation after a lost response.
|
|
112
154
|
export async function requestOwnerArchive({ root, command, signal, timeoutMs = 55_000 }) {
|
|
@@ -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
|
-
|
|
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
|
|
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
|
});
|