@awak-app/simy-cli 0.5.71 → 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 (52) hide show
  1. package/README.md +101 -3
  2. package/package.json +2 -2
  3. package/src/agent.js +205 -12
  4. package/src/backend-executable.js +45 -2
  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 +180 -31
  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 +17 -12
  16. package/src/desktop-host.js +19 -8
  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 +18 -1
  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/evidence.js +20 -19
  29. package/src/orchestrator/independent-audit.js +9 -0
  30. package/src/orchestrator/instruction.js +6 -1
  31. package/src/orchestrator/loop.js +66 -41
  32. package/src/orchestrator/outcome-monitor.js +95 -44
  33. package/src/orchestrator/outcome-progress.js +2 -0
  34. package/src/orchestrator/recovery.js +3 -0
  35. package/src/purpose-local-proof.js +380 -0
  36. package/src/runner.js +227 -19
  37. package/src/runtime-diagnostics.js +77 -0
  38. package/src/session-supervision/codex-app-server.js +3 -2
  39. package/src/session-supervision/index.js +16 -4
  40. package/src/sqm/bundle-store.js +14 -7
  41. package/src/sqm/claude-plugin.js +2 -0
  42. package/src/sqm/command.js +42 -4
  43. package/src/sqm/diagnostics.js +23 -0
  44. package/src/sqm/evidence-client.js +24 -7
  45. package/src/sqm/history-store.js +21 -5
  46. package/src/sqm/index.js +116 -17
  47. package/src/sqm/proof.js +4 -23
  48. package/src/sqm/report.js +9 -2
  49. package/src/sqm/request-recovery.js +82 -0
  50. package/src/sqm/review-worker.js +97 -15
  51. package/src/sqm/session.js +33 -3
  52. 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,7 +439,12 @@ 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;
442
+ const previousAudit = run.snapshot.final_audit;
443
+ const resolvedBudgetAudit = previousAudit?.findings?.some(
444
+ (finding) => finding.code === "TOKEN_BUDGET_EXHAUSTED",
445
+ ) ? structuredClone(previousAudit) : null;
355
446
  const budget = increaseProviderTokenBudget(run.snapshot, value);
447
+ refreshRecoveryBudgetAudit(run.snapshot, budget);
356
448
  run.request.token_budget = budget.token_budget;
