@awak-app/simy-cli 0.7.43 → 0.7.45

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.43",
3
+ "version": "0.7.45",
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.43";
17
+ export const CLI_VERSION = "0.7.45";
18
18
  export const CLI_API_CONTRACT_VERSION = 10;
19
19
 
20
20
  export function narrativeImageCapabilityMetadata() {
@@ -160,7 +160,7 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
160
160
  });
161
161
  }
162
162
 
163
- export async function tickManagedTasks({ root, webOrigin, now = Date.now(), execute, inspectProcess = processIdentity, signal, beforeClaim, authorizeDispatch, readRecords, observeTurn, observeAuthentication, inspectProvider = inspectCodexAppServer, shouldYield, nativeHandoff = reconcileNativeHandoff } = {}) {
163
+ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), execute, inspectProcess = processIdentity, signal, beforeClaim, authorizeDispatch, readRecords, readCancellationRecords, observeTurn, observeAuthentication, inspectProvider = inspectCodexAppServer, shouldYield, nativeHandoff = reconcileNativeHandoff } = {}) {
164
164
  const tickWallStart = Date.now();
165
165
  const currentTime = () => now + Math.max(0, Date.now() - tickWallStart);
166
166
  if (signal?.aborted) return;
@@ -170,7 +170,11 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
170
170
  // OFF changes the consent epoch and removes a task from a scoped activation.
171
171
  // Still reconcile its old native queue on reconnect, without admitting work
172
172
  // outside this supervisor's selected scope.
173
- const cancellationOnly = readRecords ? (await listRecords(root)).filter(r => r.run?.managed?.nativeHandoff && !selectedIds.has(identity(r))) : [];
173
+ // Desktop callers can share their fresh metadata-validated global index.
174
+ // Keep it separate from the selected scope: a revoked native queue must still
175
+ // be cancelled, and the handoff reconciler re-reads authority before acting.
176
+ const cancellationOnly = readRecords ? (await (readCancellationRecords ? readCancellationRecords() : listRecords(root)))
177
+ .filter(r => r.run?.managed?.nativeHandoff && !selectedIds.has(identity(r))) : [];
174
178
  for (const record of [...selected, ...cancellationOnly]) {
175
179
  if (signal?.aborted || await shouldYield?.()) return;
176
180
  if ((!execute || record.run?.managed?.nativeHandoff) && await nativeHandoff({ root, record, signal, authorize: authorizeDispatch, cancelOnly: !selectedIds.has(identity(record)) })) nativeOwned.add(identity(record));
@@ -41,6 +41,38 @@ function updateRows(previous, incoming, full, { unchanged = false, immutableInpu
41
41
  return { rows: immutableInput ? incoming : rows, payload: { count: incoming.length, changes } };
42
42
  }
43
43
 
44
+ // A display ordering change is not a row-content change. Version 2 references
45
+ // positions in the last delivered base; ambiguous identities are sent in full.
46
+ const rowIdentity = row => row && typeof row.session === 'string' && row.session
47
+ ? JSON.stringify([row.hostId ?? 'local', row.provider ?? null, row.session]) : null;
48
+ function updateOrderedRows(previous, incoming, full, { unchanged = false, immutableInput = false } = {}) {
49
+ if (full || previous === null) return updateRows(previous, incoming, true, { immutableInput });
50
+ if (unchanged) return { rows: previous, payload: { count: previous.length, changes: [] } };
51
+ const positions = new Map(), incomingCounts = new Map();
52
+ for (let index = 0; index < previous.length; index++) {
53
+ const key = rowIdentity(previous[index]);
54
+ if (key !== null) positions.set(key, positions.has(key) ? -1 : index);
55
+ }
56
+ for (const row of incoming) { const key = rowIdentity(row); if (key !== null) incomingCounts.set(key, (incomingCounts.get(key) || 0) + 1); }
57
+ const order = [], changes = [], rows = [], reused = new Set();
58
+ let reordered = previous.length !== incoming.length;
59
+ for (let index = 0; index < incoming.length; index++) {
60
+ const row = incoming[index], key = rowIdentity(row), source = positions.get(key);
61
+ const unique = key !== null && incomingCounts.get(key) === 1 && source >= 0;
62
+ const reuse = unique && !reused.has(source) && isDeepStrictEqual(previous[source], row) ? source
63
+ : !unique && index < previous.length && !reused.has(index) && isDeepStrictEqual(previous[index], row) ? index : null;
64
+ if (reuse !== null) {
65
+ rows.push(previous[reuse]); order.push(reuse); reused.add(reuse);
66
+ if (reuse !== index) reordered = true;
67
+ } else {
68
+ const detached = immutableInput ? row : structuredClone(row);
69
+ rows.push(detached); order.push(null); changes.push([index, detached]);
70
+ }
71
+ }
72
+ return { rows: immutableInput ? incoming : !reordered && changes.length === 0 ? previous : rows,
73
+ payload: { count: incoming.length, changes, ...(reordered ? { order } : {}) } };
74
+ }
75
+
44
76
  // Detach mutable producer data once. Unchanged catalog rows may be shared only
45
77
  // between these owned read-only snapshots; changed rows get detached copies and
46
78
  // a new array. Older queued frames and durable writes must remain immutable.
@@ -69,7 +101,9 @@ export function createDesktopStatusSnapshot() {
69
101
  // One encoder per ordered output stream. Call only when a frame actually enters
70
102
  // that stream, after replaceable/backpressured snapshots have been coalesced.
71
103
  // This cache is transport state; it never authorizes work or provider dispatch.
72
- export function createDesktopCatalogEncoder({ immutableInput = false } = {}) {
104
+ export function createDesktopCatalogEncoder({ immutableInput = false, version = 1 } = {}) {
105
+ if (![1, 2].includes(version)) throw new Error('Unsupported desktop catalog version.');
106
+ const projectRows = version === 2 ? updateOrderedRows : updateRows;
73
107
  let rows = null, candidates = null, sourceRows = null, sourceCandidates = null, scope = null, revision = 0;
74
108
  const reset = () => { rows = null; candidates = null; sourceRows = null; sourceCandidates = null; scope = null; };
75
109
  return { reset, encode(frame) {
@@ -81,16 +115,16 @@ export function createDesktopCatalogEncoder({ immutableInput = false } = {}) {
81
115
  const incoming = status.sessionSupervision.sessions;
82
116
  const identity = catalogScope(status);
83
117
  const previous = revision++;
84
- const full = rows === null || scope !== identity || rows.length !== incoming.length;
85
- const snapshot = updateRows(rows, incoming, full, { immutableInput, unchanged: immutableInput && sourceRows === incoming });
118
+ const full = rows === null || scope !== identity || version === 1 && rows.length !== incoming.length;
119
+ const snapshot = projectRows(rows, incoming, full, { immutableInput, unchanged: immutableInput && sourceRows === incoming });
86
120
  rows = snapshot.rows; sourceRows = incoming; scope = identity;
87
- const catalog = { version: 1, revision, scope, ...(full ? {} : { baseRevision: previous }), ...snapshot.payload };
121
+ const catalog = { version, revision, scope, ...(full ? {} : { baseRevision: previous }), ...snapshot.payload };
88
122
  const { sessions, ...metadata } = status.sessionSupervision;
89
123
  let supervision = metadata;
90
124
  if (Array.isArray(metadata.selectionCandidates)) {
91
125
  // This is another complete directory, used by the conversation picker.
92
126
  // Do not silently retransmit it as supposedly small status metadata.
93
- const snapshot = updateRows(candidates, metadata.selectionCandidates, full,
127
+ const snapshot = projectRows(candidates, metadata.selectionCandidates, full,
94
128
  { immutableInput, unchanged: immutableInput && sourceCandidates === metadata.selectionCandidates });
95
129
  candidates = snapshot.rows; sourceCandidates = metadata.selectionCandidates; catalog.selectionCandidates = snapshot.payload;
96
130
  const { selectionCandidates, ...small } = metadata;
@@ -2,7 +2,7 @@ import { createDesktopFrameWriter } from './desktop-frame-writer.js';
2
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';
@@ -160,8 +160,8 @@ export { createDesktopFrameWriter } from './desktop-frame-writer.js';
160
160
 
161
161
  export async function startDesktopHost({ input = process.stdin, output = process.stdout, env = process.env,
162
162
  updatePackageRoot, dependencies = {}, exit = (code) => process.exit(code) } = {}) {
163
- let catalogEncoder = env.SIMY_REMOTE_CHILD !== '1' && env.SIMY_DESKTOP_CATALOG_DELTA === '1'
164
- ? createDesktopCatalogEncoder({ immutableInput: true }) : null;
163
+ let catalogEncoder = env.SIMY_REMOTE_CHILD !== '1' && ['1', '2'].includes(env.SIMY_DESKTOP_CATALOG_DELTA)
164
+ ? createDesktopCatalogEncoder({ immutableInput: true, version: env.SIMY_DESKTOP_CATALOG_VERSION === '2' ? 2 : Number(env.SIMY_DESKTOP_CATALOG_DELTA) }) : null;
165
165
  // Object frames below own detached snapshots. Queue their references rather
166
166
  // than copying multi-MiB history again whenever a native reader is paused.
167
167
  const frames = createDesktopFrameWriter(output, { capture: frame => frame,
@@ -605,6 +605,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
605
605
  const link = await requireProviderLink({ root: deliveryRoot, provider: record.provider });
606
606
  return JSON.stringify([scope, link.epoch]);
607
607
  }, readRecords: () => recordReader.list(),
608
+ readCancellationRecords: () => recordReader.list(),
608
609
  readCurrentRecord: (provider, session) => readRecord(deliveryRoot, provider, session),
609
610
  beforeClaim: record => actionAssociations.prepareCached?.(record) || record });
610
611
  let shuttingDown = null;
@@ -723,6 +724,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
723
724
  const historyTimeoutMs = dependencies.historyDiscoveryTimeoutMs ?? dependencies.discoveryTimeoutMs ?? 300_000;
724
725
  sessionRefresh = boundedDiscovery((signal, startHistory) => (dependencies.discoverSessions || discoverSessions)({
725
726
  root: deliveryRoot, signal, onDrain: trackDiscoveryDrain, includeArchived: true, includeSelectionCandidates: true, recentOnly: refresh.recentOnly,
727
+ recentArchiveCandidates: liveSessionWindow(sessionStatus.sessions.filter(item => item.provider === 'codex' && item.archived !== true && item.delegated !== true)),
726
728
  readRecords: () => recordReader.list(),
727
729
  connectedProviders: PROVIDER_LINKS.filter(provider => providerLinks[provider].connected),
728
730
  timeoutMs: firstPageTimeoutMs, historyTimeoutMs,
@@ -2562,7 +2564,8 @@ if (isDesktopHostEntry()) {
2562
2564
  try {
2563
2565
  const root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy');
2564
2566
  if (process.env.SIMY_DESKTOP_ATTACH_OWNER === '1' && await relayToOwner({ root, webOrigin: resolveWebOrigin(null, process.env), desktopClient: true,
2565
- catalogDeltas: process.env.SIMY_DESKTOP_CATALOG_DELTA === '1' })) process.exit(0);
2567
+ catalogDeltas: ['1', '2'].includes(process.env.SIMY_DESKTOP_CATALOG_DELTA)
2568
+ ? process.env.SIMY_DESKTOP_CATALOG_VERSION === '2' ? 2 : Number(process.env.SIMY_DESKTOP_CATALOG_DELTA) : false })) process.exit(0);
2566
2569
  try { await adoptInstalledRuntime({ root }); }
2567
2570
  catch (error) {
2568
2571
  if (error.code === PROCESS_CONFLICT_CODE) throw error;
@@ -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); }
@@ -45,7 +45,8 @@ export async function createOwnerTransport({ root, nonce, allowedActions, dispat
45
45
  if (!authenticated) {
46
46
  if (command.nonce !== nonce) { peer.destroy(); return; }
47
47
  authenticated = true; desktopClient = allowDesktopClient && command.client === 'desktop'; controllerArchive = allowControllerArchive && command.client === 'archive-controller'; clearTimeout(timeout); peers.add(peer);
48
- if (desktopClient && (command.desktopProtocol === 2 || command.catalogDeltas === 1)) catalogEncoders.set(peer, createDesktopCatalogEncoder());
48
+ if (desktopClient && (command.desktopProtocol === 2 || command.catalogDeltas === 1))
49
+ catalogEncoders.set(peer, createDesktopCatalogEncoder({ version: command.catalogVersion === 2 ? 2 : 1 }));
49
50
  if ([1, 2].includes(command.desktopProtocol) && desktopClient) send(peer, { type: 'desktop_owner_ready', version: catalogEncoders.has(peer) ? 2 : 1 });
50
51
  // One-shot controls need their result, not multi-megabyte broadcasts
51
52
  // that can fill the write queue before the result is sent.
@@ -119,7 +120,8 @@ export async function relayToOwner({ root, webOrigin, input = process.stdin, out
119
120
  } catch (error) { peer.destroy(); throw error; }
120
121
  // A protocol-1 owner replies with complete frames. New native windows accept
121
122
  // both versions, so attaching never requires replacing its execution owner.
122
- peer.write(JSON.stringify({ nonce: owner.nonce, ...(desktopClient ? { client: 'desktop', desktopProtocol: 1, ...(catalogDeltas ? { catalogDeltas: 1 } : {}) } : {}) }) + '\n');
123
+ peer.write(JSON.stringify({ nonce: owner.nonce, ...(desktopClient ? { client: 'desktop', desktopProtocol: 1,
124
+ ...(catalogDeltas ? { catalogDeltas: 1, ...(catalogDeltas === 2 ? { catalogVersion: 2 } : {}) } : {}) } : {}) }) + '\n');
123
125
  // Old owners ignore unknown hello fields and expose only the restricted SSH
124
126
  // projection. Never attach a native window or forward its controls as though
125
127
  // that were a complete desktop endpoint. Keep the original owner untouched.