@awak-app/simy-cli 0.6.2 → 0.6.4

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.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,7 +43,7 @@
43
43
  "check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
44
44
  "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/history-store.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
45
45
  "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",
46
- "check:syntax": "node --check ./src/purpose-local-proof.js && node --check ./src/device-signing-identity.js && 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/codex-plugins/simy/hooks/dev-work.mjs && node --check ./src/dev-work-events.js && 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/login-shell-path.js && node --check ./src/hook-runtime.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
+ "check:syntax": "node --check ./src/purpose-local-proof.js && node --check ./src/device-signing-identity.js && 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/codex-plugins/simy/hooks/dev-work.mjs && node --check ./src/dev-work-events.js && 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/login-shell-path.js && node --check ./src/hook-runtime.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 && node --check ./src/session-supervision/stop-notifications.js",
47
47
  "check:package": "node ./scripts/check-package-contents.js",
48
48
  "build:macos-installer:dev": "bash packaging/build-macos-arm64.sh --unsigned-dev",
49
49
  "build:macos-installer": "bash packaging/build-macos-arm64.sh --production",
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.6.2";
16
+ export const CLI_VERSION = "0.6.4";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -22,7 +22,7 @@ scans ports or reads stored session tokens.
22
22
  Claude distributions. SessionStart supplies the exact session identity. It does
23
23
  not start a loop. After an explicit `simy delivery start`, Stop checks that
24
24
  session's checkpoint and returns bounded next-step instructions. Approval/input
25
- blocks, interruption, completion, failures and budgets stop automatic continuation.
25
+ blocks, interruption, completion, failures and missing progress stop automatic continuation.
26
26
  Provider errors/exits are surfaced to SIMY's desktop; a dead provider is not
27
27
  silently restarted. No hook writes trust, permission settings, or SQM evidence.
28
28
 
@@ -31,27 +31,21 @@ keyed by provider and session ID and bound to the canonical working directory.
31
31
  Historical hook observations do not prove hooks are still enabled. See the
32
32
  bundled delivery-loop skill for checkpoint commands and explicit recovery.
33
33
 
34
- Managed tasks may explicitly select `--continuation-policy progress`. Existing
35
- tasks without that choice keep their bounded contract. Progress mode treats the
36
- ordinary time, continuation and token thresholds as warnings, with independent
37
- hard ceilings of 24 hours from the original start, 50 continuations and, on the
38
- host-observed Codex channel, 15 million provider tokens. Claude live hooks expose
39
- no trustworthy usage stream; their status explicitly reports that token limits
40
- are unsupported. Required approvals, missing observed usage, manual stops and
41
- unreconciled effects continue to block dispatch.
42
-
43
- The host records passing, failed and unevaluated frozen acceptance checks.
44
- Provider summaries, test counts, renamed evidence and restored old criteria
45
- cannot manufacture progress. Three attempts without new verified criteria, or
46
- 30 minutes without verified progress at a dispatch/checkpoint boundary, pause
47
- the original run. Active tools are allowed to reach their checkpoint within the
48
- hard ceiling; activity alone does not prove progress.
49
-
50
- `delivery extend` raises explicitly selected ceilings within those global caps.
51
- It records the old and new bounds, preserves run identity, usage, checkpoints,
52
- criteria and approvals, and does not start a new provider turn. A separate resume
53
- is required after an exhausted run becomes paused. `delivery continuation_policy`
54
- requires an explicit choice and never infers progress consent from an extension.
34
+ SIMY does not limit a task's elapsed time, number of turns or provider tokens.
35
+ A task stops after its failure limit (three by default). Missing progress is
36
+ evaluated after three steps without a newly verified acceptance criterion, or
37
+ 30 minutes without verified progress at a dispatch/checkpoint boundary. The
38
+ policy-specific pause behavior is described below. Provider summaries, test counts, renamed evidence and restored old
39
+ criteria cannot manufacture progress, and activity alone does not prove it.
40
+ Required approvals, manual stops and unreconciled effects still block dispatch.
41
+
42
+ Context-first tasks without host-checkable acceptance pause rather than fail at
43
+ that limit, so Auto-continue stays available after review. An explicit resume,
44
+ answer or approval resets both no-progress attempt counters and the progress clock,
45
+ even before a threshold was reached. Failure counts and verified criterion history
46
+ remain; reusing old evidence cannot count as fresh progress.
47
+ `delivery continuation_policy` switches between the `bounded` and `progress`
48
+ policies only after an explicit choice; `progress` also pauses rather than fails.
55
49
 
56
50
  ## Project 実装状況 (dev-work) hook
57
51
 
@@ -8,8 +8,8 @@ import { execFile } from 'node:child_process';
8
8
  import { promisify } from 'node:util';
9
9
  import { acquireWorkspaceLease, releaseWorkspaceLease } from './workspace-lease.mjs';
10
10
  import { associationContract, validateAssociationResult } from './action-association.mjs';
11
- import { continuationMode, MAX_COMPLETION_TOKENS } from './continuation-policy.mjs';
12
- import { evaluateOutcomeProgress } from './outcome-progress.mjs';
11
+ import { continuationMode } from './continuation-policy.mjs';
12
+ import { evaluateOutcomeProgress, restartOutcomeProgressWindow } from './outcome-progress.mjs';
13
13
 
14
14
  export const TERMINAL = new Set(['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted']);
15
15
  const EVENTS = new Set(['SessionStart', 'UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd', 'Interrupt', 'PermissionRequest', 'Notification']);
@@ -374,7 +374,7 @@ function escalate(run, cause) {
374
374
  managed.escalation = { cause, tried: history.map(item => ({ action: item.action?.summary, result: item.status, reason: item.summary })), results: history.map(item => ({ action: item.action?.summary, result: item.status, reason: item.verification })), remainingWork: text(managed.remainingWork || run.nextStep, 1000), userQuestion: managed.userQuestion || managed.plan?.userQuestion || 'Which action should be authorized or revised?', options: (managed.plan?.alternatives || []).map(option => ({ id: option.id, action: { kind: option.action.kind, scope: option.action.scope, summary: text(option.action.summary, 200) } })), recommendation: managed.plan?.recommendation || null };
375
375
  }
376
376
  export function notifyState(run, now) {
377
- if (!run?.managed || !['blocked', 'verification_pending', 'recovery_required', 'failed', 'budget_exhausted', 'completed', 'reported_done'].includes(run.state)) return;
377
+ if (!run?.managed || !['blocked', 'verification_pending', 'recovery_required', 'paused', 'failed', 'budget_exhausted', 'completed', 'reported_done'].includes(run.state)) return;
378
378
  const key = `${run.state}:${run.revision}:${run.reason}`;
379
379
  if (run.managed.notificationKey === key) return;
380
380
  run.managed.notificationKey = key;
@@ -420,11 +420,8 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
420
420
  initialAction, proposedAction: initialAction, approvalPolicy, approvalScopes: [...new Set(scopes)].slice(0, 20),
421
421
  grants: Object.fromEntries(scopes.map(scope => [scope, { decision: approvalPolicy }])), maxNoProgress: positive(config.maxNoProgress, 3, 20),
422
422
  maxNoProgressMinutes: positive(config.maxNoProgressMinutes, 30, 1440), lastVerifiedProgressAt: now,
423
- maxTokens: config.maxTokens === undefined ? null : positive(config.maxTokens, null, Number.MAX_SAFE_INTEGER), tokensUsed: 0, usageAvailable: null,
424
423
  noProgress: 0, verifiedRevision: 0, consumedVerifiedRevision: 0, notification: null, notificationKey: null,
425
424
  continuationPolicy: continuationMode(config.continuationPolicy),
426
- providerTokenCeiling: appServerChannel ? positive(config.providerTokenCeiling, MAX_COMPLETION_TOKENS, MAX_COMPLETION_TOKENS) : null,
427
- budgetCapability: { time: false, continuations: false, providerTokens: appServerChannel, localExecutionLimits: false },
428
425
  autoRecovery, resumeModel, autoRecoveryUnavailableReason: config.autoRecovery === true && !autoRecovery ? 'Exact Codex process, session UUID, and resumeModel are required.' : null,
429
426
  autoRecoverySandbox: config.autoRecoverySandbox || 'read-only', resumeChannel: config.resumeChannel || null,
430
427
  capability: appServerChannel && autoRecovery ? 'codex_app_server' : autoRecovery ? 'codex_exec_resume' : 'live_hook',
@@ -434,7 +431,7 @@ function managedSpec(spec, session, provider, observedProcess, admission, now) {
434
431
  if (initialPlan) recordDecision(managed, initialPlan, Date.now());
435
432
  return managed;
436
433
  }
437
- export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), objective, nextStep, maxContinuations, maxFailures, maxMinutes, continuationPolicy, hardMaxMinutes, hardMaxContinuations, providerTokenCeiling, managed, admission,
434
+ export async function startDelivery({ root = rootDirectory(), provider, session, cwd = process.cwd(), objective, nextStep, maxFailures, continuationPolicy, managed, admission,
438
435
  liveHookAdmission, autoContinueEpoch, inspectProcess = processIdentity, now = Date.now() }) {
439
436
  cwd = await realpath(cwd);
440
437
  if (!text(objective) || !text(nextStep)) throw new Error('Objective and concrete next step are required.');
@@ -451,8 +448,7 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
451
448
  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.');
452
449
  if (previous.run) throw new Error('This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.');
453
450
  const managedConfig = managed && { ...(managed === true ? {} : managed),
454
- ...(continuationPolicy === undefined ? {} : { continuationPolicy }),
455
- ...(providerTokenCeiling === undefined ? {} : { providerTokenCeiling }) };
451
+ ...(continuationPolicy === undefined ? {} : { continuationPolicy }) };
456
452
  const managedState = managedSpec(managedConfig, session, provider, previous.process, admitted ? admission : null, now);
457
453
  if (!managedState && continuationMode(continuationPolicy) === 'progress') throw new Error('Progress continuation requires frozen managed acceptance criteria.');
458
454
  if (autoContinueEpoch && managedState) managedState.autoContinueEpoch = autoContinueEpoch;
@@ -465,12 +461,7 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
465
461
  const waiting = managedState?.decision?.outcome === 'wait';
466
462
  const queued = managedState?.capability === 'codex_app_server';
467
463
  const run = { id: randomUUID(), objective: text(objective), state: waiting ? 'blocked' : queued ? 'recovery_required' : 'active', nextStep: text(nextStep), startedAt: now, updatedAt: now,
468
- deadline: now + positive(maxMinutes, 30, 1440) * 60_000, maxContinuations: positive(maxContinuations, 6, 50), maxFailures: positive(maxFailures, 3, 10),
469
- ...(managedState?.continuationPolicy === 'progress' ? {
470
- hardDeadline: now + positive(hardMaxMinutes ?? managedConfig.hardMaxMinutes, 1440, 1440) * 60_000,
471
- hardMaxContinuations: positive(hardMaxContinuations ?? managedConfig.hardMaxContinuations, 50, 50),
472
- budgetWarnings: [], budgetExtensionHistory: [],
473
- } : {}),
464
+ maxFailures: positive(maxFailures, 3, 10),
474
465
  continuations: 0, failures: 0, revision: 0, consumedRevision: -1, checkpoint: null, reason: waiting ? managedState.decision.reason : queued ? 'Exact-session supervision is queued; SIMY will acquire the provider writer lock before dispatch.' : 'started',
475
466
  ...(queued ? { recoveryCause: 'session_supervision' } : {}), managed: managedState };
