@awak-app/simy-cli 0.5.41 → 0.5.43

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.41",
3
+ "version": "0.5.43",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,7 +42,7 @@
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.41";
16
+ export const CLI_VERSION = "0.5.43";
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,7 @@ 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;
358
360
  if (liveHookAdmission && managedState) managedState.liveHookOwner = { pid: liveHookAdmission.pid,
359
361
  identity: liveHookAdmission.identity, hookStartedAt: liveHookAdmission.hookStartedAt };
360
362
  const waiting = managedState?.decision?.outcome === 'wait';
@@ -653,6 +655,12 @@ export function managedBudgetReason(run, now = Date.now()) {
653
655
  if (managed && managed.noProgress >= managed.maxNoProgress) return 'No verified acceptance progress within the configured continuation limit.';
654
656
  return null;
655
657
  }
658
+ // Manual/legacy runs have no marker and retain their existing controls.
659
+ export function automaticContinuationAllowed(record) {
660
+ const epoch = record?.run?.managed?.autoContinueEpoch;
661
+ return !epoch || record.autoContinue === true && record.autoContinueEpoch === epoch;
662
+ }
663
+
656
664
  export function decideStop(run, now = Date.now()) {
657
665
  if (TERMINAL.has(run.state) || ['blocked', 'recovery_required', 'paused', 'verification_pending'].includes(run.state)) return { run, output: {} };
658
666
  const next = structuredClone(run);
@@ -826,6 +834,9 @@ export async function handleHook({ input, provider, root = rootDirectory(), now
826
834
  run.reason = RETRYABLE.has(input.error) ? 'Transient provider error. Resume the existing conversation after the provider recovers.' : 'Provider error requires attention before resuming.';
827
835
  run.errorCode = text(input.error, 100); run.retryable = RETRYABLE.has(input.error);
828
836
  } else if (event === 'Stop') {
837
+ // OFF suppresses future Stop blocking; it never interrupts the provider
838
+ // turn. Lease settlement and runtime observation above still run.
839
+ if (!automaticContinuationAllowed(record)) return record;
829
840
  // Codex turn IDs make re-delivered Stop events idempotent. Claude has no
830
841
  // stable event ID; never dedupe on text (identical text can be a new turn).
831
842
  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,
@@ -2,9 +2,10 @@ import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
2
2
  import { controlDelivery, verifyDelivery } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
3
3
  import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
4
4
  import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
5
- import { answerSession, continueSession, discoverSessions, enrollSession, monitorSession, trackSession } from "./session-supervision/index.js";
5
+ import { answerSession, continueSession, discoverSessions, enrollSession, monitorSession, trackSession, setSessionAutoContinue } from "./session-supervision/index.js";
6
6
  import { createContinuationAnalysisManager } from "./session-supervision/analysis-manager.js";
7
7
  import { readContinuationContext } from "./session-supervision/continuation-analysis.js";
8
+ import { continueAnalyzedSession, createAutomaticContinuation } from "./session-supervision/automatic-continuation.js";
8
9
  import { createWindowsDesktopUpdater } from "./windows-desktop-updater.js";
9
10
  import { spawn, execFile } from "node:child_process";
10
11
  import { promisify } from "node:util";
@@ -46,7 +47,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
46
47
  }
47
48
  const lock = candidate ? null : await acquireDesktopProcessLock(root, { webOrigin });
48
49
  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
+ let agent, updater, analysisManager, automaticContinuation, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false;
50
51
  const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin });
51
52
  let actionAssociations = makeActionAssociations();
