@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
@@ -0,0 +1,129 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ function text(value) {
4
+ return typeof value === "string" ? value.trim() : "";
5
+ }
6
+
7
+ function observed(row) {
8
+ return typeof row?.passed === "boolean" && Boolean(text(row.verification_method)) &&
9
+ Boolean(text(row.observed_result)) && Array.isArray(row.evidence_refs) &&
10
+ row.evidence_refs.some((ref) => Boolean(text(ref)));
11
+ }
12
+
13
+ function classifyBlockers(findings) {
14
+ const blockers = { machine: [], human: [], external: [] };
15
+ for (const finding of findings || []) {
16
+ if (!finding || finding.passed === true) continue;
17
+ const kind = ["machine", "human", "external"].includes(finding.blocker_kind)
18
+ ? finding.blocker_kind
19
+ : finding.repairability === "external" ? "external"
20
+ : finding.repairability === "manual" ? "human" : "machine";
21
+ blockers[kind].push({
22
+ code: text(finding.code) || "OUTCOME_UNPROVEN",
23
+ target: text(finding.target) || null,
24
+ explanation: text(finding.explanation) || text(finding.detail) || null,
25
+ });
26
+ }
27
+ return blockers;
28
+ }
29
+
30
+ /**
31
+ * Compare controller-owned independent observations against one frozen objective.
32
+ * Evidence references identify proof; changing their paths/hashes is not progress.
33
+ * This measures continuation only and never replaces the completion gates.
34
+ */
35
+ export function evaluateOutcomeProgress({
36
+ criteria,
37
+ objectiveDigest = null,
38
+ objectiveVersion = null,
39
+ criterionResults = [],
40
+ userOutcome = null,
41
+ assessmentAvailable = false,
42
+ assessmentReason = null,
43
+ findings = [],
44
+ previous = null,
45
+ maxNoProgressAttempts = 3,
46
+ }) {
47
+ const rows = Array.isArray(criteria) ? criteria : [];
48
+ if (rows.some((row) => !text(row?.id) || row.id === "USER-GOAL") ||
49
+ new Set(rows.map((row) => row.id)).size !== rows.length) {
50
+ throw new Error("Outcome progress requires unique, non-reserved criterion IDs");
51
+ }
52
+ if (!Number.isInteger(maxNoProgressAttempts) || maxNoProgressAttempts < 1) {
53
+ throw new Error("maxNoProgressAttempts must be a positive integer");
54
+ }
55
+ const scopeDigest = createHash("sha256").update(JSON.stringify({
56
+ objectiveDigest, objectiveVersion, criteria: rows,
57
+ })).digest("hex");
58
+ const comparable = previous?.scope_digest === scopeDigest;
59
+ const requiredIds = [...rows.map((row) => row.id), "USER-GOAL"];
60
+ const results = Array.isArray(criterionResults) ? criterionResults : [];
61
+ const criterionStates = requiredIds.map((id) => {
62
+ const matches = id === "USER-GOAL" ? (userOutcome ? [userOutcome] : [])
63
+ : results.filter((row) => row?.criterion_id === id);
64
+ const row = matches[0];
65
+ const evaluated = assessmentAvailable && matches.length === 1 && observed(row);
66
+ return {
67
+ criterion_id: id,
68
+ state: evaluated ? row.passed ? "verified" : "unproven" : "unassessed",
69
+ reason: evaluated ? null : !assessmentAvailable ? assessmentReason || "independent_assessment_unavailable"
70
+ : matches.length > 1 ? "duplicate_independent_results" : "independent_observation_missing",
71
+ };
72
+ });
73
+ const idsWithState = (state) => criterionStates.filter((row) => row.state === state)
74
+ .map((row) => row.criterion_id).sort();
75
+ const verified = idsWithState("verified");
76
+ const unproven = idsWithState("unproven");
77
+ const unassessed = idsWithState("unassessed");
78
+ // History is only a comparison baseline, never carried into current proof.
79
+ const historicalRows = comparable ? previous.verified_history_ids || previous.verified_ids : [];
80
+ const historical = Array.isArray(historicalRows)
81
+ ? historicalRows.filter((id) => requiredIds.includes(id)) : [];
82
+ const newlyVerified = verified.filter((id) => !historical.includes(id));
83
+ const hasProgress = newlyVerified.length > 0;
84
+ const previousNoProgress = Number.isInteger(previous?.no_progress_attempts) && previous.no_progress_attempts >= 0
85
+ ? previous.no_progress_attempts : 0;
86
+ const noProgressAttempts = comparable
87
+ ? hasProgress ? 0 : previousNoProgress + 1
88
+ : verified.length > 0 ? 0 : 1;
89
+ const blockers = classifyBlockers(findings);
90
+ const complete = rows.length > 0 && unproven.length === 0 && unassessed.length === 0;
91
+ const stalled = !complete && noProgressAttempts >= maxNoProgressAttempts;
92
+ const evaluationStatus = unassessed.length === 0 ? "assessed"
93
+ : verified.length + unproven.length > 0 ? "partial" : "unavailable";
94
+ const nextActions = [];
95
+ if (blockers.machine.length || unproven.length || unassessed.length) nextActions.push("repair_or_verify");
96
+ if (blockers.human.length) nextActions.push("request_human_input");
97
+ if (blockers.external.length) nextActions.push("wait_for_external_dependency");
98
+ if (complete) nextActions.push("check_completion_gates");
99
+ if (stalled) nextActions.push("reassess_approach");
100
+ return {
101
+ version: 2,
102
+ objective_digest: objectiveDigest,
103
+ objective_version: objectiveVersion,
104
+ scope_digest: scopeDigest,
105
+ objective_changed: Boolean(previous && !comparable),
106
+ evaluation_status: evaluationStatus,
107
+ status: evaluationStatus === "assessed" ? "assessed" : "insufficient_evidence",
108
+ criterion_states: criterionStates,
109
+ verified_ids: verified,
110
+ assessed_ids: verified,
111
+ evaluated_ids: [...verified, ...unproven].sort(),
112
+ unproven_ids: [...unproven, ...unassessed].sort(),
113
+ failed_ids: unproven,
114
+ unassessed_ids: unassessed,
115
+ newly_verified_ids: newlyVerified,
116
+ verified_history_ids: [...new Set([...historical, ...verified])].sort(),
117
+ regressed_ids: comparable && Array.isArray(previous.verified_ids)
118
+ ? unproven.filter((id) => previous.verified_ids.includes(id)) : [],
119
+ has_progress: hasProgress,
120
+ no_progress_attempts: noProgressAttempts,
121
+ max_no_progress_attempts: maxNoProgressAttempts,
122
+ stalled,
123
+ stall_reason: stalled ? evaluationStatus === "unavailable"
124
+ ? "independent_assessment_repeatedly_unavailable" : "no_new_verified_outcome" : null,
125
+ blockers,
126
+ next_actions: nextActions,
127
+ next_action: stalled ? "reassess_approach" : nextActions[0] || "repair_or_verify",
128
+ };
129
+ }
@@ -0,0 +1,88 @@
1
+ import { redactExecutionValue } from "./orchestrator/execution-io.js";
2
+ import { objectiveValueDigest, verifyObjective } from "./orchestrator/outcome-monitor.js";
3
+
4
+ // Redaction changes acceptance text, so its integrity chain must undergo the
5
+ // same transformation. Never legitimize an already edited objective here.
6
+ export function recoverySnapshot(snapshot) {
7
+ const verified = snapshot?.charter && verifyObjective(snapshot.charter);
8
+ const sanitized = redactExecutionValue(snapshot);
9
+ const contract = sanitized?.charter?.thread_state?.objective_contract;
10
+ const objectiveDigest = value => objectiveValueDigest(value, contract?.digest_algorithm);
11
+ if (!contract || verified.legacy || !verified.valid) return sanitized;
12
+ if (objectiveDigest(contract.current) === contract.current_digest) return sanitized;
13
+ for (const revision of contract.revisions) {
14
+ revision.previous_digest = objectiveDigest(revision.previous);
15
+ revision.next_digest = objectiveDigest(revision.next);
16
+ }
17
+ const previousDigest = contract.current_digest;
18
+ contract.current_digest = objectiveDigest(contract.current);
19
+ if (!verifyObjective(sanitized.charter).valid) {
20
+ throw new Error("The redacted objective cannot be preserved for recovery.");
21
+ }
22
+ sanitized.metadata ??= {};
23
+ sanitized.metadata.recovery_migrations ??= [];
24
+ sanitized.metadata.recovery_migrations.push({
25
+ kind: "objective_redaction", version: 1,
26
+ previous_digest: previousDigest, current_digest: contract.current_digest,
27
+ });
28
+ return sanitized;
29
+ }
30
+
31
+ export function recoveryActorBinding(session, apiOrigin, request) {
32
+ return {
33
+ device_id: session?.device_id || request?.execution_device_id || null,
34
+ account_id: session?.account_id || session?.auth_user_id || null,
35
+ organization_id: session?.organization_id || session?.org_id || null,
36
+ api_origin: apiOrigin || null,
37
+ };
38
+ }
39
+
40
+ export function assertRecoveryActor(run) {
41
+ const saved = run.snapshot.metadata?.recovery_actor || {
42
+ device_id: run.snapshot.metadata?.execution_device_id,
43
+ };
44
+ if (!saved) return;
45
+ const current = recoveryActorBinding(run.session, run.apiOrigin, run.request);
46
+ for (const field of ["device_id", "account_id", "organization_id", "api_origin"]) {
47
+ if (saved[field] && saved[field] !== current[field]) {
48
+ const error = new Error(`The saved run belongs to another ${field}; restore its original authorized session.`);
49
+ error.code = "recovery_actor_mismatch";
50
+ throw error;
51
+ }
52
+ }
53
+ }
54
+
55
+ // Current presentation can change; historical attempts and the corresponding
56
+ // pause audit remain available for investigation.
57
+ export function refreshRecoveryBudgetAudit(snapshot, budget) {
58
+ const stale = (finding) =>
59
+ (finding.code === "TOKEN_BUDGET_EXHAUSTED" && !budget.token_exhausted) ||
60
+ (finding.code === "RETRY_BUDGET_EXHAUSTED" && !budget.retry_exhausted);
61
+ const changed = refreshRecoveryAudit(snapshot, { reason: "budget_changed", stale,
62
+ summary: "The budget permits continuation; remaining verification or approval is still required." });
63
+ if (changed && snapshot.final_audit) snapshot.final_audit.budget = budget;
64
+ return changed;
65
+ }
66
+
67
+ export function refreshRecoveryAudit(snapshot, { reason, stale, summary }) {
68
+ const audit = snapshot.final_audit;
69
+ if (!audit) return false;
70
+ const findings = Array.isArray(audit.findings) ? audit.findings : [];
71
+ if (!findings.some(stale)) return false;
72
+ snapshot.metadata ??= {};
73
+ snapshot.metadata.recovery_audit_history ??= [];
74
+ snapshot.metadata.recovery_audit_history.push({
75
+ reason, recorded_at: new Date().toISOString(),
76
+ audit: structuredClone(audit),
77
+ });
78
+ const remaining = findings.filter((finding) => !stale(finding));
79
+ snapshot.final_audit = remaining.length ? {
80
+ ...audit, findings: remaining, passed: false,
81
+ auto_repairable: remaining.every(finding => finding.repairability === "auto"),
82
+ requires_human: remaining.some(finding => finding.repairability !== "auto"),
83
+ summary,
84
+ } : null;
85
+ snapshot.done_gate_passed = false;
86
+ snapshot.implementation_gate_passed = false;
87
+ return true;
88
+ }
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
 
