@awak-app/simy-cli 0.7.41 → 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.7.41",
3
+ "version": "0.7.43",
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.41";
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 function applyArchiveFence(root, record) {
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 = JSON.parse(await readFile(file, 'utf8')).archived !== false; }
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 => { retainedBytes -= cache.get(name)?.bytes || 0; cache.delete(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
  };
@@ -1,20 +1,69 @@
1
1
  import { isDeepStrictEqual } from 'node:util';
2
2
 
3
- function updateRows(previous, incoming, full, unchanged = false) {
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 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
+
23
+ function updateRows(previous, incoming, full, { unchanged = false, immutableInput = false, copyOnWrite = false } = {}) {
4
24
  if (full || previous === null || previous.length !== incoming.length) {
5
- const rows = structuredClone(incoming);
25
+ const rows = immutableInput ? incoming : structuredClone(incoming);
6
26
  return { rows, payload: { rows } };
7
27
  }
8
28
  if (unchanged) return { rows: previous, payload: { count: previous.length, changes: [] } };
9
29
  const changes = [];
30
+ let rows = previous;
10
31
  for (let index = 0; index < incoming.length; index++) {
11
32
  if (!isDeepStrictEqual(previous[index], incoming[index])) {
12
- const row = structuredClone(incoming[index]);
13
- previous[index] = row;
33
+ const row = immutableInput ? incoming[index] : structuredClone(incoming[index]);
34
+ if (!immutableInput) {
35
+ if (copyOnWrite && rows === previous) rows = previous.slice();
36
+ rows[index] = row;
37
+ }
14
38
  changes.push([index, row]);
15
39
  }
16
40
  }
17
- return { rows: previous, payload: { count: previous.length, changes } };
41
+ return { rows: immutableInput ? incoming : rows, payload: { count: incoming.length, changes } };
42
+ }
43
+
44
+ // Detach mutable producer data once. Unchanged catalog rows may be shared only
45
+ // between these owned read-only snapshots; changed rows get detached copies and
46
+ // a new array. Older queued frames and durable writes must remain immutable.
47
+ export function createDesktopStatusSnapshot() {
48
+ let rows = null, candidates = null, scope = null;
49
+ return status => {
50
+ const supervision = status?.sessionSupervision;
51
+ if (!Array.isArray(supervision?.sessions)) {
52
+ rows = candidates = scope = null;
53
+ return structuredClone(status);
54
+ }
55
+ const nextScope = catalogScope(status);
56
+ if (nextScope !== scope) { rows = candidates = null; scope = nextScope; }
57
+ const hasCandidates = Array.isArray(supervision.selectionCandidates);
58
+ const detached = cloneStatusMetadata(status);
59
+ rows = updateRows(rows, supervision.sessions, false, { copyOnWrite: true }).rows;
60
+ detached.sessionSupervision.sessions = rows;
61
+ if (hasCandidates) {
62
+ candidates = updateRows(candidates, supervision.selectionCandidates, false, { copyOnWrite: true }).rows;
63
+ detached.sessionSupervision.selectionCandidates = candidates;
64
+ } else candidates = null;
65
+ return detached;
66
+ };
18
67
  }
19
68
 
20
69
  // One encoder per ordered output stream. Call only when a frame actually enters
@@ -30,11 +79,10 @@ export function createDesktopCatalogEncoder({ immutableInput = false } = {}) {
30
79
  }
31
80
  const status = frame.status;
32
81
  const incoming = status.sessionSupervision.sessions;
33
- const identity = JSON.stringify([status.webOrigin, status.auth?.userId, status.auth?.state, status.access?.organizationId,
34
- status.access?.revision, status.access?.state]);
82
+ const identity = catalogScope(status);
35
83
  const previous = revision++;
36
84
  const full = rows === null || scope !== identity || rows.length !== incoming.length;
37
- const snapshot = updateRows(rows, incoming, full, immutableInput && sourceRows === incoming);
85
+ const snapshot = updateRows(rows, incoming, full, { immutableInput, unchanged: immutableInput && sourceRows === incoming });
38
86
  rows = snapshot.rows; sourceRows = incoming; scope = identity;
39
87
  const catalog = { version: 1, revision, scope, ...(full ? {} : { baseRevision: previous }), ...snapshot.payload };
40
88
  const { sessions, ...metadata } = status.sessionSupervision;
@@ -42,7 +90,8 @@ export function createDesktopCatalogEncoder({ immutableInput = false } = {}) {
42
90
  if (Array.isArray(metadata.selectionCandidates)) {
43
91
  // This is another complete directory, used by the conversation picker.
44
92
  // Do not silently retransmit it as supposedly small status metadata.
45
- const snapshot = updateRows(candidates, metadata.selectionCandidates, full, immutableInput && sourceCandidates === metadata.selectionCandidates);
93
+ const snapshot = updateRows(candidates, metadata.selectionCandidates, full,
94
+ { immutableInput, unchanged: immutableInput && sourceCandidates === metadata.selectionCandidates });
46
95
  candidates = snapshot.rows; sourceCandidates = metadata.selectionCandidates; catalog.selectionCandidates = snapshot.payload;
47
96
  const { selectionCandidates, ...small } = metadata;
48
97
  supervision = small;
@@ -1,5 +1,5 @@
1
1
  import { createDesktopFrameWriter } from './desktop-frame-writer.js';
2
- import { createDesktopCatalogEncoder } 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';
@@ -197,6 +197,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
197
197
  // deltas later, after backpressure coalescing. Registries may mutate objects,
198
198
  // so the comparison snapshot must remain detached from producer references.
199
199
  let encodedStatus = null, lastStatus = null, lastStatusFrame = null, updateProgress = null;
200
+ const snapshotStatus = createDesktopStatusSnapshot();
200
201
  let projectCatalogFrame = status => status;
201
202
  let resolveFirstStatus;
202
203
  const firstStatus = new Promise(resolve => { resolveFirstStatus = resolve; });
@@ -206,6 +207,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
206
207
  let acceptanceReadbackSequence = 0;
207
208
  let remoteHosts;
208
209
  const emitStatus = status => {
210
+ let catalogObservation;
209
211
  status = projectCatalogFrame(status);
210
212
  status = { ...status, windowUpdate: { version: 1, preservesWork: Boolean(ownerTransport), prepared: windowUpdatePrepared } };
211
213
  const updateSettings = updater?.snapshot?.() || {};
@@ -219,26 +221,38 @@ export async function startDesktopHost({ input = process.stdin, output = process
219
221
  if (remoteHosts) {
220
222
  void remoteHosts.update({ ...status, providerLinks }).catch(() => {});
221
223
  status = remoteHosts.project(status, { providerLinks });
222
- for (const candidate of status.sessionSupervision?.sessions || []) {
223
- if (!candidate.hostId || candidate.hostId === 'local') continue;
224
- const key = teamThreadKey(agent?.getTeamThreadsScope?.() || {}, candidate);
224
+ const supervision = status.sessionSupervision;
225
+ if (supervision?.sessions?.some(candidate => candidate.hostId && candidate.hostId !== 'local')) {
226
+ const scope = agent?.getTeamThreadsScope?.() || {};
225
227
  const sharing = teamThreads?.snapshot();
226
- candidate.teamSharing = { threadKey: key, enabled: sharing?.own?.some(row => row.threadKey === key && row.enabled) === true, pendingStop: sharing?.pendingStops?.includes(key) === true };
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
+ }) } };
227
237
  }
228
238
  }
229
239
  if (preferenceScope && desktopAccessReady(status.access) && status.sessionSupervision?.sessions) {
230
240
  const catalogScope = preferenceScope;
231
241
  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 }) }; }) } };