52
53
  const managedSupervisor = (dependencies.createManagedSupervisor || createManagedSupervisor)({ root: join(root, "delivery-hooks"),
@@ -75,6 +76,13 @@ export async function startDesktopHost({ input = process.stdin, output = process
75
76
  if (!candidate) throw new Error('This conversation is unavailable; refresh its provider.');
76
77
  return candidate;
77
78
  };
79
+ const makeAutomaticContinuation = () => createAutomaticContinuation({ root: join(root, 'delivery-hooks'),
80
+ getManager: () => analysisManager, selectCandidate: selectedSession,
81
+ readSetting: dependencies.readSessionAutoContinue,
82
+ readContext: dependencies.readContinuationContext || readContinuationContext,
83
+ enroll: dependencies.continueSession || continueSession,
84
+ onChange: () => { if (!closed && !relay) refreshSessions(true); },
85
+ });
78
86
  let pluginSetup = { state: "unknown" };
79
87
  const inspectPlugins = dependencies.inspectPlugins || inspectSqmAgentSkill;
80
88
  const refreshPlugins = async () => {
@@ -96,7 +104,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
96
104
  status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: join(root, "delivery-hooks") }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
97
105
  await actionAssociations.decorate(status.deliveryHooks);
98
106
  const discovery = refreshSessions();
99
- status.sessionSupervision = { ...discovery, sessions: discovery.sessions.map(candidate => analysisManager.project(candidate)) };
107
+ automaticContinuation?.tick(discovery.sessions);
108
+ status.sessionSupervision = { ...discovery, sessions: discovery.sessions.map(candidate => automaticContinuation.project(analysisManager.project(candidate))) };
100
109
  if (!closed && !relay) emit({ type: "status", status });
101
110
  }).finally(() => { publishing = null; if (publishAgain && !closed) { publishAgain = false; setImmediate(() => { void publish().catch(() => {}); }); } });
102
111
  return publishing;
@@ -104,6 +113,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
104
113
  const stopAgent = async () => {
105
114
  clearInterval(timer);
106
115
  await actionAssociations.close();
116
+ await automaticContinuation?.close();
107
117
  await analysisManager?.close();
108
118
  await managedSupervisor.close();
109
119
  await stopWorkflows();
@@ -126,6 +136,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
126
136
 
127
137
  try {
128
138
  analysisManager = await makeAnalysisManager();
139
+ automaticContinuation = makeAutomaticContinuation();
129
140
  if (!candidate) {
130
141
  await (dependencies.assertNoLegacyDaemon || assertNoLegacyCliDaemon)(webOrigin);
131
142
  }
@@ -148,6 +159,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
148
159
  if (!installation?.entryPath || !installation?.executablePath) throw new Error("Signed update runtime is unavailable.");
149
160
  // The candidate shares this cache. Stop inference before it starts so a
150
161
  // signed handoff cannot create two analysis writers or duplicate calls.
162
+ await automaticContinuation.close();
151
163
  await analysisManager.close();
152
164
  // The candidate also shares association records and their sync outbox.
153
165
  // Drain the old writer before starting its replacement.
@@ -159,7 +171,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
159
171
  env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken },
160
172
  }); } catch (error) {
161
173
  actionAssociations = makeActionAssociations();
162
- analysisManager = await makeAnalysisManager(); refreshSessions(true); throw error;
174
+ analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); refreshSessions(true); throw error;
163
175
  }
164
176
  const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
165
177
  let accepted = false;
@@ -204,7 +216,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
204
216
  lines.close(); await stopUpdateCandidate(child);
205
217
  if (!closed && !relay) {
206
218
  actionAssociations = makeActionAssociations();
207
- analysisManager = await makeAnalysisManager(); refreshSessions(true);
219
+ analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); refreshSessions(true);
208
220
  }
209
221
  throw error;
210
222
  }
@@ -290,6 +302,20 @@ export async function startDesktopHost({ input = process.stdin, output = process
290
302
  taskId: command.taskId, criterionId: command.criterionId });