476
467
  if (waiting) escalate(run, run.reason);
@@ -774,37 +765,54 @@ export function prepareRecoveryDiagnosis(run, now = Date.now()) {
774
765
  run.nextStep = decision.action.summary;
775
766
  return decision;
776
767
  }
777
- export function deliveryBudgetState(run, now = Date.now(), { checkProgress = true } = {}) {
768
+ // Local time, turn and token caps were removed. A managed task stops only after
769
+ // repeated failures or when no progress is verified; both reasons are shown to
770
+ // people, so keep them plain and actionable. Codes drive state transitions.
771
+ export const STOP_CODES = Object.freeze({ failureLimit: 'failure_limit', noProgressTime: 'no_progress_time', noProgressAttempts: 'no_progress_attempts' });
772
+ export function deliveryStop(run, now = Date.now(), { checkProgress = true } = {}) {
778
773
  const managed = run.managed;
779
- const progress = managed?.continuationPolicy === 'progress';
780
- // Historical policies/counters remain immutable, but local elapsed time,
781
- // turn count and token usage no longer limit either new or restored tasks.
782
- const deadline = Infinity;
783
- const maxContinuations = Infinity;
784
- const tokenCeiling = null;
785
- const warnings = [];
786
- const checkpointDetail = run.checkpoint ? '' : ' No progress checkpoint was recorded.';
787
- if (progress) {
788
- if (now >= run.deadline) warnings.push('soft_time_limit');
789
- if (run.continuations >= run.maxContinuations) warnings.push('soft_continuation_limit');
790
- if (managed.maxTokens && managed.tokensUsed >= managed.maxTokens) warnings.push('soft_token_limit');
791
- }
792
- let reason = null;
793
- if (run.failures >= run.maxFailures) {
794
- reason = `Delivery failure limit exhausted (${run.failures}/${run.maxFailures}).${checkpointDetail}`;
795
- } else if (checkProgress && managed && now - (managed.lastVerifiedProgressAt ?? run.startedAt) >= (managed.maxNoProgressMinutes ?? 30) * 60_000) {
796
- reason = 'No verified acceptance progress within the configured time limit.';
797
- } else if (checkProgress && managed && managed.noProgress >= managed.maxNoProgress) {
798
- reason = 'No verified acceptance progress within the configured continuation limit.';
799
- }
800
- return { continuationPolicy: progress ? 'progress' : 'bounded', localLimitsRemoved: true, deadline, maxContinuations, tokenCeiling, warnings, reason };
774
+ if (run.failures >= run.maxFailures) return { code: STOP_CODES.failureLimit,
775
+ reason: `Stopped after ${run.failures} failed attempts. Open the original conversation to see what went wrong.` };
776
+ if (!checkProgress || !managed) return null;
777
+ const minutes = managed.maxNoProgressMinutes ?? 30;
778
+ if (now - (managed.lastVerifiedProgressAt ?? run.startedAt) >= minutes * 60_000) return { code: STOP_CODES.noProgressTime,
779
+ reason: `Paused because no progress was confirmed in ${minutes} minutes. Review the original conversation, then resume.` };
780
+ if (managed.noProgress >= managed.maxNoProgress) return { code: STOP_CODES.noProgressAttempts,
781
+ reason: `Paused after ${managed.noProgress} steps without confirmed progress. Review the original conversation, then resume.` };
782
+ return null;
783
+ }
784
+ // What a provider turn is told about its remaining room to work.
785
+ export function deliveryProgressContext(run) {
786
+ return { continuations: run.continuations, failures: run.failures, maxFailures: run.maxFailures,
787
+ stepsWithoutConfirmedProgress: run.managed?.noProgress ?? 0, maxStepsWithoutConfirmedProgress: run.managed?.maxNoProgress ?? null,
788
+ minutesWithoutConfirmedProgress: run.managed?.maxNoProgressMinutes ?? null };
801
789
  }
802
790
  export function managedBudgetReason(run, now = Date.now(), options) {
803
- return deliveryBudgetState(run, now, options).reason;
791
+ return deliveryStop(run, now, options)?.reason ?? null;
792
+ }
793
+ // Only repeated failures make a task unrecoverable. A missing-progress pause is a
794
+ // review point: an explicit resume starts a fresh progress window.
795
+ export function deliveryStopBlocksResume(run, now = Date.now()) {
796
+ return deliveryStop(run, now)?.code === STOP_CODES.failureLimit;
804
797
  }
805
- export function managedDispatchDeadline(run) {
806
- return deliveryBudgetState(run).deadline;
798
+ // Time spent waiting for a person is not missing work progress. An explicit
799
+ // resume, answer or approval starts a new progress window; failures remain.
800
+ function continueAfterHumanDecision(run, now) {
801
+ const stop = deliveryStop(run, now);
802
+ if (stop?.code === STOP_CODES.failureLimit) throw new Error(stop.reason);
803
+ if (!run.managed) return;
804
+ run.managed.progressWindowResets = [...(run.managed.progressWindowResets || []),
805
+ { at: now, code: stop?.code ?? 'human_decision', noProgress: run.managed.noProgress }].slice(-10);
806
+ run.managed.noProgress = 0;
807
+ run.managed.outcomeProgress = restartOutcomeProgressWindow(run.managed.outcomeProgress);
808
+ run.managed.lastVerifiedProgressAt = now;
807
809
  }
810
+ const stoppedState = stop => stop.code === STOP_CODES.failureLimit ? 'budget_exhausted' : 'paused';
811
+ // Context-first tasks usually have no host-checkable acceptance, so worker
812
+ // reports can never reset noProgress. Reaching that limit pauses for review
813
+ // instead of failing permanently and ending Auto-continue for the conversation.
814
+ const pausesWithoutProgress = managed => managed.continuationPolicy === 'progress' ||
815
+ managed.contextBootstrap === true && !managed.acceptanceCriteria.some(criterion => criterion.check?.type !== 'manual');
808
816
 
809
817
  function recordManagedOutcomeProgress(run, now, assessmentAvailable = true) {
810
818
  const managed = run.managed;
@@ -842,7 +850,6 @@ export function automaticContinuationAllowed(record) {
842
850
  export function decideStop(run, now = Date.now()) {
843
851
  if (TERMINAL.has(run.state) || ['blocked', 'recovery_required', 'paused', 'verification_pending'].includes(run.state)) return { run, output: {} };
844
852
  const next = structuredClone(run);
845
- next.budgetWarnings = deliveryBudgetState(next, now).warnings;
846
853
  const finish = (state, reason) => { next.state = state; next.reason = reason; next.updatedAt = now; return { run: next, output: {} }; };
847
854
  // A fresh, explicitly evidenced completion may finish at the budget boundary.
848
855
  const fresh = next.checkpoint && next.revision > next.consumedRevision;
@@ -859,8 +866,8 @@ export function decideStop(run, now = Date.now()) {
859
866
  next.nextStep = text(cp.nextStep) || next.nextStep;
860
867
  } else { escalate(next, cp.summary); return finish('blocked', cp.summary); }
861
868
  }
862
- const budgetReason = managedBudgetReason(next, now);
863
- if (budgetReason) return finish(budgetReason.startsWith('No verified') ? 'paused' : 'budget_exhausted', budgetReason);
869
+ const stop = deliveryStop(next, now);
870
+ if (stop) return finish(stoppedState(stop), stop.reason);
864
871
  const initialHook = next.managed?.awaitingInitialHook && next.revision === 0 && !next.checkpoint;
865
872
  const replanHook = next.managed?.capability === 'live_hook' && next.managed.awaitingReplanHook === true;
866
873
  const understandingHook = next.managed?.capability === 'live_hook' && (next.managed.awaitingUnderstandingHook === true || next.managed.awaitingAssociationHook === true);
@@ -901,7 +908,11 @@ export function decideStop(run, now = Date.now()) {
901
908
  else if (next.managed.associationCheckpointRevision !== next.revision && !initialHook && !replanHook && !understandingHook && !approvedHook && !legacyApprovedHook && !['session_understanding', 'action_association'].includes(cp?.kind)) next.managed.noProgress += 1;
902
909
  if (reportedStep) next.managed.reportedProgress = (next.managed.reportedProgress || 0) + 1;
903
910
  next.managed.consumedVerifiedRevision = next.managed.verifiedRevision;
904
- if (next.managed.noProgress >= next.managed.maxNoProgress) { escalate(next, 'No verified acceptance progress within the configured continuation limit.'); return finish(next.managed.continuationPolicy === 'progress' ? 'paused' : 'failed', 'No verified acceptance progress within the configured continuation limit.'); }
911
+ if (next.managed.noProgress >= next.managed.maxNoProgress) {
912
+ const stop = deliveryStop(next, now);
913
+ escalate(next, stop.reason);
914
+ return finish(pausesWithoutProgress(next.managed) ? 'paused' : 'failed', stop.reason);
915
+ }
905
916
  }
906
917
  next.consumedRevision = next.revision;
907
918
  if (initialHook) next.managed.awaitingInitialHook = false;
@@ -1122,7 +1133,7 @@ export function canReplanDelivery(run, provider, now = Date.now()) {
1122
1133
  managed?.capability === 'live_hook' && provider === 'claude' && Boolean(managed.liveHookOwner?.identity);
1123
1134
  return Boolean(supported && managed.autonomyPolicy && managed.approvalScopes?.length && !managed.rejectedActionOverflow &&
1124
1135
  run.state === 'blocked' && managed.pendingApproval?.denied === true &&
1125
- !managedBudgetReason(run, now));
1136
+ !deliveryStopBlocksResume(run, now));
1126
1137
  }
1127
1138
  export function canChangeDeliveryContinuation(run) {
1128
1139
  return Boolean(run?.managed && ['paused', 'blocked', 'failed', 'budget_exhausted', 'verification_pending', 'recovery_required'].includes(run.state) &&
@@ -1130,7 +1141,8 @@ export function canChangeDeliveryContinuation(run) {
1130
1141
  }
1131
1142
  function reopenLegacyLimitStop(run, now) {
1132
1143
  if (run?.state !== 'budget_exhausted' ||
1133
- managedBudgetReason(run, now, { checkProgress: false }) ||
1144
+ deliveryStop(run, now)?.code === STOP_CODES.failureLimit ||
1145
+ // Reasons persisted by older versions for stops that were not obsolete caps.
1134
1146
  /No verified|failure limit/i.test(run.reason || '') ||
1135
1147
  run.managed?.noProgress >= run.managed?.maxNoProgress) return;
1136
1148
  if (run.managed) {
@@ -1138,9 +1150,9 @@ function reopenLegacyLimitStop(run, now) {
1138
1150
  // Time spent stopped at an obsolete cap is not failed execution progress.
1139
1151
  run.managed.lastVerifiedProgressAt = now;
1140
1152
  }
1141
- run.state = 'paused'; run.reason = 'Local limits removed; resume this same task.';
1153
+ run.state = 'paused'; run.reason = 'SIMY no longer limits time or turns. Resume this task to continue.';
1142
1154
  }
1143
- export async function controlDelivery({ root = rootDirectory(), provider, session, taskId, approvalId, autonomyPolicy, continuationPolicy, limits, questionId, waitId, answer, action, now = Date.now() }) {
1155
+ export async function controlDelivery({ root = rootDirectory(), provider, session, taskId, approvalId, autonomyPolicy, continuationPolicy, questionId, waitId, answer, action, now = Date.now() }) {
1144
1156
  for (let retry = 0; ; retry += 1) {
1145
1157
  try { return await updateRecord(root, provider, session, record => {
1146
1158
  const run = record?.run;
@@ -1149,49 +1161,12 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
1149
1161
  if (['pending', 'pending_received'].includes(run.managed?.handoff?.status)) throw new Error('Task handoff is locked; use delivery handoff_cancel or retry the same target to complete the transfer.');
1150
1162
  if (action !== 'stop' && run.managed?.handoff?.status === 'transferred') throw new Error('Task handoff is locked; continue in the received target session.');
1151
1163
  reopenLegacyLimitStop(run, now);
1152
- if (action === 'extend' || action === 'continuation_policy') {
1153
- if (!canChangeDeliveryContinuation(run)) throw new Error('Pause a managed task before changing its continuation bounds.');
1154
- if (action === 'continuation_policy') {
1155
- if (!['bounded', 'progress'].includes(continuationPolicy)) throw new Error('Select an explicit continuation policy.');
1156
- run.managed.continuationPolicy = continuationMode(continuationPolicy);
1157
- if (continuationPolicy === 'progress') {
1158
- run.hardDeadline ??= run.startedAt + 1440 * 60_000;
1159
- run.hardMaxContinuations ??= 50;
1160
- if (run.managed.resumeChannel === 'codex_app_server') run.managed.providerTokenCeiling ??= MAX_COMPLETION_TOKENS;
1161
- }
1162
- run.continuationPolicyHistory ||= [];
1163
- run.continuationPolicyHistory.push({ policy: continuationPolicy, at: now, source: 'explicit_controller_selection' });
1164
- } else {
1165
- if (!limits || typeof limits !== 'object' || Array.isArray(limits) || !Object.keys(limits).length ||
1166
- Object.keys(limits).some(key => !['hardMaxMinutes', 'hardMaxContinuations', 'providerTokenCeiling'].includes(key))) throw new Error('Supply explicit continuation ceiling increases.');
1167
- const progress = run.managed.continuationPolicy === 'progress';
1168
- const before = { deadline: progress ? run.hardDeadline : run.deadline,
1169
- maxContinuations: progress ? run.hardMaxContinuations : run.maxContinuations,
1170
- tokens: progress ? run.managed.providerTokenCeiling : run.managed.maxTokens };
1171
- const after = { ...before };
1172
- if (limits.hardMaxMinutes !== undefined) {
1173
- after.deadline = run.startedAt + positive(limits.hardMaxMinutes, null, 1440) * 60_000;
1174
- if (after.deadline <= before.deadline) throw new Error('Extension must increase the original deadline within 24 hours of its start.');
1175
- }
1176
- if (limits.hardMaxContinuations !== undefined) {
1177
- after.maxContinuations = positive(limits.hardMaxContinuations, null, 50);
1178
- if (after.maxContinuations <= before.maxContinuations) throw new Error('Extension must increase the continuation ceiling within 50 turns.');
1179
- }
1180
- if (limits.providerTokenCeiling !== undefined) {
1181
- if (run.managed.resumeChannel !== 'codex_app_server') throw new Error('Provider token ceilings require host-observed Codex usage.');
1182
- after.tokens = positive(limits.providerTokenCeiling, null, MAX_COMPLETION_TOKENS);
1183
- if (after.tokens <= (before.tokens || 0)) throw new Error('Extension must increase the provider token ceiling.');
1184
- }
1185
- if (after.deadline <= now || after.maxContinuations <= run.continuations || after.tokens && after.tokens <= run.managed.tokensUsed) throw new Error('Extended ceilings must exceed already consumed time, turns, and tokens.');
1186
- if (progress) { run.hardDeadline = after.deadline; run.hardMaxContinuations = after.maxContinuations; run.managed.providerTokenCeiling = after.tokens; }
1187
- else { run.deadline = after.deadline; run.maxContinuations = after.maxContinuations; run.managed.maxTokens = after.tokens; }
1188
- run.budgetExtensionHistory ||= [];
1189
- run.budgetExtensionHistory.push({ before, after, at: now, source: 'explicit_controller_extension' });
1190
- }
1191
- run.budgetWarnings = deliveryBudgetState(run, now).warnings;
1192
- if (run.state === 'budget_exhausted' && !managedBudgetReason(run, now)) {
1193
- run.state = 'paused'; run.reason = 'Continuation bounds changed; explicitly resume this same task.';
1194
- }
1164
+ if (action === 'continuation_policy') {
1165
+ if (!canChangeDeliveryContinuation(run)) throw new Error('Pause a managed task before changing its continuation policy.');
1166
+ if (!['bounded', 'progress'].includes(continuationPolicy)) throw new Error('Select an explicit continuation policy.');
1167
+ run.managed.continuationPolicy = continuationMode(continuationPolicy);
1168
+ run.continuationPolicyHistory ||= [];
1169
+ run.continuationPolicyHistory.push({ policy: continuationPolicy, at: now, source: 'explicit_controller_selection' });
1195
1170
  run.updatedAt = now;
1196
1171
  return record;
1197
1172
  }
@@ -1209,7 +1184,7 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
1209
1184
  const question = run.managed?.understandingQuestion;
1210
1185
  if (run.managed?.phase !== 'needs_clarification' || run.state !== 'blocked' || !question || question.id !== questionId ||
1211
1186
  !text(answer, 1000) || typeof answer !== 'string' || answer.length > 1000) throw new Error('Session question changed or answer is empty; refresh its status.');
1212
- if (managedBudgetReason(run, now)) throw new Error('Original budget exhausted. Session cannot continue.');
1187
+ continueAfterHumanDecision(run, now);
1213
1188
  const read = { kind: 'read', scope: 'task', summary: 'Re-examine the original conversation using the new user answer without changing files' };
1214
1189
  const plan = assessedPlan({ diagnosis: `Resolve the recorded question before any work: ${question.text}`,
1215
1190
  alternatives: [{ id: 'understand_answer', action: read,
@@ -1250,7 +1225,8 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
1250
1225
  run.state = 'paused'; run.reason = 'Paused by user.';
1251
1226
  }
1252
1227
  else if (action === 'replan') {
1253
- if (!canReplanDelivery(run, provider, now)) throw new Error('Replanning needs a rejected assessed approval, a supported original-session channel, and remaining budget.');
1228
+ if (!canReplanDelivery(run, provider, now)) throw new Error('Replanning needs a rejected assessed approval, a supported original-session channel, and fewer failures than the limit.');
1229
+ continueAfterHumanDecision(run, now);
1254
1230
  const rejected = run.managed.pendingApproval;
1255
1231
  // Also cover denied records written by earlier versions before rejection
1256
1232
  // fingerprints existed; never re-authorize that exact action on upgrade.
@@ -1285,7 +1261,7 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
1285
1261
  if (run.managed?.executionUncertain) { prepareRecoveryDiagnosis(run, now); run.blocker = null; }
1286
1262
  if (run.managed?.pendingApproval) throw new Error('Resolve the exact pending approval before resuming.');
1287
1263
  if (run.managed?.autonomyPolicy && run.managed.decision?.outcome === 'wait' && (run.managed.approvedAction?.id !== run.managed.decision.id || run.managed.approvedAction.dispatchedAt)) throw new Error('Selected action requires a fresh scoped user decision.');
1288
- if (managedBudgetReason(run, now)) throw new Error('Original budget exhausted. Loop cannot resume.');
1264
+ continueAfterHumanDecision(run, now);
1289
1265
  if (run.managed?.approvedAction && run.recoveryCause === 'approved_action' && !run.managed.approvedAction.dispatchedAt && run.managed.capability !== 'live_hook' && run.managed.resumeChannel !== 'codex_app_server') {
1290
1266
  run.managed.approvedAction.dispatchedAt = now;
1291
1267
  run.managed.dispatches ||= [];
@@ -1308,7 +1284,7 @@ export async function controlDelivery({ root = rootDirectory(), provider, sessio
1308
1284
  if (!pending || pending.id !== approvalId || pending.denied || run.state !== 'blocked') throw new Error('Pending approval changed; refresh its status.');
1309
1285
  run.managed.notification = { key: `approval:${pending.id}`, kind: 'approval', message: `${action === 'approve' ? 'Approved' : 'Rejected'} ${pending.scope}.`, at: now };
1310
1286
  if (action === 'approve') {
1311
- if (managedBudgetReason(run, now)) throw new Error('Original budget exhausted. Task cannot resume.');
1287
+ continueAfterHumanDecision(run, now);
1312
1288
  run.managed.pendingApproval = null;
1313
1289
  run.nextStep = run.checkpoint?.nextStep || run.nextStep;
1314
1290
  run.state = 'recovery_required'; run.recoveryCause = 'approved_action';
@@ -1351,11 +1327,10 @@ export async function reconcileRecords({ root = rootDirectory(), now = Date.now(
1351
1327
  }
1352
1328
  const activeTurn = run.managed &&
1353
1329
  (run.state === 'auto_resuming' || current.runtime?.isRunning === true || run.managed.executionAttempt?.phase === 'dispatching');
1354
- const budgetState = deliveryBudgetState(run, now, { checkProgress: !activeTurn });
1355
- run.budgetWarnings = budgetState.warnings;
1356
- if (budgetState.reason && !TERMINAL.has(run.state) && !['verification_pending', 'paused'].includes(run.state)) {
1357
- run.state = budgetState.reason.startsWith('No verified') ? 'paused' : 'budget_exhausted';
1358
- run.reason = budgetState.reason; run.updatedAt = now;
1330
+ const stop = deliveryStop(run, now, { checkProgress: !activeTurn });
1331
+ if (stop && !TERMINAL.has(run.state) && !['verification_pending', 'paused'].includes(run.state)) {
1332
+ run.state = stoppedState(stop);
1333
+ run.reason = stop.reason; run.updatedAt = now;
1359
1334
  }
1360
1335
  else if (run.state === 'active' && observed !== null && observed !== current.process?.identity && current.process?.identity === record.process?.identity) {
1361
1336
  run.state = 'recovery_required'; run.recoveryCause = 'process_exit'; run.reason = run.managed?.autoRecovery ? 'Observed provider process exited. Managed recovery is eligible for a bounded exact-session attempt.' : 'Observed provider process exited. Reopen the existing conversation and explicitly resume.'; run.updatedAt = now;
@@ -27,6 +27,16 @@ function classifyBlockers(findings) {
27
27
  return blockers;
28
28
  }
29
29
 
30
+ // Human review grants a new attempt window, not new proof. Keep the comparison
31
+ // history so previously verified criteria cannot be presented as fresh progress.
32
+ export function restartOutcomeProgressWindow(previous) {
33
+ if (!previous) return previous;
34
+ const nextActions = (previous.next_actions || []).filter(action => action !== "reassess_approach");
35
+ return { ...previous, no_progress_attempts: 0, stalled: false, stall_reason: null,
36
+ has_progress: false, newly_verified_ids: [], next_actions: nextActions,
37
+ next_action: nextActions[0] || "repair_or_verify" };
38
+ }
39
+
30
40
  /**
31
41
  * Compare controller-owned independent observations against one frozen objective.
32
42
  * Evidence references identify proof; changing their paths/hashes is not progress.
@@ -8,7 +8,7 @@ description: Continue an explicitly requested SIMY Delivery Loop in the current
8
8
  Use only when the user explicitly requests this skill or SIMY Delivery Loop for the current task. Installing this plugin does not opt a task in. Do not run SQM unless separately requested.
9
9
 
10
10
  1. Require the SIMY SessionStart hook context with the exact provider and session ID. If absent, explain how to install the SIMY plugin, review and enable its hooks with `/hooks`, and start a new conversation. Codex settings can be opened at `codex://settings`; a hooks subpage deep link is not guaranteed. Never change hook trust or bypass permissions yourself.
11
- 2. For a regular Delivery Loop, use `simy delivery start --provider <provider> --session <exact-session-id> --objective '<user outcome and acceptance criteria>' --next-step '<concrete next action>'`. When the user explicitly asks for a managed task with durable acceptance tracking, add `--managed-file <task.json>` as described below. Default limits: 6 automatic continuations, 3 consecutive failures/missing checkpoints, 30 minutes. Set different finite limits only if the user requests them. Use the current working directory. Never invent a session ID, start another loop to reset limits, or resume a stopped loop without a user request.
11
+ 2. For a regular Delivery Loop, use `simy delivery start --provider <provider> --session <exact-session-id> --objective '<user outcome and acceptance criteria>' --next-step '<concrete next action>'`. When the user explicitly asks for a managed task with durable acceptance tracking, add `--managed-file <task.json>` as described below. The loop stops after 3 failures/missing checkpoints and pauses for review when no progress is verified; it does not limit elapsed time or turns. Set a different failure limit only if the user requests it. Use the current working directory. Never invent a session ID, start another loop to reset limits, or resume a stopped loop without a user request.
12
12
  3. Work in the current conversation with its normal permissions. Before ending each turn, submit a JSON checkpoint through standard input with `simy delivery checkpoint --provider <provider> --session <id> --file -` (for example, a shell heredoc). This updates SIMY state; it does not require writing a checkpoint file to the user's workspace. A file path remains supported when authorized.
13
13
 
14
14
  Checkpoint shapes:
@@ -21,15 +21,14 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
21
21
  if (action === 'activity') return runActivityCommand(args, { root, output });
22
22
  if (!action || action === '--help') {
23
23
  output('simy delivery activity [--watch] [--json] [--provider codex|claude] [--session ID] — local observations every 60 seconds; not productive time.');
24
- output('Progress: --continuation-policy progress [--hard-max-minutes 1440] [--hard-max-continuations 50] [--provider-token-ceiling 15000000 (Codex only)]. Soft thresholds warn; approvals, missing usage and no verified progress still stop.');
24
+ output('A task stops after repeated failures, and pauses for review when no progress is confirmed. Resume starts a new progress window.');
25
25
  output('simy delivery continuation_policy --provider PROVIDER --session ID --task-id ID --continuation-policy bounded|progress');
26
- output('simy delivery extend --provider PROVIDER --session ID --task-id ID --hard-max-minutes N|--hard-max-continuations N|--provider-token-ceiling N');
27
- output('simy delivery continue --provider codex|claude --session ID [--max-no-progress-minutes 30] [--max-tokens N (Codex only)]\nsimy delivery dependency_ready --provider PROVIDER --session ID --task-id ID --wait-id ID --answer TEXT\nsimy delivery handoff|handoff_cancel --provider codex --session ID --task-id ID --target-session ID');
28
- output('simy delivery start --provider codex|claude --session ID --objective TEXT --next-step TEXT [--managed-file task.json] [--max-continuations 6] [--max-failures 3] [--max-minutes 30]\nsimy delivery checkpoint --provider codex|claude --session ID --file checkpoint.json|-\nsimy delivery status\nsimy delivery stop|pause|resume --provider codex|claude --session ID [--task-id ID]\nsimy delivery replan --provider PROVIDER --session ID --task-id ID\nsimy delivery approve|reject --provider PROVIDER --session ID --task-id ID --approval-id ID\nsimy delivery verify --provider PROVIDER --session ID --task-id ID --criterion-id ID\nsimy delivery set_policy --provider PROVIDER --session ID --task-id ID --autonomy-policy strict|balanced|autonomous\nEnable and review SIMY hooks in your provider first. Manual managed start requires an acceptance specification. Desktop Continue infers the original conversation goal first. Resume preserves original task identity, usage history and approvals; local time, turn and token counters do not stop work.');
26
+ output('simy delivery continue --provider codex|claude --session ID [--max-no-progress-minutes 30] [--max-failures 3]\nsimy delivery dependency_ready --provider PROVIDER --session ID --task-id ID --wait-id ID --answer TEXT\nsimy delivery handoff|handoff_cancel --provider codex --session ID --task-id ID --target-session ID');
27
+ output('simy delivery start --provider codex|claude --session ID --objective TEXT --next-step TEXT [--managed-file task.json] [--max-failures 3]\nsimy delivery checkpoint --provider codex|claude --session ID --file checkpoint.json|-\nsimy delivery status\nsimy delivery stop|pause|resume --provider codex|claude --session ID [--task-id ID]\nsimy delivery replan --provider PROVIDER --session ID --task-id ID\nsimy delivery approve|reject --provider PROVIDER --session ID --task-id ID --approval-id ID\nsimy delivery verify --provider PROVIDER --session ID --task-id ID --criterion-id ID\nsimy delivery set_policy --provider PROVIDER --session ID --task-id ID --autonomy-policy strict|balanced|autonomous\nEnable and review SIMY hooks in your provider first. Manual managed start requires an acceptance specification. Desktop Continue infers the original conversation goal first. Resume preserves original task identity, history and approvals.');
29
28
  return 0;
30
29
  }
31
30
  const options = {};
32
- const allowed = new Set(['provider', 'session', 'objective', 'next-step', 'max-continuations', 'max-failures', 'max-minutes', 'max-no-progress-minutes', 'max-tokens', 'continuation-policy', 'hard-max-minutes', 'hard-max-continuations', 'provider-token-ceiling', 'file', 'managed-file', 'task-id', 'approval-id', 'criterion-id', 'autonomy-policy', 'wait-id', 'answer', 'target-session']);
31
+ const allowed = new Set(['provider', 'session', 'objective', 'next-step', 'max-failures', 'max-no-progress-minutes', 'continuation-policy', 'file', 'managed-file', 'task-id', 'approval-id', 'criterion-id', 'autonomy-policy', 'wait-id', 'answer', 'target-session']);
33
32
  for (let i = 0; i < args.length; i += 2) {
34
33
  const key = args[i]?.replace(/^--/, '');
35
34
  if (!args[i]?.startsWith('--') || !allowed.has(key) || args[i + 1] === undefined || Object.hasOwn(options, key)) throw new Error('Invalid delivery option. Use simy delivery --help.');
@@ -39,9 +38,8 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
39
38
  let result;
40
39
  if (action === 'status') result = (await listRecords(root)).filter(r => (!options.provider || r.provider === options.provider) && (!options.session || r.session === options.session));
41
40
  else if (action === 'continue') result = await continueSession({ ...base, autonomyPolicy: options['autonomy-policy'], continuationPolicy: options['continuation-policy'],
42
- ...Object.fromEntries([['max-continuations', 'maxContinuations'], ['max-failures', 'maxFailures'], ['max-minutes', 'maxMinutes'],
43
- ['hard-max-minutes', 'hardMaxMinutes'], ['hard-max-continuations', 'hardMaxContinuations'], ['provider-token-ceiling', 'providerTokenCeiling'],
44
- ['max-no-progress-minutes', 'maxNoProgressMinutes'], ['max-tokens', 'maxTokens']].filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) });
41
+ ...Object.fromEntries([['max-failures', 'maxFailures'], ['max-no-progress-minutes', 'maxNoProgressMinutes']]
42
+ .filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) });
45
43
  else if (action === 'handoff') result = await handoffSession({ ...base, taskId: options['task-id'], targetSession: options['target-session'] });
46
44
  else if (action === 'handoff_cancel') result = await cancelHandoffSession({ ...base, taskId: options['task-id'], targetSession: options['target-session'] });
47
45
  else if (action === 'start') {
@@ -51,22 +49,20 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
51
49
  if (Buffer.byteLength(data) > 64 * 1024) throw new Error('Managed task specification exceeds 64 KiB.');
52
50
  managed = JSON.parse(data);
53
51
  }
54
- if (options['max-tokens'] !== undefined || options['max-no-progress-minutes'] !== undefined) {
55
- if (!managed || typeof managed !== 'object') throw new Error('Managed budget flags require --managed-file; use delivery continue for context-first supervision.');
56
- managed = { ...managed, ...Object.fromEntries([['max-tokens', 'maxTokens'], ['max-no-progress-minutes', 'maxNoProgressMinutes']]
57
- .filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) };
52
+ if (options['max-no-progress-minutes'] !== undefined) {
53
+ if (!managed || typeof managed !== 'object') throw new Error('--max-no-progress-minutes requires --managed-file; use delivery continue for context-first supervision.');
54
+ managed = { ...managed, maxNoProgressMinutes: Number(options['max-no-progress-minutes']) };
58
55
  }
59
56
  result = await startDelivery({ ...base, objective: options.objective, nextStep: options['next-step'], managed, continuationPolicy: options['continuation-policy'],
60
- ...Object.fromEntries([['max-continuations', 'maxContinuations'], ['max-failures', 'maxFailures'], ['max-minutes', 'maxMinutes'],
61
- ['hard-max-minutes', 'hardMaxMinutes'], ['hard-max-continuations', 'hardMaxContinuations'], ['provider-token-ceiling', 'providerTokenCeiling']].filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) });
57
+ ...(options['max-failures'] === undefined ? {} : { maxFailures: Number(options['max-failures']) }) });
62
58
  }
63
59
  else if (action === 'checkpoint') {
64
60
  if (!options.file) throw new Error('--file is required.');
65
61
  const data = options.file === '-' ? await readCheckpointInput(input) : await readFile(options.file, 'utf8');
66
62
  if (Buffer.byteLength(data) > 64 * 1024) throw new Error('Checkpoint exceeds 64 KiB.');
67
63
  result = await checkpointDelivery({ ...base, checkpoint: JSON.parse(data) });
68
- } else if (['stop', 'pause', 'resume', 'replan', 'approve', 'reject', 'set_policy', 'dependency_ready', 'extend', 'continuation_policy'].includes(action)) result = await controlDelivery({ ...base, taskId: options['task-id'], approvalId: options['approval-id'], autonomyPolicy: options['autonomy-policy'], continuationPolicy: options['continuation-policy'],
69
- limits: Object.fromEntries([['hard-max-minutes', 'hardMaxMinutes'], ['hard-max-continuations', 'hardMaxContinuations'], ['provider-token-ceiling', 'providerTokenCeiling']].filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])), waitId: options['wait-id'], answer: options.answer, action });
64
+ } else if (['stop', 'pause', 'resume', 'replan', 'approve', 'reject', 'set_policy', 'dependency_ready', 'continuation_policy'].includes(action)) result = await controlDelivery({ ...base, taskId: options['task-id'], approvalId: options['approval-id'], autonomyPolicy: options['autonomy-policy'], continuationPolicy: options['continuation-policy'],
65
+ waitId: options['wait-id'], answer: options.answer, action });
70
66
  else if (action === 'verify') result = await verifyDelivery({ ...base, taskId: options['task-id'], criterionId: options['criterion-id'] });
71
67
  else throw new Error('Unknown delivery command.');
72
68
  output(JSON.stringify(result, null, 2));
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import { canReplanDelivery, deliveryBudgetState, managedBudgetReason, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+ import { canReplanDelivery, deliveryStop, deliveryStopBlocksResume, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
4
  import { originalSessionTarget } from '../session-supervision/original-session-target.js';
5
5
 
6
6
  export const HOOK_SETUP = Object.freeze({
@@ -31,17 +31,11 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
31
31
  runs: records.filter(r => r.run).sort((a, b) => b.run.updatedAt - a.run.updatedAt).slice(0, 30).map(r => ({
32
32
  provider: r.provider, session: r.session, originalSessionTarget: originalSessionTarget(r.provider, r.session),
33
33
  objective: r.run.objective, state: r.run.state, reason: r.run.reason,
34
- continuations: r.run.continuations, maxContinuations: null, deadline: null,
35
- localExecutionLimits: false,
34
+ continuations: r.run.continuations,
36
35
  continuationPolicy: r.run.managed?.continuationPolicy || 'bounded',
37
36
  startedAt: r.run.startedAt,
38
- continuationLimits: deliveryBudgetState(r.run, now),
39
- canChangeContinuation: false,
40
- hardDeadline: null, hardMaxContinuations: null,
41
- budgetWarnings: deliveryBudgetState(r.run, now).warnings,
42
- budgetCapability: r.run.managed?.budgetCapability || null,
37
+ stopCode: deliveryStop(r.run, now)?.code ?? null,
43
38
  outcomeProgress: r.run.managed?.outcomeProgress || null,
44
- budgetExtensionHistory: r.run.budgetExtensionHistory || [],
45
39
  needsAttention: ['blocked', 'failed', 'recovery_required', 'budget_exhausted', 'verification_pending'].includes(r.run.state), updatedAt: r.run.updatedAt,
46
40
  managed: Boolean(r.run.managed), taskId: r.run.managed?.taskId || null, actionId: r.run.managed?.actionId || null,
47
41
  actionDueAt: r.run.managed?.actionDueAt ?? null,
@@ -56,8 +50,6 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
56
50
  handoff: r.run.managed?.handoff || null, threadHistory: r.run.managed?.threadHistory || [],
57
51
  progressBudget: r.run.managed ? { minutes: r.run.managed.maxNoProgressMinutes, lastVerifiedProgressAt: r.run.managed.lastVerifiedProgressAt,
58
52
  attempts: r.run.managed.noProgress, maxAttempts: r.run.managed.maxNoProgress } : null,
59
- tokenBudget: r.run.managed?.maxTokens ? { limit: r.run.managed.maxTokens, used: r.run.managed.tokensUsed,
60
- usageAvailable: r.run.managed.usageAvailable, source: 'provider_observed', dollarCost: null } : null,
61
53
  pendingApproval: r.run.managed?.pendingApproval || null,
62
54
  autonomyPolicy: r.run.managed?.autonomyPolicy || null, plan: r.run.managed?.plan || null,
63
55
  decision: r.run.managed?.decision || null, decisionHistory: r.run.managed?.decisionHistory || [],
@@ -77,14 +69,14 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
77
69
  r.run.managed.resumeChannel === 'codex_app_server' && r.run.managed.autoRecovery),
78
70
  canResume: ['blocked', 'recovery_required', 'paused'].includes(r.run.state) && r.run.managed?.phase !== 'needs_clarification' &&
79
71
  !r.run.managed?.pendingApproval && !r.run.managed?.dependencyWait && !['pending', 'pending_received', 'transferred'].includes(r.run.managed?.handoff?.status) &&
80
- !managedBudgetReason(r.run, now),
72
+ !deliveryStopBlocksResume(r.run, now),
81
73
  canApprove: r.run.state === 'blocked' && Boolean(r.run.managed?.pendingApproval) && !r.run.managed.pendingApproval.denied,
82
74
  canReject: r.run.state === 'blocked' && Boolean(r.run.managed?.pendingApproval) && !r.run.managed.pendingApproval.denied,
83
75
  canReplan: canReplanDelivery(r.run, r.provider, now),
84
76
  canAnswer: r.run.managed?.phase === 'needs_clarification' && r.run.state === 'blocked' && Boolean(r.run.managed.understandingQuestion?.id) &&
85
- !managedBudgetReason(r.run, now),
77
+ !deliveryStopBlocksResume(r.run, now),
86
78
  canResolveDependency: r.run.state === 'blocked' && r.run.blocker === 'external_dependency' && Boolean(r.run.managed?.dependencyWait?.id) &&
87
- !managedBudgetReason(r.run, now) && !['pending', 'pending_received', 'transferred'].includes(r.run.managed?.handoff?.status),
79
+ !deliveryStopBlocksResume(r.run, now) && !['pending', 'pending_received', 'transferred'].includes(r.run.managed?.handoff?.status),
88
80
  canSetPolicy: Boolean(r.run.managed?.autonomyPolicy) && !['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted'].includes(r.run.state),
89
81
  })),
90
82
  };
@@ -1,14 +1,14 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { dirname } from 'node:path';
4
- import { automaticContinuationAllowed, assessedCheckpointContract, checkpointDelivery, decideStop, deliveryBudgetState, listRecords, readRecord, managedBudgetReason, managedDispatchDeadline, notifyState, processIdentity, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
+ import { automaticContinuationAllowed, assessedCheckpointContract, checkpointDelivery, decideStop, deliveryProgressContext, deliveryStop, listRecords, readRecord, 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';
8
8
 
9
9
  const MAX_OUTPUT = 64 * 1024;
10
10
  const SAFE_PROVIDER_CODES = new Set(['identity', 'permission_narrowed', 'permission_restore', 'turn_failed', 'checkpoint',
11
- 'closed', 'timeout', 'unsupported', 'unavailable', 'manual_provider_action', 'checkpoint_rejected', 'provider_approval', 'usage_unavailable']);
11
+ 'closed', 'timeout', 'unsupported', 'unavailable', 'manual_provider_action', 'checkpoint_rejected', 'provider_approval']);
12
12
  function safeProviderCode(code) { return SAFE_PROVIDER_CODES.has(code) ? code : 'provider_error'; }
13
13
  function providerFailureReason(code) {
14
14
  if (code === 'permission_restore') return 'Codex sandbox restoration could not be confirmed; inspect the original conversation before resuming.';
@@ -40,9 +40,8 @@ async function assertActiveLease(root, provider, session, leaseId, signal, curre
40
40
  if (signal?.aborted || current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) {
41
41
  throw new Error('Managed recovery lease changed before provider dispatch.');
42
42
  }
43
- if (currentTime() >= managedDispatchDeadline(current.run)) throw new Error('Managed recovery deadline expired before provider dispatch.');
44
43
  if (!automaticContinuationAllowed(current)) throw new Error('Automatic continuation was disabled before provider dispatch.');
45
- if (managedBudgetReason(current.run, currentTime())) throw new Error('Managed progress budget expired before provider dispatch.');
44
+ if (managedBudgetReason(current.run, currentTime())) throw new Error('Managed task stopped before provider dispatch.');
46
45
  const managed = current.run.managed;
47
46
  const approvedWait = managed.decision?.outcome === 'wait' && managed.approvedAction?.id === managed.decision.id && !managed.approvedAction.dispatchedAt;
48
47
  if (managed.autonomyPolicy && ((managed.decision?.outcome === 'wait' && !approvedWait) || managed.resumeLease.decisionId !== managed.decision?.id)) throw new Error('Assessed action changed before provider dispatch.');
@@ -80,13 +79,11 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
80
79
  lastDependencyResolution: run.managed.lastDependencyResolution,
81
80
  remainingWork: run.managed.remainingWork, acceptanceCriteria: run.managed.acceptanceCriteria, proposedAction: run.managed.proposedAction,
82
81
  currentDecision: run.managed.autonomyPolicy ? run.managed.decision : undefined, diagnosis: run.managed.plan?.diagnosis,
83
- budgets: { ...deliveryBudgetState(run), continuations: run.continuations, failures: run.failures, maxFailures: run.maxFailures,
84
- localExecutionLimits: false } };
82
+ progress: deliveryProgressContext(run) };
85
83
  const prompt = run.managed.autonomyPolicy ? `Continue the existing SIMY managed task in this exact session. Context: ${JSON.stringify(context)}\nExecute ONLY currentDecision.action. Its expectedVerification and rollback apply to this turn. Stay within provider permissions. End with ONLY one JSON checkpoint. ${assessedCheckpointContract(run.managed.decision)} Do not write SIMY state directly.` : `Continue the existing SIMY managed task in this exact session. Context: ${JSON.stringify(context)}\nStay within the original task and provider permissions. End with ONLY one JSON object: {"status":"in_progress|completed|blocked|error","summary":"...","nextStep":"...","remainingWork":"...","proposedAction":{"kind":"read|local_edit|local_test|external|destructive|scope_change|unknown","scope":"...","summary":"..."},"evidence":[],"acceptanceMet":false}. For blocked include blocker (authorization, missing_information, external_dependency), and for authorization include approval {kind,scope,summary}. Do not claim completion without checking frozen acceptance. SIMY independently verifies supported criteria. Do not write SIMY state directly. If approval or input is needed, return blocked and stop.`;
86
84
  const sandbox = run.managed.autonomyPolicy && run.managed.decision?.action.kind === 'read' ? 'read-only' : run.managed.autoRecoverySandbox;
87
85
  const args = ['exec', '--json', '--skip-git-repo-check', '-s', sandbox, '-m', run.managed.resumeModel, 'resume', record.session, prompt];
88
86
  if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
89
- if (currentTime() >= managedDispatchDeadline(run)) throw new Error('Managed recovery deadline expired before provider dispatch.');
90
87
  await beforeDispatch();
91
88
  if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
92
89
 
@@ -196,9 +193,9 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
196
193
  const approved = live?.managed?.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test'].includes(live.managed.decision.action?.kind) &&
197
194
  live.managed.approvedAction?.id === live.managed.decision.id && !live.managed.approvedAction.dispatchedAt;
198
195
  if (!live?.managed?.autoRecovery || live.state !== 'recovery_required' || !allowedCause || live.managed.pendingApproval || live.managed.autonomyPolicy && (!live.managed.decision || live.managed.decision.outcome === 'wait' && !approved) || appChannel && live.managed.nextAttemptAt > currentTime()) return current;
199
- const budgetReason = managedBudgetReason(live, currentTime());
200
- if (budgetReason) {
201
- live.state = budgetReason.startsWith('No verified') ? 'paused' : 'failed'; live.reason = budgetReason; live.updatedAt = currentTime(); notifyState(live, live.updatedAt); return current;
196
+ const stop = deliveryStop(live, currentTime());
197
+ if (stop) {
198
+ live.state = stop.code === 'failure_limit' ? 'failed' : 'paused'; live.reason = stop.reason; live.updatedAt = currentTime(); notifyState(live, live.updatedAt); return current;
202
199
  }
203
200
  live.state = 'auto_resuming'; live.managed.resumeLease = { id: leaseId, claimedAt: currentTime(), checkpointRevision: live.revision, decisionId: live.managed.decision?.id || null,
204
201
  owner: { pid: process.pid, identity: ownerIdentity } };
@@ -217,7 +214,6 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
217
214
  let result;
218
215
  let resource;
219
216
  let lastActivityAt = currentTime();
220
- let usage = null;
221
217
  let dispatched = false, waitingForTool = false;
222
218
  const controller = new AbortController();
223
219
  const abort = () => controller.abort();
@@ -262,10 +258,6 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
262
258
  const runner = execute || (appServer ? executeCodexAppServer : executeCodexResume);
263
259
  result = await runner(claimed, { signal: controller.signal, root, leaseId, currentTime, leaseMonitored: true,
264
260
  onActivity: observation => { lastActivityAt = currentTime(); waitingForTool = observation?.waitingForTool === true; },
265
- onUsage: observed => {
266
- usage = observed;
267
-
268
- },
269
261
  beforeDispatch: async providerSandbox => {
270
262
  await assertActiveLease(root, record.provider, record.session, leaseId, controller.signal, currentTime);
271
263
  await updateRecord(root, record.provider, record.session, current => {
@@ -298,9 +290,6 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
298
290
  errorCode: error?.code || 'provider_error', restorationUnverified: error?.restorationUnverified === true }; }
299
291
  finally { stopWatch(); signal?.removeEventListener('abort', abort); }
300
292
  if (controller.signal.aborted) result = { ...result, code: -2, cancelled: true };
301
- if (deliveryBudgetState(claimed.run).tokenCeiling && !usage && !result.busy && dispatched) {
302
- result = { ...result, usageUnavailable: true };
303
- }
304
293
  if (result.code === 0) {
305
294
  try {
306
295
  await checkpointDelivery({ root, provider: record.provider, session: record.session, cwd: record.cwd,
@@ -327,14 +316,6 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
327
316
  const live = current?.run;
328
317
  if (live?.managed?.resumeLease?.id !== leaseId) return current;
329
318
  delete live.managed.resumeLease;
330
- delete live.managed.usageBlocked;
331
- if (usage) {
332
- live.managed.tokensUsed = (live.managed.tokensUsed || 0) + usage.tokens;
333
- live.managed.usageAvailable = true;
334
- live.managed.lastUsage = { ...usage, attemptId: leaseId };
335
- } else if (!result.busy && deliveryBudgetState(live).tokenCeiling && dispatched) {
336
- live.managed.usageAvailable = false; live.managed.usageBlocked = true;
337
- }
338
319
  const attempt = live.managed.executionAttempt;
339
320
  if (!result.busy && attempt?.phase === 'dispatching' && attempt.action?.kind !== 'read') live.managed.executionUncertain = {
340
321
  attemptId: leaseId, decisionId: attempt.decisionId, action: attempt.action, at: Date.now() };
@@ -351,13 +332,8 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
351
332
  if (live.state !== 'auto_resuming') live.reason = `${String(live.reason || '').slice(0, 400)} ${live.managed.providerWarning}`.trim();
352
333
  }
353
334
  if (live.state !== 'auto_resuming') {
354
- if (live.managed.usageBlocked && !['stopped', 'paused', 'completed', 'reported_done'].includes(live.state)) {
355
- live.managed.autoRecovery = false;
356
- live.reason = `${live.reason} Provider token usage is unavailable; further budgeted turns are blocked.`;
357
- }
358
335
  live.updatedAt = Date.now(); notifyState(live, live.updatedAt); return current;
359
336
  }
360
- live.budgetWarnings = deliveryBudgetState(live).warnings;
361
337
  if (result.busy) {
362
338
  if (result.accessDenied && dispatched) {
363
339
  // The final guard rejected before the provider call. Keep its one-shot approval unused.
@@ -9,6 +9,7 @@ import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
9
9
  import { controlDelivery, verifyDelivery, createRecordReader } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
10
10
  import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
11
11
  import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
12
+ import { createStopNotifier } from "./session-supervision/stop-notifications.js";
12
13
  import { answerSession, continueSession, discoverSessions, enrollSession, monitorSession, trackSession, setSessionAutoContinue } from "./session-supervision/index.js";
13
14
  import { createContinuationAnalysisManager } from "./session-supervision/analysis-manager.js";
14
15
  import { analyzeContinuation, readContinuationContext } from "./session-supervision/continuation-analysis.js";
@@ -95,6 +96,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
95
96
  const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, list: () => recordReader.list(), onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
96
97
  let actionAssociations = makeActionAssociations();
97
98
  const statisticsCache = new Map();
99
+ const stopNotifier = createStopNotifier({ emit: event => { if (!closed && !relay) emit(event); } });
98
100
  let currentAccess = { state: 'checking', organizationId: null, organizationName: null };
99
101
  const accessScope = access => JSON.stringify([access?.revision ?? null, access?.organizationId ?? null]);
100
102
  const setAccessSnapshot = async access => {
@@ -105,7 +107,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
105
107
  discoveryController?.abort();
106
108
  sessionRefreshAgain = Boolean(sessionRefresh) && desktopAccessReady(currentAccess);
107
109
  for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
108
- sessionStatus = { sessions: [], providers: {}, refreshedAt: null }; sessionRefreshedAt = 0;
110
+ sessionStatus = { sessions: [], providers: {}, refreshedAt: null, initialLoading: true }; sessionRefreshedAt = 0;
109
111
  await analysisManager?.update([]);
110
112
  }
111
113
  return currentAccess;
@@ -134,7 +136,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
134
136
  beforeClaim: record => actionAssociations.prepareCached?.(record) || record });
135
137
  let shuttingDown = null;
136
138
  let commands;
137
- let sessionStatus = { sessions: [], providers: { codex: { discovery: "loading", reason: null }, claude: { discovery: "loading", reason: null } }, refreshedAt: null };
139
+ let sessionStatus = { sessions: [], providers: { codex: { discovery: "loading", reason: null }, claude: { discovery: "loading", reason: null } }, refreshedAt: null, initialLoading: true };
138
140
  let sessionRefresh = null, sessionRefreshedAt = 0, sessionRefreshAgain = false, discoveryController = null, discoveryStopped = false;
139
141
  const stopDiscovery = async ({ drain = false } = {}) => {
140
142
  discoveryController?.abort();
@@ -158,8 +160,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
158
160
  sessionRefreshAgain = desktopAccessReady(latestAccess);
159
161
  return;
160
162
  }
161
- await analysisManager.update(value.sessions); sessionStatus = value; sessionRefreshedAt = Date.now(); })
162
- .catch(() => { if (closed || relay || controller.signal.aborted) return; sessionStatus = { ...sessionStatus, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
163
+ await analysisManager.update(value.sessions); sessionStatus = { ...value, initialLoading: false }; sessionRefreshedAt = Date.now(); })
164
+ .catch(() => { if (closed || relay || controller.signal.aborted) return; sessionStatus = { ...sessionStatus, initialLoading: false, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
163
165
  .finally(() => { if (discoveryController === controller) discoveryController = null; sessionRefresh = null; if (sessionRefreshAgain && !closed && !relay) { sessionRefreshAgain = false; refreshSessions(true); }
164
166
  if (!closed) setImmediate(() => { void publish().catch(() => {}); }); });
165
167
  }
@@ -244,6 +246,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
244
246
  }
245
247
  await analysisManager.updateProviderStatuses?.(status.providers);
246
248
  const records = await recordReader.list();
249
+ stopNotifier.observe(records);
247
250
  const projection = await actionAssociations.beginProjection?.({ records });
248
251
  status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
249
252
  await actionAssociations.decorate(status.deliveryHooks, projection);
@@ -573,9 +576,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
573
576
  else if (action === "codex_hooks" || action === "claude_hooks") {
574
577
  const provider = action === "codex_hooks" ? "codex" : "claude";
575
578
  await (dependencies.openHookSettings || openHookSetup)(provider);
576
- } else if (["delivery_stop", "delivery_resume", "delivery_pause", "delivery_approve", "delivery_reject", "delivery_replan", "delivery_set_policy", "delivery_dependency_ready", "delivery_extend", "delivery_continuation_policy"].includes(action)) {
579
+ } else if (["delivery_stop", "delivery_resume", "delivery_pause", "delivery_approve", "delivery_reject", "delivery_replan", "delivery_set_policy", "delivery_dependency_ready", "delivery_continuation_policy"].includes(action)) {
577
580
  await controlDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
578
- taskId: command.taskId, approvalId: command.approvalId, autonomyPolicy: command.autonomyPolicy, limits: command.limits, continuationPolicy: command.continuationPolicy, waitId: command.waitId, answer: command.answer, action: action.slice("delivery_".length) });
581
+ taskId: command.taskId, approvalId: command.approvalId, autonomyPolicy: command.autonomyPolicy, continuationPolicy: command.continuationPolicy, waitId: command.waitId, answer: command.answer, action: action.slice("delivery_".length) });
579
582
  } else if (action === "delivery_verify") {
580
583
  await verifyDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
581
584
  taskId: command.taskId, criterionId: command.criterionId });
@@ -591,7 +594,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
591
594
  }
592
595
  if (command.enabled) await authorizeWork(commandScope);
593
596
  await (dependencies.setSessionAutoContinue || setSessionAutoContinue)({ root: join(root, 'delivery-hooks'),
594
- provider: command.provider, session: command.session, enabled: command.enabled, continuationPolicy: command.continuationPolicy, hardMaxMinutes: command.hardMaxMinutes, hardMaxContinuations: command.hardMaxContinuations, providerTokenCeiling: command.providerTokenCeiling });
597
+ provider: command.provider, session: command.session, enabled: command.enabled, continuationPolicy: command.continuationPolicy });
595
598
  if (!command.enabled) automaticContinuation.revoke(command.provider, command.session);
596
599
  // Acknowledge the durable preference before discovery or analysis.
597
600
  emit({ type: "result", id: command.id, ok: true });
@@ -626,8 +629,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
626
629
  } else if (action === "session_enroll") {
627
630
  await (dependencies.enrollSession || enrollSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
628
631
  session: command.session, objective: command.objective, acceptanceCriteria: command.acceptanceCriteria,
629
- autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
630
- maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens, continuationPolicy: command.continuationPolicy, hardMaxMinutes: command.hardMaxMinutes, hardMaxContinuations: command.hardMaxContinuations, providerTokenCeiling: command.providerTokenCeiling });
632
+ autonomyPolicy: command.autonomyPolicy, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, continuationPolicy: command.continuationPolicy });
631
633
  await refreshSessions(true);
632
634
  } else if (action === "session_analyze") {
633
635
  const candidate = await selectedSession(command, { reuseDiscovery: true });
@@ -639,9 +641,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
639
641
  await continueAnalyzedSession({ root: join(root, 'delivery-hooks'), candidate, manager,
640
642
  readContext: dependencies.readContinuationContext || readContinuationContext,
641
643
  enroll: async options => { await authorizeWork(commandScope); return guardedEnroll(options); }, beforeEnroll: () => authorizeWork(commandScope),
642
- settings: { autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
643
- maxContinuations: command.maxContinuations, maxFailures: command.maxFailures,
644
- maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens, continuationPolicy: command.continuationPolicy, hardMaxMinutes: command.hardMaxMinutes, hardMaxContinuations: command.hardMaxContinuations, providerTokenCeiling: command.providerTokenCeiling } });
644
+ settings: { autonomyPolicy: command.autonomyPolicy, maxFailures: command.maxFailures,
645
+ maxNoProgressMinutes: command.maxNoProgressMinutes, continuationPolicy: command.continuationPolicy } });
645
646
  await refreshSessions(true);
646
647
  } else if (action === "session_answer") {
647
648
  await (dependencies.answerSession || answerSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
@@ -160,7 +160,7 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
160
160
  if (closed) return;
161
161
  const managed = record.run.managed, job = managed.actionAssociation?.job;
162
162
  if (job && !managed.resumeLease && (!expected || TERMINAL.has(record.run.state) || ['paused', 'blocked'].includes(record.run.state) || record.run.state === 'recovery_required' && record.run.recoveryCause !== 'session_supervision' ||
163
- managedBudgetReason(record.run, now()) || now() >= record.run.deadline || record.run.continuations >= record.run.maxContinuations ||
163
+ managedBudgetReason(record.run, now()) ||
164
164
  managed.understandingRevision !== job.understandingRevision || record.run.revision !== job.runRevision || managed.actionAssociation.invalidated)) {
165
165
  await cancelActionAssociation({ root, ...identity(record), expectedJobId: job.id, now: now() });
166
166
  }
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { listRecords, updateRecord, TERMINAL } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
2
+ import { listRecords, managedBudgetReason, updateRecord, TERMINAL } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
3
  import { associationSignals, associationRevision } from '../codex-plugins/simy/hooks/action-association.mjs';
4
4
  export { associationSignals, associationRevision, associationContract, validateAssociationResult } from '../codex-plugins/simy/hooks/action-association.mjs';
5
5
 
@@ -25,7 +25,7 @@ export async function queueActionAssociation({ root, provider, session, taskId,
25
25
  await assertScope(validate);
26
26
  const run = record?.run, managed = run?.managed;
27
27
  if (!managed || managed.taskId !== taskId || run.revision !== expectedRevision) return record;
28
- if (!stable(run) || now >= run.deadline || run.continuations >= run.maxContinuations) {
28
+ if (!stable(run) || managedBudgetReason(run, now)) {
29
29
  if (!managed.actionAssociation?.job && !managed.actionAssociation?.outbox && !managed.actionAssociation?.manualOverride) managed.actionAssociation = { ...managed.actionAssociation, status: 'waiting_boundary', retryOn: 'original_session_safe_boundary', updatedAt: now };
30
30
  return record;
31
31
  }
@@ -2,7 +2,7 @@ import { associationContract } from '../codex-plugins/simy/hooks/action-associat
2
2
  import { spawn } from 'node:child_process';
3
3
  import { realpath } from 'node:fs/promises';
4
4
  import { CODEX_WRITER_LOCK_MINIMUM_VERSION, compareVersions, inspectBackendExecutable } from '../backend-executable.js';
5
- import { assessedCheckpointContract, deliveryBudgetState, understandingCheckpointContract } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
5
+ import { assessedCheckpointContract, deliveryProgressContext, understandingCheckpointContract } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
6
6
  import { boundConversation, boundProviderGoal, textContent } from './conversation-context.js';
7
7
  import { JsonDataUrlFilter } from './json-data-url-filter.js';
8
8
 
@@ -394,7 +394,7 @@ export function isCodexWriterBusy(error) {
394
394
  return error instanceof CodexAppServerError && error.code === -32600 && /already has an active writer/i.test(error.message);
395
395
  }
396
396
 
397
- export async function executeCodexAppServer(record, { openClient = openCodexAppServer, signal, beforeDispatch = async () => {}, onActivity = () => {}, onUsage = () => {} } = {}) {
397
+ export async function executeCodexAppServer(record, { openClient = openCodexAppServer, signal, beforeDispatch = async () => {}, onActivity = () => {} } = {}) {
398
398
  const run = record.run, decision = run.managed?.decision;
399
399
  if (!decision || !run.managed.autonomyPolicy) throw new CodexAppServerError('decision', 'An assessed action is required before Codex dispatch.');
400
400
  if (!['read', 'local_edit', 'local_test'].includes(decision.action?.kind)) throw new CodexAppServerError('manual_provider_action', 'This action must be completed in the original provider conversation.');
@@ -405,7 +405,6 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
405
405
  const client = await openClient({ signal });
406
406
  let originalSandbox = null, overrideStarted = false, activeTurnId = null, turnCompleted = false, turnStart = null;
407
407
  let primaryError = null;
408
- let usage = null, lastTotal = null;
409
408
  try {
410
409
  const metadata = await readCodexSession(client, record.session);
411
410
  if (await realpath(metadata.cwd) !== record.cwd || metadata.model !== run.managed.resumeModel) throw new CodexAppServerError('identity', 'Codex session workspace or model changed.');
@@ -422,8 +421,7 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
422
421
  enrolledScopes: run.managed.approvalScopes,
423
422
  lastDependencyResolution: run.managed.lastDependencyResolution,
424
423
  ...(authorizedWait ? { approvedAction: { approvalId: approved.id, action: approved.action, oneShot: true } } : {}),
425
- budgets: { ...deliveryBudgetState(run), continuations: run.continuations, failures: run.failures, maxFailures: run.maxFailures,
426
- localExecutionLimits: false } };
424
+ progress: deliveryProgressContext(run) };
427
425
  const approvalInstruction = authorizedWait ? `SIMY recorded a matching, one-shot scoped user approval ${approved.id} for this exact selected action. The decision outcome "wait" describes its pre-approval assessment; the approved action is authorized now. Execute it once, then report what actually happened. ` : '';
428
426
  const prompt = run.managed.phase === 'action_association' ? associationContract(run.managed.actionAssociation.job) : run.managed.phase === 'understanding' ? `Understand the ORIGINAL user request in this exact Codex conversation. This first/clarifying turn is read-only and must not perform task work. Context: ${JSON.stringify({ ...context, priorUnderstanding: run.managed.sessionUnderstanding, userAnswer: run.managed.understandingAnswer })}\n${understandingCheckpointContract(decision)} Do not write SIMY state directly.` : `Continue this existing SIMY managed task in the exact Codex conversation. Context: ${JSON.stringify(context)}\n${approvalInstruction}Execute ONLY the selected decision action. Do not perform any action from the NEXT plan in this turn. For a next local action within the enrolled task use the exact scope "task"; do not relabel external, broad, or destructive work as local. Preserve provider permissions. End with one JSON checkpoint. ${assessedCheckpointContract(decision)} Do not write SIMY state directly.`;
429
427
  const initialSandbox = run.managed.providerSandbox || resumed.sandbox;
@@ -439,21 +437,12 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
439
437
  let turnId = null, completion = null, settled = false;
440
438
  const messages = new Map();
441
439
  const activeTools = new Set();
442
- const earlyUsage = [];
443
440
  const earlyActivity = [];
444
441
  const observeActivity = (method, params) => {
445
442
  if (method === 'item/started' && ['commandExecution', 'mcpToolCall', 'webSearch', 'fileChange'].includes(params.item?.type)) activeTools.add(params.item.id);
446
443
  if (method === 'item/completed') activeTools.delete(params.item?.id);
447
444
  onActivity({ waitingForTool: activeTools.size > 0 });
448
445
  };
449
- const observeUsage = params => {
450
- const total = params.tokenUsage?.total?.totalTokens, last = params.tokenUsage?.last?.totalTokens;
451
- if (!Number.isSafeInteger(total) || total < 0 || !Number.isSafeInteger(last) || last < 0 || last > total) return;
452
- if (lastTotal === null) usage = { source: 'codex_app_server', tokens: last, observedAt: Date.now() };
453
- else usage.tokens += Math.max(0, total - lastTotal);
454
- lastTotal = Math.max(lastTotal ?? 0, total);
455
- onUsage({ ...usage });
456
- };
457
446
  const cleanup = () => { unsubscribe(); signal?.removeEventListener('abort', cancelled); client.child.removeListener('close', closed); };
458
447
  const finish = (error, value) => { if (settled) return; settled = true; cleanup(); if (error) reject(error); else resolve(value); };
459
448
  const cancelled = () => finish(new CodexAppServerError('cancelled', 'Codex turn was cancelled.'));
@@ -474,10 +463,6 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
474
463
  if (turnId) observeActivity(method, params);
475
464
  } else if (!turnId && typeof params.turnId === 'string' && earlyActivity.length < 100) earlyActivity.push({ method, turnId: params.turnId,
476
465
  item: { id: params.item?.id, type: params.item?.type } });
477
- if (method === 'thread/tokenUsage/updated') {
478
- if (turnId && params.turnId === turnId) observeUsage(params);
479
- else if (!turnId && earlyUsage.length < 100) earlyUsage.push({ turnId: params.turnId, tokenUsage: params.tokenUsage });
480
- }
481
466
  if (method === 'item/completed' && params.item?.type === 'agentMessage' && typeof params.item.text === 'string' && typeof params.turnId === 'string') messages.set(params.turnId, params.item.text);
482
467
  if (method === 'turn/completed') { completion = params; if (params.turn?.id === turnId) turnCompleted = true; complete(); }
483
468
  });
@@ -487,14 +472,13 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
487
472
  turnStart = client.request('turn/start', { threadId: record.session, input: [{ type: 'text', text: prompt }], cwd: record.cwd,
488
473
  model: run.managed.resumeModel, sandboxPolicy })
489
474
  .then(response => { turnId = response?.turn?.id; activeTurnId = turnId; if (!turnId) finish(new CodexAppServerError('turn_failed', 'Codex did not start a turn.')); else {
490
- for (const params of earlyUsage) if (params.turnId === turnId) observeUsage(params);
491
475
  for (const params of earlyActivity) if (params.turnId === turnId) observeActivity(params.method, params);
492
476
  complete();
493
477
  } })
494
478
  .catch(error => finish(error));
495
479
  });
496
480
  const checkpoint = await done;
497
- return { code: 0, checkpoint, usage };
481
+ return { code: 0, checkpoint };
498
482
  } catch (error) {
499
483
  primaryError = error;
500
484
  throw error;
@@ -12,7 +12,8 @@ function unsettled(managed) {
12
12
  }
13
13
  function exactSource(record, taskId, targetSession, now, allowStoppedPending = false) {
14
14
  if (!record?.run?.managed || !record.admission || record.admission.session !== record.session || record.admission.cwd !== record.cwd || record.run.managed.taskId !== taskId) throw new Error('Exact managed task is unavailable.');
15
- if (now !== undefined && managedBudgetReason(record.run, now)) throw new Error('Original task budget exhausted; handoff cannot restore it.');
15
+ const stopReason = now === undefined ? null : managedBudgetReason(record.run, now);
16
+ if (stopReason) throw new Error(`${stopReason} Resume it in its original conversation before handing it off.`);
16
17
  const prior = record.run.managed.handoff;
17
18
  const transfer = prior?.status === 'received' ? null : prior;
18
19
  if (transfer && transfer.targetSession !== targetSession) throw new Error('A different handoff is already recorded.');
@@ -144,7 +144,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
144
144
  }
145
145
 
146
146
  // Explicit execution authority: legacy monitor/tracked records stay read-only.
147
- export async function setSessionAutoContinue({ root, provider, session, enabled, continuationPolicy, hardMaxMinutes, hardMaxContinuations, providerTokenCeiling, ...options }) {
147
+ export async function setSessionAutoContinue({ root, provider, session, enabled, continuationPolicy, ...options }) {
148
148
  if (!['codex', 'claude'].includes(provider) || typeof enabled !== 'boolean') throw new Error('Unsupported automatic continuation request.');
149
149
  if (enabled) await trackSession({ root, provider, session, tracked: true, ...options });
150
150
  return updateRecord(root, provider, session, async previous => {
@@ -155,18 +155,13 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
155
155
  if (enabled && provider === 'claude') await requireClaudeChannel(previous,
156
156
  { session, cwd: previous.cwd, observation: { source: previous.admission?.source } }, options.inspectProcess || processIdentity);
157
157
  const epoch = previous.autoContinue === enabled && previous.autoContinueEpoch ? previous.autoContinueEpoch : randomUUID();
158
- const settings = { autonomyPolicy: 'balanced', maxMinutes: 30, maxContinuations: 6, maxFailures: 3,
159
- ...previous.autoContinueSettings,
160
- ...(continuationPolicy === undefined ? {} : { continuationPolicy }),
161
- ...(hardMaxMinutes === undefined ? {} : { hardMaxMinutes }),
162
- ...(hardMaxContinuations === undefined ? {} : { hardMaxContinuations }),
163
- ...(providerTokenCeiling === undefined ? {} : { providerTokenCeiling }) };
158
+ // Settings persisted by older versions may carry removed time, turn and
159
+ // token caps; only the fields that still govern a task are forwarded.
160
+ const { autonomyPolicy = 'balanced', maxFailures = 3, continuationPolicy: savedPolicy } = previous.autoContinueSettings || {};
161
+ const settings = { autonomyPolicy, maxFailures,
162
+ ...(continuationPolicy ?? savedPolicy) === undefined ? {} : { continuationPolicy: continuationPolicy ?? savedPolicy } };
164
163
  if (settings.continuationPolicy !== undefined && !['bounded', 'progress'].includes(settings.continuationPolicy)) throw new Error('Choose a continuation policy.');
165
- for (const [key, maximum] of [['hardMaxMinutes', 1440], ['hardMaxContinuations', 50], ['providerTokenCeiling', 15_000_000]]) {
166
- if (settings[key] !== undefined && (!Number.isSafeInteger(settings[key]) || settings[key] < 1 || settings[key] > maximum)) throw new Error('Invalid continuation ceiling.');
167
- }
168
- if (providerTokenCeiling !== undefined && provider !== 'codex') throw new Error('Token ceilings require host-observed Codex usage.');
169
- if (previous.run && [continuationPolicy, hardMaxMinutes, hardMaxContinuations, providerTokenCeiling].some(value => value !== undefined)) throw new Error('Change an existing task through its explicit continuation controls.');
164
+ if (previous.run && continuationPolicy !== undefined) throw new Error('Change an existing task through its explicit continuation controls.');
170
165
  const next = { ...previous, autoContinue: enabled, autoContinueEpoch: epoch,
171
166
  ...(enabled ? { tracked: true, monitor: true, autoContinueSettings: settings } : { tracked: false, monitor: false }) };
172
167
  // Adopt existing supervision without restarting it or renewing its budgets.
@@ -224,7 +219,7 @@ export async function monitorSession({ root, provider, session, monitor = true,
224
219
  }
225
220
 
226
221
  export async function enrollSession({ root, provider, session, objective, acceptanceCriteria, contextBootstrap = false, autonomyPolicy = 'balanced',
227
- maxMinutes = 30, maxContinuations = 6, maxFailures = 3, maxNoProgressMinutes, maxTokens, continuationPolicy, hardMaxMinutes, hardMaxContinuations, providerTokenCeiling, autoContinueEpoch, openClient = openCodexAppServer,
222
+ maxFailures = 3, maxNoProgressMinutes, continuationPolicy, autoContinueEpoch, openClient = openCodexAppServer,
228
223
  readClaude = readClaudeSession, inspectProcess = processIdentity, now = Date.now() }) {
229
224
  if (!['codex', 'claude'].includes(provider)) throw new Error('This provider does not have a verified exact-session enrollment channel.');
230
225
  if (typeof objective !== 'string' || !objective.trim() || objective.length > 4000) throw new Error('A concrete objective is required.');
@@ -249,13 +244,13 @@ export async function enrollSession({ root, provider, session, objective, accept
249
244
  const admission = provider === 'codex' ? { source: 'codex_app_server', provider, session, cwd, model: metadata.model } : undefined;
250
245
  const liveRecord = provider === 'claude' ? await readRecord(root, provider, session) : null;
251
246
  const run = await startDelivery({ root, provider, session, cwd, objective: objective.trim(), nextStep: 'Inspect current evidence and propose the next assessed action.',
252
- maxMinutes, maxContinuations, maxFailures, continuationPolicy, hardMaxMinutes, hardMaxContinuations, providerTokenCeiling, now, admission, inspectProcess, autoContinueEpoch,
247
+ maxFailures, continuationPolicy, now, admission, inspectProcess, autoContinueEpoch,
253
248
  liveHookAdmission: liveRecord ? { hookStartedAt: liveRecord.hookStartedAt, pid: liveRecord.process.pid, identity: liveRecord.process.identity } : undefined,
254
- managed: { acceptanceCriteria, contextBootstrap, autonomyPolicy, maxNoProgressMinutes, maxTokens, approvalScopes: ['task'], approvalPolicy: 'wait',
249
+ managed: { acceptanceCriteria, contextBootstrap, autonomyPolicy, maxNoProgressMinutes, approvalScopes: ['task'], approvalPolicy: 'wait',
255
250
  initialAction: initialPlan.alternatives[0].action, initialPlan, remainingWork: objective.trim(),
256
251
  autoRecovery: provider === 'codex', resumeChannel: provider === 'codex' ? 'codex_app_server' : null,
257
252
  resumeModel: metadata.model } });
258
- return { provider, session, taskId: run.run.managed.taskId, state: run.run.state, deadline: run.run.deadline };
253
+ return { provider, session, taskId: run.run.managed.taskId, state: run.run.state };
259
254
  }
260
255
 
261
256
  export async function continueSession(options) {
@@ -0,0 +1,43 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { deliveryStop } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+
4
+ // Auto-continue works while nobody is watching, so a person must learn when it
5
+ // stops. Map a stopped run to a notification kind; the native window owns copy.
6
+ export function stopNotificationKind(run, now = Date.now()) {
7
+ if (!run?.managed?.autoContinueEpoch) return null;
8
+ // The clock keeps aging while paused; the persisted human decision takes
9
+ // precedence over a threshold that was reached only after that decision.
10
+ if (run.state === 'paused') return run.reason === 'Paused by user.' ? null : deliveryStop(run, now) ? 'paused' : null;
11
+ if (run.state === 'blocked' || run.state === 'verification_pending') return 'needs_decision';
12
+ if (run.state === 'recovery_required' && run.managed.autoRecovery !== true) return 'needs_decision';
13
+ if (run.state === 'failed' || run.state === 'budget_exhausted') return 'failed';
14
+ if (run.state === 'completed' || run.state === 'reported_done') return 'finished';
15
+ return null;
16
+ }
17
+
18
+ // The first observation is a baseline: restarting SIMY must not replay alerts
19
+ // for runs that stopped earlier. Each distinct stop is announced once.
20
+ export function createStopNotifier({ emit, now = Date.now } = {}) {
21
+ // Retain one stop fingerprint per currently observed task, rather than an
22
+ // event cache whose eviction could replay the entire historical baseline.
23
+ const seen = new Map();
24
+ let baseline = true;
25
+ return {
26
+ observe(records) {
27
+ const present = new Set();
28
+ for (const record of records) {
29
+ const run = record?.run, kind = stopNotificationKind(run, now());
30
+ const key = JSON.stringify([record.provider, record.session, run?.managed?.taskId]);
31
+ present.add(key);
32
+ if (!kind) { seen.delete(key); continue; }
33
+ const id = createHash('sha256').update(JSON.stringify([record.provider, record.session, run.managed.taskId, run.state, run.revision, run.reason])).digest('hex').slice(0, 32);
34
+ if (seen.get(key) === id) continue;
35
+ seen.set(key, id);
36
+ if (!baseline) emit({ type: 'notify', id, kind, provider: record.provider, session: record.session,
37
+ objective: String(run.objective || '').slice(0, 200) });
38
+ }
39
+ for (const key of seen.keys()) if (!present.has(key)) seen.delete(key);
40
+ baseline = false;
41
+ },
42
+ };
43
+ }