6
- import { redactExecutionValue } from "./orchestrator/execution-io.js";
6
+ import { recoverySnapshot } from "./coding-run-recovery.js";
7
7
 
8
8
  const RUN_ID = /^[A-Za-z0-9_-]{1,160}$/;
9
9
  const writes = new Map();
@@ -16,7 +16,7 @@ export async function saveCodingRunSnapshot(root, snapshot) {
16
16
  const id = String(snapshot?.id || "");
17
17
  if (!RUN_ID.test(id)) throw new Error("Invalid coding run ID for durable storage.");
18
18
  // Capture at enqueue time: later mutations must not rewrite a queued version.
19
- snapshot = redactExecutionValue(snapshot);
19
+ snapshot = recoverySnapshot(snapshot);
20
20
  const directory = path.resolve(root);
21
21
  const key = path.join(directory, id);
22
22
  const previous = writes.get(key) || Promise.resolve();
@@ -0,0 +1,2 @@
1
+ // The installed provider plugins use the same continuation policy validation.
2
+ export * from "./codex-plugins/simy/hooks/continuation-policy.mjs";
@@ -18,12 +18,15 @@ async function readCheckpointInput(input) {
18
18
  export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = process.cwd(), input = process.stdin, output = console.log } = {}) {
19
19
  const [action, ...args] = argv;
20
20
  if (!action || action === '--help') {
21
+ output('Progress: --continuation-policy progress [--hard-max-minutes 1440] [--hard-max-continuations 50] [--provider-token-ceiling 15000000 (Codex only)]. Soft thresholds warn; approvals, missing usage and no verified progress still stop.');
22
+ output('simy delivery continuation_policy --provider PROVIDER --session ID --task-id ID --continuation-policy bounded|progress');
23
+ output('simy delivery extend --provider PROVIDER --session ID --task-id ID --hard-max-minutes N|--hard-max-continuations N|--provider-token-ceiling N');
21
24
  output('simy delivery continue --provider codex|claude --session ID [--max-no-progress-minutes 30] [--max-tokens N (Codex only)]\nsimy delivery dependency_ready --provider PROVIDER --session ID --task-id ID --wait-id ID --answer TEXT\nsimy delivery handoff|handoff_cancel --provider codex --session ID --task-id ID --target-session ID');
22
25
  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 budgets.');
23
26
  return 0;
24
27
  }
25
28
  const options = {};
26
- const allowed = new Set(['provider', 'session', 'objective', 'next-step', 'max-continuations', 'max-failures', 'max-minutes', 'max-no-progress-minutes', 'max-tokens', 'file', 'managed-file', 'task-id', 'approval-id', 'criterion-id', 'autonomy-policy', 'wait-id', 'answer', 'target-session']);
29
+ const allowed = new Set(['provider', 'session', 'objective', 'next-step', 'max-continuations', 'max-failures', 'max-minutes', 'max-no-progress-minutes', 'max-tokens', 'continuation-policy', 'hard-max-minutes', 'hard-max-continuations', 'provider-token-ceiling', 'file', 'managed-file', 'task-id', 'approval-id', 'criterion-id', 'autonomy-policy', 'wait-id', 'answer', 'target-session']);
27
30
  for (let i = 0; i < args.length; i += 2) {
28
31
  const key = args[i]?.replace(/^--/, '');
29
32
  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.');
@@ -32,8 +35,9 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
32
35
  const base = { root, cwd, provider: options.provider, session: options.session };
33
36
  let result;
34
37
  if (action === 'status') result = (await listRecords(root)).filter(r => (!options.provider || r.provider === options.provider) && (!options.session || r.session === options.session));
35
- else if (action === 'continue') result = await continueSession({ ...base, autonomyPolicy: options['autonomy-policy'],
38
+ else if (action === 'continue') result = await continueSession({ ...base, autonomyPolicy: options['autonomy-policy'], continuationPolicy: options['continuation-policy'],
36
39
  ...Object.fromEntries([['max-continuations', 'maxContinuations'], ['max-failures', 'maxFailures'], ['max-minutes', 'maxMinutes'],
40
+ ['hard-max-minutes', 'hardMaxMinutes'], ['hard-max-continuations', 'hardMaxContinuations'], ['provider-token-ceiling', 'providerTokenCeiling'],
37
41
  ['max-no-progress-minutes', 'maxNoProgressMinutes'], ['max-tokens', 'maxTokens']].filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) });
38
42
  else if (action === 'handoff') result = await handoffSession({ ...base, taskId: options['task-id'], targetSession: options['target-session'] });
39
43
  else if (action === 'handoff_cancel') result = await cancelHandoffSession({ ...base, taskId: options['task-id'], targetSession: options['target-session'] });
@@ -49,15 +53,17 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
49
53
  managed = { ...managed, ...Object.fromEntries([['max-tokens', 'maxTokens'], ['max-no-progress-minutes', 'maxNoProgressMinutes']]
50
54
  .filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) };
51
55
  }
52
- result = await startDelivery({ ...base, objective: options.objective, nextStep: options['next-step'], managed,
53
- ...Object.fromEntries([['max-continuations', 'maxContinuations'], ['max-failures', 'maxFailures'], ['max-minutes', 'maxMinutes']].filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) });
56
+ result = await startDelivery({ ...base, objective: options.objective, nextStep: options['next-step'], managed, continuationPolicy: options['continuation-policy'],
57
+ ...Object.fromEntries([['max-continuations', 'maxContinuations'], ['max-failures', 'maxFailures'], ['max-minutes', 'maxMinutes'],
58
+ ['hard-max-minutes', 'hardMaxMinutes'], ['hard-max-continuations', 'hardMaxContinuations'], ['provider-token-ceiling', 'providerTokenCeiling']].filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])) });
54
59
  }
