@awak-app/simy-cli 0.5.42 → 0.5.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.5.42",
3
+ "version": "0.5.44",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,12 +37,12 @@
37
37
  "test:e2e:console:real-provider": "node ./scripts/real-provider-console-e2e.js",
38
38
  "test:e2e:console:fullstack": "node ./scripts/fullstack-cli-smoke.js",
39
39
  "check": "npm run check:auto-update && node --check ./src/desktop-executor.js && node --check ./src/local-task.js && node --check ./scripts/desktop-executor-e2e.js && node --check ./scripts/pre-guardrail-e2e.js && node --check ./scripts/fixtures/fake-desktop-provider.js && npm run check:syntax && npm run check:session-supervision-syntax && npm run check:real-provider-syntax && npm test && npm run check:package",
40
- "check:session-supervision-syntax": "node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/codex-app-server.js && node --check ./src/session-supervision/claude-sessions.js && node --check ./src/session-supervision/original-session-target.js && node --check ./src/session-supervision/action-association.js && node --check ./src/session-supervision/action-association-host.js && node --check ./src/session-supervision/action-association-api.js && node --check ./src/codex-plugins/simy/hooks/action-association.mjs",
40
+ "check:session-supervision-syntax": "node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/codex-app-server.js && node --check ./src/session-supervision/claude-sessions.js && node --check ./src/session-supervision/original-session-target.js && node --check ./src/session-supervision/action-association.js && node --check ./src/session-supervision/action-association-host.js && node --check ./src/session-supervision/action-association-api.js && node --check ./src/session-supervision/association-store.js && node --check ./src/session-supervision/action-deadline.js && node --check ./src/session-supervision/action-deadline-store.js && node --check ./src/codex-plugins/simy/hooks/action-association.mjs",
41
41
  "check:auto-update": "node --check ./src/auto-update.js && node --check ./scripts/auto-update-e2e.js && npm run check:sqm",
42
42
  "check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
43
43
  "check:sqm": "node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.js && node --check ./src/sqm/reliability.js && node --check ./src/sqm/repository.js && node --check ./src/sqm/checkers.js && node --check ./src/sqm/bundle-store.js && node --check ./src/sqm/proof.js && node --check ./src/sqm/evidence-client.js && node --check ./src/sqm/session.js && node --check ./src/sqm/index.js && node --check ./src/sqm/command.js && node --check ./src/sqm/agent-skill.js && node --check ./src/sqm/workflow-onboarding.js && node --check ./src/sqm/workflow-update.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
44
44
  "check:real-provider-syntax": "node --check ./scripts/real-codex-probe.js && node --check ./scripts/real-claude-probe.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/claude-parity-runtime-e2e.js && node --check ./scripts/claude-plugin-runtime-e2e.js",
45
- "check:syntax": "node --check ./src/index.js && node --check ./src/install-update.js && node --check ./src/agent.js && node --check ./src/connection-discovery.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/pr-preparation.js && node --check ./src/orchestrator/minimum-completion.js && node --check ./src/orchestrator/delivery/scripts/check-minimum-completion.mjs && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./src/coding-run-store.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/local-task-default-workspace-e2e.js && node --check ./scripts/local-task-idempotency-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/console-recovery-e2e-fixture.js && node --check ./scripts/console-recovery-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js && node --check ./src/codex-plugins/simy/hooks/delivery-loop.mjs && node --check ./src/delivery-hooks/command.js && node --check ./src/delivery-hooks/desktop.js && node --check ./src/delivery-hooks/supervisor.js",
45
+ "check:syntax": "node --check ./src/index.js && node --check ./src/install-update.js && node --check ./src/agent.js && node --check ./src/connection-discovery.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/pr-preparation.js && node --check ./src/orchestrator/minimum-completion.js && node --check ./src/orchestrator/delivery/scripts/check-minimum-completion.mjs && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./src/coding-run-store.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/local-task-default-workspace-e2e.js && node --check ./scripts/local-task-idempotency-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/console-recovery-e2e-fixture.js && node --check ./scripts/console-recovery-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js && node --check ./src/codex-plugins/simy/hooks/delivery-loop.mjs && node --check ./src/delivery-hooks/command.js && node --check ./src/delivery-hooks/desktop.js && node --check ./src/delivery-hooks/supervisor.js && node --check ./src/session-supervision/automatic-continuation.js",
46
46
  "check:package": "node ./scripts/check-package-contents.js",
47
47
  "build:macos-installer:dev": "bash packaging/build-macos-arm64.sh --unsigned-dev",
48
48
  "build:macos-installer": "bash packaging/build-macos-arm64.sh --production",
package/src/agent.js CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  } from "./runner.js";
28
28
  import { codingRunsRoot, loadCodingRunSnapshots, saveCodingRunSnapshot } from "./coding-run-store.js";
