@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.7.42",
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.42";
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
  };
@@ -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 = structuredClone({ ...status, sessionSupervision: { ...supervision,
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) {
@@ -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
- for (const candidate of status.sessionSupervision?.sessions || []) {
225
- if (!candidate.hostId || candidate.hostId === 'local') continue;
226
- 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?.() || {};
227
227
  const sharing = teamThreads?.snapshot();
228
- 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
+ }) } };
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) ? structuredClone(lastStatus) : null;
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
- 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() {