@awak-app/simy-cli 0.5.72 → 0.5.73

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.
Files changed (48) hide show
  1. package/README.md +84 -0
  2. package/package.json +2 -2
  3. package/src/agent.js +191 -11
  4. package/src/backend-executable.js +21 -1
  5. package/src/cli-contract.js +1 -1
  6. package/src/codex-plugins/simy/hooks/README.md +22 -0
  7. package/src/codex-plugins/simy/hooks/continuation-policy.mjs +27 -0
  8. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +179 -34
  9. package/src/codex-plugins/simy/hooks/outcome-progress.mjs +129 -0
  10. package/src/coding-run-recovery.js +88 -0
  11. package/src/coding-run-store.js +2 -2
  12. package/src/continuation-policy.js +2 -0
  13. package/src/delivery-hooks/command.js +11 -5
  14. package/src/delivery-hooks/desktop.js +12 -3
  15. package/src/delivery-hooks/supervisor.js +14 -10
  16. package/src/desktop-host.js +8 -6
  17. package/src/desktop-sqm-settings.js +24 -7
  18. package/src/device-signing-identity.js +65 -0
  19. package/src/diagnostic-log.js +68 -0
  20. package/src/doctor.js +55 -0
  21. package/src/index.js +7 -0
  22. package/src/local-task.js +68 -3
  23. package/src/orchestrator/budget.js +55 -4
  24. package/src/orchestrator/charter-validation.js +33 -0
  25. package/src/orchestrator/completion-contract.js +1 -1
  26. package/src/orchestrator/contract.js +2 -0
  27. package/src/orchestrator/delivery/references/state-and-receipts.md +21 -3
  28. package/src/orchestrator/independent-audit.js +9 -0
  29. package/src/orchestrator/instruction.js +6 -1
  30. package/src/orchestrator/loop.js +38 -41
  31. package/src/orchestrator/outcome-monitor.js +58 -38
  32. package/src/orchestrator/outcome-progress.js +2 -0
  33. package/src/purpose-local-proof.js +380 -0
  34. package/src/runner.js +224 -36
  35. package/src/session-supervision/codex-app-server.js +3 -2
  36. package/src/session-supervision/index.js +16 -4
  37. package/src/sqm/bundle-store.js +11 -7
  38. package/src/sqm/claude-plugin.js +2 -0
  39. package/src/sqm/command.js +36 -4
  40. package/src/sqm/diagnostics.js +1 -1
  41. package/src/sqm/evidence-client.js +23 -7
  42. package/src/sqm/history-store.js +4 -0
  43. package/src/sqm/index.js +104 -14
  44. package/src/sqm/proof.js +3 -23
  45. package/src/sqm/request-recovery.js +82 -0
  46. package/src/sqm/review-worker.js +59 -12
  47. package/src/sqm/session.js +33 -3
  48. package/src/startup-preflight.js +44 -0
package/src/runner.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFile, execFileSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { EventEmitter, once } from "node:events";
3
4
  import { access } from "node:fs/promises";
4
5
  import path from "node:path";
@@ -20,7 +21,7 @@ import {
20
21
  } from "./orchestrator/execution-io.js";
21
22
  import { summarizeCodingLoopEvent } from "./orchestrator/presentation.js";
22
23
  import { PR_PREPARATION_CHECKS } from "./orchestrator/pr-preparation.js";
23
- import { increaseProviderTokenBudget, refreshBudgetState } from "./orchestrator/budget.js";
24
+ import { budgetState, extendProgressCeilings, increaseProviderTokenBudget, providerBudgetTokens, refreshBudgetState } from "./orchestrator/budget.js";
24
25
  import { appendEvent } from "./orchestrator/shared.js";
