@awak-app/simy-cli 0.6.19 → 0.6.20

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/README.md CHANGED
@@ -107,6 +107,27 @@ run retains the legacy metadata endpoint
107
107
  no longer impose local limits and changing them never resets usage or resumes work. Both routes require the original authorized
108
108
  account, organization and device throughout the request.
109
109
 
110
+ ## Provider reconnection and saved consent
111
+
112
+ Disconnecting an AI provider revokes its conversation monitoring and automatic
113
+ continuation with a durable connection epoch. Reconnecting restores discovery;
114
+ previous work remains paused until explicitly selected again. Old or damaged
115
+ conversation locks do not block this operation and are never deleted by it.
116
+ Individual locked conversations may still require support before they can be
117
+ edited or continued. Provider login is separate: for a signed-out Codex CLI,
118
+ run `codex login` in Terminal and refresh connections.
119
+
120
+ After the first connection change, the saved settings deliberately appear
121
+ **disconnected to older CLI/plugin versions** that cannot enforce the consent
122
+ epoch. Update the runtime and installed SIMY plugins together and start a fresh
123
+ provider session before using their hooks. Do not edit the saved `connected`
124
+ flag to bypass this fence. A rollback to older code leaves those integrations
125
+ disconnected; pause work and use a compatible version to restore the connection.
126
+ Connection failures write only provider, operation, and a bounded error code to
127
+ the private `logs/desktop-host.log`; they do not include credentials or conversation
128
+ content. Verify reconnect, fresh explicit consent, and that previous work remains
129
+ paused when validating a rollout. This does not prove provider model access.
130
+
110
131
  ## Apple Silicon Mac app
111
132
 
112
133
  The macOS installer includes a native status window and menu bar app. Open
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.6.19",
3
+ "version": "0.6.20",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.6.19";
16
+ export const CLI_VERSION = "0.6.20";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -37,11 +37,29 @@ export async function providerLinkState(root, provider) {
37
37
  const valid = settings?.schemaVersion === 1 && ['codex', 'claude'].every(key => {
38
38
  const value = settings.providers?.[key];
39
39
  return value && typeof value.connected === 'boolean' && typeof value.requiresConsent === 'boolean' &&
40
- (value.epoch === null || typeof value.epoch === 'string' && value.epoch.length > 0 && value.epoch.length <= 128);
40
+ (value.epoch === null || typeof value.epoch === 'string' && value.epoch.length > 0 && value.epoch.length <= 128) &&
41
+ (value.consentEpoch === undefined || value.consentEpoch === value.epoch && Boolean(value.consentEpoch) && value.connected === false && typeof value.enabled === 'boolean');
41
42
  });
42
- return valid ? settings.providers[provider] ?? { connected: false, epoch: null } : { connected: false, epoch: null };
43
+ const value = valid ? settings.providers[provider] : null;
44
+ return value ? { ...value, connected: value.consentEpoch ? value.enabled : value.connected } : { connected: false, epoch: null };
43
45
  } catch (error) { return { connected: error.code === 'ENOENT', epoch: null }; }
44
46
  }
47
+ // Revocation is durable before touching any conversation lock. Project it on every
48
+ // read (including cached records) and under the write lock, so stale consent never
49
+ // returns after reconnect, restart, or a late writer from the previous epoch.
50
+ function applyProviderConsent(record, link) {
51
+ if (!record || !link.consentEpoch || record.providerConsentEpoch === link.consentEpoch) return record;
52
+ return { ...record, providerConsentEpoch: link.consentEpoch,
53
+ autoContinue: false, autoContinueEpoch: null, tracked: false, monitor: false,
54
+ ...(record.run && !TERMINAL.has(record.run.state) ? { run: { ...record.run,
55
+ state: 'paused', reason: 'SIMY connection changed. Select this conversation again before continuing.',
56
+ revision: (record.run.revision || 0) + 1,
57
+ ...(record.run.managed ? { managed: { ...record.run.managed, autoRecovery: false } } : {}) } } : {}) };
58
+ }
59
+ async function currentProviderConsent(root, records) {
60
+ const links = Object.fromEntries(await Promise.all(['codex', 'claude'].map(async provider => [provider, await providerLinkState(root, provider)])));
61
+ return records.map(record => applyProviderConsent(record, links[record.provider] || {}));
62
+ }
45
63
  export async function linkedProvider(root, provider) {
46
64
  return (await providerLinkState(root, provider)).connected === true;
47
65
  }
