@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.
Files changed (77) hide show
  1. package/README.md +180 -26
  2. package/package.json +3 -2
  3. package/src/agent.js +128 -7
  4. package/src/bounded-local-task-subtask-pool.js +32 -6
  5. package/src/bounded-local-task-subtasks-contract.js +1 -1
  6. package/src/cli-contract.js +1 -1
  7. package/src/codex-plugins/simy/.codex-plugin/plugin.json +2 -2
  8. package/src/codex-plugins/simy/hooks/README.md +8 -3
  9. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +261 -67
  10. package/src/codex-plugins/simy/hooks/hooks.json +1 -1
  11. package/src/codex-plugins/simy/hooks/loop-kernel/conformance.json +136 -0
  12. package/src/codex-plugins/simy/hooks/loop-kernel/index.mjs +42 -0
  13. package/src/codex-plugins/simy/hooks/loop-kernel/outcome-review.mjs +31 -0
  14. package/src/codex-plugins/simy/hooks/loop-kernel/pin.json +6 -0
  15. package/src/codex-plugins/simy/hooks/loop-kernel/policy.json +141 -0
  16. package/src/codex-plugins/simy/hooks/loop-kernel/task-pool.mjs +34 -0
  17. package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +132 -0
  18. package/src/codex-plugins/simy/hooks/loop-kernel.mjs +6 -0
  19. package/src/codex-plugins/simy/hooks/outcome-progress.mjs +15 -3
  20. package/src/codex-plugins/simy/hooks/purpose-completion-contract.mjs +10 -0
  21. package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +8 -6
  22. package/src/codex-plugins/simy/hooks/result-cycle.mjs +32 -6
  23. package/src/codex-plugins/simy/hooks/work-frontier.mjs +2 -0
  24. package/src/codex-plugins/simy/skills/diagnose/SKILL.md +10 -4
  25. package/src/coding-run-recovery.js +4 -2
  26. package/src/delivery-hooks/claude-workflow.md +10 -7
  27. package/src/delivery-hooks/command.js +45 -10
  28. package/src/delivery-hooks/consult.js +60 -0
  29. package/src/delivery-hooks/diagnosis.js +2 -2
  30. package/src/delivery-hooks/legacy-hook-routing.js +46 -8
  31. package/src/delivery-hooks/local-runtime.js +50 -1
  32. package/src/delivery-hooks/local-supervisor.js +1 -1
  33. package/src/delivery-hooks/repair-continuation.js +80 -0
  34. package/src/delivery-hooks/repair.js +164 -0
  35. package/src/delivery-hooks/supervisor-service.js +3 -1
  36. package/src/delivery-hooks/supervisor.js +88 -12
  37. package/src/desktop-access.js +2 -1
  38. package/src/desktop-host.js +35 -2
  39. package/src/durable-local-task-worker.js +372 -58
  40. package/src/execution-capability-contract.js +15 -1
  41. package/src/index.js +3 -3
  42. package/src/local-photos-read.swift +33 -0
  43. package/src/local-photos.js +47 -0
  44. package/src/local-task-attachment-store.js +31 -0
  45. package/src/local-task-destination.js +174 -0
  46. package/src/local-task.js +119 -7
  47. package/src/native-delegation-contract.js +68 -0
  48. package/src/native-provider-identity.js +60 -0
  49. package/src/orchestrator/budget.js +22 -11
  50. package/src/orchestrator/independent-audit.js +9 -18
  51. package/src/orchestrator/loop.js +27 -59
  52. package/src/orchestrator/recovery.js +32 -2
  53. package/src/provider-delegation.js +336 -0
  54. package/src/runner.js +2 -1
  55. package/src/session-supervision/artifact-observer.js +138 -0
  56. package/src/session-supervision/codex-app-server.js +19 -5
  57. package/src/session-supervision/completion-artifacts.js +44 -0
  58. package/src/session-supervision/consistency-auditor.js +8 -11
  59. package/src/session-supervision/continuation-analysis.js +46 -28
  60. package/src/session-supervision/final-response-recovery.js +5 -3
  61. package/src/session-supervision/index.js +22 -5
  62. package/src/session-supervision/purpose-recovery.js +12 -3
  63. package/src/session-supervision/purpose-verification.js +9 -57
  64. package/src/session-supervision/recovery-frontier.js +55 -0
  65. package/src/session-supervision/stop-notifications.js +8 -8
  66. package/src/sqm/agent-skill.js +1 -1
  67. package/src/sqm/checkers.js +12 -6
  68. package/src/sqm/claude-plugin-update.js +22 -8
  69. package/src/sqm/claude-plugin.js +4 -0
  70. package/src/sqm/local-tests.js +2 -2
  71. package/src/sqm/plugin-cache.js +67 -0
  72. package/src/sqm/proof.js +25 -0
  73. package/src/sqm/report.js +1 -0
  74. package/src/sqm/workflow-onboarding.js +3 -39
  75. package/src/sqm/workflow-update.js +17 -6
  76. package/src/team-status-fence-store.js +32 -0
  77. package/src/team-threads.js +457 -0
@@ -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
- if (attempt.budget.token_exhausted) {
355
- await stopForBudget({ snapshot, attempt, reason: "token_budget_exhausted", onUpdate });
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 progress = attempt.outcome_progress;
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
- passed: false,
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, "blocked", "AI diagnosis could not recover the outcome assessment; preserve the concrete execution blocker.", {
705
- code: "outcome_assessment_unavailable",
706
- finding_codes: ["OUTCOME_ASSESSMENT_UNAVAILABLE"],
707
- attempt_number: attemptNumber,
708
- consecutive_unassessed_attempts: progress.consecutive_unassessed_attempts,
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 === "token_budget_exhausted"
764
- ? "Agentic loop reached its Provider token budget."
765
- : "Agentic loop exhausted its retry budget.";
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 === "token_budget_exhausted") {
745
+ if (reason === "provider_token_ceiling_reached") {
778
746
  return {
779
747
  passed: false,
780
- code: "TOKEN_BUDGET_EXHAUSTED",
748
+ code: "PROVIDER_TOKEN_CEILING_REACHED",
781
749
  severity: "blocker",
782
750
  target: "provider_token_budget",
783
- explanation: `The run used ${budget.tokens_used} of ${budget.hard_token_ceiling} Provider tokens. The ${budget.continuation_policy === "progress" ? "hard safety ceiling" : "budget"} prevents another provider call. Preserve this run and review its remaining acceptance conditions before resuming. Provider tokens are not billed as SIMY tokens.`,
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: "RETRY_BUDGET_EXHAUSTED",
758
+ code: "ATTEMPT_CEILING_REACHED",
791
759
  severity: "blocker",
792
760
  target: "retry_budget",
793
- explanation: budget.continuation_policy === "progress" ? `The run reached its hard ceiling of ${budget.hard_attempt_ceiling} attempts. Preserve the checkpoint and review remaining acceptance conditions before resuming.` : `The run used ${budget.retries_used} of ${budget.retry_budget} automatic retries.`,
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("The Agentic Loop run also used its retry budget. Start a new run to continue.");
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;