291
303
  } else if (["session_action_select", "session_action_unlink", "session_action_refresh"].includes(action)) {
292
304
  await actionAssociations.correct(command);
305
+ } else if (action === "session_set_auto_continue") {
306
+ if (typeof command.enabled !== 'boolean') throw new Error('Choose whether automatic continuation is enabled.');
307
+ if (command.enabled) {
308
+ const candidate = await selectedSession(command);
309
+ if (candidate.capabilities?.canAutoContinue !== true) throw new Error('Automatic continuation is unavailable for this conversation.');
310
+ }
311
+ await (dependencies.setSessionAutoContinue || setSessionAutoContinue)({ root: join(root, 'delivery-hooks'),
312
+ provider: command.provider, session: command.session, enabled: command.enabled });
313
+ if (!command.enabled) automaticContinuation.revoke(command.provider, command.session);
314
+ refreshSessions(true);
315
+ if (command.enabled) {
316
+ const fresh = await selectedSession(command);
317
+ if (fresh.capabilities?.canAnalyze === true) await analysisManager.request(fresh);
318
+ }
293
319
  } else if (action === "session_track" || action === "session_untrack") {
294
320
  const candidate = await selectedSession(command);
295
321
  if (candidate.capabilities?.canMonitor !== true) throw new Error('Tracking is unavailable for this conversation.');
@@ -307,25 +333,18 @@ export async function startDesktopHost({ input = process.stdin, output = process
307
333
  maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens });
308
334
  await refreshSessions(true);
309
335
  } else if (action === "session_analyze") {
310
- await analysisManager.request(await selectedSession(command));
336
+ const candidate = await selectedSession(command);
337
+ await analysisManager.request(candidate);
338
+ if (candidate.autoContinue === true && candidate.capabilities?.canContinue === true) automaticContinuation.retry(candidate.provider, candidate.session);
311
339
  } else if (action === "session_continue") {
312
340
  const candidate = await selectedSession(command);
313
341
  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 });
342
+ await continueAnalyzedSession({ root: join(root, 'delivery-hooks'), candidate, manager,
343
+ readContext: dependencies.readContinuationContext || readContinuationContext,
344
+ enroll: dependencies.continueSession || continueSession,
345
+ settings: { autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
346
+ maxContinuations: command.maxContinuations, maxFailures: command.maxFailures,
347
+ maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens } });
329
348
  await refreshSessions(true);