@@ -158,8 +176,9 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
158
176
  const lockToken = await acquireStateLock(lock, inspectProcess);
159
177
  let temporary;
160
178
  try {
161
- const previous = await read(file);
162
- const previousBytes = previous === null ? null : JSON.stringify(previous);
179
+ const stored = await read(file);
180
+ const previous = applyProviderConsent(stored, await providerLinkState(root, provider));
181
+ const previousBytes = stored === null ? null : JSON.stringify(stored);
163
182
  const next = await mutate(previous);
164
183
  if (next) {
165
184
  // Older file_equals drafts used `path`, which an older verifier could
@@ -184,7 +203,7 @@ export async function readRecord(root = rootDirectory(), provider, session) {
184
203
  const path = recordPath(root, provider, session);
185
204
  try {
186
205
  const record = await read(path);
187
- return record?.schemaVersion === 1 && record.provider === provider && record.session === session ? record : null;
206
+ return record?.schemaVersion === 1 && record.provider === provider && record.session === session ? applyProviderConsent(record, await providerLinkState(root, provider)) : null;
188
207
  } catch { return null; } // Match listRecords' treatment of unreadable records.
189
208
  }
190
209
  const RECORD_NAME = /^[a-f0-9]{64}\.json$/;
@@ -209,7 +228,7 @@ async function readNamedRecord(root, name) {
209
228
  } catch { return null; }
210
229
  }
211
230
  export async function listRecords(root = rootDirectory()) {
212
- return mapRecordNames(await recordNames(root), name => readNamedRecord(root, name));
231
+ return currentProviderConsent(root, await mapRecordNames(await recordNames(root), name => readNamedRecord(root, name)));
213
232
  }
214
233
 
215
234
  // A shared candidate/display index, never mutation authority. Every list checks
@@ -244,7 +263,7 @@ export function createRecordReader(root = rootDirectory()) {
244
263
  if (!inFlight) inFlight = scan().finally(() => { inFlight = null; });
245
264
  // Consumers can decorate/mutate their own snapshots without corrupting the
246
265
  // cached records or a simultaneous caller's view.
247
- return structuredClone(await inFlight);
266
+ return currentProviderConsent(root, structuredClone(await inFlight));
248
267
  },
249
268
  async close() { closed = true; await inFlight?.catch(() => {}); cache.clear(); },
250
269
  };
