@triflux/core 10.1.0 → 10.25.1

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 (143) hide show
  1. package/hooks/agent-route-guard.mjs +14 -10
  2. package/hooks/cross-review-tracker.mjs +21 -8
  3. package/hooks/error-context.mjs +20 -8
  4. package/hooks/hook-adaptive-collector.mjs +18 -16
  5. package/hooks/hook-manager.mjs +93 -32
  6. package/hooks/hook-orchestrator.mjs +267 -35
  7. package/hooks/hook-registry.json +343 -256
  8. package/hooks/hooks.json +49 -1
  9. package/hooks/keyword-rules.json +183 -23
  10. package/hooks/lib/resolve-root.mjs +1 -1
  11. package/hooks/mcp-config-watcher.mjs +6 -2
  12. package/hooks/permission-safe-allow.mjs +233 -0
  13. package/hooks/pipeline-stop.mjs +369 -22
  14. package/hooks/post-tool-tips.mjs +306 -0
  15. package/hooks/pre-compact-snapshot.mjs +181 -0
  16. package/hooks/safety-guard.mjs +254 -33
  17. package/hooks/session-end-cleanup.mjs +345 -0
  18. package/hooks/session-start-fast.mjs +214 -0
  19. package/hooks/subagent-tracker.mjs +267 -0
  20. package/hooks/subagent-verifier.mjs +9 -4
  21. package/hub/account-broker.mjs +1036 -66
  22. package/hub/adaptive-diagnostic.mjs +75 -48
  23. package/hub/adaptive-inject.mjs +96 -58
  24. package/hub/adaptive-memory.mjs +156 -42
  25. package/hub/adaptive.mjs +60 -31
  26. package/hub/assign-callbacks.mjs +163 -126
  27. package/hub/bridge.mjs +831 -290
  28. package/hub/cli-adapter-base.mjs +321 -97
  29. package/hub/codex-adapter.mjs +179 -93
  30. package/hub/codex-compat.mjs +4 -4
  31. package/hub/codex-preflight.mjs +113 -37
  32. package/hub/delegator/contracts.mjs +19 -23
  33. package/hub/delegator/index.mjs +14 -14
  34. package/hub/delegator/service.mjs +88 -64
  35. package/hub/delegator/tool-definitions.mjs +35 -35
  36. package/hub/dynamic-routing-engine.mjs +511 -0
  37. package/hub/fullcycle.mjs +34 -18
  38. package/hub/gemini-adapter.mjs +77 -95
  39. package/hub/hitl.mjs +202 -143
  40. package/hub/intent.mjs +167 -38
  41. package/hub/lib/bash-path.mjs +73 -0
  42. package/hub/lib/cache-guard.mjs +43 -17
  43. package/hub/lib/env-detect.mjs +145 -0
  44. package/hub/lib/hosts-compat.mjs +319 -0
  45. package/hub/lib/mcp-response-cache.mjs +239 -0
  46. package/hub/lib/memory-store.mjs +285 -111
  47. package/hub/lib/path-utils.mjs +165 -0
  48. package/hub/lib/phase-manager.mjs +493 -0
  49. package/hub/lib/process-utils.mjs +797 -104
  50. package/hub/lib/prompt-tmp.mjs +33 -0
  51. package/hub/lib/risk-tier.mjs +53 -0
  52. package/hub/lib/spawn-trace.mjs +561 -0
  53. package/hub/lib/ssh-command.mjs +44 -24
  54. package/hub/lib/ssh-retry.mjs +5 -1
  55. package/hub/lib/staged-file-detect.mjs +43 -0
  56. package/hub/lib/state-snapshot.mjs +293 -0
  57. package/hub/lib/tfx-route-args.mjs +309 -0
  58. package/hub/lib/timeline-adapter.mjs +190 -0
  59. package/hub/lib/trace-recorder.mjs +153 -0
  60. package/hub/lib/uuidv7.mjs +4 -3
  61. package/hub/memory-doctor.mjs +266 -106
  62. package/hub/middleware/quota-middleware.mjs +70 -0
  63. package/hub/middleware/request-logger.mjs +61 -34
  64. package/hub/paths.mjs +9 -9
  65. package/hub/pipeline/gates/confidence.mjs +34 -15
  66. package/hub/pipeline/gates/consensus.mjs +27 -15
  67. package/hub/pipeline/gates/index.mjs +7 -3
  68. package/hub/pipeline/gates/selfcheck.mjs +57 -19
  69. package/hub/pipeline/index.mjs +77 -42
  70. package/hub/pipeline/state.mjs +10 -10
  71. package/hub/pipeline/transitions.mjs +40 -23
  72. package/hub/platform.mjs +65 -48
  73. package/hub/quality/deslop.mjs +70 -49
  74. package/hub/reflexion.mjs +87 -13
  75. package/hub/research.mjs +33 -25
  76. package/hub/router.mjs +941 -791
  77. package/hub/routing/complexity.mjs +132 -29
  78. package/hub/routing/index.mjs +17 -12
  79. package/hub/routing/q-learning.mjs +76 -28
  80. package/hub/routing-snapshot.mjs +261 -0
  81. package/hub/session-fingerprint.mjs +127 -61
  82. package/hub/state.mjs +86 -44
  83. package/hub/team/retry-state-machine.mjs +267 -0
  84. package/hub/token-mode.mjs +72 -71
  85. package/hub/workers/worker-utils.mjs +22 -16
  86. package/hud/colors.mjs +27 -9
  87. package/hud/constants.mjs +184 -27
  88. package/hud/context-monitor.mjs +133 -44
  89. package/hud/hud-qos-status.mjs +153 -50
  90. package/hud/mission-board.mjs +56 -0
  91. package/hud/providers/claude.mjs +398 -114
  92. package/hud/providers/codex.mjs +96 -21
  93. package/hud/providers/gemini.mjs +184 -51
  94. package/hud/renderers.mjs +413 -123
  95. package/hud/terminal.mjs +101 -31
  96. package/hud/utils.mjs +81 -39
  97. package/mesh/index.mjs +69 -0
  98. package/mesh/mesh-budget.mjs +137 -0
  99. package/mesh/mesh-heartbeat.mjs +100 -0
  100. package/mesh/mesh-protocol.mjs +96 -0
  101. package/mesh/mesh-queue.mjs +163 -0
  102. package/mesh/mesh-registry.mjs +78 -0
  103. package/mesh/mesh-router.mjs +77 -0
  104. package/package.json +5 -1
  105. package/scripts/lib/agent-json.mjs +27 -0
  106. package/scripts/lib/async.mjs +174 -0
  107. package/scripts/lib/claudemd-scanner.mjs +6 -1
  108. package/scripts/lib/cli-agy.mjs +62 -0
  109. package/scripts/lib/cli-claude.mjs +78 -0
  110. package/scripts/lib/cli-codex.mjs +199 -0
  111. package/scripts/lib/cli-gemini.mjs +67 -0
  112. package/scripts/lib/codex-recovery.sh +50 -0
  113. package/scripts/lib/context.mjs +3 -3
  114. package/scripts/lib/cross-review-utils.mjs +13 -5
  115. package/scripts/lib/doctor-env-checks.mjs +121 -0
  116. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  117. package/scripts/lib/env-probe.mjs +58 -30
  118. package/scripts/lib/env.mjs +14 -0
  119. package/scripts/lib/gemini-profiles.mjs +44 -10
  120. package/scripts/lib/handoff.mjs +33 -17
  121. package/scripts/lib/hook-utils.mjs +8 -6
  122. package/scripts/lib/hub.mjs +48 -0
  123. package/scripts/lib/keyword-rules.mjs +48 -19
  124. package/scripts/lib/logger.mjs +24 -24
  125. package/scripts/lib/mcp-filter.mjs +391 -247
  126. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  127. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  128. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  129. package/scripts/lib/mcp-guard-engine.mjs +1281 -115
  130. package/scripts/lib/mcp-health.mjs +773 -0
  131. package/scripts/lib/mcp-manifest.mjs +24 -14
  132. package/scripts/lib/mcp-server-catalog.mjs +355 -118
  133. package/scripts/lib/pid.mjs +63 -0
  134. package/scripts/lib/process-utils.mjs +26 -0
  135. package/scripts/lib/psmux-info.mjs +70 -10
  136. package/scripts/lib/quota.mjs +47 -0
  137. package/scripts/lib/remote-spawn-transfer.mjs +44 -14
  138. package/scripts/lib/skill-state.mjs +220 -0
  139. package/scripts/lib/skill-template.mjs +32 -7
  140. package/scripts/lib/team.mjs +75 -0
  141. package/scripts/lib/timeout.mjs +66 -0
  142. package/scripts/lib/tmp.mjs +26 -0
  143. package/scripts/lib/toml.mjs +113 -0