25
26
  import {
26
27
  createProviderStreamDecoder,
@@ -42,7 +43,7 @@ import { LocalRunRegistry } from "./run-registry.js";
42
43
  import { webApiHeaders, webApiUrl } from "./web-api.js";
43
44
  import { normalizeGitHubRemote } from "./workspace-context.js";
44
45
  import { CLI_VERSION } from "./cli-contract.js";
45
- import { reviseObjective, verifyObjective } from "./orchestrator/outcome-monitor.js";
46
+ import { freezeObjective, reviseObjective, verifyObjective } from "./orchestrator/outcome-monitor.js";
46
47
  import { hasUserOutcomeProof } from "./orchestrator/completion-contract.js";
47
48
  import { createSqmChecker } from "./sqm/index.js";
48
49
  import {
@@ -51,6 +52,10 @@ import {
51
52
  normalizeWorkflowStartIntent,
52
53
  } from "./workflow-observation-sender.js";
53
54
  import { saveCodingRunSnapshot } from "./coding-run-store.js";
55
+ import { acquireDesktopProcessLock } from "./desktop-process-lock.js";
56
+ import {
57
+ assertRecoveryActor, recoveryActorBinding, refreshRecoveryAudit, refreshRecoveryBudgetAudit,
58
+ } from "./coding-run-recovery.js";
54
59
 
55
60
  const execFileAsync = promisify(execFile);
56
61
  const MAX_LOCAL_LOG_LINES = 1_000;
@@ -133,6 +138,7 @@ export function createRun({ runId, request, session, apiOrigin, localStateRoot =
133
138
  metadata: {
134
139
  local_orchestrator_version: 1,
135
140
  local_output_persisted: false,
141
+ recovery_actor: recoveryActorBinding(session, apiOrigin, normalizedRequest),
136
142
  ...(normalizedRequest.workflow_observation_binding
137
143
  ? {
138
144
  workflow_observation: {
@@ -188,6 +194,10 @@ export function restoreRun({ snapshot, session, apiOrigin, localPath = null, loc
188
194
  max_attempts: charter.max_attempts,
189
195
  retry_budget: charter.retry_budget,
190
196
  token_budget: charter.token_budget,
197
+ continuation_policy: charter.continuation_policy,
198
+ provider_token_ceiling: charter.provider_token_ceiling,
199
+ attempt_ceiling: charter.attempt_ceiling,
200
+ max_no_progress_attempts: charter.max_no_progress_attempts,
191
201
  ui_evidence_root: String(charter.ui_evidence_root || ""),
192
202
  acceptance_criteria: Array.isArray(charter.acceptance_criteria)
193
203
  ? charter.acceptance_criteria
@@ -245,6 +255,15 @@ export function restoreRun({ snapshot, session, apiOrigin, localPath = null, loc
245
255
  const objectiveUnverified = !objectiveCheck.valid || objectiveCheck.legacy || savedGoalUnproven;
246
256
  const restoredState = restoredFromState || objectiveUnverified ? "waiting_human" : persistedState;
247
257
  if (restoredFromState) {
258
+ restoredSnapshot.metadata ??= {};
259
+ restoredSnapshot.metadata.recovery = {
260
+ version: 1,
261
+ status: ["dispatching", "coding"].includes(restoredFromState)
262
+ ? "needs_reconciliation" : "ready",
263
+ interrupted_state: restoredFromState,
264
+ interrupted_attempt: restoredSnapshot.events?.findLast((item) =>
265
+ Number.isInteger(item?.detail?.attempt_number))?.detail.attempt_number ?? null,
266
+ };
248
267
  restoredSnapshot.events = Array.isArray(restoredSnapshot.events)
249
268
  ? restoredSnapshot.events
250
269
  : [];
@@ -302,6 +321,63 @@ export async function publishRestoredLocalCodingRun(run) {
302
321
  return run.snapshot;
303
322
  }
304
323
 
324
+ export async function prepareLocalCodingRunRecovery(run) {
325
+ assertRecoveryActor(run);
326
+ if (run.operation || run.child) throw new Error("The selected run is still active.");
327
+ const repositoryPath = await resolveRepositoryPath(run.request);
328
+ const git = async (args) => (await execFileAsync("git", args, {
329
+ cwd: repositoryPath, maxBuffer: 16 * 1024 * 1024,
330
+ })).stdout;
331
+ const head = (await git(["rev-parse", "HEAD"])).trim();
332
+ const status = await git(["status", "--porcelain=v1", "--untracked-files=all"]);
333
+ const diff = await git(["diff", "HEAD", "--binary"]);
334
+ return {
335
+ version: 1, run_id: run.id,
336
+ objective: verifyObjective(run.snapshot.charter),
337
+ recovery: structuredClone(run.snapshot.metadata?.recovery || { status: "ready" }),
338
+ checkout: { head_sha: head, status_sha256: executionTextSha256(status), diff_sha256: executionTextSha256(diff) },
339
+ attempts_recorded: run.snapshot.attempts.length,
340
+ last_pr_url: run.snapshot.attempts.at(-1)?.pr_url || null,
341
+ };
342
+ }
343
+
344
+ // Only the controller's direct human decision or a trusted reconciler may
345
+ // provide this result. A refreshed checkout alone cannot prove an external
346
+ // publication failed or authorize replaying it.
347
+ export async function reconcileLocalCodingRun(run, { operationStatus, observation, evidenceRefs, authorize } = {}) {
348
+ assertRecoveryActor(run);
349
+ if (run.operation || run.child || !RESUMABLE_STATES.has(run.status)) {
350
+ throw new Error("Interrupted operation reconciliation is available only while the run is waiting.");
351
+ }
352
+ if (run.snapshot.metadata?.recovery?.status !== "needs_reconciliation") {
353
+ throw new Error("This run has no interrupted operation requiring reconciliation.");
354
+ }
355
+ if (!["completed", "not_started", "failed", "none"].includes(operationStatus) ||
356
+ !String(observation || "").trim() || !Array.isArray(evidenceRefs) ||
357
+ !evidenceRefs.some((item) => typeof item === "string" && item.trim())) {
358
+ throw new Error("An authoritative operation status, observation and evidence references are required; unknown success cannot be replayed.");
359
+ }
360
+ const descriptor = await prepareLocalCodingRunRecovery(run);
361
+ await authorize?.();
362
+ assertRecoveryActor(run);
363
+ if (run.operation || run.child || !RESUMABLE_STATES.has(run.status) || run.snapshot.metadata?.recovery?.status !== 'needs_reconciliation') {
364
+ throw new Error('The interrupted run changed while reconciliation evidence was inspected.');
365
+ }
366
+ run.snapshot.metadata.recovery = {
367
+ ...descriptor.recovery, status: "ready", operation_status: operationStatus,
368
+ reconciled_at: new Date().toISOString(), checkout: descriptor.checkout,
369
+ observation_sha256: executionTextSha256(String(observation).trim()),
370
+ evidence_refs: evidenceRefs.filter((item) => typeof item === "string" && item.trim()),
371
+ actor: recoveryActorBinding(run.session, run.apiOrigin, run.request),
372
+ };
373
+ appendEvent(run.snapshot, "waiting_human", "The interrupted operation was reconciled before continuation.", {
374
+ code: "interrupted_operation_reconciled", operation_status: operationStatus,
375
+ interrupted_attempt: descriptor.recovery.interrupted_attempt,
376
+ });
377
+ await updateRun(run, "waiting_human");
378
+ return run.snapshot.metadata.recovery;
379
+ }
380
+
305
381
  export async function startLocalCodingRun(
306
382
  run,
307
383
  dependencies = {},
@@ -329,18 +405,29 @@ function assertRunCanContinue(run) {
329
405
  if (!RESUMABLE_STATES.has(run.status)) {
330
406
  throw new Error("Human guidance is available only when the selected run is waiting.");
331
407
  }
408
+ assertRecoveryActor(run);
409
+ if (run.snapshot.metadata?.recovery?.status === "needs_reconciliation") {
410
+ const error = new Error("Reconcile the interrupted operation before continuing the same run; its side effects may already exist.");
411
+ error.code = "interrupted_operation_unverified";
412
+ throw error;
413
+ }
414
+ if (verifyObjective(run.snapshot.charter).legacy) {
415
+ const error = new Error("Explicitly confirm the saved objective before continuing this legacy run.");
416
+ error.code = "objective_baseline_unverified";
417
+ throw error;
418
+ }
332
419
 
333
- const nextAttempt = run.snapshot.attempts.length + 1;
334
420
  const budget = refreshBudgetState(run.snapshot);
335
421
  if (budget.token_exhausted) {
336
422
  throw new Error("The Agentic Loop run used its token budget. Start a new run to continue.");
337
423
  }
338
- if (nextAttempt > run.snapshot.charter.max_attempts) {
424
+ if (budget.retry_exhausted) {
339
425
  throw new Error("The Agentic Loop run used its retry budget. Start a new run to continue.");
340
426
  }
341
427
  }
342
428
 
343
429
  export async function increaseLocalCodingRunProviderTokenBudget(run, value) {
430
+ assertRecoveryActor(run);
344
431
  if (run.operation || run.child) {
345
432
  throw new Error("Provider token budget can change only while the run is waiting.");
346
433
  }
@@ -352,27 +439,13 @@ export async function increaseLocalCodingRunProviderTokenBudget(run, value) {
352
439
  throw new Error("The Agentic Loop run also used its retry budget. Start a new run to continue.");
353
440
  }
354
441
  const previousBudget = currentBudget.token_budget;
355
- const budget = increaseProviderTokenBudget(run.snapshot, value);
356
- run.request.token_budget = budget.token_budget;
357
- // A budget change resolves only this blocker; retain the previous audit as evidence.
358
442
  const previousAudit = run.snapshot.final_audit;
359
443
  const resolvedBudgetAudit = previousAudit?.findings?.some(
360
444
  (finding) => finding.code === "TOKEN_BUDGET_EXHAUSTED",
361
445
  ) ? structuredClone(previousAudit) : null;
362
- if (resolvedBudgetAudit) {
363
- const findings = previousAudit.findings.filter((finding) => finding.code !== "TOKEN_BUDGET_EXHAUSTED");
364
- run.snapshot.final_audit = {
365
- ...previousAudit,
366
- passed: false,
367
- summary: findings.length > 0
368
- ? "Provider token budget increased; other verification findings remain."
369
- : "Provider token budget increased; continue this run to verify completion.",
370
- findings,
371
- budget,
372
- auto_repairable: findings.length > 0 && findings.every((finding) => finding.repairability === "auto"),
373
- requires_human: findings.some((finding) => finding.repairability !== "auto"),
374
- };
375
- }
446
+ const budget = increaseProviderTokenBudget(run.snapshot, value);
447
+ refreshRecoveryBudgetAudit(run.snapshot, budget);
448
+ run.request.token_budget = budget.token_budget;
376
449
  appendEvent(
377
450
  run.snapshot,
378
451
  "waiting_human",
@@ -389,6 +462,29 @@ export async function increaseLocalCodingRunProviderTokenBudget(run, value) {
389
462
  return budget;
390
463
  }
391
464
 
465
+ export async function extendLocalCodingRunCeilings(run, changes) {
466
+ assertRecoveryActor(run);
467
+ if (run.operation || run.child || !RESUMABLE_STATES.has(run.status)) {
468
+ throw new Error("Hard ceilings can change only while the selected run is waiting and idle.");
469
+ }
470
+ const previous = budgetState(run.snapshot);
471
+ const budget = extendProgressCeilings(run.snapshot, changes);
472
+ refreshRecoveryBudgetAudit(run.snapshot, budget);
473
+ run.request.provider_token_ceiling = run.snapshot.charter.provider_token_ceiling;
474
+ run.request.attempt_ceiling = run.snapshot.charter.attempt_ceiling;
475
+ appendEvent(run.snapshot, run.status, "The human operator explicitly increased this saved run's hard ceilings.", {
476
+ code: "continuation_ceilings_extended",
477
+ previous_provider_token_ceiling: previous.hard_token_ceiling,
478
+ previous_attempt_ceiling: previous.hard_attempt_ceiling,
479
+ provider_token_ceiling: budget.hard_token_ceiling,
480
+ attempt_ceiling: budget.hard_attempt_ceiling,
481
+ budget,
482
+ });
483
+ // Extending consent is separate from continuing work, including a stopped run.
484
+ await updateRun(run, run.status);
485
+ return budget;
486
+ }
487
+
392
488
  export async function continueLocalCodingRunAfterRepositoryApproval(
393
489
  run,
394
490
  { repository, localPath } = {},
@@ -457,11 +553,39 @@ export function queueLocalCodingGuidance(run, guidance) {
457
553
 
458
554
  export async function applyLocalHumanDecision(
459
555
  run,
460
- { message, designApproval = false, requiredChecks = [], acceptanceCriteria = [] } = {},
556
+ { message, designApproval = false, requiredChecks = [], acceptanceCriteria = [], confirmObjective = false, confirmedObjective = null } = {},
461
557
  dependencies = {},
462
558
  ) {
463
559
  const guidance = String(message || "").trim();
464
560
  if (!guidance) throw new Error("A human decision note is required.");
561
+ if (confirmObjective) {
562
+ assertRecoveryActor(run);
563
+ if (run.operation || run.child || !RESUMABLE_STATES.has(run.status)) {
564
+ throw new Error("Objective confirmation is available only while the saved run is waiting.");
565
+ }
566
+ const objective = verifyObjective(run.snapshot.charter);
567
+ if (!objective.valid) throw new Error("Restore the saved objective or explicitly revise its changed criteria; an invalid integrity chain cannot be rebaselined.");
568
+ if (objective.legacy) {
569
+ const charter = run.snapshot.charter;
570
+ if (confirmedObjective?.originalRequest !== charter.thread_state.original_request ||
571
+ confirmedObjective?.userGoal !== charter.thread_state.user_goal ||
572
+ JSON.stringify(confirmedObjective?.acceptanceCriteria) !== JSON.stringify(charter.acceptance_criteria)) {
573
+ throw new Error("Confirm the exact saved original request, user goal and acceptance criteria to establish a legacy baseline.");
574
+ }
575
+ freezeObjective(run.snapshot.charter);
576
+ refreshRecoveryAudit(run.snapshot, {
577
+ reason: "objective_baseline_confirmed",
578
+ stale: (finding) => finding.code === "OBJECTIVE_BASELINE_UNVERIFIED",
579
+ summary: "The saved objective was confirmed; remaining verification or approval is still required.",
580
+ });
581
+ run.snapshot.done_gate_passed = false;
582
+ run.snapshot.implementation_gate_passed = false;
583
+ appendEvent(run.snapshot, "waiting_human", "The original saved objective was explicitly confirmed.", {
584
+ code: "objective_baseline_confirmed", note_sha256: executionTextSha256(guidance),
585
+ });
586
+ await updateRun(run, "waiting_human");
587
+ }
588
+ }
465
589
  assertRunCanContinue(run);
466
590
  const charter = run.snapshot.charter;
467
591
  if (acceptanceCriteria.length > 0) {
@@ -560,10 +684,12 @@ export async function stopLocalCodingRun(run) {
560
684
  async function signalAndWaitForClose(child, signal, timeoutMs) {
561
685
  const close = once(child, "close").then(() => "closed");
562
686
  signalExecutorProcess(child, signal);
563
- return Promise.race([
564
- close,
565
- new Promise((resolve) => setTimeout(() => resolve("timeout"), timeoutMs)),
566
- ]);
687
+ let timer;
688
+ try {
689
+ return await Promise.race([close,
690
+ new Promise((resolve) => { timer = setTimeout(() => resolve("timeout"), timeoutMs); }),
691
+ ]);
692
+ } finally { clearTimeout(timer); }
567
693
  }
568
694
 
569
695
  async function waitForLocalStop(run) {
@@ -618,13 +744,17 @@ async function executeLocalCodingRun(
618
744
  { executeAttempt, executeIndependentAudit, collectEvidence } = {},
619
745
  { humanGuidance = "", resume = false } = {},
620
746
  ) {
621
- const operation = runLocalCodingRun(run, {
622
- executeAttempt,
623
- executeIndependentAudit,
624
- collectEvidence,
625
- humanGuidance,
626
- resume,
627
- });
747
+ const operation = (async () => {
748
+ assertRecoveryActor(run);
749
+ const lease = run.localStateRoot ? await acquireDesktopProcessLock(
750
+ path.join(run.localStateRoot, ".controllers", executionTextSha256(run.id)),
751
+ ) : null;
752
+ try {
753
+ return await runLocalCodingRun(run, {
754
+ executeAttempt, executeIndependentAudit, collectEvidence, humanGuidance, resume,
755
+ });
756
+ } finally { await lease?.release(); }
757
+ })();
628
758
  run.operation = operation;
629
759
  try {
630
760
  return await operation;
@@ -666,6 +796,16 @@ async function runLocalCodingRun(
666
796
  await updateRun(run, run.status);
667
797
  }
668
798
 
799
+ const recovery = run.snapshot.metadata?.recovery;
800
+ if (resume && recovery?.checkout) {
801
+ const recoveredGuidance = [
802
+ "Resume the same saved objective and approvals. Reconcile existing work before taking another action.",
803
+ `Interrupted operation status: ${recovery.operation_status}. Checkout at reconciliation: ${JSON.stringify(recovery.checkout)}.`,
804
+ "Never duplicate a completed external operation; inspect its existing identity and evidence. If its current outcome is unknown, stop for reconciliation instead of replaying it.",
805
+ ].join("\n");
806
+ humanGuidance = [recoveredGuidance, humanGuidance].filter(Boolean).join("\n\n");
807
+ }
808
+
669
809
  const executor =
670
810
  executeAttempt ||
671
811
  ((context) =>
@@ -707,6 +847,7 @@ async function runLocalCodingRun(
707
847
  deviceId: run.session?.device_id,
708
848
  });
709
849
  run.sqmChecker = sqmChecker;
850
+ refreshSqmCheckerSession(run, sqmChecker);
710
851
 
711
852
  try {
712
853
  await runCodingLoop({
@@ -782,6 +923,7 @@ export async function recheckLocalCodingRun(run, { collectEvidence } = {}) {
782
923
  deviceId: run.session?.device_id,
783
924
  });
784
925
  run.sqmChecker = sqmChecker;
926
+ refreshSqmCheckerSession(run, sqmChecker);
785
927
  await recheckPrReadiness({
786
928
  snapshot: run.snapshot,
787
929
  collectEvidence: evidenceCollector,
@@ -820,6 +962,16 @@ function sqmBundleEndpoint(session) {
820
962
  return new URL("sqm/knowledge-bundle", session.api_base_url).href;
821
963
  }
822
964
 
965
+ function refreshSqmCheckerSession(run, checker) {
966
+ assertRecoveryActor(run);
967
+ if (!run.session?.token || !checker.updateSession) return;
968
+ checker.updateSession({ token: run.session.token,
969
+ accountId: run.session.account_id || run.session.auth_user_id || run.session.device_id,
970
+ organizationId: run.session.organization_id || run.session.org_id,
971
+ deviceId: run.session.device_id, endpoint: sqmBundleEndpoint(run.session), webOrigin: run.apiOrigin,
972
+ });
973
+ }
974
+
823
975
  function sqmEvidenceEndpoint(session) {
824
976
  if (process.env.SIMY_SQM_EVIDENCE_URL?.trim()) return process.env.SIMY_SQM_EVIDENCE_URL.trim();
825
977
  if (!session?.api_base_url) return undefined;
@@ -833,8 +985,9 @@ export async function executeProcessAttempt({
833
985
  run,
834
986
  marker = "SIMY_RESULT_JSON:",
835
987
  attemptNumber = null,
988
+ resolveCommandImpl = resolveDesktopExecutorCommand,
836
989
  }) {
837
- const command = await resolveDesktopExecutorCommand({
990
+ const command = await resolveCommandImpl({
838
991
  backend,
839
992
  instruction,
840
993
  repositoryPath,
@@ -844,6 +997,8 @@ export async function executeProcessAttempt({
844
997
  const stderr = [];
845
998
  const tokenUsageRecords = [];
846
999
  const phase = marker === "SIMY_AUDIT_JSON:" ? "reviewer" : "executor";
1000
+ const initialBudget = budgetState(run.snapshot);
1001
+ const invocationId = randomUUID();
847
1002
  const observationSender =
848
1003
  phase === "executor" &&
849
1004
  !["accepted", "already_recorded"].includes(run.snapshot.metadata?.workflow_observation?.status)
@@ -859,6 +1014,8 @@ export async function executeProcessAttempt({
859
1014
  return new Promise((resolve) => {
860
1015
  let settled = false;
861
1016
  let terminalError = null;
1017
+ let terminalErrorCode = null;
1018
+ const checkpointWrites = [];
862
1019
  const child = command.spawn(command.bin, command.args, {
863
1020
  cwd: repositoryPath,
864
1021
  env: { ...process.env, ...command.env },
@@ -873,13 +1030,42 @@ export async function executeProcessAttempt({
873
1030
  onLine: (line) => emitOutput(run, line, { backend }),
874
1031
  onEvent: (event) => observationSender?.observe(event),
875
1032
  onTerminalError: (message) => { terminalError ||= message; },
876
- onUsage: (usage) =>
1033
+ onUsage: (usage) => {
877
1034
  tokenUsageRecords.push({
878
1035
  ...usage,
879
1036
  phase,
880
1037
  ...(Number.isInteger(attemptNumber) ? { attempt_number: attemptNumber } : {}),
881
1038
  backend,
882
- }),
1039
+ ...(initialBudget.continuation_policy === "progress" ? { invocation_id: invocationId } : {}),
1040
+ });
1041
+ if (initialBudget.continuation_policy !== "progress") return;
1042
+ const currentUsed = initialBudget.tokens_used + tokenUsageRecords.reduce((sum, record) => sum + providerBudgetTokens(record), 0);
1043
+ run.snapshot.metadata.provider_usage_checkpoints ??= {};
1044
+ run.snapshot.metadata.provider_usage_checkpoints[invocationId] = {
1045
+ invocation_id: invocationId, phase, attempt_number: attemptNumber, started_at: startedAt,
1046
+ token_usage: { records: structuredClone(tokenUsageRecords) },
1047
+ total_provider_tokens: currentUsed,
1048
+ hard_token_ceiling: initialBudget.hard_token_ceiling,
1049
+ };
1050
+ refreshBudgetState(run.snapshot);
1051
+ if (run.localStateRoot) checkpointWrites.push(saveCodingRunSnapshot(run.localStateRoot, run.snapshot).catch(() => {
1052
+ emitOutput(run, "The Provider usage checkpoint could not be persisted; recovery usage remains unverified.", { backend });
1053
+ }));
1054
+ if (currentUsed < initialBudget.hard_token_ceiling || terminalErrorCode || run.stopRequested) return;
1055
+ terminalErrorCode = "TOKEN_BUDGET_EXHAUSTED";
1056
+ terminalError = `Provider token hard ceiling reached (${currentUsed} / ${initialBudget.hard_token_ceiling}). Extend the same run's ceiling explicitly before continuing.`;
1057
+ appendEvent(run.snapshot, run.status, terminalError, {
1058
+ code: "provider_token_ceiling_reached", phase, attempt_number: attemptNumber,
1059
+ provider_tokens_used: currentUsed, provider_token_ceiling: initialBudget.hard_token_ceiling,
1060
+ });
1061
+ if (!settled) {
1062
+ run.controlState = "stopping";
1063
+ void signalAndWaitForClose(child, "SIGINT", 1_500).then(async result => {
1064
+ if (result === "timeout" && !settled && run.child === child) result = await signalAndWaitForClose(child, "SIGTERM", 1_500);
1065
+ if (result === "timeout" && !settled && run.child === child) await signalAndWaitForClose(child, "SIGKILL", 750);
1066
+ }).catch(() => {});
1067
+ }
1068
+ },
883
1069
  });
884
1070
  const stderrDecoder = createProviderStreamDecoder({
885
1071
  backend,
@@ -904,6 +1090,7 @@ export async function executeProcessAttempt({
904
1090
  stdoutDecoder.flush();
905
1091
  stderrDecoder.flush();
906
1092
  run.child = null;
1093
+ await Promise.all(checkpointWrites);
907
1094
  // The run must always resolve: a WorkFlow observation failure (or a
908
1095
  // sender bug) never changes or blocks the Provider result. flush() is
909
1096
  // bounded by per-request timeouts and is designed not to reject.
@@ -940,6 +1127,7 @@ export async function executeProcessAttempt({
940
1127
  resolve({
941
1128
  exitCode: terminalError && exitCode === 0 ? 1 : exitCode,
942
1129
  error: error || terminalError,
1130
+ ...(terminalErrorCode ? { errorCode: terminalErrorCode } : {}),
943
1131
  result: Object.keys(result).length > 0 ? result : parse(rawStdout),
944
1132
  tokenUsage:
945
1133
  tokenUsageRecords.length > 0 ? { records: tokenUsageRecords } : {},
@@ -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 { inspectBackendExecutable } from '../backend-executable.js';
5
- import { assessedCheckpointContract, understandingCheckpointContract } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
5
+ import { assessedCheckpointContract, deliveryBudgetState, 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
 
@@ -422,7 +422,8 @@ export async function executeCodexAppServer(record, { openClient = openCodexAppS
422
422
  enrolledScopes: run.managed.approvalScopes,
423
423
  lastDependencyResolution: run.managed.lastDependencyResolution,
424
424
  ...(authorizedWait ? { approvedAction: { approvalId: approved.id, action: approved.action, oneShot: true } } : {}),
425
- budgets: { deadline: run.deadline, continuations: run.continuations, maxContinuations: run.maxContinuations, failures: run.failures, maxFailures: run.maxFailures } };
425
+ budgets: { ...deliveryBudgetState(run), continuations: run.continuations, failures: run.failures, maxFailures: run.maxFailures,
426
+ softDeadline: run.deadline, softMaxContinuations: run.maxContinuations } };
426
427
  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. ` : '';
427
428
  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.`;
428
429
  const initialSandbox = run.managed.providerSandbox || resumed.sandbox;
@@ -141,7 +141,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
141
141
  }
142
142
 
143
143
  // Explicit execution authority: legacy monitor/tracked records stay read-only.
144
- export async function setSessionAutoContinue({ root, provider, session, enabled, ...options }) {
144
+ export async function setSessionAutoContinue({ root, provider, session, enabled, continuationPolicy, hardMaxMinutes, hardMaxContinuations, providerTokenCeiling, ...options }) {
145
145
  if (!['codex', 'claude'].includes(provider) || typeof enabled !== 'boolean') throw new Error('Unsupported automatic continuation request.');
146
146
  if (enabled) await trackSession({ root, provider, session, tracked: true, ...options });
147
147
  return updateRecord(root, provider, session, async previous => {
@@ -152,8 +152,20 @@ export async function setSessionAutoContinue({ root, provider, session, enabled,
152
152
  if (enabled && provider === 'claude' && !await liveClaudeHook(previous,
153
153
  { session, cwd: previous.cwd, observation: { source: previous.admission?.source } }, options.inspectProcess || processIdentity)) throw new Error('Claude needs a live matching SIMY SessionStart hook; cold CLI takeover is unavailable.');
154
154
  const epoch = previous.autoContinue === enabled && previous.autoContinueEpoch ? previous.autoContinueEpoch : randomUUID();
155
+ const settings = { autonomyPolicy: 'balanced', maxMinutes: 30, maxContinuations: 6, maxFailures: 3,
156
+ ...previous.autoContinueSettings,
157
+ ...(continuationPolicy === undefined ? {} : { continuationPolicy }),
158
+ ...(hardMaxMinutes === undefined ? {} : { hardMaxMinutes }),
159
+ ...(hardMaxContinuations === undefined ? {} : { hardMaxContinuations }),
160
+ ...(providerTokenCeiling === undefined ? {} : { providerTokenCeiling }) };
161
+ if (settings.continuationPolicy !== undefined && !['bounded', 'progress'].includes(settings.continuationPolicy)) throw new Error('Choose a continuation policy.');
162
+ for (const [key, maximum] of [['hardMaxMinutes', 1440], ['hardMaxContinuations', 50], ['providerTokenCeiling', 15_000_000]]) {
163
+ if (settings[key] !== undefined && (!Number.isSafeInteger(settings[key]) || settings[key] < 1 || settings[key] > maximum)) throw new Error('Invalid continuation ceiling.');
164
+ }
165
+ if (providerTokenCeiling !== undefined && provider !== 'codex') throw new Error('Token ceilings require host-observed Codex usage.');
166
+ if (previous.run && [continuationPolicy, hardMaxMinutes, hardMaxContinuations, providerTokenCeiling].some(value => value !== undefined)) throw new Error('Change an existing task through its explicit continuation controls.');
155
167
  const next = { ...previous, autoContinue: enabled, autoContinueEpoch: epoch,
156
- ...(enabled ? { tracked: true, monitor: true, autoContinueSettings: { autonomyPolicy: 'balanced', maxMinutes: 30, maxContinuations: 6, maxFailures: 3 } } : { tracked: false, monitor: false }) };
168
+ ...(enabled ? { tracked: true, monitor: true, autoContinueSettings: settings } : { tracked: false, monitor: false }) };
157
169
  // Adopt existing supervision without restarting it or renewing its budgets.
158
170
  if (next.run?.managed) next.run.managed.autoContinueEpoch = epoch;
159
171
  return next;
@@ -209,7 +221,7 @@ export async function monitorSession({ root, provider, session, monitor = true,
209
221
  }
210
222
 
211
223
  export async function enrollSession({ root, provider, session, objective, acceptanceCriteria, contextBootstrap = false, autonomyPolicy = 'balanced',
212
- maxMinutes = 30, maxContinuations = 6, maxFailures = 3, maxNoProgressMinutes, maxTokens, autoContinueEpoch, openClient = openCodexAppServer,
224
+ maxMinutes = 30, maxContinuations = 6, maxFailures = 3, maxNoProgressMinutes, maxTokens, continuationPolicy, hardMaxMinutes, hardMaxContinuations, providerTokenCeiling, autoContinueEpoch, openClient = openCodexAppServer,
213
225
  readClaude = readClaudeSession, inspectProcess = processIdentity, now = Date.now() }) {
214
226
  if (!['codex', 'claude'].includes(provider)) throw new Error('This provider does not have a verified exact-session enrollment channel.');
215
227
  if (maxTokens !== undefined && provider !== 'codex') throw new Error('Token-budgeted supervision requires host-observed Codex usage; Claude live hooks cannot enforce this budget.');
@@ -235,7 +247,7 @@ export async function enrollSession({ root, provider, session, objective, accept
235
247
  const admission = provider === 'codex' ? { source: 'codex_app_server', provider, session, cwd, model: metadata.model } : undefined;
236
248
  const liveRecord = provider === 'claude' ? await readRecord(root, provider, session) : null;
237
249
  const run = await startDelivery({ root, provider, session, cwd, objective: objective.trim(), nextStep: 'Inspect current evidence and propose the next assessed action.',
238
- maxMinutes, maxContinuations, maxFailures, now, admission, inspectProcess, autoContinueEpoch,
250
+ maxMinutes, maxContinuations, maxFailures, continuationPolicy, hardMaxMinutes, hardMaxContinuations, providerTokenCeiling, now, admission, inspectProcess, autoContinueEpoch,
239
251
  liveHookAdmission: liveRecord ? { hookStartedAt: liveRecord.hookStartedAt, pid: liveRecord.process.pid, identity: liveRecord.process.identity } : undefined,
240
252
  managed: { acceptanceCriteria, contextBootstrap, autonomyPolicy, maxNoProgressMinutes, maxTokens, approvalScopes: ['task'], approvalPolicy: 'wait',
241
253
  initialAction: initialPlan.alternatives[0].action, initialPlan, remainingWork: objective.trim(),
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
 
7
7
  import { verifyBundleIntegrity } from "./canonical.js";
8
8
  import { validateKnowledgeBundle } from "./validation.js";
9
+ import { fetchSqmRead, sqmDiagnostic } from "./request-recovery.js";
9
10
 
10
11
  const DEFAULT_TTL_MS = 5 * 60 * 1000;
11
12
  export const SQM_BUNDLE_FETCH_TIMEOUT_MS = 15_000;
@@ -36,6 +37,9 @@ export async function loadKnowledgeBundle({
36
37
  fetchImpl = globalThis.fetch,
37
38
  now = Date.now(),
38
39
  publicKeys = publicKeysFromEnvironment(),
40
+ signal,
41
+ recoveryOptions,
42
+ onRecoveryEvent,
39
43
  }) {
40
44
  if (!organizationId) {
41
45
  return result(null, ["SQM organization identity is unavailable; shadow checks were skipped."], "unavailable");
@@ -50,7 +54,7 @@ export async function loadKnowledgeBundle({
50
54
  Date.parse(cached.bundle.expires_at) > now;
51
55
  if (!offline && endpoint && (!fresh || refresh || cached?.bundle.system_scope)) {
52
56
  try {
53
- const fetched = await fetchBundle({ endpoint, repository, cliVersion, token, organizationId, etag: cached?.etag, fetchImpl });
57
+ const fetched = await fetchBundle({ endpoint, repository, cliVersion, token, organizationId, etag: cached?.etag, fetchImpl, signal, recoveryOptions, onRecoveryEvent });
54
58
  if (fetched.notModified) {
55
59
  if (!cached) throw new Error("SQM Cloud returned 304 without a verified local bundle.");
56
60
  if (Date.parse(cached.bundle.expires_at) <= now) throw nonFallbackError("SQM Cloud returned 304 for an expired knowledge bundle.");
@@ -82,9 +86,10 @@ export async function loadKnowledgeBundle({
82
86
  });
83
87
  return result(bundle, [], "cloud");
84
88
  } catch (error) {
89
+ if (signal?.aborted) throw signal.reason;
85
90
  if (error?.code === "incompatible_cli") throw error;
86
91
  if (error?.fallbackAllowed !== false && cached && !cached.bundle.system_scope && Date.parse(cached.bundle.expires_at) > now) return result(cached.bundle, [`SQM Cloud unavailable; using last verified bundle: ${message(error)}`], "stale_cache");
87
- return { ...result(null, [`SQM knowledge could not be used; shadow checks were skipped: ${message(error)}`], "unavailable"), diagnostic: safeSqmDiagnostic(error, "knowledge_bundle", "knowledge_bundle_unavailable") };
92
+ return { ...result(null, [`SQM knowledge could not be used; shadow checks were skipped: ${message(error)}`], "unavailable"), diagnostic: { ...safeSqmDiagnostic(error, "knowledge_bundle", "knowledge_bundle_unavailable"), ...sqmDiagnostic(error, "knowledge_bundle") } };
88
93
  }
89
94
  }
90
95
  if (cached) {
@@ -167,7 +172,7 @@ function trustedDiscoveryEndpoint(endpoint) {
167
172
  (url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname));
168
173
  }
169
174
 
170
- async function fetchBundle({ endpoint, repository, cliVersion, token, organizationId, etag, fetchImpl }) {
175
+ async function fetchBundle({ endpoint, repository, cliVersion, token, organizationId, etag, fetchImpl, signal, recoveryOptions, onRecoveryEvent }) {
171
176
  const url = new URL(endpoint);
172
177
  url.searchParams.set("repository", repository);
173
178
  url.searchParams.set("cli_version", cliVersion);
@@ -176,10 +181,8 @@ async function fetchBundle({ endpoint, repository, cliVersion, token, organizati
176
181
  if (token) headers.Authorization = `Bearer ${token}`;
177
182
  if (organizationId) headers["x-simy-org-id"] = organizationId;
178
183
  if (etag) headers["If-None-Match"] = etag;
179
- const response = await fetchImpl(url, {
180
- headers,
181
- signal: AbortSignal.timeout(SQM_BUNDLE_FETCH_TIMEOUT_MS),
182
- });
184
+ const response = await fetchSqmRead(url, { ...recoveryOptions, headers, signal, fetchImpl,
185
+ requestTimeoutMs: SQM_BUNDLE_FETCH_TIMEOUT_MS, onRecoveryEvent });
183
186
  const responseEtag = response.headers?.get?.("etag") || null;
184
187
  const signingKeyId = response.headers?.get?.("x-simy-sqm-key-id") || null;
185
188
  const signingPublicKey = response.headers?.get?.("x-simy-sqm-public-key") || null;
@@ -201,6 +204,7 @@ async function fetchBundle({ endpoint, repository, cliVersion, token, organizati
201
204
  );
202
205
  }
203
206
  const error = new Error(`SQM Cloud returned HTTP ${response.status}${body ? `: ${body.slice(0, 300)}` : ""}.`);
207
+ error.status = response.status;
204
208
  Object.assign(error, safeSqmDiagnostic({ status: response.status, code: parsed?.error?.code || parsed?.detail?.code,
205
209
  requestId: response.headers?.get?.("x-request-id") }, "knowledge_bundle", "knowledge_bundle_http_error"));
206
210
  error.fallbackAllowed = response.status >= 500 && response.status <= 599;
@@ -21,6 +21,8 @@ export async function claudePluginFiles() {
21
21
  files.set(relative, content.replaceAll("current Codex thread", "current Claude Code conversation"));
22
22
  }
23
23
  files.set("hooks/delivery-loop.mjs", await readFile(path.join(SOURCE, "hooks/delivery-loop.mjs"), "utf8"));
24
+ files.set("hooks/outcome-progress.mjs", await readFile(path.join(SOURCE, "hooks/outcome-progress.mjs"), "utf8"));
25
+ files.set("hooks/continuation-policy.mjs", await readFile(path.join(SOURCE, "hooks/continuation-policy.mjs"), "utf8"));
24
26
  files.set("hooks/action-association.mjs", await readFile(path.join(SOURCE, "hooks/action-association.mjs"), "utf8"));
25
27
  files.set("hooks/workspace-lease.mjs", await readFile(path.join(SOURCE, "hooks/workspace-lease.mjs"), "utf8"));
26
28
  files.set("hooks/connection-reminder.mjs", await readFile(path.join(SOURCE, "hooks/connection-reminder.mjs"), "utf8"));