330
349
  } else if (action === "session_answer") {
331
350
  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
 
@@ -0,0 +1,81 @@
1
+ import { continueSession, readSessionAutoContinue } from './index.js';
2
+ import { readContinuationContext } from './continuation-analysis.js';
3
+
4
+ // Shared by explicit Continue and automatic enrollment. Analysis alone never
5
+ // dispatches work: the exact current provider context is checked again here.
6
+ export async function continueAnalyzedSession({ candidate, manager, root, settings = {}, signal,
7
+ readContext = readContinuationContext, enroll = continueSession, beforeEnroll = () => {} }) {
8
+ const recommendation = manager.requireRecommendation(candidate);
9
+ if (candidate.isRunning || candidate.capabilities?.canContinue !== true) throw new Error('Automatic continuation is unavailable for this conversation.');
10
+ const context = await readContext(candidate, { signal });
11
+ if (context.awaitingTurn || context.incomplete || !recommendation.contextDigest || context.contextDigest !== recommendation.contextDigest) {
12
+ await manager.invalidate(candidate);
13
+ throw new Error('This conversation changed; wait for its updated analysis.');
14
+ }
15
+ manager.requireRecommendation(candidate);
16
+ await beforeEnroll();
17
+ if (signal?.aborted) throw new Error('Automatic continuation was cancelled before enrollment.');
18
+ return enroll({ root, provider: candidate.provider, session: candidate.session, ...settings });
19
+ }
20
+
21
+ // One pending enrollment per identity. A failed attempt is retried only after
22
+ // context/opt-in changes; an existing (including terminal) run is never reset.
23
+ export function createAutomaticContinuation({ root, getManager, selectCandidate,
24
+ readSetting = readSessionAutoContinue, readContext = readContinuationContext, enroll = continueSession,
25
+ onChange = () => {} }) {
26
+ const pending = new Map(), attempted = new Map(), errors = new Map();
27
+ let closed = false;
28
+ const tick = candidates => {
29
+ if (closed) return;
30
+ for (const candidate of candidates) {
31
+ if (pending.size >= 3) break;
32
+ if (candidate.autoContinue !== true || !candidate.autoContinueEpoch || candidate.isRunning ||
33
+ candidate.capabilities?.canAutoContinue !== true || candidate.capabilities?.canContinue !== true) continue;
34
+ const key = `${candidate.provider}:${candidate.session}`;
35
+ if (pending.has(key)) continue;
36
+ let recommendation;
37
+ const manager = getManager();
38
+ try { recommendation = manager.requireRecommendation(candidate); } catch { continue; }
39
+ const attempt = `${candidate.autoContinueEpoch}:${recommendation.contextDigest}`;
40
+ if (attempted.get(key) === attempt) continue;
41
+ attempted.set(key, attempt);
42
+ errors.delete(key);
43
+ const controller = new AbortController();
44
+ const guard = async () => {
45
+ const setting = await readSetting({ root, provider: candidate.provider, session: candidate.session });
46
+ if (closed || controller.signal.aborted || !setting.enabled || setting.epoch !== candidate.autoContinueEpoch || setting.hasRun) throw new Error('Automatic continuation was disabled or already enrolled.');
47
+ return setting;
48
+ };
49
+ const job = Promise.resolve().then(async () => {
50
+ const setting = await guard();
51
+ const fresh = await selectCandidate(candidate);
52
+ if (!fresh.autoContinue || fresh.autoContinueEpoch !== candidate.autoContinueEpoch) throw new Error('Automatic continuation setting changed.');
53
+ await continueAnalyzedSession({ root, candidate: fresh, manager, signal: controller.signal,
54
+ readContext, enroll, beforeEnroll: guard,
55
+ settings: { ...setting.settings, autoContinueEpoch: candidate.autoContinueEpoch } });
56
+ }).catch(error => {
57
+ if (!closed && !controller.signal.aborted && !/disabled|already enrolled|setting changed|conversation changed; wait|needs a current recommendation/i.test(error?.message || '')) {
58
+ errors.set(key, { epoch: candidate.autoContinueEpoch, reason: 'Could not start auto-continue. Check the original conversation and retry.' });
59
+ }
60
+ }).finally(() => { pending.delete(key); if (!closed) onChange(); });
61
+ pending.set(key, { job, controller });
62
+ }
63
+ };
64
+ return { tick,
65
+ retry(provider, session) {
66
+ const key = `${provider}:${session}`;
67
+ if (!closed && !pending.has(key)) { attempted.delete(key); errors.delete(key); }
68
+ },
69
+ project(candidate) {
70
+ const error = errors.get(`${candidate.provider}:${candidate.session}`);
71
+ if (candidate.autoContinue === true && candidate.autoContinueEpoch === error?.epoch && candidate.capabilities?.canContinue === true &&
72
+ !['no_action', 'needs_user', 'waiting', 'uncertain'].includes(candidate.analysis?.state)) return {
73
+ ...candidate, autoContinueStatus: 'error', autoContinueReason: error.reason,
74
+ };
75
+ return candidate;
76
+ },
77
+ revoke(provider, session) { pending.get(`${provider}:${session}`)?.controller.abort(); },
78
+ async whenIdle() { await Promise.allSettled([...pending.values()].map(entry => entry.job)); },
79
+ async close() { closed = true; for (const entry of pending.values()) entry.controller.abort(); await Promise.allSettled([...pending.values()].map(entry => entry.job)); },
80
+ };
81
+ }
@@ -1,9 +1,26 @@
1
1
  import { realpath } from 'node:fs/promises';
2
- import { controlDelivery, listRecords, processIdentity, startDelivery, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { TERMINAL as TERMINAL_RUNS, controlDelivery, listRecords, processIdentity, startDelivery, updateRecord } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
4
  import { listCodexSessions, openCodexAppServer, readCodexSession } from './codex-app-server.js';
4
5
  import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
5
6
  import { originalSessionTarget } from './original-session-target.js';
6
7
 
8
+ function autoContinueState(record, isRunning, supported) {
9
+ if (record?.autoContinue !== true) return { autoContinueStatus: 'off', autoContinueReason: null };
10
+ const state = record.run?.state;
11
+ if (state === 'budget_exhausted') return { autoContinueStatus: 'limit', autoContinueReason: 'This conversation reached its continuation limit. Its budgets cannot be reset.' };
12
+ if (['completed', 'reported_done'].includes(state)) return { autoContinueStatus: 'finished', autoContinueReason: 'Bounded continuation finished for this conversation.' };
13
+ const managed = record.run?.managed;
14
+ const recoverableCause = record.run?.recoveryCause === 'process_exit' || managed?.resumeChannel === 'codex_app_server' && ['session_supervision', 'approved_action'].includes(record.run?.recoveryCause);
15
+ if (state === 'recovery_required' && (!managed?.autoRecovery || !recoverableCause || managed.pendingApproval)) return {
16
+ autoContinueStatus: 'needs_user', autoContinueReason: 'Existing supervision needs your attention before more work can continue.',
17
+ };
18
+ if (['paused', 'blocked', 'stopped', 'failed', 'verification_pending'].includes(state)) return { autoContinueStatus: 'needs_user', autoContinueReason: 'Existing supervision needs your attention before more work can continue.' };
19
+ if (!supported) return { autoContinueStatus: 'unavailable', autoContinueReason: 'The original provider continuation channel is unavailable.' };
20
+ return { autoContinueStatus: record.run ? 'supervising' : isRunning ? 'waiting' : 'watching',
21
+ autoContinueReason: record.run ? 'Continuing within the existing task budget.' : isRunning ? 'Waiting for the original turn to finish.' : 'Checking settled requests for worthwhile remaining work.' };
22
+ }
23
+
7
24
  function initialDiagnosisPlan(objective, contextBootstrap = false) {
8
25
  const action = { kind: 'read', scope: 'task', summary: contextBootstrap ? 'Understand the original user request and current progress without changing files' : 'Inspect the selected conversation and acceptance evidence without changing files' };
9
26
  return {
@@ -36,7 +53,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
36
53
  const records = await listRecords(root);
37
54
  let discovered = [], codexError = null, claude = [], claudeError = null;
38
55
  try { discovered = await withClient(openClient, client => listCodexSessions({ client, inspectState: true,
39
- prioritySessions: records.filter(record => record.provider === 'codex' && (record.monitor || record.tracked || record.run)).map(record => record.session) })); }
56
+ prioritySessions: records.filter(record => record.provider === 'codex' && (record.autoContinue || record.monitor || record.tracked || record.run)).map(record => record.session) })); }
40
57
  catch (error) { codexError = error?.code === 'unavailable' ? 'Codex executable unavailable.' : error?.code === 'unsupported' ? 'This Codex version lacks a verified app-server writer lock; update before enrollment.' : 'Codex conversation discovery unavailable.'; }
