@awak-app/simy-cli 0.7.48 → 0.7.50

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.48",
3
+ "version": "0.7.50",
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.48";
17
+ export const CLI_VERSION = "0.7.50";
18
18
  export const CLI_API_CONTRACT_VERSION = 10;
19
19
 
20
20
  export function narrativeImageCapabilityMetadata() {
@@ -87,9 +87,8 @@ function applyProviderConsent(record, link) {
87
87
  ...(record.run.managed ? { managed: { ...record.run.managed, autoRecovery: false } } : {}) } } : {}) };
88
88
  }
89
89
  const readArchiveFence = async file => JSON.parse(await readFile(file, 'utf8')).archived !== false;
90
- async function applyArchiveFence(root, record, readFence = readArchiveFence) {
90
+ async function applyArchiveFence(root, record, readFence = readArchiveFence, file = record && `${recordPath(root, record.provider, record.session)}.archive-fence`) {
91
91
  if (!record) return record;
92
- const file = `${recordPath(root, record.provider, record.session)}.archive-fence`;
93
92
  let archived;
94
93
  try { archived = await readFence(file); }
95
94
  catch (error) { if (error.code === 'ENOENT') return record; archived = true; }
@@ -111,15 +110,16 @@ export async function recordProviderArchive(root, provider, session, archived, o
111
110
  await rename(temporary, file);
112
111
  } finally { await rm(temporary, { force: true }); await releaseStateLock(lock, token); }
113
112
  }
114
- async function currentProviderConsent(root, records, readFence = readArchiveFence) {
113
+ async function currentProviderConsent(root, records, readFence = readArchiveFence, recordNames) {
115
114
  const links = Object.fromEntries(await Promise.all(['codex', 'claude'].map(async provider => [provider, await providerLinkState(root, provider)])));
116
115
  let archiveNames;
117
116
  try { archiveNames = new Set((await readdir(root)).filter(name => name.endsWith('.archive-fence'))); }
118
117
  catch { /* A failed index read falls back to fresh per-record fencing. */ }
119
118
  return Promise.all(records.map(record => {
120
119
  const current = applyProviderConsent(record, links[record.provider] || {});
121
- if (archiveNames && !archiveNames.has(`${basename(recordPath(root, record.provider, record.session))}.archive-fence`)) return current;
122
- return applyArchiveFence(root, current, readFence);
120
+ const name = recordNames?.get(record) || basename(recordPath(root, record.provider, record.session));
121
+ if (archiveNames && !archiveNames.has(`${name}.archive-fence`)) return current;
122
+ return applyArchiveFence(root, current, readFence, join(root, `${name}.archive-fence`));
123
123
  }));
124
124
  }
125
125
  export async function linkedProvider(root, provider) {
@@ -413,7 +413,7 @@ export async function listRecords(root = rootDirectory()) {
413
413
  export function createRecordReader(root = rootDirectory(), { cacheEntries = RECORD_CACHE_LIMIT, cacheBytes = RECORD_CACHE_BYTES } = {}) {
414
414
  if (!Number.isSafeInteger(cacheEntries) || cacheEntries < 1 || cacheEntries > RECORD_CACHE_LIMIT ||
415
415
  !Number.isSafeInteger(cacheBytes) || cacheBytes < 1 || cacheBytes > RECORD_CACHE_BYTES) throw new Error('Invalid record cache limits.');
416
- const cache = new Map();
416
+ const cache = new Map(), validatedNames = new WeakMap();
417
417
  let inFlight = null, closed = false, retainedBytes = 0;
418
418
  const discard = name => {
419
419
  const entry = cache.get(name);
@@ -469,6 +469,9 @@ export function createRecordReader(root = rootDirectory(), { cacheEntries = RECO
469
469
  const cached = cache.get(name);
470
470
  if (cached?.key === key) return cached.record;
471
471
  const record = before.size <= BigInt(MAX_STATE_BYTES) ? await readNamedRecord(root, name) : null;
472
+ // A private, validated identity need not be hashed again on every list.
473
+ // Weak keys share the existing record lifetime and cache budgets.
474
+ if (record) validatedNames.set(record, name);
472
475
  // Do not retain a value when a write races the read; the next list must
473
476
  // inspect it again. This also prevents malformed transient JSON sticking.
474
477
  const after = await stat(path, { bigint: true });
@@ -488,7 +491,9 @@ export function createRecordReader(root = rootDirectory(), { cacheEntries = RECO
488
491
  if (!inFlight) inFlight = scan().finally(() => { inFlight = null; });
489
492
  // Consumers can decorate/mutate their own snapshots without corrupting the
490
493
  // cached records or a simultaneous caller's view.
491
- return currentProviderConsent(root, structuredClone(await inFlight), readCachedFence);
494
+ // Consent/fence projection never mutates private records. Detach after
495
+ // projection so their validated filenames survive through fresh fencing.
496
+ return structuredClone(await currentProviderConsent(root, await inFlight, readCachedFence, validatedNames));
492
497
  },
493
498
  async close() { closed = true; await inFlight?.catch(() => {}); cache.clear(); retainedBytes = 0; },
494
499
  };
@@ -799,12 +799,13 @@ export async function startDesktopHost({ input = process.stdin, output = process
799
799
  for (const item of incoming || []) rows.set(`${item.provider}:${item.session}`, item);
800
800
  return [...rows.values()];
801
801
  };
802
+ const firstUsablePage = sessionStatus.initialLoading !== false;
802
803
  sessionStatus = { ...sessionStatus, ...value,
803
804
  sessions: linkedSessions(merge(sessionStatus.sessions, value.sessions)).sort(compareSessionWork),
804
805
  selectionCandidates: merge(sessionStatus.selectionCandidates || [], value.selectionCandidates),
805
806
  initialLoading: false, completeProviders: [...complete], discoveryAttempt: attempt, catalogRevision: ++catalogRevision };
806
807
  sessionRefreshedAt = Date.now();
807
- void publish().catch(() => {});
808
+ void (firstUsablePage || force ? publish() : publishBackground()).catch(() => {});
808
809
  void refreshCatalogAnalysis();
809
810
  };
810
811
  const firstPageTimeoutMs = dependencies.discoveryTimeoutMs ?? 30_000;
@@ -825,9 +826,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
825
826
  refresh.complete(!failed);
826
827
  sessionStatus = { ...sessionStatus, historyLoading: false,
827
828
  discoveryAttempt: { ...attempt, state: failed ? 'failed' : 'succeeded', completedAt: Date.now() } };
828
- // Publish before PR/network observation and queue maintenance. Neither
829
- // operation owns the first usable catalog or the discovery promise.
830
- void publish().catch(() => {});
829
+ // The first usable page already published without waiting for optional
830
+ // enrichment. Coalesce later pages/completion with background updates.
831
+ void (force ? publish() : publishBackground()).catch(() => {});
831
832
  const enrichment = enrichmentController = new AbortController();
832
833
  const catalog = sessionStatus;
833
834
  sessionEnrichment = boundedDiscovery(async signal => {
@@ -838,7 +839,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
838
839
  if (closed || relay || signal.aborted || !desktopAccessReady(latestAccess) || accessScope(latestAccess) !== requestedScope || sessionStatus !== catalog) return;
839
840
  sessionStatus = { ...sessionStatus, sessions: linkedSessions(sessions) };
840
841
  await queue;
841
- if (!signal.aborted) void publish().catch(() => {});
842
+ if (!signal.aborted) void (force ? publish() : publishBackground()).catch(() => {});
842
843
  }, { signal: enrichment.signal, timeoutMs: dependencies.enrichmentTimeoutMs ?? 10_000, onDrain: trackDiscoveryDrain })
843
844
  .catch(() => {}).finally(() => { if (enrichmentController === enrichment) { enrichmentController = null; sessionEnrichment = null; } });
844
845
  })
@@ -846,7 +847,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
846
847
  historyLoading: false, discoveryAttempt: { ...attempt, state: 'failed', completedAt: Date.now() },
847
848
  providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
848
849
  .finally(() => { if (discoveryController === controller) discoveryController = null; sessionRefresh = null; if (sessionRefreshAgain && !closed && !relay) { sessionRefreshAgain = false; refreshSessions(true); }
849
- if (!closed) setImmediate(() => { void publish().catch(() => {}); }); });
850
+ if (!closed) setImmediate(() => { void (force ? publish() : publishBackground()).catch(() => {}); }); });
850
851
  }
851
852
  return sessionStatus;
852
853
  };
@@ -1053,17 +1054,29 @@ export async function startDesktopHost({ input = process.stdin, output = process
1053
1054
  setImmediate(() => { void publish().catch(() => {}); });
1054
1055
  } },
1055
1056
  });
