triflux 10.13.0 → 10.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/triflux.mjs CHANGED
@@ -134,7 +134,8 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
134
134
  ],
135
135
  },
136
136
  doctor: {
137
- usage: "tfx doctor [--fix] [--reset] [--audit] [--diagnose] [--json]",
137
+ usage:
138
+ "tfx doctor [--fix] [--reset] [--audit] [--diagnose] [--purge-logs] [--json]",
138
139
  description: "설치 상태 진단 및 자동 복구",
139
140
  options: [
140
141
  {
@@ -158,6 +159,12 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
158
159
  description:
159
160
  "진단 번들(zip) 생성: spawn-trace + hook timing + system info",
160
161
  },
162
+ {
163
+ name: "--purge-logs",
164
+ type: "boolean",
165
+ description:
166
+ "--fix 와 함께 사용. cli-issues.jsonl 에서 7일 초과 항목 물리 삭제 (#144)",
167
+ },
161
168
  {
162
169
  name: "--json",
163
170
  type: "boolean",
@@ -1742,7 +1749,12 @@ function ensureValidRegistryState() {
1742
1749
  }
1743
1750
 
1744
1751
  async function cmdDoctor(options = {}) {
1745
- const { fix = false, reset = false, json = false } = options;
1752
+ const {
1753
+ fix = false,
1754
+ reset = false,
1755
+ purgeLogs = false,
1756
+ json = false,
1757
+ } = options;
1746
1758
  const report = {
1747
1759
  status: "ok",
1748
1760
  mode: reset ? "reset" : fix ? "fix" : "check",
@@ -2357,9 +2369,20 @@ async function cmdDoctor(options = {}) {
2357
2369
  info(`업데이트 권장:\n${formatPsmuxUpdateGuidance(" ")}`);
2358
2370
  }
2359
2371
  if (psmuxSupport.missingOptionalCommands?.length > 0) {
2372
+ // #144: 단순히 "detach-first hardening 경로에서만 사용" 만으로는 사용자가
2373
+ // 영향 범위와 해결 방법을 알 수 없다. 각 capability 별 영향과 업그레이드 명령을 명시.
2360
2374
  info(
2361
- `선택 capability 미지원: ${psmuxSupport.missingOptionalCommands.join(", ")} (detach-first hardening 경로에서만 사용)`,
2375
+ `선택 capability 미지원: ${psmuxSupport.missingOptionalCommands.join(", ")}`,
2362
2376
  );
2377
+ if (psmuxSupport.missingOptionalCommands.includes("detach-client")) {
2378
+ info(
2379
+ " detach-client: WT 1.24 ConPTY close-race 회피용. WT 기반 병렬 실행(swarm dashboard, tfx-multi wt 모드) 에서 pane freeze/ConPTY hang 위험 증가.",
2380
+ );
2381
+ info(
2382
+ " 해결: psmux v3.4+ 로 업그레이드. 현재 psmux 업그레이드 명령:",
2383
+ );
2384
+ info(`${formatPsmuxUpdateGuidance(" ")}`);
2385
+ }
2363
2386
  }
2364
2387
 
2365
2388
  // 기본 셸 확인: psmux 세션의 기본 셸이 PowerShell인지 cmd.exe인지
@@ -2771,6 +2794,10 @@ async function cmdDoctor(options = {}) {
2771
2794
  ).version;
2772
2795
  let cleaned = 0;
2773
2796
 
2797
+ // #144: 오래된 로그 노이즈 완화 — 7일 초과 항목은 INFO 레벨로 downgrade.
2798
+ // --fix --purge-logs 플래그가 있으면 해당 오래된 항목은 실제 삭제.
2799
+ const STALE_AGE_MS = 7 * 24 * 3600 * 1000;
2800
+ let purged = 0;
2774
2801
  for (const [key, g] of Object.entries(groups)) {
2775
2802
  const fixVer = KNOWN_FIXES[key];
2776
2803
  if (fixVer && semverGte(currentVer, fixVer)) {
@@ -2785,30 +2812,62 @@ async function cmdDoctor(options = {}) {
2785
2812
  : age < 86400000
2786
2813
  ? `${Math.round(age / 3600000)}시간 전`
2787
2814
  : `${Math.round(age / 86400000)}일 전`;
2788
- const sev =
2789
- g.severity === "error"
2815
+ const isStale = age >= STALE_AGE_MS;
2816
+ if (isStale && fix && purgeLogs) {
2817
+ purged += g.count;
2818
+ continue;
2819
+ }
2820
+ const sev = isStale
2821
+ ? `${CYAN}INFO${RESET}`
2822
+ : g.severity === "error"
2790
2823
  ? `${RED}ERROR${RESET}`
2791
2824
  : `${YELLOW}WARN${RESET}`;
2792
- warn(`[${sev}] ${g.cli}/${g.pattern} x${g.count} (최근: ${ago})`);
2825
+ const staleTag = isStale ? " [STALE]" : "";
2826
+ if (isStale) {
2827
+ info(
2828
+ `[${sev}]${staleTag} ${g.cli}/${g.pattern} x${g.count} (최근: ${ago})`,
2829
+ );
2830
+ } else {
2831
+ warn(`[${sev}] ${g.cli}/${g.pattern} x${g.count} (최근: ${ago})`);
2832
+ }
2793
2833
  if (g.snippet) info(` ${g.snippet.substring(0, 120)}`);
2794
2834
  if (fixVer)
2795
2835
  info(` 해결: triflux >= v${fixVer} (npm update -g triflux)`);
2796
- issues++;
2836
+ if (isStale && !purgeLogs) {
2837
+ info(` 7일 초과 — 삭제: tfx doctor --fix --purge-logs`);
2838
+ }
2839
+ if (!isStale) issues++;
2797
2840
  }
2798
2841
 
2799
- // 해결된 이슈 자동 정리
2800
- if (cleaned > 0) {
2842
+ // #144 Codex review P2: 두 filter (stale purge + KNOWN_FIXES) 가 각각 원본 entries 로
2843
+ // write 하면 번째 write 가 첫 번째 결과를 되살린다. 단일 통합 필터로 한 번만 저장.
2844
+ if (purged > 0 || cleaned > 0) {
2845
+ const now = Date.now();
2801
2846
  const remaining = entries.filter((e) => {
2847
+ // purge-logs 로 물리 삭제 대상: 7일 초과
2848
+ if (purged > 0 && now - e.ts >= STALE_AGE_MS) return false;
2849
+ // KNOWN_FIXES 해결된 이슈 제거
2802
2850
  const key = `${e.cli}:${e.pattern}`;
2803
2851
  const fixVer = KNOWN_FIXES[key];
2804
- return !(fixVer && semverGte(currentVer, fixVer));
2852
+ if (fixVer && semverGte(currentVer, fixVer)) return false;
2853
+ return true;
2805
2854
  });
2806
2855
  writeFileSync(
2807
2856
  issuesFile,
2808
2857
  remaining.map((e) => JSON.stringify(e)).join("\n") +
2809
2858
  (remaining.length ? "\n" : ""),
2810
2859
  );
2811
- ok(`${cleaned}개 해결된 이슈 자동 정리됨`);
2860
+ if (purged > 0) {
2861
+ ok(`${purged}개 stale 로그 항목 삭제 (7일 초과)`);
2862
+ report.actions.push({
2863
+ name: "purge-stale-logs",
2864
+ status: "applied",
2865
+ count: purged,
2866
+ });
2867
+ }
2868
+ if (cleaned > 0) {
2869
+ ok(`${cleaned}개 해결된 이슈 자동 정리됨`);
2870
+ }
2812
2871
  }
2813
2872
  addDoctorCheck(report, {
2814
2873
  name: "cli-issues",
@@ -3373,6 +3432,56 @@ async function cmdDoctor(options = {}) {
3373
3432
  if (row.actualUrl) info(`actual ${row.actualUrl}`);
3374
3433
  }
3375
3434
 
3435
+ // #144: --fix 모드에서 tfx-hub URL 불일치를 hub status 기준으로 자동 갱신.
3436
+ // Project MCP (.mcp.json) 와 Codex/Claude/Gemini settings 모두 대상.
3437
+ // Codex review P2: fix 성공 시 issues 집계에서 차감해야 doctor 결과가 ok 로 반영됨.
3438
+ let autoFixedMismatches = 0;
3439
+ if (fix && mismatchRows.some((r) => r.name === "tfx-hub")) {
3440
+ try {
3441
+ const hubUrl = mismatchRows.find(
3442
+ (r) => r.name === "tfx-hub",
3443
+ )?.expectedUrl;
3444
+ if (hubUrl) {
3445
+ const { syncHubMcpSettings, syncProjectMcpJson } = await import(
3446
+ "../scripts/sync-hub-mcp-settings.mjs"
3447
+ );
3448
+ const settingsResult = await syncHubMcpSettings({
3449
+ hubUrl,
3450
+ logger: { log() {}, warn() {}, error() {} },
3451
+ });
3452
+ const projectResult = await syncProjectMcpJson({
3453
+ hubUrl,
3454
+ projectRoot: process.cwd(),
3455
+ logger: { log() {}, warn() {}, error() {} },
3456
+ });
3457
+ const totalUpdated =
3458
+ (settingsResult?.updated?.length || 0) +
3459
+ (projectResult?.updated?.length || 0);
3460
+ if (totalUpdated > 0) {
3461
+ ok(`tfx-hub URL ${totalUpdated}개 파일 자동 갱신 (${hubUrl})`);
3462
+ report.actions.push({
3463
+ name: "sync-hub-url",
3464
+ status: "applied",
3465
+ files: [
3466
+ ...(settingsResult?.updated || []),
3467
+ ...(projectResult?.updated || []),
3468
+ ],
3469
+ });
3470
+ // fix 성공 — mismatchRows 중 tfx-hub 엔트리는 해결된 것으로 집계
3471
+ autoFixedMismatches = mismatchRows.filter(
3472
+ (r) => r.name === "tfx-hub",
3473
+ ).length;
3474
+ } else {
3475
+ info("tfx-hub URL 자동 갱신: 대상 파일 없음");
3476
+ }
3477
+ }
3478
+ } catch (e) {
3479
+ warn(
3480
+ `tfx-hub URL 자동 갱신 실패: ${e?.message?.split(/\r?\n/)[0] || e}`,
3481
+ );
3482
+ }
3483
+ }
3484
+
3376
3485
  for (const row of missingFileRows) {
3377
3486
  info(
3378
3487
  `${row.label}: ${row.name} 미배치 (${formatPathForDisplay(row.filePath)})`,
@@ -3395,7 +3504,7 @@ async function cmdDoctor(options = {}) {
3395
3504
  }
3396
3505
 
3397
3506
  issues += invalidConfigs.length;
3398
- issues += mismatchRows.length;
3507
+ issues += Math.max(0, mismatchRows.length - autoFixedMismatches);
3399
3508
  issues += stdioRows.length;
3400
3509
  }
3401
3510
  }
@@ -5508,7 +5617,8 @@ async function main() {
5508
5617
  }
5509
5618
  const fix = cmdArgs.includes("--fix");
5510
5619
  const reset = cmdArgs.includes("--reset");
5511
- await cmdDoctor({ fix, reset, json: JSON_OUTPUT });
5620
+ const purgeLogs = cmdArgs.includes("--purge-logs");
5621
+ await cmdDoctor({ fix, reset, purgeLogs, json: JSON_OUTPUT });
5512
5622
  return;
5513
5623
  }
5514
5624
  case "mcp":
package/hub/bridge.mjs CHANGED
@@ -23,7 +23,6 @@ import { parseArgs as nodeParseArgs } from "node:util";
23
23
  import { getPipelineStateDbPath } from "./pipeline/state.mjs";
24
24
  import {
25
25
  createRetryStateMachine,
26
- DEFAULT_ESCALATION_CHAIN,
27
26
  loadSnapshot,
28
27
  saveSnapshot,
29
28
  } from "./team/retry-state-machine.mjs";
@@ -45,7 +45,7 @@ export async function startHeadlessTeam({
45
45
  ok(`headless ${assignments.length}워커 시작`);
46
46
 
47
47
  const handle = await runHeadlessInteractive(sessionId, assignments, {
48
- timeoutSec: timeoutSec || 300,
48
+ timeoutSec: timeoutSec || 900,
49
49
  layout,
50
50
  autoAttach: !!autoAttach,
51
51
  dashboard: !!dashboard,
@@ -11,6 +11,7 @@ import {
11
11
  mkdirSync,
12
12
  readFileSync,
13
13
  renameSync,
14
+ rmSync,
14
15
  statSync,
15
16
  writeFileSync,
16
17
  } from "node:fs";
@@ -240,16 +241,53 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
240
241
  return `cd '${safeCwd.replace(/'/g, "'\\''")}' && ${backendCommand}`;
241
242
  }
242
243
 
244
+ /**
245
+ * 이전 run 의 stale .txt / .partial / .err 를 제거한다.
246
+ * Issue #118 Codex review R1 HIGH: resultFile 경로 재사용 시 (워커 restart 또는
247
+ * 동일 세션명 재실행) 이전 run 의 HANDOFF 가 새 run 결과로 오인될 수 있다.
248
+ *
249
+ * Issue #118 R2 MEDIUM: Windows 에서 locked/open 파일로 인한 삭제 실패를
250
+ * silent swallow 하면 stale artifact 가 살아남아 bug 가 non-deterministic 하게
251
+ * 재발한다. ENOENT 외 에러는 경고 로그 + 재시도(1회) 후 계속.
252
+ * @param {string} resultFile
253
+ */
254
+ export function cleanStaleResultArtifacts(resultFile) {
255
+ for (const suffix of ["", ".partial", ".err"]) {
256
+ const path = `${resultFile}${suffix}`;
257
+ for (let attempt = 0; attempt < 2; attempt++) {
258
+ try {
259
+ rmSync(path);
260
+ break;
261
+ } catch (err) {
262
+ if (err?.code === "ENOENT") break; // 이미 없음 — 정상
263
+ if (attempt === 0) continue; // 1회 재시도
264
+ // 재시도도 실패: locked 파일일 수 있음. 경고 로그 후 계속 진행
265
+ // (dispatch 중단 시 워커 자체가 시작 못하므로 best-effort 유지)
266
+ console.warn(
267
+ `[headless] cleanStaleResultArtifacts: ${path} 삭제 실패 (${err?.code || "unknown"}). stale leak 가능.`,
268
+ );
269
+ }
270
+ }
271
+ }
272
+ }
273
+
243
274
  /**
244
275
  * 결과 파일 읽기 (없으면 capture-pane fallback)
276
+ * Issue #118 fallback chain: .txt → .partial ([partial] prefix) → .err → capture-pane
245
277
  * @param {string} resultFile
246
278
  * @param {string} paneId
247
279
  * @returns {string}
248
280
  */
249
- function readResult(resultFile, paneId) {
281
+ export function readResult(resultFile, paneId) {
250
282
  if (existsSync(resultFile)) {
251
283
  return readFileSync(resultFile, "utf8").trim();
252
284
  }
285
+ // Issue #118 fallback 0: timeout 직전 persist 된 부분 출력 (.partial)
286
+ const partialFile = `${resultFile}.partial`;
287
+ if (existsSync(partialFile)) {
288
+ const partial = readFileSync(partialFile, "utf8").trim();
289
+ if (partial) return `[partial] ${partial}`;
290
+ }
253
291
  // fallback 1: stderr 파일 (codex 실패 시 원인 추적)
254
292
  const errFile = `${resultFile}.err`;
255
293
  if (existsSync(errFile)) {
@@ -417,6 +455,17 @@ export async function waitForCompletionWithStallDetect(
417
455
 
418
456
  // 전체 타임아웃
419
457
  if (now - startedAt > completionTimeout) {
458
+ // Issue #118: timeout kill 전에 capture-pane 출력을 .partial 파일로 persist.
459
+ // resultFile(`.txt`)이 아직 생성되지 않았을 수 있으므로 readResult 가 fallback 으로 읽는다.
460
+ try {
461
+ const partialSnapshot = _capture(currentPaneId, 200);
462
+ if (partialSnapshot && partialSnapshot.trim().length > 0) {
463
+ const writer = deps.writeFileSync || writeFileSync;
464
+ writer(`${resultFile}.partial`, partialSnapshot, "utf8");
465
+ }
466
+ } catch {
467
+ /* best-effort, timeout 은 이미 확정 */
468
+ }
420
469
  return {
421
470
  matched: false,
422
471
  exitCode: null,
@@ -610,6 +659,8 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
610
659
  RESULT_DIR,
611
660
  `${sessionName}-${paneName}.txt`,
612
661
  ).replace(/\\/g, "/");
662
+ // Issue #118 review R1 HIGH: stale artifact 제거 (이전 run / restart 잔재)
663
+ cleanStaleResultArtifacts(resultFile);
613
664
  const cmd = buildHeadlessCommand(
614
665
  assignment.cli,
615
666
  assignment.prompt,
@@ -686,6 +737,8 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
686
737
  RESULT_DIR,
687
738
  `${sessionName}-${paneName}.txt`,
688
739
  ).replace(/\\/g, "/");
740
+ // Issue #118 review R1 HIGH: stale artifact 제거 (이전 run / restart 잔재)
741
+ cleanStaleResultArtifacts(resultFile);
689
742
  const cmd = buildHeadlessCommand(
690
743
  assignment.cli,
691
744
  assignment.prompt,
@@ -832,9 +885,20 @@ async function awaitAll(
832
885
  );
833
886
  }
834
887
 
835
- const output = completion.matched
836
- ? readResult(d.resultFile, d.paneId)
837
- : "";
888
+ // Issue #118: matched=false(timeout kill) 에도 capture-pane 스냅샷을
889
+ // .partial 로 persist 하여 readResult fallback chain 이 복구할 수 있도록 한다.
890
+ if (!completion.matched) {
891
+ try {
892
+ const snap = capturePsmuxPane(d.paneId || d.paneName, 200);
893
+ if (snap && snap.trim().length > 0) {
894
+ writeFileSync(`${d.resultFile}.partial`, snap, "utf8");
895
+ }
896
+ } catch {
897
+ /* best-effort */
898
+ }
899
+ }
900
+
901
+ const output = readResult(d.resultFile, d.paneId);
838
902
  unregisterHeadlessSynapseWorker(d.workerId);
839
903
 
840
904
  if (safeProgress) {
@@ -945,7 +1009,7 @@ function collectGitDiffFiles(cwd) {
945
1009
  */
946
1010
  export async function runHeadless(sessionName, assignments, opts = {}) {
947
1011
  const {
948
- timeoutSec = 300,
1012
+ timeoutSec = 900,
949
1013
  layout = "2x2",
950
1014
  onProgress,
951
1015
  progressIntervalSec = 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.13.0",
3
+ "version": "10.13.2",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,105 @@
1
+ // scripts/__tests__/setup-cleanup-stale-skills.test.mjs
2
+ // #144: cleanupStaleSkills 가 nested directory 를 가진 stale 스킬도 재귀 삭제하는지 확인.
3
+ //
4
+ // 이전 구현은 top-level 파일만 unlinkSync → 하위 폴더 있는 과거 스킬
5
+ // (tfx-deep-*, tfx-codex-swarm 등) 은 제거 실패 → "triflux update 돌려도 13개 그대로" UX bug.
6
+
7
+ import assert from "node:assert/strict";
8
+ import {
9
+ existsSync,
10
+ mkdirSync,
11
+ mkdtempSync,
12
+ rmSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import path from "node:path";
17
+ import { after, describe, it } from "node:test";
18
+
19
+ const SETUP_MJS_URL = new URL("../setup.mjs", import.meta.url).href;
20
+ const { cleanupStaleSkills } = await import(SETUP_MJS_URL);
21
+
22
+ describe("#144 cleanupStaleSkills — 재귀 삭제", () => {
23
+ const cleanupDirs = [];
24
+ after(() => {
25
+ for (const d of cleanupDirs) rmSync(d, { recursive: true, force: true });
26
+ });
27
+
28
+ function setupFixture() {
29
+ const root = mkdtempSync(path.join(tmpdir(), "tfx-cleanup-"));
30
+ cleanupDirs.push(root);
31
+ const installedDir = path.join(root, "installed");
32
+ const pkgDir = path.join(root, "pkg");
33
+ mkdirSync(installedDir, { recursive: true });
34
+ mkdirSync(pkgDir, { recursive: true });
35
+ // pkg 에는 tfx-auto 만 있음 (나머지는 installed 에서 stale 로 감지)
36
+ mkdirSync(path.join(pkgDir, "tfx-auto"), { recursive: true });
37
+ return { installedDir, pkgDir };
38
+ }
39
+
40
+ it("nested directory 가 있는 stale 스킬도 전부 제거된다 (과거 회귀 bug)", () => {
41
+ const { installedDir, pkgDir } = setupFixture();
42
+ // stale 스킬: top-level 파일 + nested 디렉토리
43
+ const staleSkill = path.join(installedDir, "tfx-deep-review");
44
+ mkdirSync(staleSkill, { recursive: true });
45
+ writeFileSync(path.join(staleSkill, "SKILL.md"), "# deprecated");
46
+ const nested = path.join(staleSkill, "snapshot");
47
+ mkdirSync(nested, { recursive: true });
48
+ writeFileSync(path.join(nested, "data.json"), "{}");
49
+ mkdirSync(path.join(nested, "sub"), { recursive: true });
50
+ writeFileSync(path.join(nested, "sub", "more.txt"), "xxx");
51
+
52
+ // 유지해야 할 스킬 (pkg 에 있음)
53
+ mkdirSync(path.join(installedDir, "tfx-auto"), { recursive: true });
54
+ writeFileSync(path.join(installedDir, "tfx-auto", "SKILL.md"), "# ok");
55
+
56
+ const result = cleanupStaleSkills(installedDir, pkgDir);
57
+ assert.equal(result.count, 1);
58
+ assert.deepEqual(result.removed, ["tfx-deep-review"]);
59
+ assert.equal(existsSync(staleSkill), false, "nested dir 포함 전부 삭제");
60
+ assert.equal(
61
+ existsSync(path.join(installedDir, "tfx-auto")),
62
+ true,
63
+ "pkg 에 있는 스킬은 보존",
64
+ );
65
+ });
66
+
67
+ it("top-level 파일만 있는 stale 스킬도 제거된다 (legacy behavior 회귀 방지)", () => {
68
+ const { installedDir, pkgDir } = setupFixture();
69
+ const staleSkill = path.join(installedDir, "tfx-autoresearch");
70
+ mkdirSync(staleSkill, { recursive: true });
71
+ writeFileSync(path.join(staleSkill, "SKILL.md"), "# deprecated");
72
+ writeFileSync(path.join(staleSkill, "config.json"), "{}");
73
+
74
+ const result = cleanupStaleSkills(installedDir, pkgDir);
75
+ assert.equal(result.count, 1);
76
+ assert.equal(existsSync(staleSkill), false);
77
+ });
78
+
79
+ it("SKILL_ALIASES 에 있는 alias 는 유지된다", () => {
80
+ const { installedDir, pkgDir } = setupFixture();
81
+ // alias (tfx-autopilot) 는 SKILL_ALIASES 에 있으므로 pkgNames 에 자동 포함
82
+ mkdirSync(path.join(installedDir, "tfx-autopilot"), { recursive: true });
83
+ writeFileSync(
84
+ path.join(installedDir, "tfx-autopilot", "SKILL.md"),
85
+ "# alias",
86
+ );
87
+
88
+ const result = cleanupStaleSkills(installedDir, pkgDir);
89
+ assert.equal(result.count, 0);
90
+ assert.equal(existsSync(path.join(installedDir, "tfx-autopilot")), true);
91
+ });
92
+
93
+ it("tfx- 접두사 없는 디렉토리는 건드리지 않음", () => {
94
+ const { installedDir, pkgDir } = setupFixture();
95
+ mkdirSync(path.join(installedDir, "other-skill"), { recursive: true });
96
+ writeFileSync(
97
+ path.join(installedDir, "other-skill", "SKILL.md"),
98
+ "# other",
99
+ );
100
+
101
+ const result = cleanupStaleSkills(installedDir, pkgDir);
102
+ assert.equal(result.count, 0);
103
+ assert.equal(existsSync(path.join(installedDir, "other-skill")), true);
104
+ });
105
+ });
package/scripts/setup.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  mkdirSync,
14
14
  readdirSync,
15
15
  readFileSync,
16
+ rmSync,
16
17
  unlinkSync,
17
18
  writeFileSync,
18
19
  } from "fs";
@@ -56,10 +57,45 @@ const REQUIRED_CODEX_PROFILES = [
56
57
  name: "codex53_xhigh",
57
58
  lines: ['model = "gpt-5.3-codex"', 'model_reasoning_effort = "xhigh"'],
58
59
  },
60
+ {
61
+ name: "codex53_med",
62
+ lines: ['model = "gpt-5.3-codex"', 'model_reasoning_effort = "medium"'],
63
+ },
59
64
  {
60
65
  name: "spark53_low",
61
66
  lines: ['model = "gpt-5.3-codex-spark"', 'model_reasoning_effort = "low"'],
62
67
  },
68
+ {
69
+ name: "spark53_med",
70
+ lines: [
71
+ 'model = "gpt-5.3-codex-spark"',
72
+ 'model_reasoning_effort = "medium"',
73
+ ],
74
+ },
75
+ {
76
+ name: "gpt54_xhigh",
77
+ lines: ['model = "gpt-5.4"', 'model_reasoning_effort = "xhigh"'],
78
+ },
79
+ {
80
+ name: "gpt54_high",
81
+ lines: ['model = "gpt-5.4"', 'model_reasoning_effort = "high"'],
82
+ },
83
+ {
84
+ name: "gpt54_low",
85
+ lines: ['model = "gpt-5.4"', 'model_reasoning_effort = "low"'],
86
+ },
87
+ {
88
+ name: "mini54_low",
89
+ lines: ['model = "gpt-5.4-mini"', 'model_reasoning_effort = "low"'],
90
+ },
91
+ {
92
+ name: "mini54_med",
93
+ lines: ['model = "gpt-5.4-mini"', 'model_reasoning_effort = "medium"'],
94
+ },
95
+ {
96
+ name: "mini54_high",
97
+ lines: ['model = "gpt-5.4-mini"', 'model_reasoning_effort = "high"'],
98
+ },
63
99
  ];
64
100
 
65
101
  const HUD_SYNC_EXCLUDES = new Set(["omc-hud.mjs", "omc-hud.mjs.bak"]);
@@ -395,17 +431,15 @@ function cleanupStaleSkills(installedDir, pkgDir) {
395
431
  if (pkgNames.has(name)) continue;
396
432
 
397
433
  const skillPath = join(installedDir, name);
434
+ // #144: 재귀 삭제 필요 — 과거 구현은 파일만 unlink 하여 nested 디렉토리가 있는 스킬을
435
+ // 온전히 제거하지 못했다. `tfx-deep-*`, `tfx-codex-swarm` 같은 과거 잔재 디렉토리는
436
+ // workspace/snapshot 같은 하위 폴더를 가지므로 rmSync recursive 가 필수.
398
437
  try {
399
- const entries = readdirSync(skillPath);
400
- for (const f of entries) unlinkSync(join(skillPath, f));
401
- // rmdir only works on empty dirs; ignore errors for nested
402
- try {
403
- readdirSync(skillPath).length === 0 && unlinkSync(skillPath);
404
- } catch {}
438
+ rmSync(skillPath, { recursive: true, force: true });
439
+ removed.push(name);
405
440
  } catch {
406
- /* best effort */
441
+ /* best effort — next setup/update cycle 에서 재시도 */
407
442
  }
408
- removed.push(name);
409
443
  }
410
444
  return { count: removed.length, removed };
411
445
  }
@@ -556,6 +590,15 @@ function ensureCodexHubServerConfig({
556
590
  }
557
591
  }
558
592
 
593
+ // Top-level config.toml keys that must exist with these defaults.
594
+ // Only injected when the key is completely absent — existing user values are
595
+ // never overwritten, regardless of what value was set.
596
+ const REQUIRED_TOP_LEVEL_SETTINGS = [
597
+ { key: "model", value: '"gpt-5.4"' },
598
+ { key: "model_reasoning_effort", value: '"high"' },
599
+ { key: "service_tier", value: '"fast"' },
600
+ ];
601
+
559
602
  function ensureCodexProfiles() {
560
603
  try {
561
604
  if (!existsSync(CODEX_DIR)) mkdirSync(CODEX_DIR, { recursive: true });
@@ -564,9 +607,45 @@ function ensureCodexProfiles() {
564
607
  ? readFileSync(CODEX_CONFIG_PATH, "utf8")
565
608
  : "";
566
609
 
610
+ // Safety guard: if the file exists but is suspiciously small (< 100 bytes)
611
+ // skip all writes to avoid perpetuating a corrupted state.
612
+ if (original.length > 0 && original.length < 100) {
613
+ process.stderr.write(
614
+ `[tfx-setup] config.toml 크기 이상 (${original.length} bytes) — 쓰기 스킵. 수동 확인 필요: ${CODEX_CONFIG_PATH}\n`,
615
+ );
616
+ return { ok: false, changed: 0, message: "config too small, skipped" };
617
+ }
618
+
567
619
  let updated = original;
568
620
  let changed = 0;
569
621
 
622
+ // ── 1. top-level 필수 설정 주입 (없을 때만, 기존 값 보존) ──
623
+ // 파일 상단 [profiles.*] / [mcp_servers.*] 이전 영역만 검사한다.
624
+ // 프로필 섹션 내부의 동명 키(예: model = "gpt-5.3-codex")는 무시한다.
625
+ // 이미 존재하는 키는 절대 덮어쓰지 않는다.
626
+ for (const { key, value } of REQUIRED_TOP_LEVEL_SETTINGS) {
627
+ // top-level 영역 = 첫 번째 [profiles.*] / [mcp_servers.*] 헤더 이전
628
+ const firstSectionIdx = updated.search(/^\[(?:profiles|mcp_servers)\./m);
629
+ const topLevelRegion =
630
+ firstSectionIdx === -1 ? updated : updated.slice(0, firstSectionIdx);
631
+ const topLevelKeyRe = new RegExp(`^${key}\\s*=`, "m");
632
+ if (!topLevelKeyRe.test(topLevelRegion)) {
633
+ // firstSectionIdx already computed above for this iteration
634
+ const line = `${key} = ${value}\n`;
635
+ if (firstSectionIdx === -1) {
636
+ // 섹션이 없으면 파일 맨 앞에 추가
637
+ updated = line + updated;
638
+ } else {
639
+ updated =
640
+ updated.slice(0, firstSectionIdx) +
641
+ line +
642
+ updated.slice(firstSectionIdx);
643
+ }
644
+ changed++;
645
+ }
646
+ }
647
+
648
+ // ── 2. 필수 프로필 보장 ──
570
649
  for (const profile of REQUIRED_CODEX_PROFILES) {
571
650
  const desired = `[profiles.${profile.name}]\n${profile.lines.join("\n")}\n`;
572
651
 
@@ -898,6 +977,7 @@ export {
898
977
  LEGACY_CODEX_MODELS,
899
978
  PLUGIN_ROOT,
900
979
  REQUIRED_CODEX_PROFILES,
980
+ REQUIRED_TOP_LEVEL_SETTINGS,
901
981
  readMarker,
902
982
  replaceProfileSection,
903
983
  SETUP_MARKER_PATH,
@@ -1348,6 +1348,32 @@ heartbeat_monitor() {
1348
1348
  echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B status=draining(${post_exit_checks}/${max_post_exit_checks})" >&2
1349
1349
  fi
1350
1350
  elif [[ "$stall_count" -ge "$stall_threshold" ]]; then
1351
+ # STALL kill (#144/#66 regression guard): stall=threshold+grace 이상 지속 시 SIGTERM→SIGKILL.
1352
+ # 기본 활성화. TFX_STALL_KILL=0 으로 opt-out. grace=30s (기본) 은 SSE/MCP 정상 handshake 여유.
1353
+ local kill_on_stall="${TFX_STALL_KILL:-1}"
1354
+ local kill_grace="${TFX_STALL_KILL_GRACE:-30}"
1355
+ if [[ "$kill_on_stall" -eq 1 && "$stall_count" -ge $((stall_threshold + kill_grace)) ]]; then
1356
+ echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B status=STALL_KILL stall=${stall_count}s — SIGTERM" >&2
1357
+ kill -TERM "$pid" 2>/dev/null || true
1358
+ local _grace_waited=0
1359
+ while kill -0 "$pid" 2>/dev/null && [[ "$_grace_waited" -lt 5 ]]; do
1360
+ sleep 1
1361
+ _grace_waited=$((_grace_waited + 1))
1362
+ done
1363
+ if kill -0 "$pid" 2>/dev/null; then
1364
+ # Windows/MSYS: POSIX SIGKILL 이 Win32 자식 트리까지 닿지 않는다.
1365
+ # cleanup_workers 와 동일하게 taskkill /T /F 로 트리 종료.
1366
+ case "$(uname -s)" in
1367
+ MINGW*|MSYS*)
1368
+ echo "[tfx-heartbeat] pid=$pid SIGTERM 무시 — taskkill /T /F" >&2
1369
+ MSYS_NO_PATHCONV=1 cmd.exe //c "taskkill /T /F /PID $pid" 2>/dev/null || true ;;
1370
+ *)
1371
+ echo "[tfx-heartbeat] pid=$pid SIGTERM 무시 — SIGKILL 강제" >&2
1372
+ kill -KILL "$pid" 2>/dev/null || true ;;
1373
+ esac
1374
+ fi
1375
+ break
1376
+ fi
1351
1377
  echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B status=STALL stall=${stall_count}s" >&2
1352
1378
  else
1353
1379
  echo "[tfx-heartbeat] pid=$pid elapsed=${elapsed}s output=${current_size}B status=quiet stall=${stall_count}s" >&2
@@ -1445,15 +1471,63 @@ _codex_config_swap() {
1445
1471
  return 0
1446
1472
  fi
1447
1473
 
1448
- # 백업 생성 (이미 있으면 다른 워커가 swap 건드리지 않음)
1449
- if [[ -f "$backup" ]]; then
1450
- echo "[tfx-route] config.toml swap 스킵: 다른 워커가 사용 중 ($backup)" >&2
1474
+ # Pre-validation: config.toml이 500 bytes 미만이면 이미 손상된 상태일 있음스킵
1475
+ local config_size
1476
+ config_size=$(wc -c < "$config" 2>/dev/null | tr -d ' ') || config_size=0
1477
+ if [[ "$config_size" -lt 500 ]]; then
1478
+ echo "[tfx-route] 경고: config.toml 크기 ${config_size} bytes — 손상 의심, swap 스킵 (수동 확인 필요)" >&2
1451
1479
  return 0
1452
1480
  fi
1481
+
1482
+ # 백업 생성 (이미 있으면 다른 워커가 swap 중 — 단, owner-dead + 백업 안전 복원 시 이어받기)
1483
+ if [[ -f "$backup" ]]; then
1484
+ # Owner PID marker (P1 fix): mtime 만으로 stale 을 판정하면 장시간 정상 실행 워커도 오탐.
1485
+ # $backup.owner 에 생성 워커 PID 기록 → kill -0 로 alive 확인. PID 파일 없거나 죽었으면 stale.
1486
+ # mtime 은 신뢰성 낮아 soft 보조 지표로만 사용 (owner 파일 유실 대비 fallback).
1487
+ local owner_file="${backup}.owner"
1488
+ local owner_alive=false
1489
+ local owner_pid=""
1490
+ if [[ -f "$owner_file" ]]; then
1491
+ owner_pid=$(cat "$owner_file" 2>/dev/null | tr -d '[:space:]')
1492
+ if [[ -n "$owner_pid" ]] && kill -0 "$owner_pid" 2>/dev/null; then
1493
+ owner_alive=true
1494
+ fi
1495
+ fi
1496
+
1497
+ if [[ "$owner_alive" == "true" ]]; then
1498
+ echo "[tfx-route] config.toml swap 스킵: 소유 워커 살아있음 (pid=$owner_pid, $backup)" >&2
1499
+ return 0
1500
+ fi
1501
+
1502
+ # Owner dead or unknown — stale 후보. 다만 backup-loss 방지를 위해 원본 복원 먼저.
1503
+ # P2 fix: `rm -f $backup` 후 현재 config 를 새 backup 으로 cp 하면, 이전 워커가 이미
1504
+ # filter 한 상태에서 crash 했을 때 원본이 영구 소실. 여기서 먼저 restore 를 시도해
1505
+ # backup 이 원본을 담고 있는 한 그것을 살린다.
1506
+ local backup_restore_guard_size
1507
+ backup_restore_guard_size=$(wc -c < "$backup" 2>/dev/null | tr -d ' ') || backup_restore_guard_size=0
1508
+ if [[ "$backup_restore_guard_size" -lt 500 ]]; then
1509
+ # 작은 backup 은 이미 손상된 state. 현재 config 도 필터된 상태일 수 있으므로
1510
+ # 추가 swap 은 상황을 악화시킬 위험. 전체 스킵하고 수동 확인 유도.
1511
+ echo "[tfx-route] stale backup 작음 (size=${backup_restore_guard_size}B, pid=${owner_pid:-?} dead) — swap 스킵, 수동 확인: $backup" >&2
1512
+ return 0
1513
+ fi
1514
+ local stale_tmp="${config}.stale-restore.$$"
1515
+ if cp "$backup" "$stale_tmp" && mv "$stale_tmp" "$config"; then
1516
+ echo "[tfx-route] stale backup 감지 (pid=${owner_pid:-?} dead) — 원본 복원 후 swap 재진행" >&2
1517
+ else
1518
+ echo "[tfx-route] 경고: stale backup 복원 실패, swap 스킵 (수동 확인: $backup)" >&2
1519
+ rm -f "$stale_tmp" 2>/dev/null
1520
+ return 0
1521
+ fi
1522
+ rm -f "$backup" "$owner_file" 2>/dev/null || true
1523
+ fi
1453
1524
  cp "$config" "$backup"
1525
+ # Owner marker: 이 워커가 backup 소유자임을 기록. 다음 워커의 stale detection 기준.
1526
+ echo "$$" > "${backup}.owner" 2>/dev/null || true
1454
1527
 
1455
1528
  # awk로 필터링: 비허용 MCP 서버 섹션 제거, 나머지 그대로 유지.
1456
1529
  # keep="" 은 진입 가드에서 return 됐지만 defense-in-depth 유지.
1530
+ local tmp_filtered="${config}.filter.$$"
1457
1531
  awk -v keep="$allowed_pat" '
1458
1532
  BEGIN { skip=0 }
1459
1533
  /^\[mcp_servers\./ {
@@ -1464,7 +1538,26 @@ _codex_config_swap() {
1464
1538
  }
1465
1539
  /^\[/ && !/^\[mcp_servers\./ { skip=0 }
1466
1540
  !skip { print }
1467
- ' "$backup" > "$config"
1541
+ ' "$backup" > "$tmp_filtered"
1542
+
1543
+ # Output sanity check: 필터 결과가 비었거나 백업의 30% 미만이면 적용 거부
1544
+ local filtered_size backup_size threshold
1545
+ filtered_size=$(wc -c < "$tmp_filtered" 2>/dev/null | tr -d ' ') || filtered_size=0
1546
+ backup_size=$(wc -c < "$backup" 2>/dev/null | tr -d ' ') || backup_size=1
1547
+ threshold=$(( backup_size * 30 / 100 ))
1548
+ if [[ "$filtered_size" -eq 0 || "$filtered_size" -lt "$threshold" ]]; then
1549
+ echo "[tfx-route] 경고: 필터 결과 크기 ${filtered_size} bytes (백업 ${backup_size} bytes의 30% 미만) — 적용 거부, 백업에서 복원" >&2
1550
+ rm -f "$tmp_filtered" 2>/dev/null
1551
+ rm -f "$backup" 2>/dev/null
1552
+ return 1
1553
+ fi
1554
+
1555
+ # 검증 통과 — atomic rename으로 적용
1556
+ if ! mv "$tmp_filtered" "$config"; then
1557
+ echo "[tfx-route] 경고: 필터 결과 적용 실패 (atomic rename), 백업 보존: $backup" >&2
1558
+ rm -f "$tmp_filtered" 2>/dev/null
1559
+ return 1
1560
+ fi
1468
1561
 
1469
1562
  local kept
1470
1563
  kept=$(echo "$allowed_pat" | tr '|' '\n' | wc -l | tr -d ' ')
@@ -1475,6 +1568,15 @@ _codex_config_swap() {
1475
1568
  # `cat > $config` 는 cat 실행 전에 dest 가 truncate 되어 mid-stream 실패 시
1476
1569
  # 빈/부분 파일이 남는다. 같은 디렉토리 내 mv 는 POSIX 상 atomic 이므로
1477
1570
  # 실패해도 기존 config 와 backup 모두 보존된다.
1571
+
1572
+ # Restore sanity check: 백업 자체가 비었거나 500 bytes 미만이면 복원 중단
1573
+ local backup_restore_size
1574
+ backup_restore_size=$(wc -c < "$backup" 2>/dev/null | tr -d ' ') || backup_restore_size=0
1575
+ if [[ "$backup_restore_size" -lt 500 ]]; then
1576
+ echo "[tfx-route] 경고: backup 크기 ${backup_restore_size} bytes — 손상 의심, 복원 중단. 수동 확인 필요: $backup" >&2
1577
+ return 1
1578
+ fi
1579
+
1478
1580
  local tmp="${config}.restore.$$"
1479
1581
  if ! cp "$backup" "$tmp"; then
1480
1582
  echo "[tfx-route] 경고: config.toml 복원 실패 (temp copy). backup 보존: $backup" >&2
@@ -1489,6 +1591,7 @@ _codex_config_swap() {
1489
1591
  if ! rm -f "$backup"; then
1490
1592
  echo "[tfx-route] 경고: backup 삭제 실패: $backup (수동 정리 필요)" >&2
1491
1593
  fi
1594
+ rm -f "${backup}.owner" 2>/dev/null || true
1492
1595
  echo "[tfx-route] config.toml 복원 완료" >&2
1493
1596
  fi
1494
1597
  }