29
29
  import {
30
+ defaultSessionRoot,
30
31
  expiresAtFromNow,
31
32
  isSessionValid,
32
33
  readSession,
@@ -177,6 +178,9 @@ export async function startAgent({
177
178
  install: (options) => installSqmAgentSkill({ home: dependencies.home, ...options }),
178
179
  };
179
180
  let session = await readSession(apiOrigin, sessionRoot);
181
+ // The last session the Web stopped accepting. A fresh sign-in may continue
182
+ // as its device (see /v1/auth/complete), so keep it after it is cleared.
183
+ let reclaimableSession = null;
180
184
  if (session && sessionRequiresWebAuthorization(session, apiOrigin)) session = null;
181
185
  await cleanupExpiredRuns();
182
186
  for (const task of await restoreLocalTasks({ root: taskStorageRoot })) {
@@ -215,6 +219,7 @@ export async function startAgent({
215
219
  };
216
220
  refreshAvailableCapabilities();
217
221
  const heartbeatDevice = dependencies.heartbeatDevice || heartbeatLocalDevice;
222
+ const reclaimDevice = dependencies.reclaimDevice || reclaimLocalDevice;
218
223
  const runOptions = dependencies.runOptions || {};
219
224
  const repositoryScanRoot = resolve(
220
225
  String(
@@ -258,6 +263,10 @@ export async function startAgent({
258
263
  if (dependencies.codexWorkPatternSync) {
259
264
  codexWorkPatternSync = dependencies.codexWorkPatternSync;
260
265
  } else {
266
+ // Callers usually leave sessionRoot unset and rely on the session-store
267
+ // default (SIMY_HOME or ~/.simy); the sync needs that concrete root for
268
+ // its cursor, otherwise it reports not_connected forever.
269
+ const syncRoot = sessionRoot ?? defaultSessionRoot();
261
270
  const homes = workPatternHistoryHomes({
262
271
  codexHome: dependencies.codexHome,
263
272
  claudeHome: dependencies.claudeHome,
@@ -265,10 +274,10 @@ export async function startAgent({
265
274
  reportCodexWorkPatternSyncStatus(
266
275
  `reading history roots CODEX_HOME=${homes.codexHome} `
267
276
  + `(sessions/, archived_sessions/) and CLAUDE_CONFIG_DIR=${homes.claudeHome} (projects/); `
268
- + `state under ${sessionRoot}.`,
277
+ + `state under ${syncRoot}.`,
269
278
  quiet,
270
279
  );
271
- codexWorkPatternSync = createCodexWorkPatternSync({ sessionRoot, ...homes });
280
+ codexWorkPatternSync = createCodexWorkPatternSync({ sessionRoot: syncRoot, ...homes });
272
281
  }
273
282
  }
274
283
  let syncPromise;
@@ -559,6 +568,7 @@ export async function startAgent({
559
568
  const state = connectionStatus.failure(error);
560
569
  notifyDesktopStatusChange();
561
570
  if (state === "unauthorized") {
571
+ if (session?.device_id && session.token) reclaimableSession = session;
562
572
  session = null;
563
573
  loginUrl = null;
564
574
  refreshAvailableCapabilities();
@@ -933,6 +943,8 @@ export async function startAgent({
933
943
  });
934
944
  return;
935
945
  }
946
+ const previousSession = session ?? reclaimableSession;
947
+ reclaimableSession = null;
936
948
  session = {
937
949
  token: String(body.token || ""),
938
950
  device_id: typeof body.device_id === "string" ? body.device_id : null,
@@ -950,6 +962,37 @@ export async function startAgent({
950
962
  : null,
951
963
  expires_at: typeof body.expires_at === "string" ? body.expires_at : expiresAtFromNow(),
952
964
  };
965
+ // A fresh sign-in issues a new device. Keep running as the device used
966
+ // before (its desktop WorkFlow evidence stays resumable) when this agent
967
+ // still holds that device's token. Otherwise continue as the new device.
968
+ if (
969
+ previousSession?.device_id &&
970
+ previousSession.token &&
971
+ session.device_id &&
972
+ previousSession.device_id !== session.device_id &&
973
+ previousSession.api_origin &&
974
+ previousApiBaseUrl(previousSession) === session.api_base_url
975
+ ) {
976
+ try {
977
+ const reclaimed = await reclaimDevice({
978
+ apiOrigin,
979
+ apiBaseUrl: session.api_base_url,
980
+ token: session.token,
981
+ previousDeviceId: previousSession.device_id,
982
+ previousToken: previousSession.token,
983
+ });
984
+ if (reclaimed?.device_id === previousSession.device_id) {
985
+ session.device_id = reclaimed.device_id;
986
+ if (typeof reclaimed.expires_at === "string") session.expires_at = reclaimed.expires_at;
987
+ }
988
+ } catch (error) {
989
+ if (!quiet) {
990
+ console.error(
991
+ `SIMY could not continue as the previous device: ${error instanceof Error ? error.message : String(error)}`,
992
+ );
993
+ }
994
+ }
995
+ }
953
996
  await writeSession(apiOrigin, session, sessionRoot);
954
997
  startHeartbeatTimer();
955
998
  loginUrl = null;
@@ -2212,6 +2255,54 @@ export async function heartbeatLocalDevice({
2212
2255
  }
2213
2256
  }
2214
2257
 
2258
+ // The previous token is only ever presented to the Web API it was issued by.
2259
+ function previousApiBaseUrl(previousSession) {
2260
+ try {
2261
+ return resolveWebApiBaseUrl(previousSession.api_origin, previousSession.api_base_url);
2262
+ } catch {
2263
+ return null;
2264
+ }
2265
+ }
2266
+
2267
+ /**
2268
+ * Moves a fresh sign-in onto the device this agent used before. Returns the
2269
+ * receipt, or null when the Web refuses the reclaim (the fresh device stays).
2270
+ */
2271
+ export async function reclaimLocalDevice({
2272
+ apiOrigin,
2273
+ apiBaseUrl,
2274
+ token,
2275
+ previousDeviceId,
2276
+ previousToken,
2277
+ }) {
2278
+ const response = await fetch(
2279
+ webApiUrl("devices/reclaim", { webOrigin: apiOrigin, apiBaseUrl }),
2280
+ {
2281
+ method: "POST",
2282
+ headers: webApiHeaders(
2283
+ { token },
2284
+ { "Content-Type": "application/json" },
2285
+ ),
2286
+ body: JSON.stringify({
2287
+ previous_device_id: previousDeviceId,
2288
+ previous_token: previousToken,
2289
+ }),
2290
+ signal: AbortSignal.timeout(5_000),
2291
+ },
2292
+ );
2293
+ if (response.status === 409) return null;
2294
+ if (!response.ok) {
2295
+ const payload = await response.json().catch(() => null);
2296
+ const error = new Error(webApiErrorMessage(payload, response.status));
2297
+ error.status = response.status;
2298
+ throw error;
2299
+ }
2300
+ const payload = await response.json();
2301
+ return payload && typeof payload.device_id === "string"
2302
+ ? { device_id: payload.device_id, expires_at: payload.expires_at }
2303
+ : null;
2304
+ }
2305
+
2215
2306
  function reportHeartbeatError(error, quiet) {
2216
2307
  if (quiet) return;
2217
2308
  console.error(
@@ -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.5.42";
16
+ export const CLI_VERSION = "0.5.44";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -340,10 +340,11 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
340
340
  return managed;
341
341
  }
342
342
  export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), objective, nextStep, maxContinuations, maxFailures, maxMinutes, managed, admission,
343
- liveHookAdmission, inspectProcess = processIdentity, now = Date.now() }) {
343
+ liveHookAdmission, autoContinueEpoch, inspectProcess = processIdentity, now = Date.now() }) {
344
344
  cwd = await realpath(cwd);
345
345
  if (!text(objective) || !text(nextStep)) throw new Error('Objective and concrete next step are required.');
346
346
  return updateRecord(root, provider, session, async previous => {
347
+ if (autoContinueEpoch && (previous?.autoContinue !== true || previous.autoContinueEpoch !== autoContinueEpoch)) throw new Error('Automatic continuation was disabled before enrollment.');
347
348
  const admitted = admission?.source === 'codex_app_server' && admission.provider === provider && admission.session === session && admission.cwd === cwd && previous?.admission?.source === admission.source && previous.admission.session === session && previous.cwd === cwd;
348
349
  if (liveHookAdmission) {
349
350
  const process = previous?.process;
@@ -355,6 +356,11 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
355
356
  if ((!previous?.observedAt && !admitted) || previous.cwd !== cwd) throw new Error('No matching verified provider session observed here. Select an exact discovered session or enable SIMY hooks.');
356
357
  if (previous.run) throw new Error('This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.');
357
358
  const managedState = managedSpec(managed, session, provider, previous.process, admitted ? admission : null, now);
359
+ if (autoContinueEpoch && managedState) managedState.autoContinueEpoch = autoContinueEpoch;
360
+ if (managedState && previous.associationTaskId) {
361
+ managedState.taskId = previous.associationTaskId;
362
+ if (previous.actionAssociation) managedState.actionAssociation = structuredClone(previous.actionAssociation);
363
+ }
358
364
  if (liveHookAdmission && managedState) managedState.liveHookOwner = { pid: liveHookAdmission.pid,
359
365
  identity: liveHookAdmission.identity, hookStartedAt: liveHookAdmission.hookStartedAt };
360
366
  const waiting = managedState?.decision?.outcome === 'wait';
@@ -653,6 +659,12 @@ export function managedBudgetReason(run, now = Date.now()) {
653
659
  if (managed && managed.noProgress >= managed.maxNoProgress) return 'No verified acceptance progress within the configured continuation limit.';
654
660
  return null;
655
661
  }
662
+ // Manual/legacy runs have no marker and retain their existing controls.
663
+ export function automaticContinuationAllowed(record) {
664
+ const epoch = record?.run?.managed?.autoContinueEpoch;
665
+ return !epoch || record.autoContinue === true && record.autoContinueEpoch === epoch;
666
+ }
667
+
656
668
  export function decideStop(run, now = Date.now()) {
657
669
  if (TERMINAL.has(run.state) || ['blocked', 'recovery_required', 'paused', 'verification_pending'].includes(run.state)) return { run, output: {} };
658
670
  const next = structuredClone(run);
@@ -826,6 +838,9 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
826
838
  run.reason = RETRYABLE.has(input.error) ? 'Transient provider error. Resume the existing conversation after the provider recovers.' : 'Provider error requires attention before resuming.';
827
839
  run.errorCode = text(input.error, 100); run.retryable = RETRYABLE.has(input.error);
828
840
  } else if (event === 'Stop') {
841
+ // OFF suppresses future Stop blocking; it never interrupts the provider
842
+ // turn. Lease settlement and runtime observation above still run.
843
+ if (!automaticContinuationAllowed(record)) return record;
829
844
  // Codex turn IDs make re-delivered Stop events idempotent. Claude has no
830
845
  // stable event ID; never dedupe on text (identical text can be a new turn).
831
846
  const key = input.turn_id ? `${input.turn_id}:${Boolean(input.stop_hook_active)}:${run.revision}` : null;
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { dirname } from 'node:path';
4
- import { assessedCheckpointContract, checkpointDelivery, decideStop, listRecords, managedBudgetReason, notifyState, processIdentity, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
+ import { automaticContinuationAllowed, assessedCheckpointContract, checkpointDelivery, decideStop, listRecords, managedBudgetReason, notifyState, processIdentity, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
5
5
  import { resolveBackendExecutable } from '../backend-executable.js';
6
6
  import { executeCodexAppServer } from '../session-supervision/codex-app-server.js';
7
7
  import { acquireWorkspaceLease, leaseOwnerDead } from '../session-supervision/workspace-lease.js';
@@ -25,6 +25,7 @@ async function assertActiveLease(root, provider, session, leaseId, signal, curre
25
25
  throw new Error('Managed recovery lease changed before provider dispatch.');
26
26
  }
27
27
  if (currentTime() >= current.run.deadline) throw new Error('Managed recovery deadline expired before provider dispatch.');
28
+ if (!automaticContinuationAllowed(current)) throw new Error('Automatic continuation was disabled before provider dispatch.');
28
29
  if (managedBudgetReason(current.run, currentTime())) throw new Error('Managed progress budget expired before provider dispatch.');
29
30
  const managed = current.run.managed;
30
31
  const approvedWait = managed.decision?.outcome === 'wait' && managed.approvedAction?.id === managed.decision.id && !managed.approvedAction.dispatchedAt;
@@ -143,6 +144,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
143
144
  });
144
145
  continue;
145
146
  }
147
+ if (!automaticContinuationAllowed(record)) continue;
146
148
  const appServer = run.managed.resumeChannel === 'codex_app_server';
147
149
  const causeAllowed = run.recoveryCause === 'process_exit' || appServer && ['session_supervision', 'approved_action'].includes(run.recoveryCause);
148
150
  const approvedWait = run.managed.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test'].includes(run.managed.decision.action?.kind) &&
@@ -162,6 +164,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
162
164
  let claimed;
163
165
  try { claimed = await updateRecord(root, record.provider, record.session, current => {
164
166
  const live = current?.run;
167
+ if (!automaticContinuationAllowed(current)) return current;
165
168
  const appChannel = live?.managed?.resumeChannel === 'codex_app_server';
166
169
  const allowedCause = live?.recoveryCause === 'process_exit' || appChannel && ['session_supervision', 'approved_action'].includes(live?.recoveryCause);
167
170
  const approved = live?.managed?.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test'].includes(live.managed.decision.action?.kind) &&
@@ -248,6 +251,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
248
251
  await updateRecord(root, record.provider, record.session, current => {
249
252
  const live = current?.run;
250
253
  if (live?.state !== 'auto_resuming' || live.managed?.resumeLease?.id !== leaseId) throw new Error('Managed recovery lease changed before provider dispatch.');
254
+ if (!automaticContinuationAllowed(current)) throw new Error('Automatic continuation was disabled before provider dispatch.');
251
255
  if (appServer && !live.managed.providerSandbox) {
252
256
  if (!providerSandbox || !['readOnly', 'workspaceWrite', 'dangerFullAccess', 'externalSandbox'].includes(providerSandbox.type)) throw new Error('Original Codex sandbox policy is unavailable.');
253
257
  live.managed.providerSandbox = { type: providerSandbox.type,
@@ -1,10 +1,12 @@
1
+ import { createActionDeadlineService } from './session-supervision/action-deadline.js';
1
2
  import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
2
3
  import { controlDelivery, verifyDelivery } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
3
4
  import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
4
5
  import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
5
- import { answerSession, continueSession, discoverSessions, enrollSession, monitorSession, trackSession } from "./session-supervision/index.js";
6
+ import { answerSession, continueSession, discoverSessions, enrollSession, monitorSession, trackSession, setSessionAutoContinue } from "./session-supervision/index.js";
6
7
  import { createContinuationAnalysisManager } from "./session-supervision/analysis-manager.js";
7
8
  import { readContinuationContext } from "./session-supervision/continuation-analysis.js";
9
+ import { continueAnalyzedSession, createAutomaticContinuation } from "./session-supervision/automatic-continuation.js";
8
10
  import { createWindowsDesktopUpdater } from "./windows-desktop-updater.js";
9
11
  import { spawn, execFile } from "node:child_process";
10
12
  import { promisify } from "node:util";
@@ -46,9 +48,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
46
48
  }
47
49
  const lock = candidate ? null : await acquireDesktopProcessLock(root, { webOrigin });
48
50
  const supervisorToken = lock?.nonce || env.SIMY_DESKTOP_SUPERVISOR_TOKEN;
49
- let agent, updater, analysisManager, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false;
50
- const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin });
51
+ let agent, updater, analysisManager, automaticContinuation, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false;
52
+ const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
51
53
  let actionAssociations = makeActionAssociations();
54
+ const deadlines = createActionDeadlineService({ root: join(root, "action-deadlines") });
52
55
  const managedSupervisor = (dependencies.createManagedSupervisor || createManagedSupervisor)({ root: join(root, "delivery-hooks"),
53
56
  beforeClaim: record => actionAssociations.prepareCached?.(record) || record });
54
57
  let shuttingDown = null;
@@ -75,6 +78,13 @@ export async function startDesktopHost({ input = process.stdin, output = process
75
78
  if (!candidate) throw new Error('This conversation is unavailable; refresh its provider.');
76
79
  return candidate;
77
80
  };
81
+ const makeAutomaticContinuation = () => createAutomaticContinuation({ root: join(root, 'delivery-hooks'),
82
+ getManager: () => analysisManager, selectCandidate: selectedSession,
83
+ readSetting: dependencies.readSessionAutoContinue,
84
+ readContext: dependencies.readContinuationContext || readContinuationContext,
85
+ enroll: dependencies.continueSession || continueSession,
86
+ onChange: () => { if (!closed && !relay) refreshSessions(true); },
87
+ });
78
88
  let pluginSetup = { state: "unknown" };
79
89
  const inspectPlugins = dependencies.inspectPlugins || inspectSqmAgentSkill;
80
90
  const refreshPlugins = async () => {
@@ -96,7 +106,17 @@ export async function startDesktopHost({ input = process.stdin, output = process
96
106
  status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: join(root, "delivery-hooks") }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
97
107
  await actionAssociations.decorate(status.deliveryHooks);
98
108
  const discovery = refreshSessions();
99
- status.sessionSupervision = { ...discovery, sessions: discovery.sessions.map(candidate => analysisManager.project(candidate)) };
109
+ automaticContinuation?.tick(discovery.sessions);
110
+ const sessions = discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate)));
111
+ await actionAssociations.decorateSessions?.(sessions);
112
+ for (const session of sessions) {
113
+ const context = await actionAssociations.deadlineContext?.(session);
114
+ if (context) session.actionDeadline = await deadlines.reconcile(context);
115
+ else session.actionDeadline = { status: 'unavailable', online: false, reminder: null };
116
+ const run = status.deliveryHooks.runs?.find(item => item.provider === session.provider && item.session === session.session);
117
+ if (run) run.actionDeadline = session.actionDeadline;
118
+ }
119
+ status.sessionSupervision = { ...discovery, sessions };
100
120
  if (!closed && !relay) emit({ type: "status", status });
101
121
  }).finally(() => { publishing = null; if (publishAgain && !closed) { publishAgain = false; setImmediate(() => { void publish().catch(() => {}); }); } });
102
122
  return publishing;
@@ -104,6 +124,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
104
124
  const stopAgent = async () => {
105
125
  clearInterval(timer);
106
126
  await actionAssociations.close();
127
+ await automaticContinuation?.close();
107
128
  await analysisManager?.close();
108
129
  await managedSupervisor.close();
109
130
  await stopWorkflows();
@@ -126,6 +147,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
126
147
 
127
148
  try {
128
149
  analysisManager = await makeAnalysisManager();
150
+ automaticContinuation = makeAutomaticContinuation();
129
151
  if (!candidate) {
130
152
  await (dependencies.assertNoLegacyDaemon || assertNoLegacyCliDaemon)(webOrigin);
131
153
  }
@@ -148,6 +170,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
148
170
  if (!installation?.entryPath || !installation?.executablePath) throw new Error("Signed update runtime is unavailable.");
149
171
  // The candidate shares this cache. Stop inference before it starts so a
150
172
  // signed handoff cannot create two analysis writers or duplicate calls.
173
+ await automaticContinuation.close();
151
174
  await analysisManager.close();
152
175
  // The candidate also shares association records and their sync outbox.
153
176
  // Drain the old writer before starting its replacement.
@@ -159,7 +182,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
159
182
  env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken },
160
183
  }); } catch (error) {
161
184
  actionAssociations = makeActionAssociations();
162
- analysisManager = await makeAnalysisManager(); refreshSessions(true); throw error;
185
+ analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); refreshSessions(true); throw error;
163
186
  }
164
187
  const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
165
188
  let accepted = false;
@@ -204,7 +227,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
204
227
  lines.close(); await stopUpdateCandidate(child);
205
228
  if (!closed && !relay) {
206
229
  actionAssociations = makeActionAssociations();
207
- analysisManager = await makeAnalysisManager(); refreshSessions(true);
230
+ analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); refreshSessions(true);
208
231
  }
209
232
  throw error;
210
233
  }
@@ -289,7 +312,33 @@ export async function startDesktopHost({ input = process.stdin, output = process
289
312
  await verifyDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
290
313
  taskId: command.taskId, criterionId: command.criterionId });
291
314
  } else if (["session_action_select", "session_action_unlink", "session_action_refresh"].includes(action)) {
292
- await actionAssociations.correct(command);
315
+ const candidate = await selectedSession(command);
316
+ await actionAssociations.correct(command, analysisManager.project(candidate));
317
+ } else if (action === "session_set_auto_continue") {
318
+ if (typeof command.enabled !== 'boolean') throw new Error('Choose whether automatic continuation is enabled.');
319
+ if (command.enabled) {
320
+ const candidate = await selectedSession(command);
321
+ if (candidate.capabilities?.canAutoContinue !== true) throw new Error('Automatic continuation is unavailable for this conversation.');
322
+ }
323
+ await (dependencies.setSessionAutoContinue || setSessionAutoContinue)({ root: join(root, 'delivery-hooks'),
324
+ provider: command.provider, session: command.session, enabled: command.enabled });
325
+ if (!command.enabled) automaticContinuation.revoke(command.provider, command.session);
326
+ refreshSessions(true);
327
+ if (command.enabled) {
328
+ const fresh = await selectedSession(command);
329
+ if (fresh.capabilities?.canAnalyze === true) await analysisManager.request(fresh);
330
+ }
331
+ } else if (action === "session_deadline_decide") {
332
+ if (command.cancel === true) { emit({ type: "result", id: command.id, ok: true }); return; }
333
+ const candidate = await selectedSession(command);
334
+ await actionAssociations.correct({ ...command, action: 'session_action_refresh' }, analysisManager.project(candidate));
335
+ const projected = analysisManager.project(candidate);
336
+ await actionAssociations.decorateSessions?.([projected]);
337
+ const context = await actionAssociations.deadlineContext?.(projected);
338
+ if (!context || context.taskId !== command.taskId) throw new Error('Refresh this conversation before deciding its deadline.');
339
+ await deadlines.reconcile(context);
340
+ const result = await deadlines.decide(context, { ...command, requestId: command.requestId || command.decisionId });
341
+ if (result.status === 'conflict') throw Object.assign(new Error('Deadline changed; refresh before deciding.'), { code: 'conflict' });
293
342
  } else if (action === "session_track" || action === "session_untrack") {
294
343
  const candidate = await selectedSession(command);
295
344
  if (candidate.capabilities?.canMonitor !== true) throw new Error('Tracking is unavailable for this conversation.');
@@ -307,25 +356,18 @@ export async function startDesktopHost({ input = process.stdin, output = process
307
356
  maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens });
308
357
  await refreshSessions(true);
309
358
  } else if (action === "session_analyze") {
310
- await analysisManager.request(await selectedSession(command));
359
+ const candidate = await selectedSession(command);
360
+ await analysisManager.request(candidate);
361
+ if (candidate.autoContinue === true && candidate.capabilities?.canContinue === true) automaticContinuation.retry(candidate.provider, candidate.session);
311
362
  } else if (action === "session_continue") {
312
363
  const candidate = await selectedSession(command);
313
364
  const manager = analysisManager;
314
- const recommendation = manager.requireRecommendation(candidate);
315
- if (candidate.capabilities?.canContinue !== true) throw new Error('Automatic continuation is unavailable for this conversation.');
316
- const context = await (dependencies.readContinuationContext || readContinuationContext)(candidate);
317
- // Provider activity timestamps can have one-second precision. Re-read
318
- // the bounded context before enrollment to reject same-second edits.
319
- if (context.awaitingTurn || context.incomplete || !recommendation.contextDigest || context.contextDigest !== recommendation.contextDigest) {
320
- await manager.invalidate(candidate);
321
- throw new Error('This conversation changed; wait for its updated analysis.');
322
- }
323
- // Reading provider context can overlap an update handoff. The old
324
- // manager must still be active before task enrollment is dispatched.
325
- manager.requireRecommendation(candidate);
326
- await (dependencies.continueSession || continueSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
327
- session: command.session, autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
328
- maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens });
365
+ await continueAnalyzedSession({ root: join(root, 'delivery-hooks'), candidate, manager,
366
+ readContext: dependencies.readContinuationContext || readContinuationContext,
367
+ enroll: dependencies.continueSession || continueSession,
368
+ settings: { autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
369
+ maxContinuations: command.maxContinuations, maxFailures: command.maxFailures,
370
+ maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens } });
329
371
  await refreshSessions(true);
330
372
  } else if (action === "session_answer") {
331
373
  await (dependencies.answerSession || answerSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
@@ -43,7 +43,7 @@ export async function writeSession(webOrigin, session, sessionRoot = defaultSess
43
43
  });
44
44
  }
45
45
 
46
- function defaultSessionRoot() {
46
+ export function defaultSessionRoot() {
47
47
  return process.env.SIMY_HOME?.trim() || join(homedir(), ".simy");
48
48
  }
49
49
 
@@ -1,3 +1,4 @@
1
+ import { createOrdinaryAssociationStore } from './association-store.js';
1
2
  import { listRecords, updateRecord, TERMINAL, managedBudgetReason } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
2
3
  import { createActionAssociationApi, ActionAssociationApiError } from './action-association-api.js';
3
4
  import { actionCandidateRequest, prepareActionAssociation, importActionBinding,
@@ -14,8 +15,9 @@ const hardFailure = code => ['unauthorized', 'unavailable', 'invalid_request', '
14
15
  // Association is optional metadata. Network failures never control the task's
15
16
  // work state; provider turns and their budgets remain owned by the supervisor.
16
17
  export function createActionAssociationHost({ root, sessionRoot, webOrigin, api = createActionAssociationApi({ webOrigin, sessionRoot }),
17
- now = Date.now, refreshMs = 60_000, list = listRecords } = {}) {
18
+ now = Date.now, refreshMs = 60_000, list = listRecords, onChange = () => {} } = {}) {
18
19
  const abort = new AbortController(), views = new Map();
20
+ const ordinary = createOrdinaryAssociationStore({ root, api, webOrigin, now, onChange });
19
21
  let auth = null, closed = false, running = null, serial = Promise.resolve();
20
22
  const enqueue = work => {
21
23
  const result = serial.then(work); serial = result.catch(() => {}); return result;
@@ -144,6 +146,34 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
144
146
  }));
145
147
  }
146
148
  return {
149
+ async decorateSessions(sessions) {
150
+ await ordinary.decorate(sessions);
151
+ const records = await list(root);
152
+ const status = { runs: sessions.filter(item => records.some(record => record.provider === item.provider && record.session === item.session && record.run?.managed)).map(item => {
153
+ const record = records.find(record => record.provider === item.provider && record.session === item.session);
154
+ return { managed: true, taskId: record.run.managed.taskId, actionAssociation: record.run.managed.actionAssociation, candidate: item };
155
+ }) };
156
+ await this.decorate(status);
157
+ for (const run of status.runs) run.candidate.actionAssociation = run.actionAssociation;
158
+ return sessions;
159
+ },
160
+ async deadlineContext(candidate) {
161
+ const expected = await current(), record = (await list(root)).find(item => item.provider === candidate.provider && item.session === candidate.session);
162
+ const local = record?.run?.managed?.actionAssociation || record?.actionAssociation;
163
+ if (!expected || local?.authRevision !== expected || !candidate.actionAssociation?.canCorrect) return null;
164
+ const binding = candidate.actionAssociation.binding;
165
+ const fingerprint = value => JSON.stringify([value?.revision, value?.action?.id, value?.action?.revision, value?.action?.dueAt, value?.action?.timeZone, value?.action?.status]);
166
+ return { namespace: local.namespace, provider: candidate.provider, session: candidate.session, taskId: candidate.taskId, binding,
167
+ taskStatus: null, remainingWork: record.run?.managed?.sessionUnderstanding?.remaining || record.run?.managed?.remainingWork || candidate.analysis?.remaining || candidate.analysis?.remainingWork || null,
168
+ plan: record.run?.managed?.plan || candidate.analysis?.plan || null, blocker: record.run?.blocker || candidate.analysis?.blocker || null, online: true,
169
+ validate: async () => {
170
+ if (!await valid(expected)) return false;
171
+ const latest = (await list(root)).find(item => item.provider === candidate.provider && item.session === candidate.session);
172
+ const scoped = latest?.run?.managed?.actionAssociation || latest?.actionAssociation;
173
+ return (latest?.run?.managed?.taskId || latest?.associationTaskId) === candidate.taskId && scoped?.namespace === local.namespace && scoped?.authRevision === expected && fingerprint(scoped.binding) === fingerprint(binding);
174
+ } };
175
+
176
+ },
147
177
  async prepareCached(record) {
148
178
  const view = views.get(record?.run?.managed?.taskId), request = actionCandidateRequest(record);
149
179
  if (closed || !auth || !view || view.auth !== auth || view.error || !view.snapshot || !request || view.contextRevision !== request.context.revision || view.snapshot.contextRevision !== request.context.revision || view.namespace !== namespaceFor(webOrigin, view.snapshot.scope)) return record;
@@ -170,7 +200,7 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
170
200
  status: view.error || local?.status || 'waiting_boundary',
171
201
  binding: view.namespace && local?.namespace === view.namespace ? publicBinding(local.binding) : null,
172
202
  candidates: (view.candidates || []).slice(0,5), canCorrect: !view.error,
173
- retryOn: local?.retryOn || null, truncated: view.candidateTruncated || false,
203
+ contextRevision: view.contextRevision, candidateRevision: view.snapshot?.candidateRevision || null, retryOn: local?.retryOn || null, truncated: view.candidateTruncated || false,
174
204
  };
175
205
  let bytes = Buffer.byteLength(JSON.stringify(run.actionAssociation));
176
206
  if (bytes > Math.min(remainingBytes,perRunBytes)) { run.actionAssociation.candidates = []; run.actionAssociation.truncated = true; bytes = Buffer.byteLength(JSON.stringify(run.actionAssociation)); }
@@ -183,7 +213,8 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
183
213
  }
184
214
  return status;
185
215
  },
186
- correct(command) {
216
+ correct(command, candidate) {
217
+ if (candidate && candidate.binding?.mode !== 'managed' && candidate.binding?.mode !== 'pending_takeover') return ordinary.correct(command, candidate);
187
218
  return enqueue(async () => {
188
219
  const expected = await current(), record = await fresh(command);
189
220
  if (!expected) throw new ActionAssociationApiError('signed_out');
@@ -192,9 +223,11 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
192
223
  const view = views.get(command.taskId);
193
224
  if (!await valid(expected) || !view?.namespace || view.error) throw new ActionAssociationApiError(view?.error || 'unavailable');
194
225
  if (command.action === 'session_action_refresh') { view.nextRefresh = 0; return; }
226
+ if (command.expectedContextRevision && command.expectedContextRevision !== view.contextRevision || command.expectedCandidateRevision && command.expectedCandidateRevision !== view.snapshot?.candidateRevision) throw new ActionAssociationApiError('conflict');
195
227
  const latest = await fresh(command), binding = latest?.run.managed.actionAssociation?.binding;
196
228
  if ((binding?.revision ?? null) !== command.expectedBindingRevision) throw new ActionAssociationApiError('conflict');
197
229
  const selected = command.action === 'session_action_select' ? view.snapshot?.candidates.find(item => item.id === command.actionId) || (!view.snapshot && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(command.actionId || '') ? { id: command.actionId, revision: null } : null) : null;
230
+ if (selected && command.expectedActionRevision && command.expectedActionRevision !== selected.revision) throw new ActionAssociationApiError('conflict');
198
231
  if (command.action !== 'session_action_unlink' && !selected) throw new ActionAssociationApiError('invalid_request');
199
232
  const envelope = await api.saveBinding(command.taskId, {
200
233
  expectedRevision: command.expectedBindingRevision, contextRevision: view.contextRevision,
@@ -208,6 +241,6 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
208
241
  view.error = null; view.nextRefresh = 0;
209
242
  });
210
243
  },
211
- async close() { closed = true; abort.abort(); await Promise.allSettled([serial, running]); views.clear(); },
244
+ async close() { closed = true; abort.abort(); await Promise.allSettled([serial, running, ordinary.close()]); views.clear(); },
212
245
  };
213
246
  }
@@ -0,0 +1,57 @@
1
+ import { mkdir, readFile, writeFile, rename, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+
5
+ // All instances in a host share a queue; every transaction reloads disk so a
6
+ // newly created host sees durable decisions instead of an in-memory snapshot.
7
+ const queues = new Map();
8
+ export function createActionDeadlineStore({ root }) {
9
+ const directory = join(root, "action-deadlines");
10
+ const pathFor = (identity) =>
11
+ join(
12
+ directory,
13
+ `${createHash("sha256").update(JSON.stringify(identity)).digest("hex")}.json`,
14
+ );
15
+ const read = async (identity) => {
16
+ try {
17
+ return JSON.parse(await readFile(pathFor(identity), "utf8"));
18
+ } catch (error) {
19
+ if (error.code === "ENOENT") return null;
20
+ throw error;
21
+ }
22
+ };
23
+ return {
24
+ read,
25
+ transact(identity, update, validate = async () => true) {
26
+ const path = pathFor(identity);
27
+ const result = (queues.get(path) || Promise.resolve()).then(async () => {
28
+ const previous = await read(identity);
29
+ const assertCurrent = async () => {
30
+ if (!(await validate()))
31
+ throw Object.assign(new Error("Deadline context changed."), {
32
+ code: "conflict",
33
+ });
34
+ };
35
+ await assertCurrent();
36
+ const next = await update(previous);
37
+ if (next !== previous && next !== undefined) {
38
+ await mkdir(directory, { recursive: true, mode: 0o700 });
39
+ const temporary = `${path}.${randomUUID()}.tmp`;
40
+ await writeFile(temporary, JSON.stringify(next), { mode: 0o600 });
41
+ try {
42
+ await assertCurrent();
43
+ await rename(temporary, path);
44
+ } finally {
45
+ await rm(temporary, { force: true });
46
+ }
47
+ }
48
+ return next === undefined ? previous : next;
49
+ });
50
+ queues.set(
51
+ path,
52
+ result.catch(() => {}),
53
+ );
54
+ return result;
55
+ },
56
+ };
57
+ }