@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 +2 -2
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/README.md +16 -22
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +76 -101
- package/src/codex-plugins/simy/hooks/outcome-progress.mjs +10 -0
- package/src/codex-plugins/simy/skills/delivery-loop/SKILL.md +1 -1
- package/src/delivery-hooks/command.js +12 -16
- package/src/delivery-hooks/desktop.js +6 -14
- package/src/delivery-hooks/supervisor.js +7 -31
- package/src/desktop-host.js +13 -12
- package/src/session-supervision/action-association-host.js +1 -1
- package/src/session-supervision/action-association.js +2 -2
- package/src/session-supervision/codex-app-server.js +4 -20
- package/src/session-supervision/handoff.js +2 -1
- package/src/session-supervision/index.js +11 -16
- package/src/session-supervision/stop-notifications.js +43 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awak-app/simy-cli",
|
|
3
|
-
"version": "0.6.
|
|
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",
|
package/src/cli-contract.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
const
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
|
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
|
-
|
|
806
|
-
|
|
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
|
|
863
|
-
if (
|
|
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) {
|
|
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
|
-
!
|
|
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
|
-
|
|
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 = '
|
|
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,
|
|
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 === '
|
|
1153
|
-
if (!canChangeDeliveryContinuation(run)) throw new Error('Pause a managed task before changing its continuation
|
|
1154
|
-
if (
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1355
|
-
run.
|
|
1356
|
-
|
|
1357
|
-
run.
|
|
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.
|
|
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('
|
|
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
|
|
27
|
-
output('simy delivery
|
|
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-
|
|
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-
|
|
43
|
-
[
|
|
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-
|
|
55
|
-
if (!managed || typeof managed !== 'object') throw new Error('
|
|
56
|
-
managed = { ...managed,
|
|
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
|
-
...
|
|
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', '
|
|
69
|
-
|
|
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,
|
|
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,
|
|
35
|
-
localExecutionLimits: false,
|
|
34
|
+
continuations: r.run.continuations,
|
|
36
35
|
continuationPolicy: r.run.managed?.continuationPolicy || 'bounded',
|
|
37
36
|
startedAt: r.run.startedAt,
|
|
38
|
-
|
|
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
|
-
!
|
|
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
|
-
!
|
|
77
|
+
!deliveryStopBlocksResume(r.run, now),
|
|
86
78
|
canResolveDependency: r.run.state === 'blocked' && r.run.blocker === 'external_dependency' && Boolean(r.run.managed?.dependencyWait?.id) &&
|
|
87
|
-
!
|
|
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,
|
|
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'
|
|
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
|
|
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
|
-
|
|
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
|
|
200
|
-
if (
|
|
201
|
-
live.state =
|
|
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.
|
package/src/desktop-host.js
CHANGED
|
@@ -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", "
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
643
|
-
|
|
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()) ||
|
|
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) ||
|
|
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,
|
|
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 = () => {}
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
+
}
|