1056
- // Batch burst notifications and heartbeat/status requests into one frame. The
1057
- // short window leaves commands responsive and the regular heartbeat intact.
1058
- const publish = () => {
1057
+ // Background producers share the normal heartbeat window. Explicit commands
1058
+ // promote a pending background publication instead of inheriting its delay.
1059
+ const schedulePublication = delay => {
1059
1060
  if (closed || relay || !agent) return Promise.resolve();
1060
- if (!scheduledPublish) {
1061
+ const dueAt = Date.now() + delay;
1062
+ if (scheduledPublish && scheduledPublish.dueAt <= dueAt) return scheduledPublish.promise;
1063
+ let pending = scheduledPublish;
1064
+ if (pending) clearTimeout(pending.timer);
1065
+ else {
1061
1066
  let resolve, reject;
1062
1067
  const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
1063
- scheduledPublish = { promise, resolve, timer: setTimeout(() => { scheduledPublish = null; projectStatus().then(resolve, reject); }, 75) };
1068
+ pending = { promise, resolve, reject };
1064
1069
  }
1065
- return scheduledPublish.promise;
1070
+ pending.dueAt = dueAt;
1071
+ pending.timer = setTimeout(() => {
1072
+ scheduledPublish = null;
1073
+ projectStatus().then(pending.resolve, pending.reject);
1074
+ }, delay);
1075
+ scheduledPublish = pending;
1076
+ return pending.promise;
1066
1077
  };
1078
+ const publish = () => schedulePublication(75);
1079
+ const publishBackground = () => schedulePublication(3000);
1067
1080
  let lastDeadlineFailureAt = -Infinity;
1068
1081
  let connectionPublishing = null;
1069
1082
  let connectionRevision = 0;
@@ -1419,7 +1432,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1419
1432
  deferStartupSynchronization: true, enforceDesktopAccess: true,
1420
1433
  suspendOrganizationAnalyses, resumeOrganizationAnalyses,
1421
1434
  onDesktopStatusChange: () => {
1422
- // Let an in-progress frame finish before publishing its new snapshot.
1435
+ // Connection/auth/task observations must not wait for background metadata.
1423
1436
  setImmediate(() => { void publish().catch(() => {}); });
1424
1437
  },
1425
1438
  });