357
449
  appendEvent(
358
450
  run.snapshot,
@@ -361,6 +453,7 @@ export async function increaseLocalCodingRunProviderTokenBudget(run, value) {
361
453
  {
362
454
  code: "provider_token_budget_increased",
363
455
  previous_provider_token_budget: previousBudget,
456
+ ...(resolvedBudgetAudit ? { previous_final_audit: resolvedBudgetAudit } : {}),
364
457
  provider_token_budget: budget.token_budget,
365
458
  budget,
366
459
  },
@@ -369,6 +462,29 @@ export async function increaseLocalCodingRunProviderTokenBudget(run, value) {
369
462
  return budget;
370
463
  }
371
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
+
372
488
  export async function continueLocalCodingRunAfterRepositoryApproval(
373
489
  run,
374
490
  { repository, localPath } = {},
@@ -437,11 +553,39 @@ export function queueLocalCodingGuidance(run, guidance) {
437
553
 
438
554
  export async function applyLocalHumanDecision(
439
555
  run,
440
- { message, designApproval = false, requiredChecks = [], acceptanceCriteria = [] } = {},
556
+ { message, designApproval = false, requiredChecks = [], acceptanceCriteria = [], confirmObjective = false, confirmedObjective = null } = {},
441
557
  dependencies = {},
442
558
  ) {
443
559
  const guidance = String(message || "").trim();
444
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
+ }
445
589
  assertRunCanContinue(run);
446
590
  const charter = run.snapshot.charter;
447
591
  if (acceptanceCriteria.length > 0) {
@@ -540,10 +684,12 @@ export async function stopLocalCodingRun(run) {
540
684
  async function signalAndWaitForClose(child, signal, timeoutMs) {
541
685
  const close = once(child, "close").then(() => "closed");
542
686
  signalExecutorProcess(child, signal);
543
- return Promise.race([
544
- close,
545
- new Promise((resolve) => setTimeout(() => resolve("timeout"), timeoutMs)),
546
- ]);
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); }
547
693
  }
548
694
 
549
695
  async function waitForLocalStop(run) {
@@ -598,13 +744,17 @@ async function executeLocalCodingRun(
598
744
  { executeAttempt, executeIndependentAudit, collectEvidence } = {},
599
745
  { humanGuidance = "", resume = false } = {},
600
746
  ) {
601
- const operation = runLocalCodingRun(run, {
602
- executeAttempt,
603
- executeIndependentAudit,
604
- collectEvidence,
605
- humanGuidance,
606
- resume,
607
- });
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
+ })();
608
758
  run.operation = operation;
609
759
  try {
610
760
  return await operation;
@@ -646,6 +796,16 @@ async function runLocalCodingRun(
646
796
  await updateRun(run, run.status);
647
797
  }
648
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
+
649
809
  const executor =
650
810
  executeAttempt ||
651
811
  ((context) =>
@@ -687,6 +847,7 @@ async function runLocalCodingRun(
687
847
  deviceId: run.session?.device_id,
688
848
  });
689
849
  run.sqmChecker = sqmChecker;
850
+ refreshSqmCheckerSession(run, sqmChecker);
690
851
 
691
852
  try {
692
853
  await runCodingLoop({
@@ -762,6 +923,7 @@ export async function recheckLocalCodingRun(run, { collectEvidence } = {}) {
762
923
  deviceId: run.session?.device_id,
763
924
  });
764
925
  run.sqmChecker = sqmChecker;
926
+ refreshSqmCheckerSession(run, sqmChecker);
765
927
  await recheckPrReadiness({
766
928
  snapshot: run.snapshot,
767
929
  collectEvidence: evidenceCollector,
@@ -800,6 +962,16 @@ function sqmBundleEndpoint(session) {
800
962
  return new URL("sqm/knowledge-bundle", session.api_base_url).href;
801
963
  }
802
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
+
803
975
  function sqmEvidenceEndpoint(session) {
804
976
  if (process.env.SIMY_SQM_EVIDENCE_URL?.trim()) return process.env.SIMY_SQM_EVIDENCE_URL.trim();
805
977
  if (!session?.api_base_url) return undefined;
@@ -813,8 +985,9 @@ export async function executeProcessAttempt({
813
985
  run,
814
986
  marker = "SIMY_RESULT_JSON:",
815
987
  attemptNumber = null,
988
+ resolveCommandImpl = resolveDesktopExecutorCommand,
816
989
  }) {
817
- const command = await resolveDesktopExecutorCommand({
990
+ const command = await resolveCommandImpl({
818
991
  backend,
819
992
  instruction,
820
993
  repositoryPath,
@@ -824,6 +997,8 @@ export async function executeProcessAttempt({
824
997
  const stderr = [];
825
998
  const tokenUsageRecords = [];
826
999
  const phase = marker === "SIMY_AUDIT_JSON:" ? "reviewer" : "executor";
1000
+ const initialBudget = budgetState(run.snapshot);
1001
+ const invocationId = randomUUID();
827
1002
  const observationSender =
828
1003
  phase === "executor" &&
829
1004
  !["accepted", "already_recorded"].includes(run.snapshot.metadata?.workflow_observation?.status)
@@ -839,6 +1014,8 @@ export async function executeProcessAttempt({
839
1014
  return new Promise((resolve) => {
840
1015
  let settled = false;
841
1016
  let terminalError = null;
1017
+ let terminalErrorCode = null;
1018
+ const checkpointWrites = [];
842
1019
  const child = command.spawn(command.bin, command.args, {
843
1020
  cwd: repositoryPath,
844
1021
  env: { ...process.env, ...command.env },
@@ -853,13 +1030,42 @@ export async function executeProcessAttempt({
853
1030
  onLine: (line) => emitOutput(run, line, { backend }),
854
1031
  onEvent: (event) => observationSender?.observe(event),
855
1032
  onTerminalError: (message) => { terminalError ||= message; },
856
- onUsage: (usage) =>
1033
+ onUsage: (usage) => {
857
1034
  tokenUsageRecords.push({
858
1035
  ...usage,
859
1036
  phase,
860
1037
  ...(Number.isInteger(attemptNumber) ? { attempt_number: attemptNumber } : {}),
861
1038
  backend,
862
- }),
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
+ },
863
1069
  });
864
1070
  const stderrDecoder = createProviderStreamDecoder({
865
1071
  backend,
@@ -884,6 +1090,7 @@ export async function executeProcessAttempt({
884
1090
  stdoutDecoder.flush();
885
1091
  stderrDecoder.flush();
886
1092
  run.child = null;
1093
+ await Promise.all(checkpointWrites);
887
1094
  // The run must always resolve: a WorkFlow observation failure (or a
888
1095
  // sender bug) never changes or blocks the Provider result. flush() is
889
1096
  // bounded by per-request timeouts and is designed not to reject.
@@ -920,6 +1127,7 @@ export async function executeProcessAttempt({
920
1127
  resolve({
921
1128
  exitCode: terminalError && exitCode === 0 ? 1 : exitCode,
922
1129
  error: error || terminalError,
1130
+ ...(terminalErrorCode ? { errorCode: terminalErrorCode } : {}),
923
1131
  result: Object.keys(result).length > 0 ? result : parse(rawStdout),
924
1132
  tokenUsage:
925
1133
  tokenUsageRecords.length > 0 ? { records: tokenUsageRecords } : {},
@@ -0,0 +1,77 @@
1
+ import { appendFileSync, chmodSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { StringDecoder } from 'node:string_decoder';
4
+ import { redactExecutionText } from './orchestrator/execution-io.js';
5
+
6
+ // Local diagnostics are deliberately bounded and private. Use the same credential
7
+ // policy as execution history, with URL queries removed (login nonces live there).
8
+ export function createRuntimeDiagnostics(root, { name = 'daemon', maxBytes = 1024 * 1024, backups = 2 } = {}) {
9
+ const directory = join(root, 'logs');
10
+ const file = join(directory, `${name}.log`);
11
+ const write = value => {
12
+ try {
13
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
14
+ const text = redactExecutionText(String(value)
15
+ .replace(/(\b(?:set-cookie|cookie)\s*:\s*)[^\r\n]*/gi, '$1[REDACTED]')
16
+ .replace(/(https?:\/\/[^\s?#]+)[?#][^\s]*/g, '$1?[REDACTED]'), { maxChars: 8192 }).text;
17
+ // Do not retain fragments of PEM keys, whose boundaries may span writes.
18
+ const safe = /PRIVATE KEY|^[A-Za-z0-9+/=]{64,}$/.test(text.trim()) ? '[REDACTED KEY MATERIAL]' : text;
19
+ const record = Buffer.from(`${new Date().toISOString()} ${safe.replace(/[\r\n]+/g, ' ')}\n`);
20
+ const bounded = record.subarray(0, maxBytes);
21
+ let size = 0;
22
+ try { size = statSync(file).size; } catch {}
23
+ if (size + bounded.length > maxBytes) {
24
+ rmSync(`${file}.${backups}`, { force: true });
25
+ for (let index = backups - 1; index >= 1; index--) {
26
+ try { renameSync(`${file}.${index}`, `${file}.${index + 1}`); } catch (error) { if (error.code !== 'ENOENT') throw error; }
27
+ }
28
+ try { renameSync(file, `${file}.1`); } catch (error) { if (error.code !== 'ENOENT') throw error; }
29
+ }
30
+ appendFileSync(file, bounded, { mode: 0o600 });
31
+ chmodSync(file, 0o600);
32
+ } catch {
33
+ // Diagnostics must never prevent startup/recovery or recurse via stderr.
34
+ }
35
+ };
36
+ const stream = label => {
37
+ const decoder = new StringDecoder('utf8');
38
+ let pending = '', dropping = false, privateKey = false;
39
+ const writeChunk = chunk => {
40
+ const text = decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
41
+ for (const part of text.split(/(?<=\n)/)) {
42
+ const end = part.endsWith('\n');
43
+ if (!dropping) {
44
+ pending += part;
45
+ if (pending.length > 8192) {
46
+ // Drop the whole oversized line, rather than persisting an unredacted
47
+ // partial credential split across chunks or the truncation boundary.
48
+ write(`${label}: [oversized diagnostic line omitted]`);
49
+ pending = ''; dropping = !end;
50
+ } else if (end) {
51
+ if (/-----BEGIN .*PRIVATE KEY-----/.test(pending)) privateKey = true;
52
+ write(`${label}: ${privateKey ? '[REDACTED KEY MATERIAL]' : pending.trimEnd()}`);
53
+ if (/-----END .*PRIVATE KEY-----/.test(pending)) privateKey = false;
54
+ pending = '';
55
+ }
56
+ } else if (end) dropping = false;
57
+ }
58
+ };
59
+ return { write: writeChunk, flush() { if (pending && !dropping) write(`${label}: ${privateKey ? '[REDACTED KEY MATERIAL]' : pending}`); pending = ''; dropping = false; } };
60
+ };
61
+ return { file, write, stream };
62
+ }
63
+
64
+ export function captureRuntimeOutput(diagnostics, streams = { stdout: process.stdout, stderr: process.stderr }) {
65
+ const restores = [];
66
+ for (const [label, target] of Object.entries(streams)) {
67
+ const sink = diagnostics.stream(label);
68
+ const original = target.write;
69
+ target.write = function (chunk, ...args) { sink.write(chunk); return original.call(this, chunk, ...args); };
70
+ restores.push(() => { target.write = original; sink.flush(); });
71
+ }
72
+ const fatal = error => diagnostics.write(`uncaught: ${error?.stack || error}`);
73
+ process.on('uncaughtExceptionMonitor', fatal);
74
+ const restore = () => { for (const callback of restores) callback(); process.removeListener('uncaughtExceptionMonitor', fatal); process.removeListener('exit', restore); };
75
+ process.once('exit', restore);
76
+ return restore;
77
+ }
@@ -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(),
@@ -1,3 +1,4 @@
1
+ import { safeSqmDiagnostic } from "./diagnostics.js";
1
2
  import { createHash, createPublicKey } from "node:crypto";
2
3
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
4
  import { homedir } from "node:os";
@@ -5,6 +6,7 @@ import path from "node:path";
5
6
 
6
7
  import { verifyBundleIntegrity } from "./canonical.js";
7
8
  import { validateKnowledgeBundle } from "./validation.js";
9
+ import { fetchSqmRead, sqmDiagnostic } from "./request-recovery.js";
8
10
 
9
11
  const DEFAULT_TTL_MS = 5 * 60 * 1000;
10
12
  export const SQM_BUNDLE_FETCH_TIMEOUT_MS = 15_000;
@@ -35,6 +37,9 @@ export async function loadKnowledgeBundle({
35
37
  fetchImpl = globalThis.fetch,
36
38
  now = Date.now(),
37
39
  publicKeys = publicKeysFromEnvironment(),
40
+ signal,
41
+ recoveryOptions,
42
+ onRecoveryEvent,
38
43
  }) {
39
44
  if (!organizationId) {
40
45
  return result(null, ["SQM organization identity is unavailable; shadow checks were skipped."], "unavailable");
@@ -49,7 +54,7 @@ export async function loadKnowledgeBundle({
49
54
  Date.parse(cached.bundle.expires_at) > now;
50
55
  if (!offline && endpoint && (!fresh || refresh || cached?.bundle.system_scope)) {
51
56
  try {
52
- 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 });
53
58
  if (fetched.notModified) {
54
59
  if (!cached) throw new Error("SQM Cloud returned 304 without a verified local bundle.");
55
60
  if (Date.parse(cached.bundle.expires_at) <= now) throw nonFallbackError("SQM Cloud returned 304 for an expired knowledge bundle.");
@@ -81,9 +86,10 @@ export async function loadKnowledgeBundle({
81
86
  });
82
87
  return result(bundle, [], "cloud");
83
88
  } catch (error) {
89
+ if (signal?.aborted) throw signal.reason;
84
90
  if (error?.code === "incompatible_cli") throw error;
85
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");
86
- return result(null, [`SQM knowledge could not be used; shadow checks were skipped: ${message(error)}`], "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") } };
87
93
  }
88
94
  }
89
95
  if (cached) {
@@ -166,7 +172,7 @@ function trustedDiscoveryEndpoint(endpoint) {
166
172
  (url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname));
167
173
  }
168
174
 
169
- async function fetchBundle({ endpoint, repository, cliVersion, token, organizationId, etag, fetchImpl }) {
175
+ async function fetchBundle({ endpoint, repository, cliVersion, token, organizationId, etag, fetchImpl, signal, recoveryOptions, onRecoveryEvent }) {
170
176
  const url = new URL(endpoint);
171
177
  url.searchParams.set("repository", repository);
172
178
  url.searchParams.set("cli_version", cliVersion);
@@ -175,10 +181,8 @@ async function fetchBundle({ endpoint, repository, cliVersion, token, organizati
175
181
  if (token) headers.Authorization = `Bearer ${token}`;
176
182
  if (organizationId) headers["x-simy-org-id"] = organizationId;
177
183
  if (etag) headers["If-None-Match"] = etag;
178
- const response = await fetchImpl(url, {
179
- headers,
180
- signal: AbortSignal.timeout(SQM_BUNDLE_FETCH_TIMEOUT_MS),
181
- });
184
+ const response = await fetchSqmRead(url, { ...recoveryOptions, headers, signal, fetchImpl,
185
+ requestTimeoutMs: SQM_BUNDLE_FETCH_TIMEOUT_MS, onRecoveryEvent });
182
186
  const responseEtag = response.headers?.get?.("etag") || null;
183
187
  const signingKeyId = response.headers?.get?.("x-simy-sqm-key-id") || null;
184
188
  const signingPublicKey = response.headers?.get?.("x-simy-sqm-public-key") || null;
@@ -200,6 +204,9 @@ async function fetchBundle({ endpoint, repository, cliVersion, token, organizati
200
204
  );
201
205
  }
202
206
  const error = new Error(`SQM Cloud returned HTTP ${response.status}${body ? `: ${body.slice(0, 300)}` : ""}.`);
207
+ error.status = response.status;
208
+ Object.assign(error, safeSqmDiagnostic({ status: response.status, code: parsed?.error?.code || parsed?.detail?.code,
209
+ requestId: response.headers?.get?.("x-request-id") }, "knowledge_bundle", "knowledge_bundle_http_error"));
203
210
  error.fallbackAllowed = response.status >= 500 && response.status <= 599;
204
211
  throw error;
205
212
  }