41
58
  try { claude = await listClaude(); } catch { claudeError = 'Claude conversation discovery unavailable.'; }
42
59
  const byKey = new Map();
@@ -70,13 +87,17 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
70
87
  ...(item.providerGoal !== undefined ? { providerGoal: item.providerGoal } : {}),
71
88
  ...(item.goalReadState ? { goalReadState: item.goalReadState } : {}),
72
89
  tracked: typeof record?.tracked === 'boolean' ? record.tracked : record?.monitor === true,
90
+ autoContinue: record?.autoContinue === true,
91
+ autoContinueEpoch: record?.autoContinue === true ? record.autoContinueEpoch : null,
92
+ ...autoContinueState(record, isRunning, supported || liveHook),
73
93
  lastActivityAt: item.lastActivityAt, observation: item.observation, availability: item.availability,
74
94
  binding: { mode, taskId: run?.managed?.taskId || null },
75
95
  originalSessionTarget: originalSessionTarget(item.provider, item.session),
76
96
  continuationMode: item.provider === 'claude' ? liveHook ? 'live_hook' : 'manual' : supported ? 'codex_app_server' : 'manual',
77
97
  capabilities: { canMonitor: item.capabilities?.canMonitor ?? true,
78
98
  canAnalyze: !run && (item.provider === 'codex' ? item.observation.source === 'codex_app_server' && !codexError : item.observation.source === 'claude_local_index' && !claudeError),
79
- canEnroll: (supported || liveHook) && !run, canContinue: (supported || liveHook) && !run, canAutoContinue: supported || liveHook },
99
+ canEnroll: (supported || liveHook) && !run, canContinue: (supported || liveHook) && !run,
100
+ canAutoContinue: (supported || liveHook) && (!run || Boolean(run.managed) && !TERMINAL_RUNS.has(run.state)) },
80
101
  reason: item.provider === 'claude' ? liveHook ? 'Live SIMY hook can continue this Claude session at its next Stop boundary; cold CLI takeover is unavailable.' :
