@awak-app/simy-cli 0.7.42 → 0.7.43
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
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.43";
|
|
18
18
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
19
19
|
|
|
20
20
|
export function narrativeImageCapabilityMetadata() {
|
|
@@ -79,11 +79,12 @@ function applyProviderConsent(record, link) {
|
|
|
79
79
|
revision: (record.run.revision || 0) + 1,
|
|
80
80
|
...(record.run.managed ? { managed: { ...record.run.managed, autoRecovery: false } } : {}) } } : {}) };
|
|
81
81
|
}
|
|
82
|
-
async
|
|
82
|
+
const readArchiveFence = async file => JSON.parse(await readFile(file, 'utf8')).archived !== false;
|
|
83
|
+
async function applyArchiveFence(root, record, readFence = readArchiveFence) {
|
|
83
84
|
if (!record) return record;
|
|
84
85
|
const file = `${recordPath(root, record.provider, record.session)}.archive-fence`;
|
|
85
86
|
let archived;
|
|
86
|
-
try { archived =
|
|
87
|
+
try { archived = await readFence(file); }
|
|
87
88
|
catch (error) { if (error.code === 'ENOENT') return record; archived = true; }
|
|
88
89
|
if (!archived) return record;
|
|
89
90
|
return { ...record, providerArchived: true, autoContinue: false, tracked: false, monitor: false,
|
|
@@ -103,7 +104,7 @@ export async function recordProviderArchive(root, provider, session, archived, o
|
|
|
103
104
|
await rename(temporary, file);
|
|
104
105
|
} finally { await rm(temporary, { force: true }); await releaseStateLock(lock, token); }
|
|
105
106
|
}
|
|
106
|
-
async function currentProviderConsent(root, records) {
|
|
107
|
+
async function currentProviderConsent(root, records, readFence = readArchiveFence) {
|
|
107
108
|
const links = Object.fromEntries(await Promise.all(['codex', 'claude'].map(async provider => [provider, await providerLinkState(root, provider)])));
|
|
108
109
|
let archiveNames;
|
|
109
110
|
try { archiveNames = new Set((await readdir(root)).filter(name => name.endsWith('.archive-fence'))); }
|
|
@@ -111,7 +112,7 @@ async function currentProviderConsent(root, records) {
|
|
|
111
112
|
return Promise.all(records.map(record => {
|
|
112
113
|
const current = applyProviderConsent(record, links[record.provider] || {});
|
|
113
114
|
if (archiveNames && !archiveNames.has(`${basename(recordPath(root, record.provider, record.session))}.archive-fence`)) return current;
|
|
114
|
-
return applyArchiveFence(root, current);
|
|
115
|
+
return applyArchiveFence(root, current, readFence);
|
|
115
116
|
}));
|
|
116
117
|
}
|
|
117
118
|
export async function linkedProvider(root, provider) {
|
|
@@ -375,8 +376,50 @@ export function createRecordReader(root = rootDirectory(), { cacheEntries = RECO
|
|
|
375
376
|
!Number.isSafeInteger(cacheBytes) || cacheBytes < 1 || cacheBytes > RECORD_CACHE_BYTES) throw new Error('Invalid record cache limits.');
|
|
376
377
|
const cache = new Map();
|
|
377
378
|
let inFlight = null, closed = false, retainedBytes = 0;
|
|
378
|
-
const discard = name => {
|
|
379
|
+
const discard = name => {
|
|
380
|
+
const entry = cache.get(name);
|
|
381
|
+
retainedBytes -= (entry?.bytes || 0) + (entry?.fence?.bytes || 0);
|
|
382
|
+
cache.delete(name);
|
|
383
|
+
};
|
|
379
384
|
const fingerprint = info => [info.dev, info.ino, info.size, info.mtimeNs, info.ctimeNs].join(':');
|
|
385
|
+
const forgetFence = (name, entry) => {
|
|
386
|
+
if (cache.get(name) === entry) retainedBytes -= entry.fence?.bytes || 0;
|
|
387
|
+
delete entry.fence;
|
|
388
|
+
};
|
|
389
|
+
// Display consumers share a record's bounded cache entry. Each lookup still
|
|
390
|
+
// checks fresh file metadata; execution/updateRecord use the uncached reader.
|
|
391
|
+
const readCachedFence = async file => {
|
|
392
|
+
const name = basename(file).slice(0, -'.archive-fence'.length), entry = cache.get(name);
|
|
393
|
+
if (!entry) return readArchiveFence(file);
|
|
394
|
+
let before;
|
|
395
|
+
try { before = await stat(file, { bigint: true }); }
|
|
396
|
+
catch { forgetFence(name, entry); return readArchiveFence(file); }
|
|
397
|
+
if (cache.get(name) !== entry) return readArchiveFence(file);
|
|
398
|
+
const key = fingerprint(before);
|
|
399
|
+
if (entry.fence?.key === key) return entry.fence.archived;
|
|
400
|
+
// Each caller validates metadata before sharing a content read. A later
|
|
401
|
+
// caller must not join an older read when an external writer replaced it.
|
|
402
|
+
if (entry.fenceRead?.key === key) return entry.fenceRead.promise;
|
|
403
|
+
forgetFence(name, entry);
|
|
404
|
+
const load = async () => {
|
|
405
|
+
const archived = await readArchiveFence(file);
|
|
406
|
+
let after;
|
|
407
|
+
try { after = await stat(file, { bigint: true }); } catch { return archived; }
|
|
408
|
+
// Never retain a raced value or grow a second byte/entry budget. Admit
|
|
409
|
+
// fences only from spare budget; eviction still removes whole entries.
|
|
410
|
+
if (cache.get(name) === entry && fingerprint(after) === key && after.size <= BigInt(cacheBytes)) {
|
|
411
|
+
forgetFence(name, entry);
|
|
412
|
+
const bytes = Number(after.size);
|
|
413
|
+
if (retainedBytes + bytes <= cacheBytes) {
|
|
414
|
+
entry.fence = { key, archived, bytes }; retainedBytes += bytes;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return archived;
|
|
418
|
+
};
|
|
419
|
+
const pending = load().finally(() => { if (entry.fenceRead?.promise === pending) delete entry.fenceRead; });
|
|
420
|
+
entry.fenceRead = { key, promise: pending };
|
|
421
|
+
return pending;
|
|
422
|
+
};
|
|
380
423
|
const scan = async () => {
|
|
381
424
|
const names = await recordNames(root), live = new Set(names.slice(0, cacheEntries));
|
|
382
425
|
for (const name of cache.keys()) if (!live.has(name)) discard(name);
|
|
@@ -406,7 +449,7 @@ export function createRecordReader(root = rootDirectory(), { cacheEntries = RECO
|
|
|
406
449
|
if (!inFlight) inFlight = scan().finally(() => { inFlight = null; });
|
|
407
450
|
// Consumers can decorate/mutate their own snapshots without corrupting the
|
|
408
451
|
// cached records or a simultaneous caller's view.
|
|
409
|
-
return currentProviderConsent(root, structuredClone(await inFlight));
|
|
452
|
+
return currentProviderConsent(root, structuredClone(await inFlight), readCachedFence);
|
|
410
453
|
},
|
|
411
454
|
async close() { closed = true; await inFlight?.catch(() => {}); cache.clear(); retainedBytes = 0; },
|
|
412
455
|
};
|
|
@@ -3,6 +3,23 @@ import { isDeepStrictEqual } from 'node:util';
|
|
|
3
3
|
const catalogScope = status => JSON.stringify([status.webOrigin, status.auth?.userId, status.auth?.state,
|
|
4
4
|
status.access?.organizationId, status.access?.revision, status.access?.state]);
|
|
5
5
|
|
|
6
|
+
function cloneStatusMetadata(status) {
|
|
7
|
+
const supervision = status?.sessionSupervision;
|
|
8
|
+
if (!Array.isArray(supervision?.sessions)) return structuredClone(status);
|
|
9
|
+
return structuredClone({ ...status, sessionSupervision: { ...supervision,
|
|
10
|
+
sessions: [], ...(Array.isArray(supervision.selectionCandidates) ? { selectionCandidates: [] } : {}) } });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Only use with an already detached, read-only desktop snapshot. Metadata is
|
|
14
|
+
// independent; callers must replace row arrays/rows rather than mutate them.
|
|
15
|
+
export function copyOwnedDesktopStatus(status) {
|
|
16
|
+
const detached = cloneStatusMetadata(status), supervision = status?.sessionSupervision;
|
|
17
|
+
if (!Array.isArray(supervision?.sessions)) return detached;
|
|
18
|
+
detached.sessionSupervision.sessions = supervision.sessions;
|
|
19
|
+
if (Array.isArray(supervision.selectionCandidates)) detached.sessionSupervision.selectionCandidates = supervision.selectionCandidates;
|
|
20
|
+
return detached;
|
|
21
|
+
}
|
|
22
|
+
|
|
6
23
|
function updateRows(previous, incoming, full, { unchanged = false, immutableInput = false, copyOnWrite = false } = {}) {
|
|
7
24
|
if (full || previous === null || previous.length !== incoming.length) {
|
|
8
25
|
const rows = immutableInput ? incoming : structuredClone(incoming);
|
|
@@ -38,8 +55,7 @@ export function createDesktopStatusSnapshot() {
|
|
|
38
55
|
const nextScope = catalogScope(status);
|
|
39
56
|
if (nextScope !== scope) { rows = candidates = null; scope = nextScope; }
|
|
40
57
|
const hasCandidates = Array.isArray(supervision.selectionCandidates);
|
|
41
|
-
const detached =
|
|
42
|
-
sessions: [], ...(hasCandidates ? { selectionCandidates: [] } : {}) } });
|
|
58
|
+
const detached = cloneStatusMetadata(status);
|
|
43
59
|
rows = updateRows(rows, supervision.sessions, false, { copyOnWrite: true }).rows;
|
|
44
60
|
detached.sessionSupervision.sessions = rows;
|
|
45
61
|
if (hasCandidates) {
|
package/src/desktop-host.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createDesktopFrameWriter } from './desktop-frame-writer.js';
|
|
2
|
-
import { createDesktopCatalogEncoder, createDesktopStatusSnapshot } from './desktop-catalog-frames.js';
|
|
2
|
+
import { createDesktopCatalogEncoder, createDesktopStatusSnapshot, copyOwnedDesktopStatus } from './desktop-catalog-frames.js';
|
|
3
3
|
import { boundedDiscovery } from './session-supervision/progressive-discovery.js';
|
|
4
4
|
import { createCatalogRefreshPolicy } from './session-supervision/catalog-refresh.js';
|
|
5
5
|
import { supervisedSession } from './session-supervision/discovery-window.js';
|
|
@@ -221,11 +221,19 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
221
221
|
if (remoteHosts) {
|
|
222
222
|
void remoteHosts.update({ ...status, providerLinks }).catch(() => {});
|
|
223
223
|
status = remoteHosts.project(status, { providerLinks });
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
224
|
+
const supervision = status.sessionSupervision;
|
|
225
|
+
if (supervision?.sessions?.some(candidate => candidate.hostId && candidate.hostId !== 'local')) {
|
|
226
|
+
const scope = agent?.getTeamThreadsScope?.() || {};
|
|
227
227
|
const sharing = teamThreads?.snapshot();
|
|
228
|
-
|
|
228
|
+
// An unusable/scoped-out remote manager may return the borrowed input.
|
|
229
|
+
// Decorate new rows rather than modifying an older published snapshot.
|
|
230
|
+
status = { ...status, sessionSupervision: { ...supervision, sessions: supervision.sessions.map(candidate => {
|
|
231
|
+
if (!candidate.hostId || candidate.hostId === 'local') return candidate;
|
|
232
|
+
const key = teamThreadKey(scope, candidate);
|
|
233
|
+
return { ...candidate, teamSharing: { threadKey: key,
|
|
234
|
+
enabled: sharing?.own?.some(row => row.threadKey === key && row.enabled) === true,
|
|
235
|
+
pendingStop: sharing?.pendingStops?.includes(key) === true } };
|
|
236
|
+
}) } };
|
|
229
237
|
}
|
|
230
238
|
}
|
|
231
239
|
if (preferenceScope && desktopAccessReady(status.access) && status.sessionSupervision?.sessions) {
|
|
@@ -999,7 +1007,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
999
1007
|
// a full projection may have advanced it while those reads were waiting.
|
|
1000
1008
|
const sameScope = desktopAccessReady(currentAccess) &&
|
|
1001
1009
|
accessScope(lastStatus?.access) === accessScope(currentAccess);
|
|
1002
|
-
const cached = sameScope && lastStatus && controllerScope(lastStatus) === controllerScope(status) ?
|
|
1010
|
+
const cached = sameScope && lastStatus && controllerScope(lastStatus) === controllerScope(status) ? copyOwnedDesktopStatus(lastStatus) : null;
|
|
1003
1011
|
// A heartbeat is a complete frame: retain optional scoped fields until
|
|
1004
1012
|
// their replacement is ready, while fresh access/provider data wins.
|
|
1005
1013
|
if (cached) status = { ...cached, ...status };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, realpath, rm, writeFile, stat } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, realpath, rename, rm, writeFile, stat } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { windowsProcessStart, windowsUserProcesses } from "./windows-processes.js";
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
@@ -52,12 +52,18 @@ export async function acquireDesktopProcessLock(root, { pid = process.pid, alive
|
|
|
52
52
|
return acquireDesktopProcessLock(root, { pid, alive, identity, graceMs, webOrigin });
|
|
53
53
|
}
|
|
54
54
|
const nonce = randomBytes(32).toString("hex");
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
const temporary = join(directory, `owner.${nonce}.tmp`);
|
|
56
|
+
// Relays can poll while the owner is starting. Publish complete metadata in
|
|
57
|
+
// one rename so they never parse the empty/partial file created by writeFile.
|
|
58
|
+
try {
|
|
59
|
+
await writeFile(temporary, JSON.stringify({
|
|
60
|
+
pid,
|
|
61
|
+
nonce,
|
|
62
|
+
startedAt: await identity(pid),
|
|
63
|
+
webOrigin: normalizedOriginOrNull(webOrigin),
|
|
64
|
+
}), { mode: 0o600 });
|
|
65
|
+
await rename(temporary, ownerFile);
|
|
66
|
+
} finally { await rm(temporary, { force: true }); }
|
|
61
67
|
return {
|
|
62
68
|
nonce,
|
|
63
69
|
async release() {
|