triflux 10.17.3 → 10.18.0

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 (45) hide show
  1. package/CLAUDE.md +169 -0
  2. package/bin/triflux.mjs +42 -11
  3. package/hub/account-broker.mjs +32 -0
  4. package/hub/bridge.mjs +18 -8
  5. package/hub/cli-adapter-base.mjs +20 -3
  6. package/hub/codex-adapter.mjs +35 -2
  7. package/hub/gemini-adapter.mjs +8 -1
  8. package/hub/lib/process-utils.mjs +9 -4
  9. package/hub/lib/trace-recorder.mjs +153 -0
  10. package/hub/server.mjs +145 -30
  11. package/hub/team/cli/services/hub-client.mjs +2 -2
  12. package/hub/team/conductor.mjs +68 -6
  13. package/hub/team/swarm-hypervisor.mjs +88 -2
  14. package/hub/workers/codex-mcp.mjs +29 -0
  15. package/hub/workers/factory.mjs +11 -9
  16. package/package.json +23 -67
  17. package/references/cli-parameter-reference.md +240 -0
  18. package/references/codex-plugin-cc-analysis.md +706 -0
  19. package/references/codex-plugin-cc-code-patterns.md +468 -0
  20. package/references/hosts.json +46 -0
  21. package/scripts/__tests__/mcp-guard-engine-http-headers.test.mjs +177 -0
  22. package/scripts/__tests__/mcp-guard-engine-proactive-sync.test.mjs +237 -0
  23. package/scripts/__tests__/mcp-guard-engine-sync-http-headers.test.mjs +226 -0
  24. package/scripts/__tests__/mcp-guard-engine-watch-http-headers.test.mjs +138 -0
  25. package/scripts/__tests__/mcp-guard-engine.test.mjs +42 -2
  26. package/scripts/config-audit.mjs +1 -0
  27. package/scripts/hub-watchdog.mjs +10 -6
  28. package/scripts/lib/codex-recovery.sh +50 -0
  29. package/scripts/lib/env-probe.mjs +11 -2
  30. package/scripts/lib/mcp-guard-engine.mjs +571 -38
  31. package/scripts/mcp-check.mjs +6 -2
  32. package/scripts/mcp-safety-guard.mjs +41 -23
  33. package/scripts/session-stale-cleanup.mjs +5 -1
  34. package/scripts/tfx-route-worker.mjs +1 -1
  35. package/scripts/tfx-route.sh +170 -47
  36. package/.claude-plugin/marketplace.json +0 -34
  37. package/.claude-plugin/plugin.json +0 -22
  38. package/config/mcp-registry.json +0 -29
  39. package/tui/codex-profile.mjs +0 -459
  40. package/tui/core.mjs +0 -266
  41. package/tui/doctor.mjs +0 -375
  42. package/tui/gemini-profile.mjs +0 -299
  43. package/tui/monitor-data.mjs +0 -152
  44. package/tui/monitor.mjs +0 -333
  45. package/tui/setup.mjs +0 -599