81
102
  process.platform === 'win32' ? 'Claude live hook enrollment is unavailable on Windows because provider process identity cannot be verified; monitoring remains available.' :
82
103
  'Claude enrollment needs a live matching SIMY SessionStart hook and provider process; cold CLI takeover is unavailable.' :
@@ -85,11 +106,37 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
85
106
  }))).sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
86
107
  // Keep existing bindings visible during a truncated discovery or provider
87
108
  // outage, even when newer unbound conversations fill the candidate window.
88
- const bound = projected.filter(item => item.binding.mode !== 'unmanaged');
89
- const sessions = [...bound, ...projected.filter(item => item.binding.mode === 'unmanaged').slice(0, Math.max(0, 200 - bound.length))];
109
+ const bound = projected.filter(item => item.autoContinue || item.binding.mode !== 'unmanaged');
110
+ const sessions = [...bound, ...projected.filter(item => !item.autoContinue && item.binding.mode === 'unmanaged').slice(0, Math.max(0, 200 - bound.length))];
90
111
  return { sessions, providers: { codex: { discovery: codexError ? 'unavailable' : 'ready', reason: codexError }, claude: { discovery: claudeError ? 'unavailable' : 'local_index', reason: claudeError } }, refreshedAt: now };
91
112
  }
92
113
 
