@awak-app/simy-cli 0.7.42 → 0.7.44

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.44",
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.44";
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,8 +1,8 @@
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
- import { supervisedSession } from './session-supervision/discovery-window.js';
5
+ import { liveSessionWindow, supervisedSession } from './session-supervision/discovery-window.js';
6
6
  import { createClaudeDesktopArchiveController } from './session-supervision/claude-desktop-archive.js';
7
7
  import { createClaudeDesktopNativeAdapter } from './session-supervision/claude-desktop-native.js';
8
8
  import { applyControllerExecutionContext } from './session-supervision/execution-context.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) {
@@ -715,6 +723,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
715
723
  const historyTimeoutMs = dependencies.historyDiscoveryTimeoutMs ?? dependencies.discoveryTimeoutMs ?? 300_000;
716
724
  sessionRefresh = boundedDiscovery((signal, startHistory) => (dependencies.discoverSessions || discoverSessions)({
717
725
  root: deliveryRoot, signal, onDrain: trackDiscoveryDrain, includeArchived: true, includeSelectionCandidates: true, recentOnly: refresh.recentOnly,
726
+ recentArchiveCandidates: liveSessionWindow(sessionStatus.sessions.filter(item => item.provider === 'codex' && item.archived !== true && item.delegated !== true)),
718
727
  readRecords: () => recordReader.list(),
719
728
  connectedProviders: PROVIDER_LINKS.filter(provider => providerLinks[provider].connected),
720
729
  timeoutMs: firstPageTimeoutMs, historyTimeoutMs,
@@ -999,7 +1008,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
999
1008
  // a full projection may have advanced it while those reads were waiting.
1000
1009
  const sameScope = desktopAccessReady(currentAccess) &&
1001
1010
  accessScope(lastStatus?.access) === accessScope(currentAccess);
1002
- const cached = sameScope && lastStatus && controllerScope(lastStatus) === controllerScope(status) ? structuredClone(lastStatus) : null;
1011
+ const cached = sameScope && lastStatus && controllerScope(lastStatus) === controllerScope(status) ? copyOwnedDesktopStatus(lastStatus) : null;
1003
1012
  // A heartbeat is a complete frame: retain optional scoped fields until
1004
1013
  // their replacement is ready, while fresh access/provider data wins.
1005
1014
  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() {
@@ -329,8 +329,13 @@ export async function listCodexSessions({ client, all = false, signal, onPage, m
329
329
  // Absence from the recent active window does not mean archived. Only the
330
330
  // provider's archived list or its exact archived rollout path proves it.
331
331
  const archiveProbeOffsets = new Map();
332
+ // Navigation eligibility is distinct from positive archive fencing: excluded
333
+ // worker sources may still revoke consent without replacing delegated rows.
334
+ export function archivedCodexMetadata(metadata) {
335
+ return metadata && SOURCE_KINDS.includes(metadata.source) ? { ...metadata, archived: true } : null;
336
+ }
332
337
  export async function readCodexArchiveStates(client, candidates, { budgetMs = 3000,
333
- codexHome = process.env.CODEX_HOME || join(homedir(), '.codex') } = {}) {
338
+ codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'), observedArchived } = {}) {
334
339
  const states = new Map();
335
340
  if (!candidates.length) return states;
336
341
  const deadline = Date.now() + budgetMs;
@@ -343,7 +348,13 @@ export async function readCodexArchiveStates(client, candidates, { budgetMs = 30
343
348
  if (method === 'thread/list' && params.archived === true && Array.isArray(response?.data)) {
344
349
  for (const thread of response.data.slice(0, params.limit)) {
345
350
  const metadata = codexThreadMetadata(thread);
346
- if (metadata && eligible.has(metadata.session)) states.set(metadata.session, true);
351
+ if (metadata && eligible.has(metadata.session)) {
352
+ states.set(metadata.session, true);
353
+ if (observedArchived) {
354
+ const archived = archivedCodexMetadata(metadata);
355
+ if (archived) observedArchived.set(metadata.session, archived);
356
+ }
357
+ }
347
358
  }
348
359
  }
349
360
  return response;
@@ -363,6 +374,12 @@ export async function readCodexArchiveStates(client, candidates, { budgetMs = 30
363
374
  if (thread?.id !== candidate.session || typeof thread.path !== 'string' || !isAbsolute(thread.path)) continue;
364
375
  const state = codexArchivePathState(thread.path, candidate.session, codexHome);
365
376
  if (state !== undefined) states.set(candidate.session, state);
377
+ if (state === false) observedArchived?.delete(candidate.session);
378
+ if (state === true && observedArchived) {
379
+ const metadata = codexThreadMetadata(thread);
380
+ const archived = archivedCodexMetadata(metadata);
381
+ if (archived) observedArchived.set(metadata.session, archived);
382
+ }
366
383
  } catch { /* Unknown or missing sessions remain visible. */ }
367
384
  }
368
385
  }));
@@ -10,7 +10,7 @@ import { realpath } from 'node:fs/promises';
10
10
  import { randomUUID } from 'node:crypto';
11
11
  import { isAbsolute } from 'node:path';
12
12
  import { TERMINAL as TERMINAL_RUNS, advanceDeliveryControlRevision, deliveryDecisionRevision, associationBlocksFollowup, canUpgradeDelivery, canExplicitlyResumeStopped, enablePurposeDelivery, claudeHookChannel, controlDelivery, listRecords, readRecord, processIdentity, startDelivery, updateRecord, recordProviderArchive, ownerRenewalPending } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
13
- import { listCodexSessions, codexThreadMetadata, openCodexAppServer, readCodexSession, readCodexObservation, readCodexArchiveStates, CodexAppServerError } from './codex-app-server.js';
13
+ import { listCodexSessions, codexThreadMetadata, openCodexAppServer, readCodexSession, readCodexObservation, readCodexArchiveStates, archivedCodexMetadata, CodexAppServerError } from './codex-app-server.js';
14
14
  import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
15
15
  import { loadPurposeRuleSet } from './purpose-instruction.js';
16
16
  import { originalSessionTarget } from './original-session-target.js';
@@ -165,7 +165,7 @@ export async function discoverSessions(options = {}) {
165
165
  }
166
166
 
167
167
  async function discoverSessionCatalog({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
168
- inspectProcess = processIdentity, now: requestedNow, signal, listCodexTitles = readCodexTitles, listCodexChildren = readCodexChildSessions, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false, includeSelectionCandidates = false, recentOnly = false, catalogSnapshot, recordSnapshot, archiveReconciliations = new Map(), readRecords = listRecords, onProgress } = {}) {
168
+ inspectProcess = processIdentity, now: requestedNow, signal, listCodexTitles = readCodexTitles, listCodexChildren = readCodexChildSessions, codexHome, connectedProviders = ['codex', 'claude'], includeArchived = false, includeSelectionCandidates = false, recentOnly = false, recentArchiveCandidates = [], catalogSnapshot, recordSnapshot, archiveReconciliations = new Map(), readRecords = listRecords, onProgress } = {}) {
169
169
  const now = requestedNow ?? Date.now();
170
170
  const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
171
171
  assertActive();
@@ -195,19 +195,22 @@ async function discoverSessionCatalog({ root, openClient = openCodexAppServer, l
195
195
  const active = await listCodexSessions({ client, all: !recentOnly, signal, inspectState: true, codexHome,
196
196
  onPage: async items => { discovered = [...items]; await progress({ discovered, archivedCodex }); },
197
197
  prioritySessions: priorities.filter(record => record.provider === 'codex').map(record => record.session) });
198
- if (includeArchived) {
199
- const observedArchives = new Map();
198
+ const observedArchives = new Map();
199
+ // Archived navigation belongs to the full/history refresh. A recent
200
+ // poll still observes missing supervised sessions below, so archival
201
+ // revokes their consent immediately without rewriting cold fences.
202
+ if (includeArchived && !recentOnly) {
200
203
  const archiveClient = { request: async (method, params) => {
201
204
  const response = await client.request(method, params);
202
205
  if (method === 'thread/list' && params.archived === true) {
203
206
  for (const thread of (response?.data || []).slice(0, params.limit)) {
204
- const item = codexThreadMetadata(thread);
205
- if (item && ['cli', 'vscode', 'appServer'].includes(item.source)) observedArchives.set(item.session, { ...item, archived: true });
207
+ const item = archivedCodexMetadata(codexThreadMetadata(thread));
208
+ if (item) observedArchives.set(item.session, item);
206
209
  }
207
210
  }
208
211
  return response;
209
212
  } };
210
- try { await listCodexSessions({ client: archiveClient, all: !recentOnly, signal, archived: true,
213
+ try { await listCodexSessions({ client: archiveClient, all: true, signal, archived: true,
211
214
  onPage: async () => { archivedCodex = [...observedArchives.values()]; await progress({ discovered, archivedCodex }); } }); }
212
215
  catch (error) {
213
216
  codexError = 'Codex archive discovery incomplete. Refresh to retry.';
@@ -216,7 +219,21 @@ async function discoverSessionCatalog({ root, openClient = openCodexAppServer, l
216
219
  archivedCodex = [...observedArchives.values()];
217
220
  }
218
221
  const ids = new Set(active.map(item => item.session));
219
- archiveStates = await readCodexArchiveStates(client, records.filter(record => record.provider === 'codex' && !ids.has(record.session) && (!recentOnly || supervisedSession(record))), { codexHome });
222
+ const archiveCandidates = new Map(records.filter(record => record.provider === 'codex' && !ids.has(record.session) && (!recentOnly || supervisedSession(record)))
223
+ .map(record => [record.session, record]));
224
+ // A recent, untracked conversation can be archived in Codex itself.
225
+ // Observe omissions from the previous bounded live window too; pins
226
+ // and historical navigation never expand this into an unbounded set.
227
+ if (recentOnly) for (const item of recentArchiveCandidates.slice(0, RECENT_SESSION_LIMIT)) {
228
+ if (item.provider === 'codex' && item.archived !== true && item.delegated !== true && !ids.has(item.session) && !archiveCandidates.has(item.session)) archiveCandidates.set(item.session, item);
229
+ }
230
+ const exactArchiveObservations = new Map();
231
+ archiveStates = await readCodexArchiveStates(client, [...archiveCandidates.values()],
232
+ { codexHome, ...(includeArchived ? { observedArchived: exactArchiveObservations } : {}) });
233
+ // Preserve current titles/ancestry from the already-required archive
234
+ // observations, including exact-read recovery during listing outages.
235
+ for (const [session, item] of exactArchiveObservations) if (!observedArchives.has(session)) observedArchives.set(session, item);
236
+ archivedCodex = [...observedArchives.values()];
220
237
  for (const item of archivedCodex) archiveStates.set(item.session, true);
221
238
  return active;
222
239
  }, signal); }