55
60
  else if (action === 'checkpoint') {
56
61
  if (!options.file) throw new Error('--file is required.');
57
62
  const data = options.file === '-' ? await readCheckpointInput(input) : await readFile(options.file, 'utf8');
58
63
  if (Buffer.byteLength(data) > 64 * 1024) throw new Error('Checkpoint exceeds 64 KiB.');
59
64
  result = await checkpointDelivery({ ...base, checkpoint: JSON.parse(data) });
60
- } else if (['stop', 'pause', 'resume', 'replan', 'approve', 'reject', 'set_policy', 'dependency_ready'].includes(action)) result = await controlDelivery({ ...base, taskId: options['task-id'], approvalId: options['approval-id'], autonomyPolicy: options['autonomy-policy'], waitId: options['wait-id'], answer: options.answer, action });
65
+ } else if (['stop', 'pause', 'resume', 'replan', 'approve', 'reject', 'set_policy', 'dependency_ready', 'extend', 'continuation_policy'].includes(action)) result = await controlDelivery({ ...base, taskId: options['task-id'], approvalId: options['approval-id'], autonomyPolicy: options['autonomy-policy'], continuationPolicy: options['continuation-policy'],
66
+ limits: Object.fromEntries([['hard-max-minutes', 'hardMaxMinutes'], ['hard-max-continuations', 'hardMaxContinuations'], ['provider-token-ceiling', 'providerTokenCeiling']].filter(([key]) => options[key] !== undefined).map(([key, field]) => [field, Number(options[key])])), waitId: options['wait-id'], answer: options.answer, action });
61
67
  else if (action === 'verify') result = await verifyDelivery({ ...base, taskId: options['task-id'], criterionId: options['criterion-id'] });