@@ -1,10 +1,66 @@
1
1
  // hub/cli-adapter-base.mjs — codex/gemini 공통 CLI adapter 인터페이스
2
2
  // Phase 2: codex-adapter.mjs에서 추출한 재사용 가능 유틸리티
3
3
 
4
- import { execSync, spawn } from 'node:child_process';
5
- import { existsSync, readFileSync } from 'node:fs';
4
+ import { execSync, spawn } from "node:child_process";
5
+ import { existsSync, readFileSync, statSync } from "node:fs";
6
6
 
7
- import { killProcess, IS_WINDOWS } from './platform.mjs';
7
+ import { writePromptToTmpFile } from "./lib/prompt-tmp.mjs";
8
+ import { IS_WINDOWS, killProcess } from "./platform.mjs";
9
+
10
+ // ── Quota retry-after 파싱 ──────────────────────────────────────
11
+
12
+ const FALLBACK_COOLDOWN_MS = { codex: 5 * 3600_000, gemini: 86400_000 };
13
+
14
+ /**
15
+ * 에러 텍스트에서 retry-after 시간을 동적 파싱.
16
+ * "try again at 2026-04-11T10:00:00Z", "retry after 3600 seconds",
17
+ * "resets in 7 days", "wait 5 hours" 등을 감지.
18
+ */
19
+ export function parseRetryAfterMs(text, provider) {
20
+ // 1. ISO timestamp: "try again at 2026-04-11T10:00:00"
21
+ const isoMatch = text.match(
22
+ /(?:try again|retry|available|resets?)\s+(?:at|after)\s+(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)/i,
23
+ );
24
+ if (isoMatch) {
25
+ const target = new Date(isoMatch[1]).getTime();
26
+ if (target > Date.now()) return target - Date.now();
27
+ }
28
+
29
+ // 2. Duration: "N seconds/minutes/hours/days"
30
+ const durMatch = text.match(
31
+ /(?:retry|wait|resets?|again)\s+(?:in\s+|after\s+)?(\d+)\s*(second|minute|hour|day|week)/i,
32
+ );
33
+ if (durMatch) {
34
+ const n = Number(durMatch[1]);
35
+ const unit = durMatch[2].toLowerCase();
36
+ const multipliers = {
37
+ second: 1000,
38
+ minute: 60_000,
39
+ hour: 3600_000,
40
+ day: 86400_000,
41
+ week: 604800_000,
42
+ };
43
+ return n * (multipliers[unit] || 3600_000);
44
+ }
45
+
46
+ // 3. "7 days" / "24 hours" 단독 (context 없이)
47
+ const standaloneMatch = text.match(/(\d+)\s*(day|hour|week|minute)/i);
48
+ if (standaloneMatch) {
49
+ const n = Number(standaloneMatch[1]);
50
+ const unit = standaloneMatch[2].toLowerCase();
51
+ const multipliers = {
52
+ minute: 60_000,
53
+ hour: 3600_000,
54
+ day: 86400_000,
55
+ week: 604800_000,
56
+ };
57
+ const parsed = n * (multipliers[unit] || 3600_000);
58
+ if (parsed >= 3600_000) return parsed; // 1시간 이상만 신뢰
59
+ }
60
+
61
+ // 4. fallback: provider 기본값
62
+ return FALLBACK_COOLDOWN_MS[provider] || 5 * 3600_000;
63
+ }
8
64
 