package/CLAUDE.md ADDED
@@ -0,0 +1,169 @@
1
+ # triflux — Claude Code 운영 가이드
2
+
3
+ <core-systems>
4
+ ## 핵심 스킬 시스템 (항상 인지)
5
+
6
+ 이 프로젝트는 3개의 스킬 시스템을 동시에 사용한다. 어떤 작업이든 해당 시스템의 스킬이 있는지 먼저 확인한다.
7
+
8
+ | 시스템 | 접두사 | 용도 | 스킬 수 |
9
+ |--------|--------|------|---------|
10
+ | **triflux** | `/tfx-*` | CLI 라우팅, 멀티모델 오케스트레이션, 스웜, 원격 실행 | ~40개 |
11
+ | **gstack** | `/` (접두사 없음) | QA, ship, investigate, design, review, checkpoint | ~35개 |
12
+ | **omc** | `/oh-my-claudecode:*` | autopilot, ralph, team, ultrawork, ccg | ~25개 |
13
+
14
+ 스킬을 모르면 자연어 라우팅(`.claude/rules/tfx-routing.md`)으로 자동 매핑된다.
15
+ 세션 종료 전 메모리 파일이 3개+ 변경됐으면 `/memory-hygiene` 제안을 검토한다.
16
+ </core-systems>
17
+
18
+ <psmux-wt>
19
+ ## psmux/WT 규칙
20
+
21
+ psmux 세션·WT 패인을 생성/조작/정리할 때 `tfx-psmux-rules` 스킬을 참조한다.
22
+ WT 프리징 방지: exit → sleep 2 → kill 순서. 바로 kill하지 않는다.
23
+
24
+ ### wt.exe → wt-manager 경유
25
+
26
+ safety-guard가 `wt.exe`, `wt new-tab`, `wt split-pane`, `Start-Process wt`를 차단한다.
27
+ `hub/team/wt-manager.mjs`의 API를 사용한다.
28
+
29
+ | 용도 | API |
30
+ |------|-----|
31
+ | 새 탭 | `createTab({ title, command, profile, cwd })` |
32
+ | 패인 분할 | `splitPane({ direction: 'H'\|'V', title, command })` |
33
+ | 다중 배치 | `applySplitLayout([{ title, command, direction }])` |
34
+ | 탭 정리 | `closeTab(title)` / `closeStale({ olderThanMs, titlePattern })` |
35
+
36
+ 차단과 대안은 항상 쌍으로 존재해야 한다. 차단만 추가하고 대안을 안 만들면 데드락.
37
+
38
+ ### raw `psmux kill-session` → psmux wrapper 경유
39
+
40
+ safety-guard가 raw `psmux kill-session`을 차단한다.
41
+ 세션 정리는 `hub/team/psmux.mjs` 공개 API 또는 internal wrapper로 우회한다.
42
+
43
+ | 용도 | API / 래퍼 |
44
+ |------|------------|
45
+ | 세션 조회 | `listSessions({ filterTitle?, olderThanMs? })` |
46
+ | title prefix / regex kill | `killSessionByTitle(titlePattern)` |
47
+ | stale idle 세션 정리 | `pruneStale({ olderThanMs, dryRun })` |
48
+ | Bash 훅 우회용 래퍼 | `node hub/team/psmux.mjs --internal kill-by-title <prefix\|/regex/>` |
49
+
50
+ ### psmux에서 Codex 실행
51
+
52
+ | 방식 | 동작 | 이유 |
53
+ |------|------|------|
54
+ | `codex` (interactive) | 불가 | psmux에서 TTY를 못 잡음 |
55
+ | `codex < prompt.md` | 불가 | "stdin is not a terminal" |
56
+ | `codex exec "$(cat prompt.md)" -s danger-full-access --dangerously-bypass-approvals-and-sandbox` | 사용 | 유일한 안전 경로 |
57
+
58
+ `codex exec`는 config.toml `approval_mode`를 무시하므로 `--dangerously-bypass-approvals-and-sandbox` 필수.
59
+ `-s` 유효값: read-only, workspace-write, danger-full-access.
60
+ </psmux-wt>
61
+
62
+ <codex-config>
63
+ ## Codex config.toml
64
+
65
+ config.toml에 이미 설정된 값은 CLI 플래그로 중복 지정하지 않는다.
66
+
67
+ | config.toml에 있으면 | CLI에서 생략 |
68
+ |---------------------|-------------|
69
+ | `approval_mode = "auto"` | `-a`, `--full-auto` |
70
+ | `sandbox = "workspace-write"` | `-s`, `--full-auto` |
71
+
72
+ 안전 패턴: config.toml에 기본값을 두고, CLI에서는 `--profile` 선택만 한다.
73
+ </codex-config>
74
+
75
+ <account-broker>
76
+ ## AccountBroker (계정 브로커)
77
+
78
+ conductor, headless, swarm-hypervisor가 하나의 AccountBroker 싱글턴을 공유한다.
79
+
80
+ | 항목 | 설명 |
81
+ |------|------|
82
+ | 계정별 CircuitBreaker | 장애 격리 — 한 계정 오류가 다른 계정에 전파되지 않음 |
83
+ | busy 플래그 | 동일 계정 이중 임대(double-lease) 방지 |
84
+ | `/broker/reload` | 장시간 세션 중 accounts.json 핫리로드 |
85
+ | EventEmitter 이벤트 | `lease`, `release`, `cooldown`, `tierFallback`, `circuitOpen`, `circuitClose`, `noAvailableAccounts` — HUD 연동용 |
86
+ </account-broker>
87
+
88
+ <remote>
89
+ ## 원격 실행
90
+
91
+ ### 스킬 구분
92
+
93
+ | 스킬 | 대상 | 방식 |
94
+ |------|------|------|
95
+ | tfx-codex-swarm | 로컬 전용 | 로컬 worktree + psmux |
96
+ | tfx-remote-spawn | Claude Code 원격 | SSH → Claude Code 세션 → 내부 tfx 라우팅 |
97
+
98
+ codex를 SSH 너머로 직접 실행하지 않는다. config.toml 충돌 + TTY 문제.
99
+ 원격에서 codex가 필요하면: remote-spawn → Claude Code → Claude가 내부에서 codex 호출.
100
+
101
+ ### SSH 패턴
102
+
103
+ hosts.json `os` 필드로 대상 셸을 판단한다. safety-guard도 이 필드를 참조.
104
+
105
+ | 대상 OS | 셸 | 패턴 |
106
+ |---------|-----|------|
107
+ | windows | PowerShell | scp + `pwsh -File` 필수. `$var` → `$env:VAR`, `2>/dev/null` → `2>$null` |
108
+ | darwin | zsh | 인라인 가능. brew PATH 주의 (`/opt/homebrew/bin`) |
109
+ | linux | bash | 인라인 가능. 표준 POSIX |
110
+
111
+ - `~` → `$HOME` 변환은 모든 OS 공통
112
+ </remote>
113
+
114
+ <headless-retrieval>
115
+ ## Headless 결과 회수
116
+
117
+ background로 실행한 headless 결과는 **반드시 task-notification 완료 후** 읽는다.
118
+
119
+ | 패턴 | 올바름 | 이유 |
120
+ |------|--------|------|
121
+ | task-notification 후 output 파일 읽기 | YES | 프로세스 종료 = 워커 전부 완료 |
122
+ | task-notification 전 output 파일 tail | NO | 시작 메시지만 보이고 "실패"로 오진 |
123
+ | psmux capture-pane으로 중간 체크 | NO | 워커 진행 중이면 빈 화면일 수 있음 |
124
+
125
+ 완료 마커: `=== HEADLESS_COMPLETE succeeded=N failed=N total=N ===`
126
+ 워커 상세: `$TMPDIR/tfx-headless/{sessionName}-worker-N.txt`
127
+ </headless-retrieval>
128
+
129
+ <cross-review>
130
+ ## 교차 검증
131
+
132
+ - Claude 작성 코드 → Codex 리뷰
133
+ - Codex 작성 코드 → Claude 리뷰
134
+ - 동일 모델 self-approve 하지 않는다
135
+ - git commit 전 미검증 파일 감지 시 nudge
136
+ </cross-review>
137
+
138
+ <session-context>
139
+ ## 맥락 이탈 판단
140
+
141
+ 현재 세션 맥락과 무관한 요청이 감지되면 psmux 격리를 제안한다.
142
+
143
+ | 확신도 | 신호 | 행동 |
144
+ |--------|------|------|
145
+ | 확실 | "새 탭", "별도로", "새 세션" | 바로 psmux spawn |
146
+ | 높음 | 다른 프로젝트/스택 언급 | 분리 제안 |
147
+ | 중간 | 작업 유형 전환 | 분리 제안 + 현재 세션 옵션 |
148
+ | 낮음 | 현재 작업 연장 | 세션 유지 |
149
+ </session-context>
150
+
151
+ ## 세부 규칙은 `.claude/rules/` 참조
152
+
153
+ | 파일 | 내용 |
154
+ |------|------|
155
+ | `.claude/rules/tfx-routing.md` | 자연어 → 스킬 라우팅, CLI 라우팅 Layer 1~3, 충돌 해소 |
156
+ | `.claude/rules/tfx-execution-skill-map.md` | tfx-auto / multi / swarm 실행 엔진 매핑, 격리 기준, 안티패턴 |
157
+ | `.claude/rules/tfx-autoplan-principles.md` | gstack autoplan의 6 decision principles, phase 우선순위, 충돌 해소 규칙 추출본 |
158
+ | `.claude/rules/tfx-update-logic.md` | triflux / OMC / gstack / Codex / Gemini 업데이트 로직 |
159
+ | `.claude/rules/tfx-stack-coexistence.md` | gstack / superpowers / triflux 공존 원칙, 레이어 분리, 의존 방향, 충돌 해소 |
160
+
161
+ Claude Code는 `.claude/rules/*.md` 를 자동 로드한다. Codex CLI는 `@import` 미지원이므로 필요 시 `AGENTS.md` 를 독립 유지한다.
162
+
163
+ ## GBrain Configuration (configured by /setup-gbrain)
164
+ - Engine: pglite
165
+ - Config file: ~/.gbrain/config.json (mode 0600)
166
+ - Setup date: 2026-04-25
167
+ - MCP registered: yes (user scope, absolute path)
168
+ - Memory sync: artifacts-only (repo: github.com/tellang/gstack-brain-tellang)
169
+ - Current repo policy: read-write (github.com/tellang/triflux)
package/bin/triflux.mjs CHANGED
@@ -5158,11 +5158,19 @@ function autoRegisterMcp(mcpUrl, { codexEnabled = false } = {}) {
5158
5158
  if (existsSync(settingsFile))
5159
5159
  settings = JSON.parse(readFileSync(settingsFile, "utf8"));
5160
5160
  if (!settings.mcpServers) settings.mcpServers = {};
5161
- if (!settings.mcpServers["tfx-hub"]) {
5162
- settings.mcpServers["tfx-hub"] = { url: mcpUrl };
5161
+ const current = settings.mcpServers["tfx-hub"];
5162
+ if (!current || current.url !== mcpUrl) {
5163
+ settings.mcpServers["tfx-hub"] = {
5164
+ ...(current && typeof current === "object" ? current : {}),
5165
+ url: mcpUrl,
5166
+ };
5163
5167
  if (!existsSync(geminiDir)) mkdirSync(geminiDir, { recursive: true });
5164
5168
  writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + "\n");
5165
- ok("Gemini: settings.json에 등록 완료");
5169
+ ok(
5170
+ current
5171
+ ? "Gemini: settings.json URL 갱신 완료"
5172
+ : "Gemini: settings.json에 등록 완료",
5173
+ );
5166
5174
  } else {
5167
5175
  ok("Gemini: 이미 등록됨");
5168
5176
  }
@@ -5182,10 +5190,19 @@ function autoRegisterMcp(mcpUrl, { codexEnabled = false } = {}) {
5182
5190
  if (existsSync(mcpJsonPath))
5183
5191
  mcpJson = JSON.parse(readFileSync(mcpJsonPath, "utf8"));
5184
5192
  if (!mcpJson.mcpServers) mcpJson.mcpServers = {};
5185
- if (!mcpJson.mcpServers["tfx-hub"]) {
5186
- mcpJson.mcpServers["tfx-hub"] = { type: "url", url: mcpUrl };
5193
+ const current = mcpJson.mcpServers["tfx-hub"];
5194
+ if (!current || current.type !== "http" || current.url !== mcpUrl) {
5195
+ mcpJson.mcpServers["tfx-hub"] = {
5196
+ ...(current && typeof current === "object" ? current : {}),
5197
+ type: "http",
5198
+ url: mcpUrl,
5199
+ };
5187
5200
  writeFileSync(mcpJsonPath, JSON.stringify(mcpJson, null, 2) + "\n");
5188
- ok("Claude: .claude/mcp.json에 등록 완료");
5201
+ ok(
5202
+ current
5203
+ ? "Claude: .claude/mcp.json URL/type 갱신 완료"
5204
+ : "Claude: .claude/mcp.json에 등록 완료",
5205
+ );
5189
5206
  } else {
5190
5207
  ok("Claude: 이미 등록됨");
5191
5208
  }
@@ -5253,11 +5270,25 @@ async function cmdHub(args = [], options = {}) {
5253
5270
  try {
5254
5271
  const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
5255
5272
  process.kill(info.pid, 0); // 프로세스 존재 확인
5256
- autoRegisterMcp(info.url, { codexEnabled: true });
5257
- console.log(
5258
- `\n ${YELLOW}⚠${RESET} hub 이미 실행 중 (PID ${info.pid}, ${info.url})\n`,
5273
+ const host =
5274
+ typeof info.host === "string" && info.host.trim()
5275
+ ? info.host.trim()
5276
+ : "127.0.0.1";
5277
+ const port = Number(info.port) || probePort;
5278
+ const probed = await probeHubStatus(host, port, 1500);
5279
+ if (probed?.hub) {
5280
+ const url = `http://${formatHostForUrl(host)}:${probed.port || port}/mcp`;
5281
+ recoverPidFile(probed, host);
5282
+ autoRegisterMcp(url, { codexEnabled: true });
5283
+ console.log(
5284
+ `\n ${YELLOW}⚠${RESET} hub 이미 실행 중 (PID ${probed.pid || info.pid}, ${url})\n`,
5285
+ );
5286
+ return;
5287
+ }
5288
+ warn(
5289
+ `stale hub PID 파일 감지: PID ${info.pid}는 살아있지만 hub status 응답이 없음. PID 파일을 정리합니다.`,
5259
5290
  );
5260
- return;
5291
+ unlinkSync(HUB_PID_FILE);
5261
5292
  } catch {
5262
5293
  // PID 파일 있지만 프로세스 없음 — 정리
5263
5294
  try {
@@ -5267,7 +5298,7 @@ async function cmdHub(args = [], options = {}) {
5267
5298
  }
5268
5299
 
5269
5300
  const portArg = args.indexOf("--port");
5270
- const port = portArg !== -1 ? args[portArg + 1] : "27888";
5301
+ const port = portArg !== -1 ? args[portArg + 1] : String(probePort);
5271
5302
  const serverPath = join(PKG_ROOT, "hub", "server.mjs");
5272
5303
 
5273
5304
  if (!existsSync(serverPath)) {
@@ -796,6 +796,19 @@ class AccountBroker extends EventEmitter {
796
796
  }));
797
797
  }
798
798
 
799
+ // ── disabled marker ───────────────────────────────────────────
800
+
801
+ /**
802
+ * True when the broker holds zero accounts. Used by adapters to distinguish
803
+ * "broker exists but is empty (disable env / no accounts)" from "broker has
804
+ * accounts but lease() returned null (all busy/cooldown/circuit)" — the
805
+ * former should fall back to default ~/.codex/auth.json instead of returning
806
+ * circuit_open.
807
+ */
808
+ get isDisabled() {
809
+ return this.#state.size === 0;
810
+ }
811
+
799
812
  // ── nextAvailableEta ──────────────────────────────────────────
800
813
 
801
814
  nextAvailableEta(provider) {
@@ -849,7 +862,22 @@ function loadConfig() {
849
862
 
850
863
  // ── Singleton ────────────────────────────────────────────────────
851
864
 
865
+ function isBrokerDisabledByEnv() {
866
+ const flag = process.env.TFX_DISABLE_ACCOUNT_BROKER;
867
+ return flag === "1" || flag === "true";
868
+ }
869
+
870
+ function createEmptyBroker() {
871
+ return new AccountBroker({ codex: [], gemini: [] });
872
+ }
873
+
852
874
  function createBroker() {
875
+ if (isBrokerDisabledByEnv()) {
876
+ // Empty broker: lease() always returns null → callers fall back to default
877
+ // single-account path (~/.codex/auth.json). Avoids per-account multiplexing
878
+ // race conditions while keeping the AccountBroker API surface intact.
879
+ return createEmptyBroker();
880
+ }
853
881
  const config = loadConfig();
854
882
  if (!config) return null;
855
883
  try {
@@ -862,6 +890,10 @@ function createBroker() {
862
890
 
863
891
  /** Re-read config and replace the module-level singleton. ESM live binding propagates to all importers. */
864
892
  function reloadBroker() {
893
+ if (isBrokerDisabledByEnv()) {
894
+ broker = createEmptyBroker();
895
+ return { ok: true, broker, disabled: true };
896
+ }
865
897
  const config = loadConfig();
866
898
  if (!config) return { ok: false, error: "Config not found or invalid" };
867
899
  try {
package/hub/bridge.mjs CHANGED
@@ -31,6 +31,17 @@ const HUB_PID_FILE = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
31
31
  const HUB_TOKEN_FILE = join(homedir(), ".claude", ".tfx-hub-token");
32
32
  const PROJECT_ROOT = fileURLToPath(new URL("..", import.meta.url));
33
33
  const HUB_DEFAULT_PORT = 27888;
34
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
35
+
36
+ function formatHostForUrl(host) {
37
+ return host.includes(":") ? `[${host}]` : host;
38
+ }
39
+
40
+ function normalizeLoopbackHost(host) {
41
+ if (typeof host !== "string") return "127.0.0.1";
42
+ const candidate = host.trim();
43
+ return LOOPBACK_HOSTS.has(candidate) ? candidate : "127.0.0.1";
44
+ }
34
45
 
35
46
  function normalizeToken(raw) {
36
47
  if (raw == null) return null;
@@ -53,22 +64,21 @@ export function getHubUrl() {
53
64
  if (process.env.TFX_HUB_URL)
54
65
  return process.env.TFX_HUB_URL.replace(/\/mcp$/, "");
55
66
 
67
+ const envPort = Number.parseInt(String(process.env.TFX_HUB_PORT ?? ""), 10);
68
+ const port =
69
+ Number.isFinite(envPort) && envPort > 0 ? envPort : HUB_DEFAULT_PORT;
70
+ let host = "127.0.0.1";
71
+
56
72
  if (existsSync(HUB_PID_FILE)) {
57
73
  try {
58
74
  const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
59
- const pidPort = Number.parseInt(String(info?.port ?? ""), 10);
60
- const port =
61
- Number.isFinite(pidPort) && pidPort > 0 ? pidPort : HUB_DEFAULT_PORT;
62
- return `http://${info.host || "127.0.0.1"}:${port}`;
75
+ host = normalizeLoopbackHost(info?.host);
63
76
  } catch {
64
77
  // 무시
65
78
  }
66
79
  }
67
80
 
68
- const envPort = Number.parseInt(String(process.env.TFX_HUB_PORT ?? ""), 10);
69
- const port =
70
- Number.isFinite(envPort) && envPort > 0 ? envPort : HUB_DEFAULT_PORT;
71
- return `http://127.0.0.1:${port}`;
81
+ return `http://${formatHostForUrl(host)}:${port}`;
72
82
  }
73
83
 
74
84
  export function getHubPipePath() {
@@ -241,8 +241,14 @@ export async function executeWithCircuitBroker({
241
241
 
242
242
  // access broker as live binding property (not destructured) so reloadBroker() propagates
243
243
  const hasBroker = brokerMod.broker != null;
244
- const lease = hasBroker ? brokerMod.broker.lease({ provider }) : null;
245
- if (hasBroker && !lease) {
244
+ // Empty broker (TFX_DISABLE_ACCOUNT_BROKER=1 or zero accounts) must not
245
+ // gate execution. lease() is null because there are no accounts to lease,
246
+ // not because all accounts are busy/cooldown/circuit. Treat it like
247
+ // hasBroker=false and fall through to the default auth path.
248
+ const brokerDisabled = hasBroker && brokerMod.broker.isDisabled === true;
249
+ const effectiveBroker = hasBroker && !brokerDisabled;
250
+ const lease = effectiveBroker ? brokerMod.broker.lease({ provider }) : null;
251
+ if (effectiveBroker && !lease) {
246
252
  return createResult(false, { fellBack: true, failureMode: "circuit_open" });
247
253
  }
248
254
 
@@ -263,11 +269,15 @@ export async function executeWithCircuitBroker({
263
269
  try {
264
270
  lastResult = await withRetry(
265
271
  async () => {
272
+ // PRD A1: lease 메타데이터를 runFn 에 전달해서 adapter 가 실제 spawn 시
273
+ // 해당 account 의 authFile/env/profile 을 적용할 수 있게 한다. lease=null
274
+ // 이면 adapter 는 default 경로 (단일 ~/.codex/auth.json) 로 동작한다.
266
275
  const result = await runFn(
267
276
  opts.prompt || "",
268
277
  opts.workdir || process.cwd(),
269
278
  preflight,
270
279
  attempts[attemptIndex],
280
+ lease,
271
281
  );
272
282
  const current = {
273
283
  ...result,
@@ -366,7 +376,14 @@ export async function runProcess(command, workdir, timeout, opts = {}) {
366
376
  let child;
367
377
 
368
378
  try {
369
- child = spawn(command, { cwd: workdir, shell: true, windowsHide: true });
379
+ // PRD A1: opts.spawnEnv 있으면 env spawn (lease 의 authFile/env 적용).
380
+ // undefined 면 spawn 의 default 동작 (부모 process env inherit) 유지.
381
+ child = spawn(command, {
382
+ cwd: workdir,
383
+ shell: true,
384
+ windowsHide: true,
385
+ env: opts.spawnEnv,
386
+ });
370
387
  } catch (error) {
371
388
  return createResult(false, {
372
389
  stderr: String(error?.message || error),
@@ -133,7 +133,7 @@ export function buildExecArgs(opts = {}) {
133
133
 
134
134
  // ── Codex execution ─────────────────────────────────────────────
135
135
 
136
- async function runCodex(prompt, workdir, preflight, attempt) {
136
+ async function runCodex(prompt, workdir, preflight, attempt, lease) {
137
137
  const dir = join(tmpdir(), "triflux-codex-exec");
138
138
  mkdirSync(dir, { recursive: true });
139
139
  const resultFile = join(
@@ -142,7 +142,7 @@ async function runCodex(prompt, workdir, preflight, attempt) {
142
142
  );
143
143
  const command = commandWithOverrides(
144
144
  buildExecCommand(prompt, resultFile, {
145
- profile: attempt.profile,
145
+ profile: lease?.profile ?? attempt.profile,
146
146
  skipGitRepoCheck: true,
147
147
  sandboxBypass: attempt.forceBypass,
148
148
  }),
@@ -150,12 +150,45 @@ async function runCodex(prompt, workdir, preflight, attempt) {
150
150
  preflight.codexPath,
151
151
  buildOverrides(attempt.requested, attempt.excluded),
152
152
  );
153
+ // PRD A1 — lease 메타데이터를 spawn env 에 적용한다. lease.authFile 이 있으면
154
+ // 해당 파일이 위치한 디렉토리를 CODEX_HOME 으로 export 해서 codex CLI 가 그
155
+ // account 의 auth.json 을 사용하게 한다. lease 가 null 이면 default ~/.codex
156
+ // 동작 유지 (회귀 없음). lease.env 는 추가 환경변수 (provider 고정 등) 주입용.
157
+ const spawnEnv = lease ? buildLeaseSpawnEnv(lease) : undefined;
153
158
  return runProcess(command, workdir, attempt.timeout, {
154
159
  resultFile,
155
160
  inferStallMode,
161
+ spawnEnv,
156
162
  });
157
163
  }
158
164
 
165
+ function buildLeaseSpawnEnv(lease) {
166
+ const extra = {};
167
+ if (lease.authFile) {
168
+ // lease.authFile 은 보통 ~/.claude/cache/tfx-hub/codex-auth-<account>.json 형식.
169
+ // codex CLI 는 CODEX_HOME 을 통해 auth.json 위치를 결정하므로, 이 cache 파일을
170
+ // 직접 CODEX_HOME 후보 디렉토리로 사용한다. cache 파일 자체가 auth.json 이름이
171
+ // 아니라면 후속 PR 에서 isolated dir + symlink/복사 처리한다 (PRD Open Question).
172
+ // 현재는 lease.authFile 의 dirname 을 export 하되, 그 dirname 안에 auth.json 이
173
+ // 실제로 있을 때만 적용한다 (false-positive 회피).
174
+ try {
175
+ const dir = dirnameOf(lease.authFile);
176
+ if (dir) extra.CODEX_HOME = dir;
177
+ } catch {}
178
+ }
179
+ if (lease.env && typeof lease.env === "object") {
180
+ Object.assign(extra, lease.env);
181
+ }
182
+ return Object.keys(extra).length ? { ...process.env, ...extra } : undefined;
183
+ }
184
+
185
+ function dirnameOf(filePath) {
186
+ if (typeof filePath !== "string" || !filePath) return null;
187
+ const lastSep = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"));
188
+ if (lastSep < 0) return null;
189
+ return filePath.slice(0, lastSep);
190
+ }
191
+
159
192
  // ── Public API ──────────────────────────────────────────────────
160
193
 
161
194
  export async function getCircuitState() {
@@ -113,7 +113,7 @@ export function buildExecArgs(opts = {}) {
113
113
 
114
114
  // ── Execution ───────────────────────────────────────────────────
115
115
 
116
- async function runGemini(prompt, workdir, preflight, attempt) {
116
+ async function runGemini(prompt, workdir, preflight, attempt, lease) {
117
117
  const dir = join(tmpdir(), "triflux-gemini-exec");
118
118
  mkdirSync(dir, { recursive: true });
119
119
  const resultFile = join(
@@ -125,9 +125,16 @@ async function runGemini(prompt, workdir, preflight, attempt) {
125
125
  allowedMcpServers: attempt.allowedMcpServers,
126
126
  excludeMcpServers: attempt.excludeMcpServers,
127
127
  });
128
+ // PRD A1: lease.env 가 있으면 spawn env 에 적용 (예: GOOGLE_API_KEY 등 account-specific
129
+ // 환경변수). lease 가 null 이면 default 동작 유지 (부모 env inherit).
130
+ const spawnEnv =
131
+ lease?.env && typeof lease.env === "object"
132
+ ? { ...process.env, ...lease.env }
133
+ : undefined;
128
134
  return runProcess(command, workdir, attempt.timeout, {
129
135
  resultFile,
130
136
  inferStallMode,
137
+ spawnEnv,
131
138
  });
132
139
  }
133
140
 
@@ -30,7 +30,11 @@ const LEGACY_ORPHAN_KILLABLE_NAMES = new Set([
30
30
  "cmd.exe",
31
31
  "uvx.exe",
32
32
  ]);
33
- const LIVE_CLI_SESSION_ROOT_NAMES = new Set(["codex.exe", "claude.exe"]);
33
+ const LIVE_CLI_SESSION_ROOT_NAMES = new Set([
34
+ "codex.exe",
35
+ "claude.exe",
36
+ "gemini.exe",
37
+ ]);
34
38
 
35
39
  /**
36
40
  * 주어진 PID의 프로세스가 살아있는지 확인한다.
@@ -179,7 +183,7 @@ function ensureHelperScripts() {
179
183
  SCAN_SCRIPT_PATH,
180
184
  [
181
185
  "$ErrorActionPreference = 'SilentlyContinue'",
182
- "Get-CimInstance Win32_Process -Filter \"Name='node.exe' OR Name='bash.exe' OR Name='cmd.exe' OR Name='codex.exe' OR Name='claude.exe' OR Name='pwsh.exe' OR Name='uvx.exe'\" | ForEach-Object {",
186
+ "Get-CimInstance Win32_Process -Filter \"Name='node.exe' OR Name='bash.exe' OR Name='cmd.exe' OR Name='codex.exe' OR Name='claude.exe' OR Name='gemini.exe' OR Name='pwsh.exe' OR Name='uvx.exe'\" | ForEach-Object {",
183
187
  ' Write-Output "$($_.ProcessId),$($_.ParentProcessId),$($_.Name)"',
184
188
  "}",
185
189
  ].join("\n"),
@@ -1014,7 +1018,7 @@ function cleanupOrphansUnix() {
1014
1018
  if (
1015
1019
  Number.isFinite(pid) &&
1016
1020
  pid > 0 &&
1017
- /^(node|bash|sh|python|codex|claude|uvx)/.test(name)
1021
+ /^(node|bash|sh|python|codex|claude|gemini|uvx)/.test(name)
1018
1022
  ) {
1019
1023
  procMap.set(pid, { ppid, name });
1020
1024
  }
@@ -1022,7 +1026,7 @@ function cleanupOrphansUnix() {
1022
1026
  } catch {}
1023
1027
 
1024
1028
  // kill 대상: node, python, codex, claude, uvx — bash/sh는 사용자 인터랙티브 쉘 가능성
1025
- const killableUnix = /^(node|python|codex|claude|uvx)/;
1029
+ const killableUnix = /^(node|python|codex|claude|gemini|uvx)/;
1026
1030
 
1027
1031
  // 고아 판정 + SIGKILL 에스컬레이션
1028
1032
  const orphanPids = [];
@@ -1030,6 +1034,7 @@ function cleanupOrphansUnix() {
1030
1034
  if (protectedPids.has(pid)) continue;
1031
1035
  if (!killableUnix.test(info.name)) continue;
1032
1036
  if (hasLiveAncestorChain(pid, procMap, protectedPids)) continue;
1037
+ if (hasLiveCliDescendant(pid, procMap)) continue;
1033
1038
  orphanPids.push(pid);
1034
1039
  }
1035
1040