62
68
  else throw new Error('Unknown delivery command.');
63
69
  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, managedBudgetReason, reconcileRecords, rootDirectory } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+ import { canChangeDeliveryContinuation, canReplanDelivery, deliveryBudgetState, managedBudgetReason, 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({
@@ -32,6 +32,15 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
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
34
  continuations: r.run.continuations, maxContinuations: r.run.maxContinuations, deadline: r.run.deadline,
35
+ continuationPolicy: r.run.managed?.continuationPolicy || 'bounded',
36
+ startedAt: r.run.startedAt,
37
+ continuationLimits: deliveryBudgetState(r.run, now),
38
+ canChangeContinuation: canChangeDeliveryContinuation(r.run),
39
+ hardDeadline: r.run.hardDeadline || null, hardMaxContinuations: r.run.hardMaxContinuations || null,
40
+ budgetWarnings: deliveryBudgetState(r.run, now).warnings,
41
+ budgetCapability: r.run.managed?.budgetCapability || null,
42
+ outcomeProgress: r.run.managed?.outcomeProgress || null,
43
+ budgetExtensionHistory: r.run.budgetExtensionHistory || [],
35
44
  needsAttention: ['blocked', 'failed', 'recovery_required', 'budget_exhausted', 'verification_pending'].includes(r.run.state), updatedAt: r.run.updatedAt,
36
45
  managed: Boolean(r.run.managed), taskId: r.run.managed?.taskId || null, actionId: r.run.managed?.actionId || null,
37
46
  actionDueAt: r.run.managed?.actionDueAt ?? null,
@@ -67,12 +76,12 @@ export async function deliveryHookStatus({ root = rootDirectory(), now = Date.no
67
76
  r.run.managed.resumeChannel === 'codex_app_server' && r.run.managed.autoRecovery),
68
77
  canResume: ['blocked', 'recovery_required', 'paused'].includes(r.run.state) && r.run.managed?.phase !== 'needs_clarification' &&
69
78
  !r.run.managed?.pendingApproval && !r.run.managed?.dependencyWait && !['pending', 'pending_received', 'transferred'].includes(r.run.managed?.handoff?.status) &&
70
- !managedBudgetReason(r.run, now) && now < r.run.deadline && r.run.continuations < r.run.maxContinuations,
79
+ !managedBudgetReason(r.run, now),
71
80
  canApprove: r.run.state === 'blocked' && Boolean(r.run.managed?.pendingApproval) && !r.run.managed.pendingApproval.denied,
72
81
  canReject: r.run.state === 'blocked' && Boolean(r.run.managed?.pendingApproval) && !r.run.managed.pendingApproval.denied,
73
82
  canReplan: canReplanDelivery(r.run, r.provider, now),
74
83
  canAnswer: r.run.managed?.phase === 'needs_clarification' && r.run.state === 'blocked' && Boolean(r.run.managed.understandingQuestion?.id) &&
75
- now < r.run.deadline && r.run.continuations < r.run.maxContinuations && r.run.failures < r.run.maxFailures,
84
+ !managedBudgetReason(r.run, now),
76
85
  canResolveDependency: r.run.state === 'blocked' && r.run.blocker === 'external_dependency' && Boolean(r.run.managed?.dependencyWait?.id) &&
77
86
  !managedBudgetReason(r.run, now) && !['pending', 'pending_received', 'transferred'].includes(r.run.managed?.handoff?.status),
78
87
  canSetPolicy: Boolean(r.run.managed?.autonomyPolicy) && !['completed', 'reported_done', 'stopped', 'failed', 'budget_exhausted'].includes(r.run.state),
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { dirname } from 'node:path';
4
- import { automaticContinuationAllowed, assessedCheckpointContract, checkpointDelivery, decideStop, listRecords, readRecord, managedBudgetReason, notifyState, processIdentity, reconcileRecords, updateRecord, verifyAutomaticCriteria } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
4
+ import { automaticContinuationAllowed, assessedCheckpointContract, checkpointDelivery, decideStop, deliveryBudgetState, listRecords, readRecord, managedBudgetReason, managedDispatchDeadline, 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';
@@ -38,7 +38,7 @@ async function assertActiveLease(root, provider, session, leaseId, signal, curre
38
38
  if (signal?.aborted || current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId) {
39
39
  throw new Error('Managed recovery lease changed before provider dispatch.');
40
40
  }
41
- if (currentTime() >= current.run.deadline) throw new Error('Managed recovery deadline expired before provider dispatch.');
41
+ if (currentTime() >= managedDispatchDeadline(current.run)) throw new Error('Managed recovery deadline expired before provider dispatch.');
42
42
  if (!automaticContinuationAllowed(current)) throw new Error('Automatic continuation was disabled before provider dispatch.');
43
43
  if (managedBudgetReason(current.run, currentTime())) throw new Error('Managed progress budget expired before provider dispatch.');
44
44
  const managed = current.run.managed;
@@ -78,12 +78,13 @@ export async function executeCodexResume(record, { signal, root, leaseId, execut
78
78
  lastDependencyResolution: run.managed.lastDependencyResolution,
79
79
  remainingWork: run.managed.remainingWork, acceptanceCriteria: run.managed.acceptanceCriteria, proposedAction: run.managed.proposedAction,
80
80
  currentDecision: run.managed.autonomyPolicy ? run.managed.decision : undefined, diagnosis: run.managed.plan?.diagnosis,
81
- budgets: { continuations: run.continuations, maxContinuations: run.maxContinuations, failures: run.failures, maxFailures: run.maxFailures, deadline: run.deadline } };
81
+ budgets: { ...deliveryBudgetState(run), continuations: run.continuations, failures: run.failures, maxFailures: run.maxFailures,
82
+ softDeadline: run.deadline, softMaxContinuations: run.maxContinuations } };
82
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.`;
83
84
  const sandbox = run.managed.autonomyPolicy && run.managed.decision?.action.kind === 'read' ? 'read-only' : run.managed.autoRecoverySandbox;
84
85
  const args = ['exec', '--json', '--skip-git-repo-check', '-s', sandbox, '-m', run.managed.resumeModel, 'resume', record.session, prompt];
85
86
  if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
86
- if (currentTime() >= run.deadline) throw new Error('Managed recovery deadline expired before provider dispatch.');
87
+ if (currentTime() >= managedDispatchDeadline(run)) throw new Error('Managed recovery deadline expired before provider dispatch.');
87
88
  await beforeDispatch();
88
89
  if (signal?.aborted) throw new Error('Managed recovery cancelled before provider dispatch.');
89
90
  if (currentTime() >= run.deadline) throw new Error('Managed recovery deadline expired before provider dispatch.');
@@ -193,8 +194,9 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
193
194
  const approved = live?.managed?.decision?.outcome === 'wait' && ['read', 'local_edit', 'local_test'].includes(live.managed.decision.action?.kind) &&
194
195
  live.managed.approvedAction?.id === live.managed.decision.id && !live.managed.approvedAction.dispatchedAt;
195
196
  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;
196
- if (managedBudgetReason(live, currentTime()) || currentTime() >= live.deadline || live.continuations >= live.maxContinuations || live.failures >= live.maxFailures) {
197
- live.state = 'budget_exhausted'; live.reason = 'Original managed continuation budget exhausted.'; live.updatedAt = currentTime(); notifyState(live, live.updatedAt); return current;
197
+ const budgetReason = managedBudgetReason(live, currentTime());
198
+ if (budgetReason) {
199
+ live.state = 'budget_exhausted'; live.reason = budgetReason; live.updatedAt = currentTime(); notifyState(live, live.updatedAt); return current;
198
200
  }
199
201
  live.state = 'auto_resuming'; live.managed.resumeLease = { id: leaseId, claimedAt: currentTime(), checkpointRevision: live.revision, decisionId: live.managed.decision?.id || null,
200
202
  owner: { pid: process.pid, identity: ownerIdentity } };
@@ -220,10 +222,10 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
220
222
  const abort = () => controller.abort();
221
223
  if (signal?.aborted) controller.abort();
222
224
  else signal?.addEventListener('abort', abort, { once: true });
223
- const timeout = setTimeout(abort, Math.max(1, claimed.run.deadline - currentTime()));
225
+ const timeout = setTimeout(abort, Math.max(1, managedDispatchDeadline(claimed.run) - currentTime()));
224
226
  const stopWatch = watchLease(root, record.provider, record.session, current => {
225
227
  if (current?.run?.state !== 'auto_resuming' || current.run.managed?.resumeLease?.id !== leaseId ||
226
- managedBudgetReason(current.run, currentTime()) || !waitingForTool && currentTime() - lastActivityAt >= 5 * 60_000) controller.abort();
228
+ managedBudgetReason(current.run, currentTime(), { checkProgress: false }) || !waitingForTool && currentTime() - lastActivityAt >= 5 * 60_000) controller.abort();
227
229
  }, () => controller.abort());
228
230
  try {
229
231
  if (!controller.signal.aborted) await assertActiveLease(root, record.provider, record.session, leaseId, controller.signal, currentTime);
@@ -262,7 +264,8 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
262
264
  onActivity: observation => { lastActivityAt = currentTime(); waitingForTool = observation?.waitingForTool === true; },
263
265
  onUsage: observed => {
264
266
  usage = observed;
265
- if (claimed.run.managed.maxTokens && (claimed.run.managed.tokensUsed || 0) + observed.tokens >= claimed.run.managed.maxTokens) {
267
+ const tokenCeiling = deliveryBudgetState(claimed.run).tokenCeiling;
268
+ if (tokenCeiling && (claimed.run.managed.tokensUsed || 0) + observed.tokens >= tokenCeiling) {
266
269
  tokenBudgetExceeded = true; controller.abort();
267
270
  }
268
271
  },
@@ -298,7 +301,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
298
301
  errorCode: error?.code || 'provider_error', restorationUnverified: error?.restorationUnverified === true }; }
299
302
  finally { clearTimeout(timeout); stopWatch(); signal?.removeEventListener('abort', abort); }
300
303
  if (controller.signal.aborted) result = { ...result, code: -2, cancelled: true };
301
- if (claimed.run.managed.maxTokens && !usage && !result.busy && dispatched) {
304
+ if (deliveryBudgetState(claimed.run).tokenCeiling && !usage && !result.busy && dispatched) {
302
305
  result = { ...result, usageUnavailable: true };
303
306
  }
304
307
  if (result.code === 0) {
@@ -331,7 +334,7 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
331
334
  live.managed.tokensUsed = (live.managed.tokensUsed || 0) + usage.tokens;
332
335
  live.managed.usageAvailable = true;
333
336
  live.managed.lastUsage = { ...usage, attemptId: leaseId };
334
- } else if (!result.busy && live.managed.maxTokens && dispatched) {
337
+ } else if (!result.busy && deliveryBudgetState(live).tokenCeiling && dispatched) {
335
338
  live.managed.usageAvailable = false; live.managed.usageBlocked = true;
336
339
  }
337
340
  const attempt = live.managed.executionAttempt;
@@ -356,7 +359,9 @@ export async function tickManagedTasks({ root, now = Date.now(), execute, inspec
356
359
  }
357
360
  live.updatedAt = Date.now(); notifyState(live, live.updatedAt); return current;
358
361
  }
359
- if (tokenBudgetExceeded || live.managed.maxTokens && live.managed.tokensUsed >= live.managed.maxTokens) {
362
+ live.budgetWarnings = deliveryBudgetState(live).warnings;
363
+ const tokenCeiling = deliveryBudgetState(live).tokenCeiling;
364
+ if (tokenBudgetExceeded || tokenCeiling && live.managed.tokensUsed >= tokenCeiling) {
360
365
  live.state = 'budget_exhausted'; live.managed.autoRecovery = false;
361
366
  live.reason = 'Observed provider token budget exhausted; inspect any interrupted action in the original conversation.';
362
367
  } else if (live.managed.usageBlocked) {
@@ -18,6 +18,7 @@ import { dirname, join } from "node:path";
18
18
  import { homedir } from "node:os";
19
19
  import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
20
20
  import { startAgent } from "./agent.js";
21
+ import { createRuntimeDiagnostics } from "./runtime-diagnostics.js";
21
22
  import { createCliAutoUpdater, readCliPackageInfo } from "./auto-update.js";
22
23
  import { createAgentShutdown } from "./shutdown.js";
23
24
  import { resolveWebOrigin } from "./web-origin.js";
@@ -51,7 +52,7 @@ function desktopActionError(action, error) {
51
52
  ? "Wait for running tasks to finish before switching organizations."
52
53
  : "Could not switch organization. Refresh organizations and try again.";
53
54
  }
54
- if (action === "sqm_set_enabled") {
55
+ if (["sqm_set_enabled", "sqm_set_engine_fallback", "sqm_retry_environment"].includes(action)) {
55
56
  return "Could not save SQM settings. Check your SIMY connection and try again.";
56
57
  }
57
58
  return "Action could not finish. Refresh the status and try again; active tasks must finish before quitting.";
@@ -74,6 +75,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
74
75
  output.write(`{"type":"status","status":{${observation}${encodedStatus}}}\n`);
75
76
  };
76
77
  const root = env.SIMY_HOME?.trim() || join(homedir(), ".simy");
78
+ const diagnostics = createRuntimeDiagnostics(root, { name: "desktop-host" });
77
79
  const webOrigin = resolveWebOrigin(null, env);
78
80
  const candidate = Boolean(env.SIMY_DESKTOP_SUPERVISOR_TOKEN);
79
81
  if (candidate && !await validateDesktopSupervisor(root, env.SIMY_DESKTOP_SUPERVISOR_TOKEN)) {
@@ -189,6 +191,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
189
191
  } catch { pluginSetup = { state: "error" }; }
190
192
  };
191
193
  const packageInfo = dependencies.packageInfo || await readCliPackageInfo();
194
+ diagnostics.write(`desktop starting version=${packageInfo.version} pid=${process.pid} role=${candidate ? "update-candidate" : "supervisor"}`);
192
195
  const makeAnalysisManager = () => (dependencies.createAnalysisManager || createContinuationAnalysisManager)({
193
196
  root: join(root, 'session-analysis'), analyze: async (...args) => {
194
197
  const scope = await authorizeWork();
@@ -291,7 +294,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
291
294
  updater = await (dependencies.createUpdater || (process.platform === "win32" ? (() => createWindowsDesktopUpdater({ version: packageInfo.version, disabled: env.SIMY_AUTO_UPDATE === "0" })) : createCliAutoUpdater))({
292
295
  daemon: true, interactive: true, webOrigin,
293
296
  disabled: env.SIMY_AUTO_UPDATE === "0",
294
- dependencies: { packageInfo, logger() {} },
297
+ dependencies: { packageInfo, logger: message => diagnostics.write(`update: ${message}`) },
295
298
  });
296
299
  agent = await (dependencies.startAgent || startAgent)({
297
300
  daemon: true, webOrigin, sessionRoot: root, quiet: true, updateManager: updater,
@@ -317,12 +320,17 @@ export async function startDesktopHost({ input = process.stdin, output = process
317
320
  let child;
318
321
  try { child = (dependencies.spawn || spawn)(installation.executablePath,
319
322
  [join(dirname(installation.entryPath), "desktop-host.js")], {
320
- stdio: ["pipe", "pipe", "ignore"],
323
+ stdio: ["pipe", "pipe", "pipe"],
321
324
  env: { ...env, SIMY_DESKTOP_SUPERVISOR_TOKEN: supervisorToken },
322
325
  }); } catch (error) {
323
326
  actionAssociations = makeActionAssociations();
324
327
  analysisManager = await makeAnalysisManager(); automaticContinuation = makeAutomaticContinuation(); discoveryStopped = false; refreshSessions(true); throw error;
325
328
  }
329
+ const stderr = diagnostics.stream("updated-runtime stderr");
330
+ child.stderr?.on("data", stderr.write);
331
+ child.stderr?.on("end", stderr.flush);
332
+ child.once("error", error => diagnostics.write(`updated-runtime error: ${error.code || "unknown"} ${error.message}`));
333
+ child.once("exit", (code, signal) => { stderr.flush(); diagnostics.write(`updated-runtime exit: code=${code} signal=${signal || "none"}`); });
326
334
  const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
327
335
  let accepted = false;
328
336
  child.stdin.on("error", () => {
@@ -444,6 +452,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
444
452
  }
445
453
  else if (action === "organizations_refresh") await agent.refreshOrganizations();
446
454
  else if (action === "sqm_set_enabled") await agent.setSqmEnabled(command.enabled);
455
+ else if (action === "sqm_set_engine_fallback") await agent.setSqmEngineFallback({ enabled: command.enabled, allowedProviders: command.allowedProviders });
456
+ else if (action === "sqm_retry_environment") await agent.retrySqmEnvironment();
447
457
  else if (action === "organization_cancel") agent.cancelOrganizationSelection();
448
458
  else if (action === "login" || action === "organization_select" || action === "authorization_open") {
449
459
  if (action === "organization_select" && (typeof command.organizationId !== "string" || !command.organizationId)) throw new Error("Select an organization first.");
@@ -490,9 +500,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
490
500
  else if (action === "codex_hooks" || action === "claude_hooks") {
491
501
  const provider = action === "codex_hooks" ? "codex" : "claude";
492
502
  await (dependencies.openHookSettings || openHookSetup)(provider);
493
- } else if (["delivery_stop", "delivery_resume", "delivery_pause", "delivery_approve", "delivery_reject", "delivery_replan", "delivery_set_policy", "delivery_dependency_ready"].includes(action)) {
503
+ } else if (["delivery_stop", "delivery_resume", "delivery_pause", "delivery_approve", "delivery_reject", "delivery_replan", "delivery_set_policy", "delivery_dependency_ready", "delivery_extend", "delivery_continuation_policy"].includes(action)) {
494
504
  await controlDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
495
- taskId: command.taskId, approvalId: command.approvalId, autonomyPolicy: command.autonomyPolicy, waitId: command.waitId, answer: command.answer, action: action.slice("delivery_".length) });
505
+ taskId: command.taskId, approvalId: command.approvalId, autonomyPolicy: command.autonomyPolicy, limits: command.limits, continuationPolicy: command.continuationPolicy, waitId: command.waitId, answer: command.answer, action: action.slice("delivery_".length) });
496
506
  } else if (action === "delivery_verify") {
497
507
  await verifyDelivery({ root: join(root, "delivery-hooks"), provider: command.provider, session: command.session,
498
508
  taskId: command.taskId, criterionId: command.criterionId });
@@ -508,7 +518,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
508
518
  }
509
519
  if (command.enabled) await authorizeWork(commandScope);
510
520
  await (dependencies.setSessionAutoContinue || setSessionAutoContinue)({ root: join(root, 'delivery-hooks'),
511
- provider: command.provider, session: command.session, enabled: command.enabled });
521
+ provider: command.provider, session: command.session, enabled: command.enabled, continuationPolicy: command.continuationPolicy, hardMaxMinutes: command.hardMaxMinutes, hardMaxContinuations: command.hardMaxContinuations, providerTokenCeiling: command.providerTokenCeiling });
512
522
  if (!command.enabled) automaticContinuation.revoke(command.provider, command.session);
513
523
  // Acknowledge the durable preference before discovery or analysis.
514
524
  emit({ type: "result", id: command.id, ok: true });
@@ -544,7 +554,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
544
554
  await (dependencies.enrollSession || enrollSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
545
555
  session: command.session, objective: command.objective, acceptanceCriteria: command.acceptanceCriteria,
546
556
  autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
547
- maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens });
557
+ maxContinuations: command.maxContinuations, maxFailures: command.maxFailures, maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens, continuationPolicy: command.continuationPolicy, hardMaxMinutes: command.hardMaxMinutes, hardMaxContinuations: command.hardMaxContinuations, providerTokenCeiling: command.providerTokenCeiling });
548
558
  await refreshSessions(true);
549
559
  } else if (action === "session_analyze") {
550
560
  const candidate = await selectedSession(command, { reuseDiscovery: true });
@@ -558,7 +568,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
558
568
  enroll: async options => { await authorizeWork(commandScope); return guardedEnroll(options); }, beforeEnroll: () => authorizeWork(commandScope),
559
569
  settings: { autonomyPolicy: command.autonomyPolicy, maxMinutes: command.maxMinutes,
560
570
  maxContinuations: command.maxContinuations, maxFailures: command.maxFailures,
561
- maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens } });
571
+ maxNoProgressMinutes: command.maxNoProgressMinutes, maxTokens: command.maxTokens, continuationPolicy: command.continuationPolicy, hardMaxMinutes: command.hardMaxMinutes, hardMaxContinuations: command.hardMaxContinuations, providerTokenCeiling: command.providerTokenCeiling } });
562
572
  await refreshSessions(true);
563
573
  } else if (action === "session_answer") {
564
574
  await (dependencies.answerSession || answerSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
@@ -668,6 +678,7 @@ if (isDesktopHostEntry()) {
668
678
  const stop = () => { void host?.close().finally(() => process.exit(0)); };
669
679
  process.once("SIGTERM", stop); process.once("SIGINT", stop);
670
680
  startDesktopHost().then((value) => { host = value; }).catch((error) => {
681
+ createRuntimeDiagnostics(process.env.SIMY_HOME?.trim() || join(homedir(), ".simy"), { name: "desktop-host" }).write(`startup failed: ${error?.stack || error}`);
671
682
  const conflict = error?.code === PROCESS_CONFLICT_CODE;
672
683
  process.stdout.write(`${JSON.stringify({
673
684
  type: "error",
@@ -1,4 +1,4 @@
1
- import { createHash } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
 
@@ -8,16 +8,33 @@ export function sqmSettingsPath(root, origin, session) {
8
8
  return join(root, 'sqm-settings', `${key}.json`);
9
9
  }
10
10
  export async function readSqmEnabled(root, origin, session) {
11
+ return (await readSqmSettings(root, origin, session)).enabled;
12
+ }
13
+ export async function readSqmSettings(root, origin, session) {
11
14
  const path = sqmSettingsPath(root, origin, session);
12
- if (!path) return false;
13
- try { return JSON.parse(await readFile(path, 'utf8')).enabled === true; }
14
- catch (error) { if (error.code === 'ENOENT' || error instanceof SyntaxError) return false; throw error; }
15
+ if (!path) return { enabled: false, enable_engine_fallback: false, allowed_fallback_providers: [] };
16
+ try {
17
+ const settings = JSON.parse(await readFile(path, 'utf8'));
18
+ return { enabled: settings.enabled === true, enable_engine_fallback: settings.enable_engine_fallback === true,
19
+ allowed_fallback_providers: Array.isArray(settings.allowed_fallback_providers)
20
+ ? [...new Set(settings.allowed_fallback_providers.filter(provider => ['codex', 'claude'].includes(provider)))] : [] };
21
+ } catch (error) { if (error.code === 'ENOENT' || error instanceof SyntaxError) return { enabled: false, enable_engine_fallback: false, allowed_fallback_providers: [] }; throw error; }
15
22
  }
16
23
  export async function writeSqmEnabled(root, origin, session, enabled) {
24
+ return writeSqmSettings(root, origin, session, { enabled });
25
+ }
26
+ export async function writeSqmEngineFallback(root, origin, session, { enabled, allowedProviders } = {}) {
27
+ if (typeof enabled !== 'boolean' || !Array.isArray(allowedProviders) || allowedProviders.some(provider => !['codex', 'claude'].includes(provider))) {
28
+ throw new Error('Select allowed local SQM fallback providers explicitly.');
29
+ }
30
+ return writeSqmSettings(root, origin, session, { enable_engine_fallback: enabled, allowed_fallback_providers: [...new Set(allowedProviders)] });
31
+ }
32
+ async function writeSqmSettings(root, origin, session, update) {
17
33
  const path = sqmSettingsPath(root, origin, session);
18
- if (!path || typeof enabled !== 'boolean') throw new Error('Sign in to an organization before enabling SQM.');
34
+ if (!path || ('enabled' in update && typeof update.enabled !== 'boolean')) throw new Error('Sign in to an organization before enabling SQM.');
35
+ const current = await readSqmSettings(root, origin, session);
19
36
  await mkdir(join(root, 'sqm-settings'), { recursive: true, mode: 0o700 });
20
- const temporary = `${path}.${process.pid}.tmp`;
21
- await writeFile(temporary, JSON.stringify({ enabled }), { mode: 0o600 });
37
+ const temporary = `${path}.${randomUUID()}.tmp`;
38
+ await writeFile(temporary, JSON.stringify({ ...current, ...update }), { mode: 0o600 });
22
39
  await rename(temporary, path);
23
40
  }