232
- const catalog = { observedAt: Date.now(), sessions: status.sessionSupervision.sessions, hosts: status.remoteHosts?.items || [], providerLinks };
233
- void controllerPreferences.replaceCatalog(catalogScope, catalog).catch(() => {});
242
+ catalogObservation = { scope: catalogScope, observedAt: Date.now() };
234
243
  }
235
244
  if (updateProgress) status = { ...status, update: { ...status.update, ...updateProgress } };
236
245
  const { sampledAt, ...content } = status;
237
246
  if (!lastStatus || !isDeepStrictEqual(content, lastStatus)) {
238
247
  if (!catalogEncoder) encodedStatus = JSON.stringify(content).slice(1, -1);
239
- lastStatus = structuredClone(content);
248
+ lastStatus = snapshotStatus(content);
240
249
  }
241
250
  const ownedStatus = { ...lastStatus, ...(sampledAt === undefined ? {} : { sampledAt }) };
251
+ if (catalogObservation) {
252
+ const catalog = { observedAt: catalogObservation.observedAt, sessions: ownedStatus.sessionSupervision.sessions,
253
+ hosts: ownedStatus.remoteHosts?.items || [], providerLinks: structuredClone(providerLinks) };
254
+ void controllerPreferences.replaceCatalog(catalogObservation.scope, catalog, { immutableInput: true }).catch(() => {});
255
+ }
242
256
  lastStatusFrame = ownedStatus;
243
257
  ownerTransport?.publishDesktop?.({ type: 'status', status: ownedStatus });
244
258
  const observation = sampledAt === undefined ? '' : `"sampledAt":${JSON.stringify(sampledAt)}${encodedStatus ? ',' : ''}`;
@@ -993,7 +1007,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
993
1007
  // a full projection may have advanced it while those reads were waiting.
994
1008
  const sameScope = desktopAccessReady(currentAccess) &&
995
1009
  accessScope(lastStatus?.access) === accessScope(currentAccess);
996
- const cached = sameScope && lastStatus && controllerScope(lastStatus) === controllerScope(status) ? structuredClone(lastStatus) : null;
1010
+ const cached = sameScope && lastStatus && controllerScope(lastStatus) === controllerScope(status) ? copyOwnedDesktopStatus(lastStatus) : null;
997
1011
  // A heartbeat is a complete frame: retain optional scoped fields until
998
1012
  // their replacement is ready, while fresh access/provider data wins.
999
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
- await writeFile(ownerFile, JSON.stringify({
56
- pid,
57
- nonce,
58
- startedAt: await identity(pid),
59
- webOrigin: normalizedOriginOrNull(webOrigin),
60
- }), { mode: 0o600 });
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() {
@@ -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;