@@ -36,6 +36,14 @@ export async function worktreeEvidence(worktree) {
36
36
  }
37
37
  const stopped = new Set(['stopped','paused','paused_by_user','cancelled']);
38
38
  const terminal = new Set(['completed','reported_done','failed','succeeded']);
39
+ const terminalLocalTasks = new Set(['succeeded','failed','stopped']);
40
+ function hasPendingExecution(task, localTask = false) {
41
+ // Coding runs use operation for an async handle. Local Tasks persist their
42
+ // operation kind (for example "read") even after completion and restoration.
43
+ const operation = localTask ? task.operation != null && typeof task.operation !== 'string' : task.operation;
44
+ return Boolean(task.child || operation || task.operation_promise || task.pendingLedgerUpdate || task.ledgerUpdateRunning ||
45
+ (localTask ? !terminalLocalTasks.has(task.status) : ['queued','running','dispatching','coding'].includes(task.status)));
46
+ }
39
47
  const taskState = run => stopped.has(run.state) ? run.state : run.managed?.pendingApproval ? 'approval_wait' : ['blocked','waiting_human','recovery_required'].includes(run.state) ? 'needs_user' : 'active';
40
48
  const originalTaskId = record => record.run.managed?.taskId || record.run.id;
41
49
  const authority = record => digest([record.run?.id,record.run?.state,record.run?.managed,record.run?.deadline,record.run?.continuations,record.run?.failures,record.autoContinue,record.process,record.cwd,record.run?.executionCwd]);
@@ -167,7 +175,8 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
167
175
  if(startingGate.connection!=='detaching')return {safe:false,liveExecutions:0,pendingWrites:0,unsettledPermissions:0};
168
176
  retainer?.assertSupported([]);
169
177
  const c=context(), execution=[...(c.runs||[]),...(c.localTasks||[])];
170
- const unowned=execution.filter(run=>run.child || run.operation || run.pendingLedgerUpdate || run.ledgerUpdateRunning || ['queued','running','dispatching','coding'].includes(run.status));
178
+ const unowned=[...(c.runs||[]).filter(run=>hasPendingExecution(run)),
179
+ ...(c.localTasks||[]).filter(task=>hasPendingExecution(task,true))];
171
180
  const workers=[];
172
181
  for(const task of c.directTasks || []) {
173
182
  if(!['queued','running','stopping'].includes(task.status)) continue;