9
65
  // ── Codex CLI compatibility ─────────────────────────────────────
10
66
 
@@ -17,12 +73,23 @@ let _cachedVersion = null;
17
73
  */
18
74
  export function getCodexVersion() {
19
75
  if (_cachedVersion !== null) return _cachedVersion;
76
+ const override = Number(process.env.TFX_CODEX_VERSION_MINOR);
77
+ if (Number.isFinite(override) && override > 0) {
78
+ _cachedVersion = override;
79
+ return _cachedVersion;
80
+ }
20
81
  try {
21
- const out = execSync('codex --version', { encoding: 'utf8', timeout: 5000 }).trim();
82
+ const out = execSync("codex --version", {
83
+ encoding: "utf8",
84
+ timeout: 5000,
85
+ }).trim();
22
86
  const match = out.match(/(\d+)\.(\d+)\.(\d+)/);
23
87
  _cachedVersion = match ? Number.parseInt(match[2], 10) : 0;
24
88
  } catch {
25
- _cachedVersion = 0;
89
+ // Command builders should remain stable in CI even when the real Codex
90
+ // CLI is absent. Runtime preflight still reports/install-gates Codex
91
+ // separately; this fallback only selects the modern argv shape.
92
+ _cachedVersion = 117;
26
93
  }
27
94
  return _cachedVersion;
28
95
  }