114
+ // Explicit execution authority: legacy monitor/tracked records stay read-only.
115
+ export async function setSessionAutoContinue({ root, provider, session, enabled, ...options }) {
116
+ if (!['codex', 'claude'].includes(provider) || typeof enabled !== 'boolean') throw new Error('Unsupported automatic continuation request.');
117
+ if (enabled) await trackSession({ root, provider, session, tracked: true, ...options });
118
+ return updateRecord(root, provider, session, async previous => {
119
+ if (!previous) throw new Error('Refresh this conversation before changing automatic continuation.');
120
+ if (enabled && previous.run && !previous.run.managed) throw new Error('Automatic continuation is unavailable for this conversation.');
121
+ if (enabled && TERMINAL_RUNS.has(previous.run?.state)) throw new Error('Bounded continuation finished or reached its limit; budgets cannot be reset.');
122
+ if (enabled && provider === 'codex' && !previous.admission?.model) throw new Error('Automatic continuation is unavailable for this conversation.');
123
+ if (enabled && provider === 'claude' && !await liveClaudeHook(previous,
124
+ { session, cwd: previous.cwd, observation: { source: previous.admission?.source } }, options.inspectProcess || processIdentity)) throw new Error('Claude needs a live matching SIMY SessionStart hook; cold CLI takeover is unavailable.');
125
+ const epoch = previous.autoContinue === enabled && previous.autoContinueEpoch ? previous.autoContinueEpoch : randomUUID();
126
+ const next = { ...previous, autoContinue: enabled, autoContinueEpoch: epoch,
127
+ ...(enabled ? { tracked: true, monitor: true, autoContinueSettings: { autonomyPolicy: 'balanced', maxMinutes: 30, maxContinuations: 6, maxFailures: 3 } } : { tracked: false, monitor: false }) };
128
+ // Adopt existing supervision without restarting it or renewing its budgets.
129
+ if (next.run?.managed) next.run.managed.autoContinueEpoch = epoch;
130
+ return next;
131
+ });
132
+ }
133
+
134
+ export async function readSessionAutoContinue({ root, provider, session }) {
135
+ const record = (await listRecords(root)).find(item => item.provider === provider && item.session === session);
136
+ return { enabled: record?.autoContinue === true, epoch: record?.autoContinueEpoch || null,
137
+ settings: record?.autoContinueSettings, hasRun: Boolean(record?.run) };
138
+ }
139
+
93
140
  // Tracking is local observation metadata. It neither starts nor stops the
94
141
  // original provider task and never grants automatic continuation authority.
95
142
  export async function trackSession({ root, provider, session, tracked = true, openClient = openCodexAppServer, readClaude = readClaudeSession, now = Date.now() }) {
@@ -133,7 +180,7 @@ export async function monitorSession({ root, provider, session, monitor = true,
133
180
  }
134
181
 
135
182
  export async function enrollSession({ root, provider, session, objective, acceptanceCriteria, contextBootstrap = false, autonomyPolicy = 'balanced',
136
- maxMinutes = 30, maxContinuations = 6, maxFailures = 3, maxNoProgressMinutes, maxTokens, openClient = openCodexAppServer,
183
+ maxMinutes = 30, maxContinuations = 6, maxFailures = 3, maxNoProgressMinutes, maxTokens, autoContinueEpoch, openClient = openCodexAppServer,
137
184
  readClaude = readClaudeSession, inspectProcess = processIdentity, now = Date.now() }) {
138
185
  if (!['codex', 'claude'].includes(provider)) throw new Error('This provider does not have a verified exact-session enrollment channel.');
139
186
  if (maxTokens !== undefined && provider !== 'codex') throw new Error('Token-budgeted supervision requires host-observed Codex usage; Claude live hooks cannot enforce this budget.');
@@ -159,7 +206,7 @@ export async function enrollSession({ root, provider, session, objective, accept
159
206
  const admission = provider === 'codex' ? { source: 'codex_app_server', provider, session, cwd, model: metadata.model } : undefined;
160
207
  const liveRecord = provider === 'claude' ? (await listRecords(root)).find(item => item.provider === provider && item.session === session) : null;
161
208
  const run = await startDelivery({ root, provider, session, cwd, objective: objective.trim(), nextStep: 'Inspect current evidence and propose the next assessed action.',
162
- maxMinutes, maxContinuations, maxFailures, now, admission, inspectProcess,
209
+ maxMinutes, maxContinuations, maxFailures, now, admission, inspectProcess, autoContinueEpoch,
163
210
  liveHookAdmission: liveRecord ? { hookStartedAt: liveRecord.hookStartedAt, pid: liveRecord.process.pid, identity: liveRecord.process.identity } : undefined,
164
211
  managed: { acceptanceCriteria, contextBootstrap, autonomyPolicy, maxNoProgressMinutes, maxTokens, approvalScopes: ['task'], approvalPolicy: 'wait',
165
212
  initialAction: initialPlan.alternatives[0].action, initialPlan, remainingWork: objective.trim(),