@awak-app/simy-cli 0.7.22 → 0.7.25

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.
@@ -6,6 +6,10 @@ import { CLI_VERSION } from "../cli-contract.js";
6
6
  import { defaultSessionRoot } from "../session-store.js";
7
7
 
8
8
  export const SQM_FAILURE_PAUSE_THRESHOLD = 6;
9
+ export const SQM_RECOVERY_DELAY_MS = 5 * 60_000;
10
+ const MAX_RECOVERY_DELAY_MS = 30 * 60_000;
11
+ const exhausted = state => state.failures >= SQM_FAILURE_PAUSE_THRESHOLD || (state.providerFailures || 0) >= 2;
12
+ const recoveryDelay = state => Math.min(MAX_RECOVERY_DELAY_MS, SQM_RECOVERY_DELAY_MS * 2 ** Math.min(state.recoveryAttempts || 0, 3));
9
13
  const scopeKey = scope => JSON.stringify([scope?.webOrigin, scope?.accountId, scope?.organizationId]);
10
14
  const validVersion = value => typeof value === "string" && /^\d+\.\d+\.\d+$/.test(value);
11
15
 
@@ -21,6 +25,9 @@ export function createSqmFailureCircuitStore({ root = defaultSessionRoot() } = {
21
25
  const state = JSON.parse(await readFile(path(scope), "utf8"));
22
26
  if (!validVersion(state.version) || !Number.isInteger(state.failures) || state.failures < 0 || state.failures > SQM_FAILURE_PAUSE_THRESHOLD || (state.inFlight !== undefined && typeof state.inFlight !== "boolean") || (state.providerFailures !== undefined && (!Number.isInteger(state.providerFailures) || state.providerFailures < 0 || state.providerFailures > 2)) || (state.providerRetryAt !== undefined && (!Number.isFinite(state.providerRetryAt) || state.providerRetryAt < 0)))
23
27
  throw new Error("Invalid saved SQM failure state.");
28
+ if (state.recoveryAt !== undefined && (!Number.isFinite(state.recoveryAt) || state.recoveryAt < 0) ||
29
+ state.recoveryAttempts !== undefined && (!Number.isSafeInteger(state.recoveryAttempts) || state.recoveryAttempts < 0))
30
+ throw new Error("Invalid saved SQM recovery state.");
24
31
  return state;
25
32
  } catch (error) { if (error.code === "ENOENT") return null; throw error; }
26
33
  },
