@awak-app/simy-cli 0.7.16 → 0.7.17
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 +180 -26
- package/package.json +3 -2
- package/src/agent.js +128 -7
- package/src/bounded-local-task-subtask-pool.js +32 -6
- package/src/bounded-local-task-subtasks-contract.js +1 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/.codex-plugin/plugin.json +2 -2
- package/src/codex-plugins/simy/hooks/README.md +8 -3
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +261 -67
- package/src/codex-plugins/simy/hooks/hooks.json +1 -1
- package/src/codex-plugins/simy/hooks/loop-kernel/conformance.json +136 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/index.mjs +42 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/outcome-review.mjs +31 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/pin.json +6 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/policy.json +141 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/task-pool.mjs +34 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +132 -0
- package/src/codex-plugins/simy/hooks/loop-kernel.mjs +6 -0
- package/src/codex-plugins/simy/hooks/outcome-progress.mjs +15 -3
- package/src/codex-plugins/simy/hooks/purpose-completion-contract.mjs +10 -0
- package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +8 -6
- package/src/codex-plugins/simy/hooks/result-cycle.mjs +32 -6
- package/src/codex-plugins/simy/hooks/work-frontier.mjs +2 -0
- package/src/codex-plugins/simy/skills/diagnose/SKILL.md +10 -4
- package/src/coding-run-recovery.js +4 -2
- package/src/delivery-hooks/claude-workflow.md +10 -7
- package/src/delivery-hooks/command.js +45 -10
- package/src/delivery-hooks/consult.js +60 -0
- package/src/delivery-hooks/diagnosis.js +2 -2
- package/src/delivery-hooks/legacy-hook-routing.js +46 -8
- package/src/delivery-hooks/local-runtime.js +50 -1
- package/src/delivery-hooks/local-supervisor.js +1 -1
- package/src/delivery-hooks/repair-continuation.js +80 -0
- package/src/delivery-hooks/repair.js +164 -0
- package/src/delivery-hooks/supervisor-service.js +3 -1
- package/src/delivery-hooks/supervisor.js +88 -12
- package/src/desktop-access.js +2 -1
- package/src/desktop-host.js +35 -2
- package/src/durable-local-task-worker.js +372 -58
- package/src/execution-capability-contract.js +15 -1
- package/src/index.js +3 -3
- package/src/local-photos-read.swift +33 -0
- package/src/local-photos.js +47 -0
- package/src/local-task-attachment-store.js +31 -0
- package/src/local-task-destination.js +174 -0
- package/src/local-task.js +119 -7
- package/src/native-delegation-contract.js +68 -0
- package/src/native-provider-identity.js +60 -0
- package/src/orchestrator/budget.js +22 -11
- package/src/orchestrator/independent-audit.js +9 -18
- package/src/orchestrator/loop.js +27 -59
- package/src/orchestrator/recovery.js +32 -2
- package/src/provider-delegation.js +336 -0
- package/src/runner.js +2 -1
- package/src/session-supervision/artifact-observer.js +138 -0
- package/src/session-supervision/codex-app-server.js +19 -5
- package/src/session-supervision/completion-artifacts.js +44 -0
- package/src/session-supervision/consistency-auditor.js +8 -11
- package/src/session-supervision/continuation-analysis.js +46 -28
- package/src/session-supervision/final-response-recovery.js +5 -3
- package/src/session-supervision/index.js +22 -5
- package/src/session-supervision/purpose-recovery.js +12 -3
- package/src/session-supervision/purpose-verification.js +9 -57
- package/src/session-supervision/recovery-frontier.js +55 -0
- package/src/session-supervision/stop-notifications.js +8 -8
- package/src/sqm/agent-skill.js +1 -1
- package/src/sqm/checkers.js +12 -6
- package/src/sqm/claude-plugin-update.js +22 -8
- package/src/sqm/claude-plugin.js +4 -0
- package/src/sqm/local-tests.js +2 -2
- package/src/sqm/plugin-cache.js +67 -0
- package/src/sqm/proof.js +25 -0
- package/src/sqm/report.js +1 -0
- package/src/sqm/workflow-onboarding.js +3 -39
- package/src/sqm/workflow-update.js +17 -6
- package/src/team-status-fence-store.js +32 -0
- package/src/team-threads.js +457 -0
package/src/orchestrator/loop.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { safetyCeilingRecoveryMessage } from "./recovery.js";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { completedWorkerResult } from "../coding-run-recovery.js";
|
|
3
4
|
import { redactExecutionText } from "./execution-io.js";
|
|
@@ -203,16 +204,6 @@ async function runCodingLoopOwned({
|
|
|
203
204
|
attempt.budget = refreshBudgetState(snapshot);
|
|
204
205
|
recordBudgetWarnings(snapshot, attempt);
|
|
205
206
|
|
|
206
|
-
if (snapshot.budget.token_exhausted) {
|
|
207
|
-
await stopForBudget({
|
|
208
|
-
snapshot,
|
|
209
|
-
attempt,
|
|
210
|
-
reason: "token_budget_exhausted",
|
|
211
|
-
onUpdate,
|
|
212
|
-
});
|
|
213
|
-
return snapshot;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
207
|
if (!attempt.audit.passed) {
|
|
217
208
|
snapshot.final_audit = attempt.audit;
|
|
218
209
|
delete snapshot.metadata?.completed_worker_result;
|
|
@@ -351,10 +342,8 @@ async function reviewAttempt({ snapshot, attempt, executeIndependentAudit, shoul
|
|
|
351
342
|
attempt.token_usage = mergeTokenUsage(attempt.token_usage, auditExecution.tokenUsage);
|
|
352
343
|
attempt.budget = refreshBudgetState(snapshot);
|
|
353
344
|
recordBudgetWarnings(snapshot, attempt);
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
return true;
|
|
357
|
-
}
|
|
345
|
+
// Finish local evidence processing even when this completed review crossed
|
|
346
|
+
// its ceiling. Admission checks fence the next provider call.
|
|
358
347
|
|
|
359
348
|
attempt.independent_audit.result_parse = normalizeResultParseDiagnostic(auditExecution.result_parse);
|
|
360
349
|
attempt.independent_audit.reviewed_checks = reviewedChecks;
|
|
@@ -584,15 +573,6 @@ async function handleFailedAudit({
|
|
|
584
573
|
onUpdate,
|
|
585
574
|
}) {
|
|
586
575
|
snapshot.charter.thread_state.failure_count += 1;
|
|
587
|
-
const budget = refreshBudgetState(snapshot);
|
|
588
|
-
if (budget.token_exhausted) {
|
|
589
|
-
return stopForBudget({
|
|
590
|
-
snapshot,
|
|
591
|
-
attempt,
|
|
592
|
-
reason: "token_budget_exhausted",
|
|
593
|
-
onUpdate,
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
576
|
const diagnosticCircuit = evaluateRetryCircuit({ attempt, previousAttempt: snapshot.attempts.at(-2) ?? null, findings });
|
|
597
577
|
const technicalStall = attempt.outcome_progress?.stalled || attempt.outcome_progress?.assessment_unavailable || diagnosticCircuit.open && !attempt.outcome_progress?.has_progress;
|
|
598
578
|
if (technicalStall && !requiresHuman) {
|
|
@@ -643,7 +623,9 @@ async function handleFailedAudit({
|
|
|
643
623
|
previousAttempt: snapshot.attempts.at(-2) ?? null,
|
|
644
624
|
findings,
|
|
645
625
|
});
|
|
646
|
-
if (retryCircuit.open && !attempt.outcome_progress?.has_progress
|
|
626
|
+
if (retryCircuit.open && !attempt.outcome_progress?.has_progress &&
|
|
627
|
+
!(attempt.audit?.passed && attempt.outcome_progress?.status === "insufficient_evidence" &&
|
|
628
|
+
!attempt.outcome_progress?.failed_ids?.length)) {
|
|
647
629
|
const avoidedAttempts = Math.max(snapshot.charter.max_attempts - attemptNumber, 0);
|
|
648
630
|
const circuitState = {
|
|
649
631
|
state: "open",
|
|
@@ -683,34 +665,21 @@ async function handleFailedAudit({
|
|
|
683
665
|
});
|
|
684
666
|
return true;
|
|
685
667
|
}
|
|
686
|
-
if (attempt.outcome_progress?.assessment_unavailable) {
|
|
687
|
-
const
|
|
668
|
+
if (attempt.outcome_progress?.assessment_unavailable && !requiresHuman) {
|
|
669
|
+
for (const finding of findings) {
|
|
670
|
+
if (finding.code === 'INDEPENDENT_AUDIT_UNAVAILABLE') {
|
|
671
|
+
finding.auto_fix_hint = 'Preserve the implementation and perform a direct check of the requested behavior using another available method. Continue concrete remaining work; do not generate evidence packages or replay completed external writes.';
|
|
672
|
+
}
|
|
673
|
+
}
|
|
688
674
|
snapshot.final_audit = {
|
|
689
|
-
...(snapshot.final_audit || {}),
|
|
690
|
-
|
|
691
|
-
summary: "Outcome progress could not be assessed across consecutive attempts.",
|
|
692
|
-
findings: [...(snapshot.final_audit?.findings || []), {
|
|
693
|
-
passed: false,
|
|
694
|
-
code: "OUTCOME_ASSESSMENT_UNAVAILABLE",
|
|
695
|
-
severity: "blocker",
|
|
696
|
-
target: "outcome_monitor",
|
|
697
|
-
explanation: "Two consecutive attempts could not establish an independent outcome assessment. Resolve the recorded verification failures and continue this run; this does not prove the work has stalled.",
|
|
698
|
-
repairability: "auto",
|
|
699
|
-
blocker_kind: "machine",
|
|
700
|
-
}],
|
|
701
|
-
auto_repairable: false,
|
|
702
|
-
requires_human: false,
|
|
675
|
+
...(snapshot.final_audit || {}), passed: false,
|
|
676
|
+
auto_repairable: true, requires_human: false,
|
|
703
677
|
};
|
|
704
|
-
appendEvent(snapshot, "
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
unavailable_reason: progress.unavailable_reason,
|
|
710
|
-
unproven_ids: progress.unproven_ids,
|
|
711
|
-
});
|
|
712
|
-
await publish(snapshot, "blocked", onUpdate);
|
|
713
|
-
return true;
|
|
678
|
+
appendEvent(snapshot, "re_instructing",
|
|
679
|
+
"Independent assessment is unavailable. Continue authorized work and check the actual outcome through another available method; do not recreate audit records.",
|
|
680
|
+
{ code: "outcome_assessment_fallback", attempt_number: attemptNumber });
|
|
681
|
+
await publish(snapshot, "re_instructing", onUpdate);
|
|
682
|
+
return false;
|
|
714
683
|
}
|
|
715
684
|
if (requiresHuman) {
|
|
716
685
|
appendEvent(snapshot, "waiting_human", "PR audit requires human input.", {
|
|
@@ -731,7 +700,6 @@ async function handleFailedAudit({
|
|
|
731
700
|
}
|
|
732
701
|
|
|
733
702
|
function recordBudgetWarnings(snapshot, attempt) {
|
|
734
|
-
if (snapshot.charter.continuation_policy !== "progress") return;
|
|
735
703
|
snapshot.budget_notifications ??= {};
|
|
736
704
|
for (const kind of ["token", "retry"]) {
|
|
737
705
|
if (!snapshot.budget[`${kind}_warning`] || snapshot.budget_notifications[kind]) continue;
|
|
@@ -760,9 +728,9 @@ async function stopForBudget({ snapshot, attempt, reason, onUpdate }) {
|
|
|
760
728
|
};
|
|
761
729
|
if (attempt) attempt.budget = budget;
|
|
762
730
|
const message =
|
|
763
|
-
reason === "
|
|
764
|
-
? "Agentic loop reached its Provider token
|
|
765
|
-
: "Agentic loop
|
|
731
|
+
reason === "provider_token_ceiling_reached"
|
|
732
|
+
? "Agentic loop reached its Provider token safety ceiling before the next call."
|
|
733
|
+
: "Agentic loop reached its attempt safety ceiling before the next call.";
|
|
766
734
|
appendEvent(snapshot, "blocked", message, {
|
|
767
735
|
code: reason,
|
|
768
736
|
attempt_number: attempt?.attempt_number ?? null,
|
|
@@ -774,23 +742,23 @@ async function stopForBudget({ snapshot, attempt, reason, onUpdate }) {
|
|
|
774
742
|
}
|
|
775
743
|
|
|
776
744
|
function budgetFinding(reason, budget) {
|
|
777
|
-
if (reason === "
|
|
745
|
+
if (reason === "provider_token_ceiling_reached") {
|
|
778
746
|
return {
|
|
779
747
|
passed: false,
|
|
780
|
-
code: "
|
|
748
|
+
code: "PROVIDER_TOKEN_CEILING_REACHED",
|
|
781
749
|
severity: "blocker",
|
|
782
750
|
target: "provider_token_budget",
|
|
783
|
-
explanation:
|
|
751
|
+
explanation: safetyCeilingRecoveryMessage(reason, budget),
|
|
784
752
|
repairability: "manual",
|
|
785
753
|
auto_fix_hint: null,
|
|
786
754
|
};
|
|
787
755
|
}
|
|
788
756
|
return {
|
|
789
757
|
passed: false,
|
|
790
|
-
code: "
|
|
758
|
+
code: "ATTEMPT_CEILING_REACHED",
|
|
791
759
|
severity: "blocker",
|
|
792
760
|
target: "retry_budget",
|
|
793
|
-
explanation:
|
|
761
|
+
explanation: safetyCeilingRecoveryMessage(reason, budget),
|
|
794
762
|
repairability: "manual",
|
|
795
763
|
auto_fix_hint: null,
|
|
796
764
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { suggestedProviderTokenBudget } from "./budget.js";
|
|
1
|
+
import { suggestedProviderTokenBudget, MAX_PROVIDER_TOKEN_BUDGET } from "./budget.js";
|
|
2
2
|
|
|
3
3
|
const RECOVERABLE_STATES = new Set(["waiting_human", "blocked", "failed"]);
|
|
4
4
|
|
|
@@ -13,7 +13,7 @@ export function recoveryContractForEvent({ snapshot, state, message, detail = {}
|
|
|
13
13
|
schema_version: 1,
|
|
14
14
|
state,
|
|
15
15
|
reason_code: reasonCode,
|
|
16
|
-
user_message: recoveryMessage(reasonCode),
|
|
16
|
+
user_message: isSafetyCeiling(reasonCode) ? safetyCeilingRecoveryMessage(reasonCode, detail.budget) : recoveryMessage(reasonCode),
|
|
17
17
|
primary_action_id: actions[0].id,
|
|
18
18
|
actions,
|
|
19
19
|
technical_details: {
|
|
@@ -22,6 +22,9 @@ export function recoveryContractForEvent({ snapshot, state, message, detail = {}
|
|
|
22
22
|
max_attempts: positiveInteger(snapshot?.charter?.max_attempts),
|
|
23
23
|
provider_token_budget: positiveInteger(snapshot?.charter?.token_budget),
|
|
24
24
|
provider_tokens_used: nonNegativeInteger(detail?.budget?.tokens_used),
|
|
25
|
+
provider_token_ceiling: positiveInteger(detail?.budget?.hard_token_ceiling),
|
|
26
|
+
attempt_ceiling: positiveInteger(detail?.budget?.hard_attempt_ceiling),
|
|
27
|
+
continuation_policy: clean(detail?.budget?.continuation_policy) || null,
|
|
25
28
|
token_budget: positiveInteger(snapshot?.charter?.token_budget),
|
|
26
29
|
retry_budget: nonNegativeInteger(snapshot?.charter?.retry_budget),
|
|
27
30
|
tokens_used: nonNegativeInteger(detail?.budget?.tokens_used),
|
|
@@ -115,6 +118,7 @@ function recoveryReasonCode(state, message, detail) {
|
|
|
115
118
|
return "retry_strategy_unchanged";
|
|
116
119
|
}
|
|
117
120
|
if (code === "outcome_assessment_unavailable") return "outcome_assessment_unavailable";
|
|
121
|
+
if (["provider_token_ceiling_reached", "attempt_ceiling_reached"].includes(code)) return code;
|
|
118
122
|
if (code === "token_budget_exhausted") return "token_budget_exhausted";
|
|
119
123
|
if (code === "retry_budget_exhausted") return "retry_budget_exhausted";
|
|
120
124
|
const findings = strings(detail.finding_codes);
|
|
@@ -138,6 +142,25 @@ function recoveryReasonCode(state, message, detail) {
|
|
|
138
142
|
return "local_orchestration_failed";
|
|
139
143
|
}
|
|
140
144
|
|
|
145
|
+
function isSafetyCeiling(code) {
|
|
146
|
+
return ["provider_token_ceiling_reached", "attempt_ceiling_reached"].includes(code);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function safetyCeilingRecoveryMessage(code, budget = {}) {
|
|
150
|
+
const token = code === "provider_token_ceiling_reached";
|
|
151
|
+
const limit = token ? budget?.hard_token_ceiling : budget?.hard_attempt_ceiling;
|
|
152
|
+
const used = token ? budget?.tokens_used : (budget?.retries_used ?? 0) + 1;
|
|
153
|
+
const maximum = token ? MAX_PROVIDER_TOKEN_BUDGET : 100;
|
|
154
|
+
const base = `SIMY retained this same run's work and usage. The ${token ? "Provider token" : "executor attempt"} safety ceiling prevents another call. Increasing a notification threshold alone does not permit another call. `;
|
|
155
|
+
if (budget?.continuation_policy !== "progress") {
|
|
156
|
+
return base + "The bounded policy does not permit a same-run safety extension. Keep this run and review its remaining acceptance conditions with the operator; guidance alone cannot resume provider work.";
|
|
157
|
+
}
|
|
158
|
+
if (!Number.isFinite(limit) || !Number.isFinite(used) || limit >= maximum || used >= maximum) {
|
|
159
|
+
return base + `The validated maximum is ${maximum.toLocaleString("en-US")}; no larger same-run ceiling is available. Preserve evidence and review remaining work with the operator; guidance alone cannot resume provider work.`;
|
|
160
|
+
}
|
|
161
|
+
return base + `Review remaining acceptance conditions and explicitly increase the relevant ceiling above both recorded usage and its current limit, up to ${maximum.toLocaleString("en-US")}. Then continue this same run. Extension never resets usage or resumes work, and other approvals remain required.`;
|
|
162
|
+
}
|
|
163
|
+
|
|
141
164
|
function recoveryMessage(reasonCode) {
|
|
142
165
|
const messages = {
|
|
143
166
|
repository_authorization_required:
|
|
@@ -198,6 +221,13 @@ function recoveryActions(reasonCode, budget = null) {
|
|
|
198
221
|
newRunAction(),
|
|
199
222
|
];
|
|
200
223
|
}
|
|
224
|
+
if (isSafetyCeiling(reasonCode)) {
|
|
225
|
+
return [guidanceAction(
|
|
226
|
+
"review_safety_ceiling",
|
|
227
|
+
"Review this run's safety ceiling",
|
|
228
|
+
safetyCeilingRecoveryMessage(reasonCode, budget),
|
|
229
|
+
)];
|
|
230
|
+
}
|
|
201
231
|
if (reasonCode === "token_budget_exhausted") {
|
|
202
232
|
const suggestedBudget = suggestedProviderTokenBudget(budget);
|
|
203
233
|
if (!suggestedBudget || budget?.retry_exhausted === true) return [newRunAction()];
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
import { matchesPairedNativeAccount } from './native-provider-identity.js';
|
|
4
|
+
import { readRecord } from './codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { readClaudeSession } from './session-supervision/claude-sessions.js';
|
|
8
|
+
import { EventEmitter } from 'node:events';
|
|
9
|
+
import { PassThrough } from 'node:stream';
|
|
10
|
+
import { realpath } from 'node:fs/promises';
|
|
11
|
+
import { PROVIDER_LINKS } from './provider-links.js';
|
|
12
|
+
import { normalizeProviderModelSelection } from './provider-model-selection.js';
|
|
13
|
+
import { resolveDesktopExecutorCommand } from './desktop-executor.js';
|
|
14
|
+
import { openCodexAppServer, readCodexSession } from './session-supervision/codex-app-server.js';
|
|
15
|
+
|
|
16
|
+
const NATIVE_SESSION_MODES = Object.freeze({ codex: 'existing', claude: 'fork' });
|
|
17
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
18
|
+
const fail = code => Object.assign(new Error(`Provider delegation unavailable: ${code}.`), { code });
|
|
19
|
+
const identifier = value => typeof value === 'string' && value.trim() && value.length <= 256;
|
|
20
|
+
|
|
21
|
+
// Routing identifiers and provider-reported account email, never credentials. API models stay in Crew's
|
|
22
|
+
// registry/gateway and SIMY conversation; this native adapter does not invent
|
|
23
|
+
// local executables or sessions for them.
|
|
24
|
+
export function normalizeProviderDelegation(value, { provider, model } = {}) {
|
|
25
|
+
if (value == null) return null;
|
|
26
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
27
|
+
value.schema_version !== 'provider_delegation.v1' || value.provider !== provider ||
|
|
28
|
+
value.model !== model || !identifier(value.model) || !identifier(value.origin_task_id) ||
|
|
29
|
+
!identifier(value.origin_conversation_id) || !identifier(value.request_id) ||
|
|
30
|
+
!identifier(value.account_id) || !UUID.test(value.session_id || '') ||
|
|
31
|
+
!['existing', 'fork'].includes(value.session_mode)) throw fail('invalid_provider_delegation');
|
|
32
|
+
normalizeProviderModelSelection({ model: value.model });
|
|
33
|
+
if (!PROVIDER_LINKS.includes(provider)) throw fail('crew_gateway_required');
|
|
34
|
+
if (!Object.hasOwn(NATIVE_SESSION_MODES, provider)) throw fail('native_adapter_unavailable');
|
|
35
|
+
if (provider === 'claude' && value.session_mode !== 'fork') throw fail('claude_exact_session_writer_unavailable');
|
|
36
|
+
if (provider === 'claude' && value.allow_new_task !== true) throw fail('new_task_permission_required');
|
|
37
|
+
if (provider === 'codex' && value.session_mode !== 'existing') throw fail('unsupported_session_mode');
|
|
38
|
+
return Object.freeze(Object.fromEntries(['schema_version', 'provider', 'model', 'account_id', 'session_id', 'session_mode',
|
|
39
|
+
'origin_task_id', 'origin_conversation_id', 'request_id', 'allow_new_task', 'allow_new_task_request_id'].filter(key => value[key] !== undefined).map(key => [key, value[key]])));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Accept P01's exact target shape at the existing Local Task boundary. A
|
|
43
|
+
// target alone carries no task/session execution authority and cannot silently
|
|
44
|
+
// fall back to a fresh native invocation.
|
|
45
|
+
export function providerDelegationFromRequest(body, requestId) {
|
|
46
|
+
const target = body.delegation_target;
|
|
47
|
+
if (!body.delegation) {
|
|
48
|
+
if (target) throw fail('delegation_context_required');
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const value = { ...body.delegation };
|
|
52
|
+
if (target) {
|
|
53
|
+
if (target.transport !== 'native') throw fail('crew_gateway_required');
|
|
54
|
+
for (const field of ['provider', 'model', 'account_id', 'session_id']) {
|
|
55
|
+
if (value[field] !== undefined && value[field] !== target[field]) throw fail('delegation_target_mismatch');
|
|
56
|
+
value[field] = target[field];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (value.request_id !== requestId) throw fail('delegation_request_mismatch');
|
|
60
|
+
const normalized = normalizeProviderDelegation(value, { provider: body.backend, model: body.model });
|
|
61
|
+
if (body.native_identity && normalized.session_mode === 'fork' && normalized.allow_new_task_request_id !== requestId)
|
|
62
|
+
throw fail('native_new_task_permission_required');
|
|
63
|
+
return normalized;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function providerDelegationTarget(value) {
|
|
67
|
+
return value ? Object.fromEntries(['provider', 'model', 'account_id', 'session_id'].map(key => [key, value[key]]).concat([['transport', 'native']])) : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function providerDelegationCoverage(capabilities = []) {
|
|
71
|
+
return capabilities.map(item => {
|
|
72
|
+
const native = item.transport !== 'api' && PROVIDER_LINKS.includes(item.provider);
|
|
73
|
+
const mode = Object.hasOwn(NATIVE_SESSION_MODES, item.provider) ? NATIVE_SESSION_MODES[item.provider] : null;
|
|
74
|
+
return { provider: item.provider, readiness: item.readiness,
|
|
75
|
+
execution_target: native ? 'local' : 'crew_gateway',
|
|
76
|
+
session_mode: native ? mode : 'simy_context',
|
|
77
|
+
...(native && mode ? { supported_session_modes: [mode],
|
|
78
|
+
continuation_mode: mode === 'existing' ? 'same_session' : 'fresh_explicit_fork',
|
|
79
|
+
cancel_scope: mode === 'existing' ? 'owned_turn' : 'owned_fork_process',
|
|
80
|
+
disconnect_recovery: 'hold_unknown_without_replay' } : {}),
|
|
81
|
+
limitation: !native ? 'requires_crew_gateway' : !mode ? 'native_adapter_unavailable' :
|
|
82
|
+
item.provider === 'claude' ? 'exact_session_writer_unavailable' : null };
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function resolveDelegatedProviderCommand(input, { openClient = openCodexAppServer,
|
|
87
|
+
resolveCommand = resolveDesktopExecutorCommand, readClaude = readClaudeSession,
|
|
88
|
+
supervisionRoot, pairedDeviceId, providerLinkEpoch,
|
|
89
|
+
onWorkspaceAuthorized = null,
|
|
90
|
+
readSupervision = target => readRecord(supervisionRoot, target.provider, target.session_id),
|
|
91
|
+
inspectClaudeAccount = async bin => JSON.parse((await promisify(execFile)(bin, ['auth', 'status', '--json'], { timeout: 5000, maxBuffer: 65536 })).stdout) } = {}) {
|
|
92
|
+
const selection = normalizeProviderDelegation(input.delegation, { provider: input.backend, model: input.model });
|
|
93
|
+
const accountMatches = email => input.nativeIdentity ? matchesPairedNativeAccount(input.nativeIdentity,
|
|
94
|
+
{ deviceId: pairedDeviceId, provider: selection.provider, email, connectionEpoch: providerLinkEpoch ?? null }) && input.nativeIdentity.account_id === selection.account_id
|
|
95
|
+
: email === selection.account_id;
|
|
96
|
+
if (!selection) return resolveCommand(input);
|
|
97
|
+
let claudeCommand;
|
|
98
|
+
if (selection.provider === 'claude') {
|
|
99
|
+
if (input.nativeIdentity && selection.allow_new_task_request_id !== selection.request_id) throw fail('new_task_permission_required');
|
|
100
|
+
claudeCommand = await resolveCommand(input);
|
|
101
|
+
if (claudeCommand.verification === 'explicit_command_override') throw fail('provider_selection_unverifiable');
|
|
102
|
+
// Keep ordinary provider approval. Never use the desktop adapter's legacy
|
|
103
|
+
// skip-permissions flag for a delegation.
|
|
104
|
+
claudeCommand.args = ['-p', input.instruction, '--output-format', 'stream-json', '--verbose',
|
|
105
|
+
'--model', selection.model, '--resume', selection.session_id, '--fork-session',
|
|
106
|
+
'--permission-mode', input.permissionMode === 'read_only' ? 'plan' : 'default'];
|
|
107
|
+
}
|
|
108
|
+
return { bin: 'codex-app-server', args: [], env: {}, backend: input.backend,
|
|
109
|
+
spawn() {
|
|
110
|
+
const child = new EventEmitter();
|
|
111
|
+
child.stdout = new PassThrough(); child.stderr = new PassThrough();
|
|
112
|
+
const controller = new AbortController();
|
|
113
|
+
let ownedChild;
|
|
114
|
+
child.kill = signal => {
|
|
115
|
+
const alreadyAborted = controller.signal.aborted;
|
|
116
|
+
controller.abort(Object.assign(fail('provider_cancelled'), { nativeSignal: signal || 'SIGINT' }));
|
|
117
|
+
// Abort invokes the owned Claude listener exactly once. Only a later
|
|
118
|
+
// escalation forwards another signal; Codex owns an RPC turn instead.
|
|
119
|
+
if (alreadyAborted) ownedChild?.kill(signal);
|
|
120
|
+
return true;
|
|
121
|
+
};
|
|
122
|
+
// Match the existing process interface; stopLocalTask only signals this
|
|
123
|
+
// owned turn through kill(), never an original session process.
|
|
124
|
+
setImmediate(async () => {
|
|
125
|
+
try {
|
|
126
|
+
const emit = event => child.stdout.write(`${JSON.stringify(event)}\n`);
|
|
127
|
+
if (selection.provider === 'claude') await runClaudeDelegation({ ...input, selection,
|
|
128
|
+
command: claudeCommand, readClaude, inspectClaudeAccount, accountMatches, signal: controller.signal, emit, onChild: value => { ownedChild = value; } });
|
|
129
|
+
else await runCodexDelegation({ ...input, selection, signal: controller.signal, openClient, emit, readSupervision, accountMatches, onWorkspaceAuthorized });
|
|
130
|
+
child.stdout.end(); child.stderr.end(); child.emit('close', 0);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const message = fail(error.code || 'provider_result_unknown').message;
|
|
133
|
+
child.stdout.write(`${JSON.stringify({ type: 'simy.delegation.state', state: error.delegationState || (selection.provider === 'claude' && !ownedChild ? 'failed' : 'result_unknown') })}\n`);
|
|
134
|
+
child.stdout.end(`${JSON.stringify(selection.provider === 'claude' ?
|
|
135
|
+
{ type: 'result', is_error: true, result: message } : { type: 'turn.failed', error: { message } })}\n`);
|
|
136
|
+
child.stderr.end(); child.emit('close', 1);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
return child;
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function runCodexDelegation({ selection, instruction, getInstruction, repositoryPath, permissionMode, signal, openClient, emit, beforeDispatch, readSupervision, accountMatches, nativeIdentity, workspaceMode, onWorkspaceAuthorized, dispatchNative = send => send() }) {
|
|
145
|
+
if (signal.aborted) throw Object.assign(fail('provider_cancelled'), { delegationState: 'stopped' });
|
|
146
|
+
const preserveSupervision = async () => {
|
|
147
|
+
if ((await readSupervision(selection))?.run) throw fail('existing_supervised_task');
|
|
148
|
+
};
|
|
149
|
+
let client;
|
|
150
|
+
try { await preserveSupervision(); client = await openClient({}); }
|
|
151
|
+
catch (error) { error.delegationState = 'failed'; throw error; }
|
|
152
|
+
let turnId, starting, primaryError, completed = false, unsubscribe = () => {};
|
|
153
|
+
try {
|
|
154
|
+
signal.throwIfAborted();
|
|
155
|
+
const account = await client.request('account/read', { refreshToken: false });
|
|
156
|
+
if (!accountMatches(account.account?.email)) throw fail('provider_account_mismatch');
|
|
157
|
+
const metadata = await readCodexSession(client, selection.session_id, { signal });
|
|
158
|
+
if (metadata.isRunning === true) throw fail('provider_session_busy');
|
|
159
|
+
const configuration = await client.request('thread/resume', { threadId: selection.session_id, excludeTurns: true });
|
|
160
|
+
if (configuration.thread?.id !== selection.session_id || configuration.model !== selection.model ||
|
|
161
|
+
!configuration.cwd || await realpath(configuration.cwd) !== await realpath(nativeIdentity ? metadata.cwd : repositoryPath) ||
|
|
162
|
+
workspaceMode === 'repository' && await realpath(configuration.cwd) !== await realpath(repositoryPath)) throw fail('provider_session_mismatch');
|
|
163
|
+
// Do not mutate provider permissions. An incompatible requested scope must
|
|
164
|
+
// be resolved in the original provider, never silently widened here.
|
|
165
|
+
if (permissionMode === 'read_only' ? configuration.sandbox?.type !== 'readOnly' :
|
|
166
|
+
configuration.sandbox?.type !== 'workspaceWrite' || configuration.sandbox.networkAccess !== false) throw fail('provider_permission_mismatch');
|
|
167
|
+
if (configuration.sandbox?.type === 'workspaceWrite') {
|
|
168
|
+
const roots = configuration.sandbox.writableRoots ?? [];
|
|
169
|
+
if (!Array.isArray(roots) || roots.some(root => typeof root !== 'string' || !isAbsolute(root))) throw fail('provider_permission_mismatch');
|
|
170
|
+
await onWorkspaceAuthorized?.([configuration.cwd, ...roots]);
|
|
171
|
+
}
|
|
172
|
+
await beforeDispatch?.();
|
|
173
|
+
signal.throwIfAborted();
|
|
174
|
+
await preserveSupervision();
|
|
175
|
+
const currentAccount = await client.request('account/read', { refreshToken: false });
|
|
176
|
+
if (!accountMatches(currentAccount.account?.email)) throw fail('provider_account_mismatch');
|
|
177
|
+
await beforeDispatch?.();
|
|
178
|
+
signal.throwIfAborted();
|
|
179
|
+
const currentInstruction = getInstruction ? await getInstruction() : instruction;
|
|
180
|
+
if (typeof currentInstruction !== 'string' || !currentInstruction.trim()) throw fail('provider_instruction_unavailable');
|
|
181
|
+
await new Promise((resolve, reject) => {
|
|
182
|
+
let started = false, hasResult = false, settled = false;
|
|
183
|
+
let cleanup = () => {};
|
|
184
|
+
const finish = error => {
|
|
185
|
+
if (settled) return;
|
|
186
|
+
settled = true; cleanup();
|
|
187
|
+
if (error) reject(error); else resolve();
|
|
188
|
+
};
|
|
189
|
+
const buffered = [];
|
|
190
|
+
const consume = (method, params, requestId) => {
|
|
191
|
+
if (settled) return;
|
|
192
|
+
if (params.threadId !== selection.session_id || (params.turnId || params.turn?.id) !== turnId) return;
|
|
193
|
+
if (requestId !== undefined) {
|
|
194
|
+
emit({ type: 'simy.delegation.question', question: { ...params, method }, request_id: requestId });
|
|
195
|
+
finish(fail('provider_input_required')); return;
|
|
196
|
+
}
|
|
197
|
+
if (method === 'item/completed' && params.item?.type === 'agentMessage' && typeof params.item.text === 'string') {
|
|
198
|
+
hasResult ||= Boolean(params.item.text.trim());
|
|
199
|
+
emit({ type: 'item.completed', item: { type: 'agent_message', text: params.item.text } });
|
|
200
|
+
}
|
|
201
|
+
if (method === 'turn/completed') {
|
|
202
|
+
completed = true;
|
|
203
|
+
if (params.turn.status === 'completed' && hasResult) finish();
|
|
204
|
+
else finish(fail('provider_turn_failed'));
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
unsubscribe = client.onNotification((method, params, requestId) => {
|
|
208
|
+
if (!started) { if (buffered.length < 100) buffered.push([method, params, requestId]); return; }
|
|
209
|
+
consume(method, params, requestId);
|
|
210
|
+
});
|
|
211
|
+
const cancelled = () => finish(fail('provider_cancelled'));
|
|
212
|
+
signal.addEventListener('abort', cancelled, { once: true });
|
|
213
|
+
const closed = () => finish(fail('provider_result_unknown'));
|
|
214
|
+
client.child.once('close', closed);
|
|
215
|
+
cleanup = () => { signal.removeEventListener('abort', cancelled); client.child.off('close', closed); };
|
|
216
|
+
// The official provider writer lock acquired by thread/resume arbitrates
|
|
217
|
+
// races with the original app. No busy fallback, steer, or second task.
|
|
218
|
+
if (signal.aborted) { finish(fail('provider_cancelled')); return; }
|
|
219
|
+
starting = dispatchNative(() => client.request('turn/start', { threadId: selection.session_id, model: selection.model,
|
|
220
|
+
input: [{ type: 'text', text: currentInstruction }] })).then(response => {
|
|
221
|
+
turnId = response?.turn?.id; started = true;
|
|
222
|
+
if (!turnId) finish(fail('provider_result_unknown'));
|
|
223
|
+
else { emit({ type: 'thread.started', thread_id: selection.session_id }); for (const event of buffered) consume(...event); }
|
|
224
|
+
}, finish);
|
|
225
|
+
});
|
|
226
|
+
emit({ type: 'turn.completed' });
|
|
227
|
+
} catch (error) {
|
|
228
|
+
primaryError = error;
|
|
229
|
+
if (!starting || completed) error.delegationState = 'failed';
|
|
230
|
+
throw error;
|
|
231
|
+
} finally {
|
|
232
|
+
unsubscribe();
|
|
233
|
+
// A cancelled request may have been accepted before its response arrived.
|
|
234
|
+
// Collect that exact ID before interrupting; never issue another start.
|
|
235
|
+
if (starting && !turnId) await starting.catch(() => {});
|
|
236
|
+
if (turnId && !completed && !client.closed) {
|
|
237
|
+
try {
|
|
238
|
+
await client.request('turn/interrupt', { threadId: selection.session_id, turnId }, 2000);
|
|
239
|
+
if (primaryError?.code === 'provider_input_required') primaryError.delegationState = 'needs_user';
|
|
240
|
+
if (primaryError?.code === 'provider_cancelled') primaryError.delegationState = 'stopped';
|
|
241
|
+
} catch {
|
|
242
|
+
if (primaryError) primaryError.delegationState = 'result_unknown';
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Transport cleanup cannot erase a known terminal result or primary error.
|
|
246
|
+
try { await client.close(); } catch { /* The submitted turn is never replayed. */ }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function runClaudeDelegation({ selection, repositoryPath, command, readClaude,
|
|
251
|
+
inspectClaudeAccount, signal, emit, beforeDispatch, onChild, accountMatches, nativeIdentity, workspaceMode, dispatchNative = send => send() }) {
|
|
252
|
+
signal.throwIfAborted();
|
|
253
|
+
const metadata = await readClaude(selection.session_id);
|
|
254
|
+
if (!metadata?.cwd || (!nativeIdentity || workspaceMode === 'repository') && await realpath(metadata.cwd) !== await realpath(repositoryPath)) throw fail('provider_session_mismatch');
|
|
255
|
+
const nativeCwd = await realpath(metadata.cwd);
|
|
256
|
+
const account = await inspectClaudeAccount(command.bin);
|
|
257
|
+
if (account.loggedIn !== true || !accountMatches(account.email)) throw fail('provider_account_mismatch');
|
|
258
|
+
await beforeDispatch?.();
|
|
259
|
+
signal.throwIfAborted();
|
|
260
|
+
// Recheck the official identity after asynchronous lease/link guards, just as
|
|
261
|
+
// the Codex adapter does. A selected account cannot change while waiting.
|
|
262
|
+
const currentAccount = await inspectClaudeAccount(command.bin);
|
|
263
|
+
if (currentAccount.loggedIn !== true || !accountMatches(currentAccount.email)) throw fail('provider_account_mismatch');
|
|
264
|
+
await beforeDispatch?.();
|
|
265
|
+
signal.throwIfAborted();
|
|
266
|
+
await new Promise((resolve, reject) => {
|
|
267
|
+
let pending = '', terminal = false, nativeSession = null, question = false, terminalFailed = false, invalidStream = false, settled = false;
|
|
268
|
+
const decoder = new StringDecoder('utf8');
|
|
269
|
+
const child = dispatchNative(() => command.spawn(command.bin, command.args, { cwd: nativeCwd,
|
|
270
|
+
env: { ...process.env, ...command.env }, stdio: ['ignore', 'pipe', 'pipe'] }));
|
|
271
|
+
// This is only the newly forked process; the original owner is never signalled.
|
|
272
|
+
onChild?.(child);
|
|
273
|
+
const cancel = () => child.kill(signal.reason?.nativeSignal || 'SIGINT');
|
|
274
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
275
|
+
const cleanup = () => signal.removeEventListener('abort', cancel);
|
|
276
|
+
child.stderr?.resume();
|
|
277
|
+
const consume = line => {
|
|
278
|
+
let event; try { event = JSON.parse(line); } catch { return; }
|
|
279
|
+
if (!event || typeof event !== 'object' || Array.isArray(event)) return;
|
|
280
|
+
if (event.type === 'system' && event.subtype === 'init') {
|
|
281
|
+
if (!UUID.test(event.session_id || '') || event.session_id === selection.session_id || event.model !== selection.model ||
|
|
282
|
+
nativeSession && nativeSession !== event.session_id) {
|
|
283
|
+
invalidStream = true; child.kill('SIGINT'); return;
|
|
284
|
+
}
|
|
285
|
+
nativeSession = event.session_id;
|
|
286
|
+
emit({ type: 'simy.delegation.session', session_id: nativeSession });
|
|
287
|
+
}
|
|
288
|
+
if (!nativeSession || event.session_id && event.session_id !== nativeSession) return;
|
|
289
|
+
if (event.type === 'result') {
|
|
290
|
+
const denials = Array.isArray(event.permission_denials) ? event.permission_denials : [];
|
|
291
|
+
const questions = denials.flatMap(item => Array.isArray(item?.tool_input?.questions) ? item.tool_input.questions : []);
|
|
292
|
+
const message = questions.map(item => item?.question).filter(item => typeof item === 'string').join('\n') ||
|
|
293
|
+
(typeof event.result === 'string' && event.result.trim() ? event.result : null) ||
|
|
294
|
+
(Array.isArray(event.errors) ? event.errors.filter(item => typeof item === 'string').join('\n') : '') ||
|
|
295
|
+
denials.map(item => item?.tool_input?.description || item?.tool_input?.command).filter(item => typeof item === 'string').join('\n');
|
|
296
|
+
terminal = typeof event.result === 'string' && Boolean(event.result.trim()) || denials.length > 0 ||
|
|
297
|
+
Array.isArray(event.errors) && event.errors.some(item => typeof item === 'string' && item.trim());
|
|
298
|
+
terminalFailed = event.is_error === true || String(event.subtype || '').startsWith('error');
|
|
299
|
+
if (denials.length && !message) invalidStream = true;
|
|
300
|
+
if (denials.length && message) {
|
|
301
|
+
question = true;
|
|
302
|
+
emit({ type: 'simy.delegation.question', question: { message, permission_denials: denials, ...(questions.length ? { questions } : {}) } });
|
|
303
|
+
event.is_error = true;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (event.type === 'result' && typeof event.result === 'string')
|
|
307
|
+
emit({ type: 'assistant', message: { content: [{ type: 'text', text: event.result }] } });
|
|
308
|
+
emit(event);
|
|
309
|
+
};
|
|
310
|
+
child.stdout.on('data', chunk => {
|
|
311
|
+
if (settled) return;
|
|
312
|
+
pending += decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
313
|
+
if (Buffer.byteLength(pending) > 256 * 1024) { invalidStream = true; child.kill('SIGINT'); return; }
|
|
314
|
+
const lines = pending.split('\n'); pending = lines.pop();
|
|
315
|
+
for (const line of lines) consume(line);
|
|
316
|
+
});
|
|
317
|
+
child.once('error', error => { if (settled) return; settled = true; cleanup(); const failure = fail('provider_result_unknown');
|
|
318
|
+
if (['ENOENT', 'EACCES'].includes(error?.code) && !child.pid) failure.delegationState = 'failed';
|
|
319
|
+
reject(failure); });
|
|
320
|
+
child.once('close', (code, closeSignal) => {
|
|
321
|
+
if (settled) return; settled = true;
|
|
322
|
+
cleanup();
|
|
323
|
+
pending += decoder.end();
|
|
324
|
+
if (!invalidStream && pending.trim()) consume(pending);
|
|
325
|
+
const rejectState = (reason, state) => reject(Object.assign(fail(reason), { delegationState: state }));
|
|
326
|
+
if (invalidStream || !nativeSession) rejectState('provider_result_unknown', 'result_unknown');
|
|
327
|
+
else if (signal.aborted) rejectState('provider_cancelled',
|
|
328
|
+
code === 130 || closeSignal === 'SIGINT' ? 'stopped' : 'result_unknown');
|
|
329
|
+
else if (question && terminal) rejectState('provider_input_required', 'needs_user');
|
|
330
|
+
else if (terminalFailed && terminal) rejectState('provider_turn_failed', 'failed');
|
|
331
|
+
else if (code !== 0 || !terminal) rejectState('provider_result_unknown', 'result_unknown');
|
|
332
|
+
else resolve();
|
|
333
|
+
});
|
|
334
|
+
if (signal.aborted) cancel();
|
|
335
|
+
});
|
|
336
|
+
}
|
package/src/runner.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { safetyCeilingRecoveryMessage } from "./orchestrator/recovery.js";
|
|
1
2
|
import { captureDispatchProvenance } from './update-detachment/provenance.js';
|
|
2
3
|
import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, observeDispatchEvent, queueDispatchReceipt } from './update-detachment/dispatch-gate.js';
|
|
3
4
|
import { execFile, execFileSync } from "node:child_process";
|
|
@@ -461,7 +462,7 @@ export async function increaseLocalCodingRunProviderTokenBudget(run, value) {
|
|
|
461
462
|
}
|
|
462
463
|
const currentBudget = refreshBudgetState(run.snapshot);
|
|
463
464
|
if (currentBudget.retry_exhausted) {
|
|
464
|
-
throw new Error("
|
|
465
|
+
throw new Error(safetyCeilingRecoveryMessage("attempt_ceiling_reached", currentBudget));
|
|
465
466
|
}
|
|
466
467
|
const previousBudget = currentBudget.token_budget;
|
|
467
468
|
const previousAudit = run.snapshot.final_audit;
|