@@ -43,19 +110,27 @@ export function gte(minMinor) {
43
110
  */
44
111
  export const FEATURES = {
45
112
  /** exec 서브커맨드 사용 가능 여부 (0.110+ 이전부터 존재) */
46
- get execSubcommand() { return gte(110); },
113
+ get execSubcommand() {
114
+ return gte(110);
115
+ },
47
116
  /** --output-last-message 플래그 지원 여부 (0.117+) */
48
- get outputLastMessage() { return gte(117); },
117
+ get outputLastMessage() {
118
+ return gte(117);
119
+ },
49
120
  /** --color <COLOR> 플래그 지원 여부 (exec와 동시 도입) */
50
- get colorNever() { return gte(110); },
121
+ get colorNever() {
122
+ return gte(110);
123
+ },
51
124
  /** 플러그인 시스템 지원 여부 (향후 확장용) */
52
- get pluginSystem() { return gte(120); },
125
+ get pluginSystem() {
126
+ return gte(120);
127
+ },
53
128
  };
54
129
 
55
130
  // ── Shell utilities ─────────────────────────────────────────────
56
131
 
57
132
  export function normalizePathForShell(value) {
58
- return IS_WINDOWS ? String(value).replace(/\\/g, '/') : String(value);
133
+ return IS_WINDOWS ? String(value).replace(/\\/g, "/") : String(value);
59
134
  }
60
135
 
61
136
  export function shellQuote(value) {
@@ -71,47 +146,84 @@ export const CODEX_MCP_EXECUTION_EXIT_CODE = 1;
71
146
 
72
147
  /**
73
148
  * long-form 플래그 기반 명령 빌더.
149
+ *
150
+ * macOS 회귀 fix (codex v0.130.0 oh-my-codex hook lifecycle): 매우 긴
151
+ * argv-inline prompt 가 SessionStart Failed 를 유발하므로 default 가 prompt
152
+ * 를 stdin 으로 전달하는 패턴. 셸별 분기:
153
+ * - Unix (bash/zsh): `codex exec ... < '/tmp/triflux-codex-prompt/prompt-*.txt'`
154
+ * - Windows (pwsh7): `Get-Content -Raw 'path' | codex exec ...`
155
+ * (pwsh7 의 `<` 는 reserved future syntax 라 호환성 보장 안 됨)
156
+ *
157
+ * 환경 변수 `TFX_CODEX_STDIN_PROMPT=0` 이면 legacy argv-inline 으로 회귀.
158
+ * 결정론 보장이 필요한 launcher path 는 호출 시 `stdinPrompt: false` 명시.
159
+ *
74
160
  * @param {string} prompt
75
161
  * @param {string|null} resultFile — null이면 --output-last-message 생략
76
- * @param {{ profile?: string, skipGitRepoCheck?: boolean, sandboxBypass?: boolean, cwd?: string, mcpServers?: string[] }} [opts]
162
+ * @param {{ profile?: string, skipGitRepoCheck?: boolean, sandboxBypass?: boolean, cwd?: string, mcpServers?: string[], stdinPrompt?: boolean }} [opts]
77
163
  * @returns {string} 실행할 셸 커맨드
78
164
  */
79
165
  export function buildExecCommand(prompt, resultFile = null, opts = {}) {
80
- const { profile, skipGitRepoCheck = true, sandboxBypass = true, cwd, mcpServers } = opts;
166
+ const {
167
+ profile,
168
+ skipGitRepoCheck = true,
169
+ sandboxBypass = true,
170
+ mcpServers,
171
+ stdinPrompt,
172
+ } = opts;
81
173
 
82
- const parts = ['codex'];
83
- if (profile) parts.push('--profile', profile);
174
+ const parts = ["codex"];
175
+ if (profile) parts.push("--profile", profile);
84
176
 
85
177
  if (FEATURES.execSubcommand) {
86
- parts.push('exec');
87
- if (sandboxBypass) parts.push('--dangerously-bypass-approvals-and-sandbox');
88
- if (skipGitRepoCheck) parts.push('--skip-git-repo-check');
178
+ parts.push("exec");
179
+ if (sandboxBypass) parts.push("--dangerously-bypass-approvals-and-sandbox");
180
+ if (skipGitRepoCheck) parts.push("--skip-git-repo-check");
89
181
  if (resultFile && FEATURES.outputLastMessage) {
90
- parts.push('--output-last-message', resultFile);
182
+ parts.push("--output-last-message", resultFile);
91
183
  }
92
- if (FEATURES.colorNever) parts.push('--color', 'never');
93
- if (cwd) parts.push('--cwd', `'${escapePwshSingleQuoted(cwd)}'`);
184
+ if (FEATURES.colorNever) parts.push("--color", "never");
185
+ // NOTE: `codex exec`는 --cwd 플래그를 지원하지 않는다. Node spawn의 cwd
186
+ // 옵션으로 child process의 working directory를 제어한다 (conductor.mjs 참조).
187
+ // opts.cwd는 기록용으로만 받아두고 CLI command에는 반영하지 않는다.
188
+ // (이전 커밋에서 잘못 추가되어 shard 전체가 exit 2로 크래시한 회귀 #94 후속 이슈)
94
189
  if (Array.isArray(mcpServers)) {
95
190
  for (const server of mcpServers) {
96
- parts.push('-c', `mcp_servers.${server}.enabled=true`);
191
+ parts.push("-c", `mcp_servers.${server}.enabled=true`);
97
192
  }
98
193
  }
99
194
  } else {
100
- parts.push('--dangerously-bypass-approvals-and-sandbox');
101
- if (skipGitRepoCheck) parts.push('--skip-git-repo-check');
195
+ parts.push("--dangerously-bypass-approvals-and-sandbox");
196
+ if (skipGitRepoCheck) parts.push("--skip-git-repo-check");
197
+ }
198
+
199
+ const useStdin = resolveStdinPromptMode(stdinPrompt);
200
+ const hasPrompt = typeof prompt === "string" && prompt.length > 0;
201
+ if (useStdin && hasPrompt) {
202
+ const promptFile = writePromptToTmpFile(prompt);
203
+ if (IS_WINDOWS) {
204
+ // pwsh7 호환: `<` redirect 는 reserved future syntax 라 동작 불안정.
205
+ // Get-Content -Raw stdin pipe 로 prompt 를 codex stdin 에 주입.
206
+ return `Get-Content -Raw '${escapePwshSingleQuoted(promptFile)}' | ${parts.join(" ")}`;
207
+ }
208
+ return `${parts.join(" ")} < ${shellQuote(promptFile)}`;
102
209
  }
103
210
 
104
211
  parts.push(JSON.stringify(prompt));
105
- return parts.join(' ');
212
+ return parts.join(" ");
213
+ }
214
+
215
+ function resolveStdinPromptMode(explicit) {
216
+ if (typeof explicit === "boolean") return explicit;
217
+ // Default ON to fix macOS codex hook regression. Opt out via env=0.
218
+ const env = process.env.TFX_CODEX_STDIN_PROMPT;
219
+ if (env === "0" || env === "false") return false;
220
+ return true;
106
221
  }
107
222
 
108
223
  // ── Sleep ───────────────────────────────────────────────────────
109
224
 
110
225
  export function sleep(ms) {
111
- return new Promise((resolve) => {
112
- const timer = setTimeout(resolve, ms);
113
- timer.unref?.();
114
- });
226
+ return new Promise((resolve) => setTimeout(resolve, ms));
115
227
  }
116
228
 
117
229
  // ── Result factory ──────────────────────────────────────────────
@@ -119,80 +231,138 @@ export function sleep(ms) {
119
231
  export function createResult(ok, extra = {}) {
120
232
  return {
121
233
  ok,
122
- output: '',
123
- stderr: '',
234
+ output: "",
235
+ stderr: "",
124
236
  exitCode: null,
125
237
  duration: 0,
126
238
  retried: false,
127
239
  fellBack: false,
128
- failureMode: ok ? null : 'crash',
240
+ failureMode: ok ? null : "crash",
129
241
  ...extra,
130
242
  };
131
243
  }
132
244
 
133
245
  export function appendWarnings(stderr, warnings = []) {
134
- const text = warnings.map((item) => `[preflight] ${item}`).join('\n');
135
- return [stderr, text].filter(Boolean).join('\n');
246
+ const text = warnings.map((item) => `[preflight] ${item}`).join("\n");
247
+ return [stderr, text].filter(Boolean).join("\n");
136
248
  }
137
249
 
138
- // ── Circuit breaker factory ─────────────────────────────────────
139
-
140
- export function createCircuitBreaker(opts = {}) {
141
- const state = {
142
- failures: [],
143
- maxFailures: opts.maxFailures ?? 3,
144
- windowMs: opts.windowMs ?? 10 * 60_000,
145
- openedAt: 0,
146
- trialInFlight: false,
147
- };
250
+ // ── Broker-integrated execution ─────────────────────────────────
148
251
 
149
- function pruneFailures(now = Date.now()) {
150
- state.failures = state.failures.filter((stamp) => now - stamp < state.windowMs);
252
+ /**
253
+ * Shared execute() logic that uses account-broker for per-account circuit
254
+ * breaking instead of a global breaker.
255
+ *
256
+ * @param {object} params
257
+ * @param {string} params.provider — 'codex' | 'gemini'
258
+ * @param {(prompt: string, workdir: string, preflight: object, attempt: object) => Promise<object>} params.runFn
259
+ * @param {(opts: object) => Promise<object>} params.preflightFn
260
+ * @param {(opts: object, preflight: object) => object[]} params.buildAttemptsFn
261
+ * @param {object} params.opts — caller-supplied execute options
262
+ * @returns {Promise<object>} createResult-shaped result
263
+ */
264
+ export async function executeWithCircuitBroker({
265
+ provider,
266
+ runFn,
267
+ preflightFn,
268
+ buildAttemptsFn,
269
+ opts = {},
270
+ }) {
271
+ // late-import to avoid circular dependency at module load time
272
+ const brokerMod = await import("./account-broker.mjs");
273
+ const { withRetry } = await import("./workers/worker-utils.mjs");
274
+
275
+ // access broker as live binding property (not destructured) so reloadBroker() propagates
276
+ const hasBroker = brokerMod.broker != null;
277
+ // Empty broker (TFX_DISABLE_ACCOUNT_BROKER=1 or zero accounts) must not
278
+ // gate execution. lease() is null because there are no accounts to lease,
279
+ // not because all accounts are busy/cooldown/circuit. Treat it like
280
+ // hasBroker=false and fall through to the default auth path.
281
+ const brokerDisabled = hasBroker && brokerMod.broker.isDisabled === true;
282
+ const effectiveBroker = hasBroker && !brokerDisabled;
283
+ const lease = effectiveBroker ? brokerMod.broker.lease({ provider }) : null;
284
+ if (effectiveBroker && !lease) {
285
+ return createResult(false, { fellBack: true, failureMode: "circuit_open" });
151
286
  }
152
287
 
153
- function reset() {
154
- state.failures = [];
155
- state.openedAt = 0;
156
- state.trialInFlight = false;
288
+ const preflight = await preflightFn(opts);
289
+ if (!preflight.ok) {
290
+ if (lease) brokerMod.broker.release(lease.id, { ok: false });
291
+ return createResult(false, {
292
+ stderr: appendWarnings("", preflight.warnings),
293
+ fellBack: opts.fallbackToClaude !== false,
294
+ failureMode: "crash",
295
+ });
157
296
  }
158
297
 
159
- function recordFailure(isHalfOpen, now = Date.now()) {
160
- pruneFailures(now);
161
- state.failures = [...state.failures, now];
162
- state.trialInFlight = false;
163
- if (isHalfOpen || state.failures.length >= state.maxFailures) {
164
- state.openedAt = now;
165
- }
166
- }
298
+ const attempts = buildAttemptsFn(opts, preflight);
299
+ let attemptIndex = 0;
300
+ let lastResult = createResult(false);
167
301
 
168
- function getState(now = Date.now()) {
169
- pruneFailures(now);
170
- const withinWindow = state.openedAt && now - state.openedAt < state.windowMs;
171
- const current = withinWindow ? 'open' : (state.openedAt ? 'half-open' : 'closed');
172
- return {
173
- state: current,
174
- failures: [...state.failures],
175
- maxFailures: state.maxFailures,
176
- windowMs: state.windowMs,
177
- openedAt: state.openedAt || null,
178
- trialInFlight: state.trialInFlight,
179
- };
302
+ try {
303
+ lastResult = await withRetry(
304
+ async () => {
305
+ // PRD A1: lease 메타데이터를 runFn 에 전달해서 adapter 가 실제 spawn 시
306
+ // 해당 account 의 authFile/env/profile 을 적용할 수 있게 한다. lease=null
307
+ // 이면 adapter 는 default 경로 (단일 ~/.codex/auth.json) 로 동작한다.
308
+ const result = await runFn(
309
+ opts.prompt || "",
310
+ opts.workdir || process.cwd(),
311
+ preflight,
312
+ attempts[attemptIndex],
313
+ lease,
314
+ );
315
+ const current = {
316
+ ...result,
317
+ stderr: appendWarnings(result.stderr, preflight.warnings),
318
+ retried: attemptIndex > 0,
319
+ };
320
+ const canRetry = !current.ok && attemptIndex < attempts.length - 1;
321
+ attemptIndex += 1;
322
+ if (!canRetry) return current;
323
+ const error = new Error("retry");
324
+ error.retryable = true;
325
+ error.result = current;
326
+ throw error;
327
+ },
328
+ {
329
+ maxAttempts: attempts.length,
330
+ baseDelayMs: 250,
331
+ maxDelayMs: 750,
332
+ shouldRetry: (error) => error?.retryable === true,
333
+ },
334
+ );
335
+ } catch (error) {
336
+ lastResult =
337
+ error?.result ||
338
+ createResult(false, { stderr: String(error?.message || error) });
180
339
  }
181
340
 
182
- function canExecute() {
183
- const circuit = getState();
184
- if (circuit.state === 'open') return { allowed: false, halfOpen: false };
185
- if (circuit.state === 'half-open' && state.trialInFlight) return { allowed: false, halfOpen: true };
186
- const halfOpen = circuit.state === 'half-open';
187
- if (halfOpen) state.trialInFlight = true;
188
- return { allowed: true, halfOpen };
341
+ if (lastResult.ok) {
342
+ if (lease) brokerMod.broker.release(lease.id, { ok: true });
343
+ return lastResult;
189
344
  }
190
345
 
191
- function clearTrial() {
192
- state.trialInFlight = false;
346
+ if (lease) {
347
+ if (lastResult.failureMode === "rate_limited") {
348
+ const text = `${lastResult.output || ""}\n${lastResult.stderr || ""}`;
349
+ const coolMs = parseRetryAfterMs(text, provider);
350
+ brokerMod.broker.markRateLimited(lease.id, coolMs);
351
+ brokerMod.broker.emit("cooldown", {
352
+ id: lease.id,
353
+ provider,
354
+ coolMs,
355
+ reason: "quota_exhausted",
356
+ });
357
+ } else {
358
+ brokerMod.broker.release(lease.id, { ok: false });
359
+ }
193
360
  }
194
-
195
- return { getState, recordFailure, reset, canExecute, clearTrial };
361
+ return {
362
+ ...lastResult,
363
+ retried: attempts.length > 1,
364
+ fellBack: opts.fallbackToClaude !== false,
365
+ };
196
366
  }
197
367
 
198
368
  // ── Process termination ─────────────────────────────────────────
@@ -200,9 +370,14 @@ export function createCircuitBreaker(opts = {}) {
200
370
  export async function terminateChild(pid, opts = {}) {
201
371
  if (!pid) return;
202
372
  const graceMs = opts.graceMs ?? 5000;
203
- killProcess(pid, { signal: 'SIGTERM', tree: true, timeout: graceMs });
373
+ killProcess(pid, { signal: "SIGTERM", tree: true, timeout: graceMs });
204
374
  await sleep(graceMs);
205
- killProcess(pid, { signal: 'SIGKILL', tree: true, force: true, timeout: graceMs });
375
+ killProcess(pid, {
376
+ signal: "SIGKILL",
377
+ tree: true,
378
+ force: true,
379
+ timeout: graceMs,
380
+ });
206
381
  }
207
382
 
208
383
  // ── Process execution with stall detection ──────────────────────
@@ -222,29 +397,61 @@ export async function terminateChild(pid, opts = {}) {
222
397
  */
223
398
  export async function runProcess(command, workdir, timeout, opts = {}) {
224
399
  const startedAt = Date.now();
225
- const inferStallMode = opts.inferStallMode || (() => 'timeout');
400
+ const inferStallMode = opts.inferStallMode || (() => "timeout");
226
401
  const stallCheckIntervalMs = opts.stallCheckIntervalMs ?? 10_000;
227
402
  const stallThresholdMs = opts.stallThresholdMs ?? 30_000;
228
403
  const resultFile = opts.resultFile || null;
229
404
 
230
- let stdout = '';
231
- let stderr = '';
405
+ let stdout = "";
406
+ let stderr = "";
232
407
  let exitCode = null;
233
408
  let failureMode = null;
234
409
  let child;
235
410
 
236
411
  try {
237
- child = spawn(command, { cwd: workdir, shell: true, windowsHide: true });
412
+ // PRD A1: opts.spawnEnv 가 있으면 그 env 로 spawn (lease 의 authFile/env 적용).
413
+ // undefined 면 spawn 의 default 동작 (부모 process env inherit) 유지.
414
+ child = spawn(command, {
415
+ cwd: workdir,
416
+ shell: true,
417
+ windowsHide: true,
418
+ env: opts.spawnEnv,
419
+ });
238
420
  } catch (error) {
239
- return createResult(false, { stderr: String(error?.message || error), duration: Date.now() - startedAt });
421
+ return createResult(false, {
422
+ stderr: String(error?.message || error),
423
+ duration: Date.now() - startedAt,
424
+ });
240
425
  }
241
426
 
427
+ const resultFileSignature = () => {
428
+ if (!resultFile) return "";
429
+ try {
430
+ const info = statSync(resultFile);
431
+ return `${info.size}:${info.mtimeMs}`;
432
+ } catch {
433
+ return "";
434
+ }
435
+ };
436
+
242
437
  let lastBytes = 0;
438
+ let lastResultFileSignature = resultFileSignature();
243
439
  let lastChange = Date.now();
244
- const touch = () => { lastChange = Date.now(); };
245
- child.stdout?.on('data', (chunk) => { stdout += String(chunk); touch(); });
246
- child.stderr?.on('data', (chunk) => { stderr += String(chunk); touch(); });
247
- child.on('error', (error) => { stderr += String(error?.message || error); failureMode ||= 'crash'; });
440
+ const touch = () => {
441
+ lastChange = Date.now();
442
+ };
443
+ child.stdout?.on("data", (chunk) => {
444
+ stdout += String(chunk);
445
+ touch();
446
+ });
447
+ child.stderr?.on("data", (chunk) => {
448
+ stderr += String(chunk);
449
+ touch();
450
+ });
451
+ child.on("error", (error) => {
452
+ stderr += String(error?.message || error);
453
+ failureMode ||= "crash";
454
+ });
248
455
 
249
456
  const stopFor = async (mode) => {
250
457
  if (failureMode) return;
@@ -252,23 +459,40 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
252
459
  await terminateChild(child.pid);
253
460
  };
254
461
 
255
- const timeoutTimer = setTimeout(() => { void stopFor('timeout'); }, timeout);
462
+ const timeoutTimer = setTimeout(() => {
463
+ void stopFor("timeout");
464
+ }, timeout);
256
465
  const stallTimer = setInterval(() => {
257
466
  const size = Buffer.byteLength(stdout) + Buffer.byteLength(stderr);
258
- if (size !== lastBytes) {
467
+ const currentResultFileSignature = resultFileSignature();
468
+ if (
469
+ size !== lastBytes ||
470
+ currentResultFileSignature !== lastResultFileSignature
471
+ ) {
259
472
  lastBytes = size;
473
+ lastResultFileSignature = currentResultFileSignature;
474
+ touch();
260
475
  return;
261
476
  }
262
- if (Date.now() - lastChange >= stallThresholdMs) void stopFor(inferStallMode(stdout, stderr));
477
+ if (Date.now() - lastChange >= stallThresholdMs)
478
+ void stopFor(inferStallMode(stdout, stderr));
263
479
  }, stallCheckIntervalMs);
264
480
  timeoutTimer.unref?.();
265
481
  stallTimer.unref?.();
266
482
 
267
- await new Promise((resolve) => child.on('close', (code) => { exitCode = code; resolve(); }));
483
+ await new Promise((resolve) =>
484
+ child.on("close", (code) => {
485
+ exitCode = code;
486
+ resolve();
487
+ }),
488
+ );
268
489
  clearTimeout(timeoutTimer);
269
490
  clearInterval(stallTimer);
270
491
 
271
- const fileOutput = resultFile && existsSync(resultFile) ? readFileSync(resultFile, 'utf8') : '';
492
+ const fileOutput =
493
+ resultFile && existsSync(resultFile)
494
+ ? readFileSync(resultFile, "utf8")
495
+ : "";
272
496
  const output = fileOutput || stdout;
273
497
  const ok = failureMode == null && exitCode === 0;
274
498
  return createResult(ok, {
@@ -276,6 +500,6 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
276
500
  stderr,
277
501
  exitCode,
278
502
  duration: Date.now() - startedAt,
279
- failureMode: ok ? null : (failureMode || 'crash'),
503
+ failureMode: ok ? null : failureMode || "crash",
280
504
  });
281
505
  }