@@ -37,12 +44,17 @@ export function createSqmFailureCircuitStore({ root = defaultSessionRoot() } = {
37
44
 
38
45
  // One counter per organization on this machine, shared by both local engines.
39
46
  // Neither reconnecting nor an empty claim demonstrates successful analysis.
40
- export function createSqmFailureCircuit({ version = CLI_VERSION, store = null } = {}) {
47
+ export function createSqmFailureCircuit({ version = CLI_VERSION, store = null, now = Date.now } = {}) {
41
48
  if (!validVersion(version)) throw new Error("SQM failure circuit requires a SIMY version.");
42
49
  const states = new Map();
43
50
  const unreadableScopes = new Set();
51
+ const scheduleRecovery = state => {
52
+ if (!exhausted(state) || state.recoveryAt > now()) return false;
53
+ state.recoveryAt = Math.max(state.providerRetryAt || 0, now() + recoveryDelay(state));
54
+ return true;
55
+ };
44
56
  const save = async (scope, state) => {
45
- try { await store?.write(scope, { version: state.version, failures: state.failures, inFlight: state.inFlight === true, ...(state.providerFailures ? { providerFailures: state.providerFailures, providerRetryAt: state.providerRetryAt } : {}) }); state.storageError = false; }
57
+ try { await store?.write(scope, { version: state.version, failures: state.failures, inFlight: state.inFlight === true, ...(state.recoveryAt !== undefined ? { recoveryAt: state.recoveryAt, recoveryAttempts: state.recoveryAttempts || 0 } : {}), ...(state.providerFailures ? { providerFailures: state.providerFailures, providerRetryAt: state.providerRetryAt } : {}) }); state.storageError = false; }
46
58
  catch (error) { state.storageError = true; throw error; }
47
59
  };
48
60
  const load = async scope => {
@@ -52,7 +64,7 @@ export function createSqmFailureCircuit({ version = CLI_VERSION, store = null }
52
64
  let saved;
53
65
  try { saved = await store?.read(scope); unreadableScopes.delete(key); }
54
66
  catch (error) { unreadableScopes.add(key); throw error; }
55
- state = saved ? { version: saved.version, failures: saved.failures, inFlight: saved.inFlight === true, providerFailures: saved.providerFailures || 0, providerRetryAt: saved.providerRetryAt || 0 } : { version, failures: 0, inFlight: false };
67
+ state = saved ? { version: saved.version, failures: saved.failures, inFlight: saved.inFlight === true, providerFailures: saved.providerFailures || 0, providerRetryAt: saved.providerRetryAt || 0, ...(saved.recoveryAt !== undefined ? { recoveryAt: saved.recoveryAt, recoveryAttempts: saved.recoveryAttempts || 0 } : {}) } : { version, failures: 0, inFlight: false };
56
68
  states.set(key, state);
57
69
  // An unfinished review from this same version means the last host exited
58
70
  // without a result or graceful cancellation. Count it once on restart.
@@ -60,6 +72,7 @@ export function createSqmFailureCircuit({ version = CLI_VERSION, store = null }
60
72
  state.failures = Math.min(SQM_FAILURE_PAUSE_THRESHOLD, state.failures + 1);
61
73
  if (state.providerFailures === 1) state.providerFailures = 2;
62
74
  state.inFlight = false;
75
+ scheduleRecovery(state);
63
76
  await save(scope, state);
64
77
  }
65
78
  }
@@ -67,20 +80,35 @@ export function createSqmFailureCircuit({ version = CLI_VERSION, store = null }
67
80
  state.version = version;
68
81
  state.providerFailures = 0; state.providerRetryAt = 0;
69
82
  state.failures = 0;
83
+ delete state.recoveryAt; delete state.recoveryAttempts;
70
84
  state.inFlight = false;
71
85
  await save(scope, state);
72
86
  } else if (state.storageError) await save(scope, state);
87
+ // Migrate a legacy update-only pause once, retaining counters and scope.
88
+ if (exhausted(state) && state.recoveryAt === undefined) {
89
+ scheduleRecovery(state); await save(scope, state);
90
+ }
73
91
  return state;
74
92
  };
75
93
  return {
76
- async canClaim(scope, now = Date.now()) {
94
+ async canClaim(scope, time = now()) {
77
95
  const state = await load(scope);
78
- return state.failures < SQM_FAILURE_PAUSE_THRESHOLD && (state.providerFailures || 0) < 2 && now >= (state.providerRetryAt || 0);
96
+ if (time < (state.providerRetryAt || 0) || state.inFlight) return false;
97
+ if (!exhausted(state)) return true;
98
+ if (time < state.recoveryAt) return false;
99
+ // Reserve the next cooldown before claiming. Empty claims, reconnects and
100
+ // cancelled work never reset failures or allow a burst of recovery turns.
101
+ state.recoveryAttempts = Math.min((state.recoveryAttempts || 0) + 1, 3);
102
+ state.recoveryAt = time + recoveryDelay(state);
103
+ await save(scope, state);
104
+ return true;
79
105
  },
80
106
  async providerFailure(scope, retryAt, retryAttempt = 0) {
81
107
  const state = await load(scope);
82
108
  state.providerFailures = retryAttempt === 1 ? 2 : Math.min(2, (state.providerFailures || 0) + 1);
83
109
  state.providerRetryAt = retryAt;
110
+ scheduleRecovery(state);
111
+ if (state.recoveryAt !== undefined) state.recoveryAt = Math.max(state.recoveryAt, retryAt);
84
112
  await save(scope, state);
85
113
  },
86
114
  async providerRetryUsed(scope) {
@@ -104,12 +132,14 @@ export function createSqmFailureCircuit({ version = CLI_VERSION, store = null }
104
132
  state.failures = Math.min(SQM_FAILURE_PAUSE_THRESHOLD, state.failures + 1);
105
133
  if (state.inFlight && state.providerFailures === 1) state.providerFailures = 2;
106
134
  state.inFlight = false;
135
+ scheduleRecovery(state);
107
136
  await save(scope, state);
108
137
  },
109
138
  async success(scope) {
110
139
  const state = await load(scope);
111
140
  state.failures = 0;
112
141
  state.providerFailures = 0; state.providerRetryAt = 0;
142
+ delete state.recoveryAt; delete state.recoveryAttempts;
113
143
  state.inFlight = false;
114
144
  await save(scope, state);
115
145
  },
@@ -117,10 +147,12 @@ export function createSqmFailureCircuit({ version = CLI_VERSION, store = null }
117
147
  const state = states.get(scopeKey(scope));
118
148
  const paused = (state?.failures || 0) >= SQM_FAILURE_PAUSE_THRESHOLD || (state?.providerFailures || 0) >= 2;
119
149
  return { consecutive_failures: state?.failures || 0,
120
- paused_until_update: paused,
150
+ paused_until_update: false,
151
+ recovery_pending: paused && state?.inFlight !== true,
152
+ next_retry_at: paused ? state.recoveryAt ?? null : null,
121
153
  provider_retry_exhausted: (state?.providerFailures || 0) >= 2,
122
154
  provider_recovery_pending: state?.providerFailures === 1,
123
- paused_at_version: paused ? state.version : null,
155
+ paused_at_version: null,
124
156
  storage_unavailable: state?.storageError === true || unreadableScopes.has(scopeKey(scope)) };
125
157
  },
126
158
  };
@@ -1,9 +1,22 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { join } from 'node:path';
3
+ import { readFile } from 'node:fs/promises';
3
4
  import { resolveBackendExecutable } from '../backend-executable.js';
4
5
  import { runProviderCommand } from './provider-command.js';
5
6
  import { CLAUDE_PLUGIN_SELECTOR } from './claude-plugin.js';
6
7
 
8
+ // The managed marketplace selects the inventory scope, never installation or
9
+ // enablement evidence. Those still come from the provider's actual response.
10
+ async function managedCodexMarketplace({ home, signal }) {
11
+ try {
12
+ const bytes = await readFile(join(home, '.agents', 'plugins', 'marketplace.json'), { encoding: 'utf8', signal });
13
+ if (Buffer.byteLength(bytes) > 64 * 1024) return null;
14
+ const manifest = JSON.parse(bytes);
15
+ return /^[A-Za-z0-9_-]+$/.test(manifest?.name || '') && Array.isArray(manifest.plugins) &&
16
+ manifest.plugins.some(entry => entry?.name === 'simy') ? manifest.name : null;
17
+ } catch { return null; }
18
+ }
19
+
7
20
  export const unavailablePluginStatus = () => ({ state: 'unknown', installed: null, enabled: null });
8
21
 
9
22
  // Provider inventories describe the actual installation and enablement. Managed
@@ -15,7 +28,7 @@ export async function inspectProviderPlugins({ home = homedir(),
15
28
  claudeHome = process.env.CLAUDE_CONFIG_DIR || join(home, '.claude'),
16
29
  resolveExecutable = resolveBackendExecutable, run = runProviderCommand,
17
30
  timeoutMs = 5_000, signal, providers = ['codex', 'claude'], details = false,
18
- onDiagnostic = () => {},
31
+ onDiagnostic = () => {}, readCodexMarketplace = managedCodexMarketplace,
19
32
  } = {}) {
20
33
  if (providers.some(provider => !['codex', 'claude'].includes(provider))) throw new Error('Unsupported provider.');
21
34
  const inspect = async provider => {
@@ -30,7 +43,12 @@ export async function inspectProviderPlugins({ home = homedir(),
30
43
  const executable = await resolveExecutable(provider, { signal, timeoutMs });
31
44
  if (signal?.aborted) return unavailable('cancelled');
32
45
  if (!executable) return unavailable('cli_missing');
33
- const { stdout } = await run(executable, ['plugin', 'list', '--json'], {
46
+ const marketplace = provider === 'codex' ? await readCodexMarketplace({ home, signal }) : null;
47
+ if (signal?.aborted) return unavailable('cancelled');
48
+ // A global inventory may contact unrelated remote marketplaces. Query the
49
+ // configured SIMY source so their network delays cannot hide local state.
50
+ const args = ['plugin', 'list', '--json', ...(marketplace ? ['--marketplace', marketplace] : [])];
51
+ const { stdout } = await run(executable, args, {
34
52
  timeout: timeoutMs, maxBuffer: 1024 * 1024, signal, cwd: home,
35
53
  env: { ...process.env, HOME: home,
36
54
  ...(provider === 'codex' ? { CODEX_HOME: codexHome } : { CLAUDE_CONFIG_DIR: claudeHome }) },
@@ -39,7 +57,7 @@ export async function inspectProviderPlugins({ home = homedir(),
39
57
  const entries = provider === 'codex' ? payload?.installed : payload;
40
58
  if (!Array.isArray(entries)) return unavailable('invalid_inventory');
41
59
  const matches = entries.filter(entry => provider === 'codex'
42
- ? typeof entry?.pluginId === 'string' && /^simy@[A-Za-z0-9_-]+$/.test(entry.pluginId)
60
+ ? typeof entry?.pluginId === 'string' && /^simy@[A-Za-z0-9_-]+$/.test(entry.pluginId) && (!marketplace || entry.pluginId === `simy@${marketplace}`)
43
61
  : entry?.id === CLAUDE_PLUGIN_SELECTOR);
44
62
  // Prefer the account installation; report a project-only install explicitly
45
63
  // rather than silently treating it as an installation for every project.
@@ -115,11 +115,12 @@ export function createSqmLocalReviewWorker({
115
115
  getHistoryScope = () => null,
116
116
  getWorkspaceUrl = () => null,
117
117
  recordHistory = async () => {},
118
+ readHistory = async () => [],
118
119
  onHistoryError = () => {},
119
120
  now = () => Date.now(),
120
121
  providerFailureCooldownMs = 5 * 60_000,
121
122
  getRecoveryKey = () => null,
122
- failureCircuit = createSqmFailureCircuit(),
123
+ failureCircuit = createSqmFailureCircuit({ now }),
123
124
  } = {}) {
124
125
  if (!api || typeof api.request !== "function") throw new TypeError("api.request is required");
125
126
  let activeJobId = null;
@@ -158,6 +159,7 @@ export function createSqmLocalReviewWorker({
158
159
  item.state = state; item.finishedAt = new Date().toISOString();
159
160
  item.result = state === "completed" ? "SQM review submitted." : "";
160
161
  item.error = state === "failed" ? formatFailure(failure, failureReported) : "";
162
+ if (state === 'completed') for (const field of ['errorCode', 'errorStage', 'httpStatus', 'providerDiagnostic']) delete item[field];
161
163
  if (failure) { item.phase = failure.stage; item.errorStage = failure.stage; item.errorCode = failure.code; item.httpStatus = failure.status; item.providerDiagnostic = failure.providerDiagnostic; }
162
164
  await persist(scope, item);
163
165
  }
@@ -273,20 +275,22 @@ export function createSqmLocalReviewWorker({
273
275
  snapshot() {
274
276
  const pause = currentPause();
275
277
  const circuit = failureCircuit.snapshot(getHistoryScope());
278
+ const requiresInteraction = pause && !pause.recoverable && pause.recoveryKey === getRecoveryKey(getProvider());
279
+ const waitingForRecovery = circuit.recovery_pending && !requiresInteraction && !activeJobId;
276
280
  return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, suspended,
277
- paused: pausedEnvironment !== null || circuit.paused_until_update || circuit.storage_unavailable,
278
- pause_reason: circuit.provider_retry_exhausted ? "provider_retry_failed_until_update" : circuit.paused_until_update ? "consecutive_failures_until_update" : circuit.storage_unavailable ? "failure_state_unavailable" : pauseReason,
281
+ paused: !activeJobId && (pausedEnvironment !== null || circuit.recovery_pending || circuit.storage_unavailable),
282
+ pause_reason: activeJobId ? null : requiresInteraction ? pause.code : waitingForRecovery ? "automatic_recovery_pending" : circuit.storage_unavailable ? "failure_state_unavailable" : pauseReason,
279
283
  ...circuit, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
280
- ...(pause ? { provider_pause: { code: pause.code, status: pause.status,
281
- retry_at: circuit.provider_retry_exhausted ? null : new Date(pause.retryAt).toISOString(),
282
- message: circuit.provider_retry_exhausted ? "The provider retry failed. New SQM reviews are paused until SIMY is updated." : pause.message } } : {}) };
284
+ ...(pause && !activeJobId ? { provider_pause: { code: pause.code, status: pause.status,
285
+ retry_at: new Date(Math.max(pause.retryAt, circuit.next_retry_at || 0)).toISOString(),
286
+ message: waitingForRecovery ? "SIMY will automatically retry after the recovery cooldown. No update or manual confirmation is required." : pause.message } } : {}) };
283
287
  },
284
288
  desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item, canStop: item.id === activeJobId })) }; },
285
289
  desktopTaskDetail(key) {
286
290
  const item = visibleHistory().find(item => item.key === key);
287
291
  return item ? { key, instruction: `SQM local review · ${item.provider}`, result: item.state === "completed" ? "SQM review submitted." : "", error: item.error || "", links: item.webUrl ? [{ label: "Analysis details", url: item.webUrl }] : [] } : null;
288
292
  },
289
- retry() { if (closed || suspended || pollPromise || failureCircuit.snapshot(getHistoryScope()).paused_until_update) return false; pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; onChange(); return true; },
293
+ retry() { if (closed || suspended || pollPromise || failureCircuit.snapshot(getHistoryScope()).recovery_pending) return false; pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null; onChange(); return true; },
290
294
  resetHistory() { if (!pollPromise) { history.splice(0); issue = null; pausedEnvironment = null; pauseReason = null; } },
291
295
  poll() {
292
296
  if (closed || suspended || pollPromise) return pollPromise || Promise.resolve(null);
@@ -295,7 +299,8 @@ export function createSqmLocalReviewWorker({
295
299
  const pause = currentPause();
296
300
  // Known provider failures already have a bounded cooldown. Do not let
297
301
  // the environment gate suppress their recovery probe indefinitely.
298
- if (!pause?.recoverable || (pause.recoveryKey === getRecoveryKey(getProvider()) && now() < pause.retryAt)) return Promise.resolve(null);
302
+ if (pause?.recoveryKey === getRecoveryKey(getProvider()) &&
303
+ (!pause.recoverable || now() < pause.retryAt)) return Promise.resolve(null);
299
304
  pausedEnvironment = null; pauseReason = null;
300
305
  } else if (pausedEnvironment !== null) {
301
306
  pausedEnvironment = null; pauseReason = null; pauses.delete(contextKey(getProvider(), getHistoryScope())); issue = null;
@@ -323,6 +328,23 @@ export function createSqmLocalReviewWorker({
323
328
  lease_seconds: leaseSeconds,
324
329
  }, { signal: reviewController.signal });
325
330
  if (issue) { issue = null; onChange(); }
331
+ if (claim?.recovered === true && claim.completed === true && typeof claim.job_id === 'string') {
332
+ // The server completed this exact saved result without re-running a
333
+ // provider. Reflect the original job's completion locally as well.
334
+ if (!history.some(item => item.id === claim.job_id)) {
335
+ let saved;
336
+ try { saved = (await readHistory(historyScope)).find(item => item.key === `sqm:${claim.job_id}`); }
337
+ catch { onHistoryError(); }
338
+ const item = { ...saved, id: claim.job_id, key: `sqm:${claim.job_id}`, kind: 'analysis',
339
+ label: saved?.label || `SQM · ${claim.job_id.slice(0, 8)}`, provider: saved?.provider || provider,
340
+ startedAt: saved?.startedAt || new Date().toISOString(), webUrl: saved?.webUrl || analysisTaskUrl(workspaceUrl, claim.job_id) };
341
+ history.unshift(item); historyScopes.set(item, JSON.stringify(historyScope)); history.splice(50);
342
+ }
343
+ await updateTask(claim.job_id, 'completed', historyScope);
344
+ await failureCircuit.success(historyScope);
345
+ pauses.delete(key);
346
+ return claim;
347
+ }
326
348
  if (!claim?.claimed) {
327
349
  // A successful empty claim proves SIMY access recovered, but it says
328
350
  // nothing about a local provider that has not executed again yet.
@@ -0,0 +1,51 @@
1
+ import {readFile,writeFile,mkdir,realpath} from 'node:fs/promises';
2
+ import {join,resolve} from 'node:path';
3
+ import {createHash,randomUUID} from 'node:crypto';
4
+ import {digest} from './state-store.js';
5
+ import {readDispatchGate,reconcileDispatchHistory} from './dispatch-gate.js';
6
+ import {createUpdateRuntime} from './runtime.js';
7
+ import {createUpdateCoordinator} from './coordinator.js';
8
+ import {readSession,isSessionValid} from '../session-store.js';
9
+ import {webApiUrl,webApiHeaders} from '../web-api.js';
10
+ import {acquireDesktopProcessLock} from '../desktop-process-lock.js';
11
+ import {listRecords} from '../codex-plugins/simy/hooks/delivery-loop.mjs';
12
+ const sha=bytes=>createHash('sha256').update(bytes).digest('hex');
13
+ export async function planDispatchHistoryRecovery({root}) {
14
+ root=await realpath(root);const state=readDispatchGate(root),items=[];
15
+ for(const attempt of Object.values(state.attempts)) {
16
+ const item={attemptId:attempt.id,attemptDigest:digest(attempt),disposition:attempt.phase==='confirmed'?'already_confirmed':'retain_blocked_unknown'};
17
+ const ref=attempt.provenance?.recordRef;
18
+ if(attempt.phase==='result_saved' && ref?.startsWith(resolve(root,'direct-tasks')+'/')) {
19
+ try {
20
+ const bytes=await readFile(ref),result=JSON.parse(bytes),match=/^(.*):provider:([1-9][0-9]*)$/.exec(attempt.id);
21
+ if(await realpath(ref)===ref && match && result.id===match[1] && result.id===attempt.provenance.taskId && digest(result.id)===attempt.taskHash &&
22
+ result.invocation_count===Number(match[2]) && ['succeeded','failed','stopped'].includes(result.status) && Object.values(state.receipts).some(r=>r.attemptId===attempt.id && r.generation===attempt.generation && r.resultRef===ref)) {
23
+ item.disposition='saved_terminal_result';item.evidence={ref,sha256:sha(bytes),status:result.status};
24
+ }
25
+ } catch { /* Unknown effects remain blocked, including removed test output. */ }
26
+ }
27
+ items.push(item);
28
+ }
29
+ const audit={version:1,gateDigest:digest(state),expectedRevision:state.revision,expectedGeneration:state.generation,items};
30
+ const directory=join(root,'update-detachment','recovery');await mkdir(directory,{recursive:true,mode:0o700});
31
+ const auditRef=join(directory,randomUUID()+'.json'),bytes=JSON.stringify(audit,null,2)+'\n';await writeFile(auditRef,bytes,{flag:'wx',mode:0o600});
32
+ return {auditRef,auditDigest:sha(bytes),revision:state.revision,generation:state.generation,results:items.filter(i=>i.disposition==='saved_terminal_result').length,blocked:items.filter(i=>i.disposition==='retain_blocked_unknown').length,replayAttempts:false};
33
+ }
34
+ export async function applyDispatchHistoryRecovery({root,auditRef,auditDigest}) {
35
+ root=await realpath(root);const lock=await acquireDesktopProcessLock(root);
36
+ try {const audit=JSON.parse(await readFile(auditRef,'utf8'));return reconcileDispatchHistory({root,auditRef,auditDigest,expectedRevision:audit.expectedRevision,expectedGeneration:audit.expectedGeneration});}
37
+ finally {await lock.release();}
38
+ }
39
+ export async function reconnectRecoveredHistory({root,origin='https://app.simy.one'}) {
40
+ root=await realpath(root);const lock=await acquireDesktopProcessLock(root);
41
+ try {
42
+ const session=await readSession(origin,root);if(!isSessionValid(session,Date.now(),origin))throw Error('Existing device sign-in required.');
43
+ const response=await fetch(webApiUrl('desktop-access',{webOrigin:origin,apiBaseUrl:session.api_base_url}),{headers:webApiHeaders(session),signal:AbortSignal.timeout(10000)});
44
+ const access=await response.json();if(!response.ok || access.state!=='ready' || access.organizationId!==session.organization_id)throw Error('Current server entitlement required.');
45
+ const runtime=createUpdateRuntime({root,origin,readContext:()=>({actor:{account:session.account_id,device:session.device_id,origin},runs:[],directTasks:[],localTasks:[]}),listRecords:()=>listRecords(join(root,'delivery-hooks'))});
46
+ // Reconciliation reads exact results or retained fences. It never resumes a task.
47
+ const coordinator=createUpdateCoordinator({store:runtime.store,gate:runtime.gate,boundary:runtime.boundary,reconcile:runtime.reconcile,initialState:'recovery_required'});
48
+ if(digest(await readSession(origin,root))!==digest(session))throw Error('Device connection changed.');
49
+ return await coordinator.reconnect();
50
+ } finally {await lock.release();}
51
+ }
@@ -6,7 +6,7 @@ import { execFile } from 'node:child_process';
6
6
  import { promisify } from 'node:util';
7
7
  import { readFile, lstat, readlink, realpath } from 'node:fs/promises';
8
8
  import { atomicSave, readState, digest, withLock } from './state-store.js';
9
- import { readDispatchGate, adoptHookOwner, DISPATCH_PROTOCOL, confirmDispatch, flushDispatchIntake, readTaskAdmission } from './dispatch-gate.js';
9
+ import { readDispatchGate, unresolvedDispatchAttempts, adoptHookOwner, DISPATCH_PROTOCOL, confirmDispatch, flushDispatchIntake, readTaskAdmission } from './dispatch-gate.js';
10
10
  import { createLedgerCoordinatorAdapters } from './ledger-adapter.js';
11
11
  import { listDispatchRegistrations, normalizeDispatchIdentity } from './provenance.js';
12
12
  import { reconcileSession } from './reconcile.js';
@@ -182,7 +182,7 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
182
182
  confirmDispatch({root,attemptId:attempt.id});
183
183
  }
184
184
  ledger=readDispatchGate(root);
185
- const unresolved=Object.values(ledger.attempts).filter(attempt=>attempt.phase!=='confirmed' && !workers.some(task=>task.handle.attemptId===attempt.id && attempt.taskHash===digest(task.taskId) && attempt.identityHash===digest(task.identity) && attempt.generation===task.generation));
185
+ const unresolved=unresolvedDispatchAttempts(ledger).filter(attempt=> !workers.some(task=>task.handle.attemptId===attempt.id && attempt.taskHash===digest(task.taskId) && attempt.identityHash===digest(task.identity) && attempt.generation===task.generation));
186
186
  if(unresolved.length)return {safe:false,liveExecutions:0,pendingWrites:0,unsettledPermissions:0};
187
187
  const currentGate=readDispatchGate(root);
188
188
  if(currentGate.connection!=='detaching' || currentGate.generation!==startingGate.generation)throw failure('boundary_owner_changed');
@@ -194,7 +194,7 @@ export function createUpdateRuntime({ root, origin, readContext, listRecords, ob
194
194
  const reconcile=async()=> {
195
195
  const actor=context().actor, assessments=[];
196
196
  const workers=(transaction?.tasks || []).filter(task=>task.handle);
197
- if(Object.values(readDispatchGate(root).attempts).some(attempt=>attempt.phase!=='confirmed' && !workers.some(task=>task.handle.attemptId===attempt.id)))return {verified:false,unresolvedAttempts:1};
197
+ if(unresolvedDispatchAttempts(readDispatchGate(root)).some(attempt=> !workers.some(task=>task.handle.attemptId===attempt.id)))return {verified:false,unresolvedAttempts:1};
198
198
  for (const task of transaction?.tasks || []) {
199
199
  if(task.handle) {
200
200
  if(actor?.account!==task.identity.account || actor?.origin!==task.identity.origin || actor?.device!==task.identity.device || task.identity.root!==root)return {verified:false,unresolvedAttempts:1};
package/src/web-origin.js CHANGED
@@ -44,3 +44,10 @@ export function isRequestOriginAllowed(requestOrigin, webOrigin) {
44
44
  return false;
45
45
  }
46
46
  }
47
+
48
+ // Explicitly retain the resolved host when a command is copied to a fresh shell.
49
+ export function daemonRestartCommand(webOrigin) {
50
+ const origin = resolveWebOrigin(webOrigin);
51
+ const quotedOrigin = '"' + origin.replace(/["\\$`]/g, "\\$&") + '"';
52
+ return `simy --daemon --host ${quotedOrigin}`;
53
+ }