@@ -66,7 +66,12 @@ async function withReadCancellation(signal, work) {
66
66
  function desktopActionError(action, error) {
67
67
  if (error?.code === "provider_link_changed") return "SIMY connection changed. Refresh and select the conversation again.";
68
68
  if (error?.code === "provider_link_disconnected") return "Reconnect this provider to SIMY before using it.";
69
- if (["provider_connect", "provider_disconnect"].includes(action)) return "Could not finish changing the SIMY connection. Refresh its status and try again.";
69
+ if (["provider_connect", "provider_disconnect"].includes(action)) {
70
+ if (error?.code === 'desktop_access_required') return "Sign in to your SIMY workspace, then try connecting again.";
71
+ if (['EACCES', 'EPERM', 'EROFS'].includes(error?.code)) return "SIMY could not save the connection settings. Check access to its local settings folder, then retry.";
72
+ if (error?.code === 'ENOSPC') return "SIMY could not save the connection settings. Free disk space, then retry.";
73
+ return "SIMY could not change the connection. Reopen SIMY and retry. If this continues, share its diagnostic log with support.";
74
+ }
70
75
  if (action === "session_set_auto_continue") {
71
76
  const messages = {
72
77
  session_workspace_changed: "The conversation workspace changed. Refresh and select it again.",
@@ -297,10 +302,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
297
302
  let organizationAnalysisDrain = Promise.resolve();
298
303
  let organizationAnalysisEpoch = 0;
299
304
  const authorizeWork = async expectedScope => {
300
- if (closed || stoppingForUpdate || updater?.acceptsNewWork?.() === false || organizationAnalysesSuspended) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
305
+ if (closed || stoppingForUpdate || updater?.acceptsNewWork?.() === false || organizationAnalysesSuspended) throw Object.assign(new Error("SIMY is stopping. Reopen SIMY before starting new work."), { code: "desktop_stopping" });
301
306
  if (agent?.requireDesktopAccess) await agent.requireDesktopAccess();
302
307
  const access = await accessSnapshot();
303
- if (closed || stoppingForUpdate || updater?.acceptsNewWork?.() === false || organizationAnalysesSuspended) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
308
+ if (closed || stoppingForUpdate || updater?.acceptsNewWork?.() === false || organizationAnalysesSuspended) throw Object.assign(new Error("SIMY is stopping. Reopen SIMY before starting new work."), { code: "desktop_stopping" });
304
309
  requireDesktopAccess(access);
305
310
  const scope = accessScope(access);
306
311
  if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
@@ -406,29 +411,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
406
411
  selectedLinkEpochs.set(selected, link.epoch);
407
412
  return selected;
408
413
  };
409
- const revokeProviderConsent = async provider => {
410
- const records = (await recordReader.list()).filter(record => record.provider === provider);
411
- for (const record of records) {
412
- automaticContinuation?.revoke(provider, record.session);
413
- await updateRecord(deliveryRoot, provider, record.session, previous => {
414
- if (!previous) return previous;
415
- const next = { ...previous, autoContinue: false, autoContinueEpoch: randomUUID(), tracked: false, monitor: false };
416
- if (previous.run && !TERMINAL.has(previous.run.state)) {
417
- next.run = { ...previous.run, state: "paused", reason: "Provider disconnected from SIMY.",
418
- revision: (previous.run.revision || 0) + 1, updatedAt: Date.now(),
419
- ...(previous.run.managed ? { managed: { ...previous.run.managed, autoRecovery: false } } : {}) };
420
- }
421
- return next;
422
- });
423
- }
424
- };
425
414
  const changeProviderLink = async (provider, connected) => {
426
415
  if (!PROVIDER_LINKS.includes(provider)) throw new Error("Unsupported SIMY provider connection.");
427
416
  if (providerLinkBusy.has(provider)) throw new Error("SIMY connection is already changing.");
428
417
  providerLinkBusy.add(provider);
429
418
  providerLinkFeedback[provider] = { state: connected ? "connecting" : "disconnecting" };
419
+ let phase = "read_settings";
430
420
  try {
431
421
  providerLinks = await readProviderLinks(root);
422
+ phase = "save_disconnect";
432
423
  if (!connected) providerLinks = await writeProviderLink(root, provider, false);
433
424
  if (activeStatisticsRead?.key.startsWith(`${provider}:`)) activeStatisticsRead.controller.abort();
434
425
  discoveryController?.abort();
@@ -437,13 +428,16 @@ export async function startDesktopHost({ input = process.stdin, output = process
437
428
  for (const key of explicitAnalyses) if (key.startsWith(`${provider}:`)) explicitAnalyses.delete(key);
438
429
  statisticsCache.clear();
439
430
  if (!connected) sessionStatus = { ...sessionStatus, sessions: linkedSessions(sessionStatus.sessions) };
431
+ phase = "cancel_analysis";
440
432
  await analysisManager.update(analysisSessions(sessionStatus.sessions));
433
+ phase = "publish_status";
441
434
  await publish();
442
- // A crash after saving disconnected must not restore old session consent
443
- // when the user reconnects. Revoke before persisting connected again.
444
- if (!connected || !providerLinks[provider].connected) await revokeProviderConsent(provider);
435
+ // The provider epoch revokes old consent atomically, independent of stale
436
+ // conversation locks. Record readers and writers enforce that boundary.
445
437
  if (connected && !providerLinks[provider].connected) {
438
+ phase = "authorize_connect";
446
439
  await authorizeWork();
440
+ phase = "save_connect";
447
441
  providerLinks = await writeProviderLink(root, provider, true);
448
442
  }
449
443
  delete providerLinkFeedback[provider];
@@ -451,6 +445,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
451
445
  catch { providerLinkFeedback[provider] = { message: "SIMY connection saved. Provider status could not be verified; refresh connections to check it." }; }
452
446
  refreshSessions(true);
453
447
  } catch (error) {
448
+ diagnostics.write(`provider connection phase provider=${provider} phase=${phase}`);
454
449
  providerLinkFeedback[provider] = { state: "error", message: desktopActionError(connected ? "provider_connect" : "provider_disconnect", error) };
455
450
  throw error;
456
451
  } finally { providerLinkBusy.delete(provider); await publish(); }
@@ -1327,6 +1322,13 @@ export async function startDesktopHost({ input = process.stdin, output = process
1327
1322
  emit({ type: "result", id: command.id, ok: true });
1328
1323
  await publish();
1329
1324
  } catch (error) {
1325
+ if (["provider_connect", "provider_disconnect"].includes(command.action)) {
1326
+ const code = ['desktop_access_required', 'desktop_stopping', 'EACCES', 'EPERM', 'ENOSPC', 'EROFS', 'EBUSY', 'ENOENT', 'ERR_INVALID_ARG_TYPE'].includes(error?.code) ? error.code : 'provider_connection_failed';
1327
+ const provider = PROVIDER_LINKS.includes(command.provider) ? command.provider : 'unsupported';
1328
+ const kind = ['Error', 'TypeError', 'RangeError', 'SyntaxError'].includes(error?.name) ? error.name : 'Error';
1329
+ const locations = [...String(error?.stack || '').matchAll(/(?:desktop-host|agent|provider-links|delivery-loop|analysis-manager)\.m?js:\d{1,6}:\d{1,6}/g)].slice(0, 3).map(match => match[0]).join(',') || 'unknown';
1330
+ diagnostics.write(`provider connection failed provider=${provider} operation=${command.action} code=${code} kind=${kind} locations=${locations}`);
1331
+ }
1330
1332
  emit({ type: "result", id: command.id, ok: false, ...(error?.code === 'cancelled' || statisticsRequest?.controller.signal.aborted ? { code: 'cancelled' } : {}), message: ["update", "prepare_update_restart"].includes(command.action) && updateProgress ? updateProgress.message : desktopActionError(command.action, error) });
1331
1333
  } finally {
1332
1334
  // Access rejection can happen before the shared reader is constructed.
@@ -17,10 +17,12 @@ async function readSettings(root) {
17
17
  if (payload?.schemaVersion !== 1 || !payload.providers || PROVIDER_LINKS.some(provider => {
18
18
  const value = payload.providers[provider];
19
19
  return !value || typeof value.connected !== 'boolean' || typeof value.requiresConsent !== 'boolean' ||
20
- value.epoch !== null && (typeof value.epoch !== 'string' || !value.epoch || value.epoch.length > 128);
20
+ value.epoch !== null && (typeof value.epoch !== 'string' || !value.epoch || value.epoch.length > 128) ||
21
+ value.consentEpoch !== undefined && (value.consentEpoch !== value.epoch || !value.consentEpoch || value.connected !== false || typeof value.enabled !== 'boolean');
21
22
  })) throw new Error('Invalid SIMY provider connection settings.');
22
23
  return Object.fromEntries(PROVIDER_LINKS.map(provider => [provider, {
23
- connected: payload.providers[provider].connected,
24
+ connected: payload.providers[provider].consentEpoch ? payload.providers[provider].enabled : payload.providers[provider].connected,
25
+ ...(payload.providers[provider].consentEpoch ? { consentEpoch: payload.providers[provider].consentEpoch } : {}),
24
26
  epoch: payload.providers[provider].epoch,
25
27
  requiresConsent: payload.providers[provider].requiresConsent,
26
28
  }]));
@@ -59,11 +61,15 @@ export function writeProviderLink(root = defaultRoot(), provider, connected) {
59
61
  const providers = await readSettings(root);
60
62
  // Reconnection restores discovery only. Session execution consent is cleared
61
63
  // separately and must be selected again, including after a host restart.
62
- providers[provider] = { connected, epoch: randomUUID(), requiresConsent: true };
64
+ const epoch = randomUUID();
65
+ providers[provider] = { connected, epoch, consentEpoch: epoch, requiresConsent: true };
66
+ // Old plugins must remain disconnected: they cannot enforce epoch revocation.
67
+ const saved = Object.fromEntries(Object.entries(providers).map(([key, value]) => [key,
68
+ value.consentEpoch ? { ...value, connected: false, enabled: value.connected } : value]));
63
69
  await mkdir(root, { recursive: true, mode: 0o700 });
64
70
  const target = join(root, 'provider-links.json'), temporary = `${target}.${randomUUID()}.tmp`;
65
71
  try {
66
- await writeFile(temporary, `${JSON.stringify({ schemaVersion: 1, providers })}\n`, { mode: 0o600 });
72
+ await writeFile(temporary, `${JSON.stringify({ schemaVersion: 1, providers: saved })}\n`, { mode: 0o600 });
67
73
  await rename(temporary, target);
68
74
  } finally { await rm(temporary, { force: true }); }
69
75
  return providers;
@@ -307,7 +307,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
307
307
  candidate = observedCandidate(candidate);
308
308
  const revision = continuationAnalysisRevision(candidate), entry = entries.get(keyOf(candidate));
309
309
  let analysis = entry?.revision === revision ? { ...entry.result, revision, requestId: entry.requestId } : { state: 'unanalysed', revision };
310
- const previous = entry?.cwd === candidate.cwd ? entry.settledResult : null;
310
+ const previous = entry && entry.cwd === candidate.cwd ? entry.settledResult : null;
311
311
  if (candidate.isRunning === true || analysis.awaitingTurn === true) {
312
312
  analysis = { state: 'waiting', awaitingTurn: true, revision,
313
313
  reason: 'This turn is still running. SIMY analyzes only finished turns and will update automatically when it ends.',
@@ -316,7 +316,7 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
316
316
  ...(continuationPolicy ?? savedPolicy) === undefined ? {} : { continuationPolicy: continuationPolicy ?? savedPolicy } };
317
317
  if (settings.continuationPolicy !== undefined && !['bounded', 'progress'].includes(settings.continuationPolicy)) throw new Error('Choose a continuation policy.');
318
318
  if (previous.run && continuationPolicy !== undefined) throw new Error('Change an existing task through its explicit continuation controls.');
319
- const next = { ...previous, autoContinue: enabled, autoContinueEpoch: epoch,
319
+ const next = { ...previous, ...(enabled && link.consentEpoch ? { providerConsentEpoch: link.consentEpoch } : {}), autoContinue: enabled, autoContinueEpoch: epoch,
320
320
  ...(enabled ? { tracked: true, monitor: true, autoContinueSettings: settings } : { tracked: false, monitor: false }) };
321
321
  // Adopt existing supervision without restarting it or renewing its budgets.
322
322
  if (next.run?.managed) next.run.managed.autoContinueEpoch = epoch;
@@ -357,7 +357,8 @@ export async function trackSession({ root, provider, session, tracked = true, op
357
357
  await requireProviderLink({ root, provider, expectedEpoch: link.epoch });
358
358
  if (previous?.providerArchived === true) throw Object.assign(new Error('Restore the original provider conversation before enabling automatic continuation or tracking.'), { code: 'session_archived' });
359
359
  if (previous && previous.cwd !== cwd) throw new Error('Session workspace changed; refresh its metadata.');
360
- return { ...(previous || { schemaVersion: 1, provider, session, cwd, run: null }), tracked: true, monitor: true,
360
+ return { ...(previous || { schemaVersion: 1, provider, session, cwd, run: null }),
361
+ ...(link.consentEpoch ? { providerConsentEpoch: link.consentEpoch } : {}), tracked: true, monitor: true,
361
362
  admission: { ...(previous?.admission || {}), source: metadata.observation.source, provider, session, cwd, model: metadata.model, validatedAt: now } };
362
363
  });
363
364
  }
@@ -380,7 +381,8 @@ export async function monitorSession({ root, provider, session, monitor = true,
380
381
  await requireProviderLink({ root, provider, expectedEpoch: link.epoch });
381
382
  if (previous?.providerArchived === true) throw new Error('Restore the original provider conversation before enabling supervision.');
382
383
  if (previous && previous.cwd !== cwd) throw new Error('Session workspace changed; refresh its metadata.');
383
- return { ...(previous || { schemaVersion: 1, provider, session, cwd, run: null }), monitor,
384
+ return { ...(previous || { schemaVersion: 1, provider, session, cwd, run: null }),
385
+ ...(link.consentEpoch ? { providerConsentEpoch: link.consentEpoch } : {}), monitor,
384
386
  admission: { source: metadata.observation.source, provider, session, cwd, model: metadata.model, validatedAt: now } };
385
387
  });
386
388
  return metadata;