@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.
- package/README.md +101 -3
- package/package.json +2 -2
- package/src/agent.js +205 -12
- package/src/backend-executable.js +45 -2
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/README.md +22 -0
- package/src/codex-plugins/simy/hooks/continuation-policy.mjs +27 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +180 -31
- package/src/codex-plugins/simy/hooks/outcome-progress.mjs +129 -0
- package/src/coding-run-recovery.js +88 -0
- package/src/coding-run-store.js +2 -2
- package/src/continuation-policy.js +2 -0
- package/src/delivery-hooks/command.js +11 -5
- package/src/delivery-hooks/desktop.js +12 -3
- package/src/delivery-hooks/supervisor.js +17 -12
- package/src/desktop-host.js +19 -8
- package/src/desktop-sqm-settings.js +24 -7
- package/src/device-signing-identity.js +65 -0
- package/src/diagnostic-log.js +68 -0
- package/src/doctor.js +55 -0
- package/src/index.js +18 -1
- package/src/local-task.js +68 -3
- package/src/orchestrator/budget.js +55 -4
- package/src/orchestrator/charter-validation.js +33 -0
- package/src/orchestrator/completion-contract.js +1 -1
- package/src/orchestrator/contract.js +2 -0
- package/src/orchestrator/delivery/references/state-and-receipts.md +21 -3
- package/src/orchestrator/evidence.js +20 -19
- package/src/orchestrator/independent-audit.js +9 -0
- package/src/orchestrator/instruction.js +6 -1
- package/src/orchestrator/loop.js +66 -41
- package/src/orchestrator/outcome-monitor.js +95 -44
- package/src/orchestrator/outcome-progress.js +2 -0
- package/src/orchestrator/recovery.js +3 -0
- package/src/purpose-local-proof.js +380 -0
- package/src/runner.js +227 -19
- package/src/runtime-diagnostics.js +77 -0
- package/src/session-supervision/codex-app-server.js +3 -2
- package/src/session-supervision/index.js +16 -4
- package/src/sqm/bundle-store.js +14 -7
- package/src/sqm/claude-plugin.js +2 -0
- package/src/sqm/command.js +42 -4
- package/src/sqm/diagnostics.js +23 -0
- package/src/sqm/evidence-client.js +24 -7
- package/src/sqm/history-store.js +21 -5
- package/src/sqm/index.js +116 -17
- package/src/sqm/proof.js +4 -23
- package/src/sqm/report.js +9 -2
- package/src/sqm/request-recovery.js +82 -0
- package/src/sqm/review-worker.js +97 -15
- package/src/sqm/session.js +33 -3
- 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
|
+
}
|
package/src/coding-run-store.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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();
|
|
@@ -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']
|
|
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'],
|
|
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)
|
|
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
|
-
|
|
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
|
|
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: {
|
|
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
|
|
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
|
-
|
|
197
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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) {
|
package/src/desktop-host.js
CHANGED
|
@@ -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 (
|
|
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", "
|
|
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 {
|
|
14
|
-
|
|
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}.${
|
|
21
|
-
await writeFile(temporary, JSON.stringify({
|
|
37
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
38
|
+
await writeFile(temporary, JSON.stringify({ ...current, ...update }), { mode: 0o600 });
|
|
22
39
|
await rename(temporary, path);
|
|
23
40
|
}
|