@triflux/core 10.53.0 → 10.55.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 (47) hide show
  1. package/hooks/codex-session-hook.mjs +1 -17
  2. package/hub/cli-adapter-base.mjs +3 -424
  3. package/hub/index.mjs +0 -5
  4. package/hub/lib/hosts-compat.mjs +18 -64
  5. package/hub/lib/private-tmp.mjs +35 -0
  6. package/hub/lib/process-utils.mjs +19 -220
  7. package/hub/lib/prompt-tmp.mjs +6 -24
  8. package/hub/lib/spawn-trace.mjs +112 -5
  9. package/hub/lib/timeout-defaults.mjs +34 -6
  10. package/hub/lib/worker-lifecycle.mjs +3 -0
  11. package/hub/platform.mjs +3 -1
  12. package/hub/team/agent-map.json +29 -0
  13. package/hub/team/intervention.mjs +6 -4
  14. package/hub/team/nativeProxy.mjs +2 -48
  15. package/hub/team/pane.mjs +329 -0
  16. package/hub/team/psmux.mjs +1765 -0
  17. package/hub/team/retry-state-machine.mjs +1 -1
  18. package/hub/team/session.mjs +541 -0
  19. package/hud/constants.mjs +0 -3
  20. package/hud/providers/claude.mjs +35 -15
  21. package/hud/providers/codex.mjs +169 -42
  22. package/hud/utils.mjs +0 -23
  23. package/package.json +2 -2
  24. package/scripts/lib/backup-rotation.mjs +60 -0
  25. package/scripts/lib/codex-profile-config.mjs +20 -7
  26. package/scripts/lib/codex-recovery.sh +2 -3
  27. package/scripts/lib/install-blob-history.json +3196 -0
  28. package/scripts/lib/install-retire.mjs +203 -0
  29. package/scripts/lib/legacy-hook-cleanup.mjs +148 -13
  30. package/scripts/lib/legacy-mcp-cleanup.mjs +405 -104
  31. package/scripts/lib/machine-profile.mjs +2 -2
  32. package/scripts/lib/mcp-filter.mjs +1 -42
  33. package/scripts/lib/mcp-guard-engine.mjs +198 -19
  34. package/scripts/lib/mcp-manifest.mjs +0 -40
  35. package/scripts/lib/process-utils.mjs +19 -0
  36. package/scripts/lib/stable-node.mjs +35 -0
  37. package/scripts/lib/test-env.mjs +20 -0
  38. package/scripts/lib/tmr-install.mjs +183 -0
  39. package/hooks/agy-session-hook.mjs +0 -155
  40. package/hooks/claude-cwd-projection-refresh.mjs +0 -11
  41. package/hooks/hook-orchestrator.mjs +0 -11
  42. package/hooks/lib/resolve-root.mjs +0 -62
  43. package/hub/codex-adapter.mjs +0 -193
  44. package/hub/codex-preflight.mjs +0 -219
  45. package/hub/lib/cto-env.mjs +0 -70
  46. package/scripts/lib/remote-spawn-transfer.mjs +0 -226
  47. package/scripts/lib/session-presence.mjs +0 -151
@@ -11,7 +11,7 @@ import {
11
11
  } from "node:fs";
12
12
  import { tmpdir } from "node:os";
13
13
  import { join } from "node:path";
14
- import { IS_WINDOWS, killProcess } from "../platform.mjs";
14
+ import { IS_WINDOWS } from "../platform.mjs";
15
15
 
16
16
  const CLEANUP_SCRIPT_DIR = join(tmpdir(), "tfx-process-utils");
17
17
  const SCAN_SCRIPT_PATH = join(CLEANUP_SCRIPT_DIR, "scan-processes.ps1");
@@ -54,80 +54,6 @@ export function isPidAlive(pid) {
54
54
  }
55
55
  }
56
56
 
57
- /**
58
- * 동기적 sleep. Atomics.wait 우선, 불가 시 busy-wait 폴백.
59
- */
60
- function sleepSyncMs(ms) {
61
- try {
62
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
63
- } catch {
64
- const end = Date.now() + ms;
65
- while (Date.now() < end) {
66
- /* spin */
67
- }
68
- }
69
- }
70
-
71
- /**
72
- * 고아 PID 목록에 SIGTERM → 3초 대기 → SIGKILL 에스컬레이션을 적용한다.
73
- * PID 재사용 레이스 방어: SIGTERM 전 alive 확인, SIGKILL 전 재검증.
74
- * @param {number[]} orphanPids
75
- * @param {Map<number, {ppid: number, name: string}>} [procMap] PID 재사용 감지용 스냅샷
76
- * @returns {number} killed count
77
- */
78
- function killWithEscalation(orphanPids, procMap) {
79
- if (orphanPids.length === 0) return 0;
80
-
81
- // SIGTERM 전 alive 스냅샷 — 이미 죽은 PID는 카운트에서 제외
82
- const aliveBeforeKill = new Set(orphanPids.filter((pid) => isPidAlive(pid)));
83
-
84
- for (const pid of aliveBeforeKill) {
85
- killProcess(pid, { signal: "SIGTERM" });
86
- }
87
-
88
- sleepSyncMs(3000);
89
-
90
- let killed = 0;
91
- for (const pid of aliveBeforeKill) {
92
- if (isPidAlive(pid)) {
93
- // PID 재사용 방어: procMap이 있으면 스캔 시점의 ppid와 현재 ppid 비교
94
- // ppid가 변경되었으면 PID가 재사용된 것이므로 kill하지 않���
95
- if (procMap) {
96
- const snapshot = procMap.get(pid);
97
- if (snapshot) {
98
- try {
99
- const current = execSync(
100
- IS_WINDOWS
101
- ? `powershell -NoProfile -WindowStyle Hidden -Command "(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}' -ErrorAction SilentlyContinue).ParentProcessId"`
102
- : `ps -o ppid= -p ${pid}`,
103
- {
104
- encoding: "utf8",
105
- timeout: 3000,
106
- stdio: ["pipe", "pipe", "pipe"],
107
- windowsHide: true,
108
- },
109
- );
110
- const currentPpid = Number.parseInt(current.trim(), 10);
111
- if (Number.isFinite(currentPpid) && currentPpid !== snapshot.ppid) {
112
- continue; // PID 재사용 감지 — skip
113
- }
114
- } catch {
115
- // 조회 실패 시 안전하게 skip
116
- continue;
117
- }
118
- }
119
- }
120
- killProcess(pid, {
121
- signal: "SIGKILL",
122
- force: true,
123
- tree: IS_WINDOWS,
124
- });
125
- }
126
- if (!isPidAlive(pid)) killed++;
127
- }
128
- return killed;
129
- }
130
-
131
57
  /**
132
58
  * PowerShell 헬퍼 스크립트를 임시 디렉토리에 생성한다.
133
59
  * bash의 $_ 이스케이핑 문제를 피하기 위해 -File로 실행.
@@ -261,12 +187,12 @@ function hasLiveCliDescendant(pid, procMap) {
261
187
  }
262
188
 
263
189
  /**
264
- * Legacy wrapper for scoped orphan node runtime cleanup.
190
+ * `tfx doctor --fix` 가 쓰는 이름. cleanupOrphanRuntimeProcesses 와 같다.
265
191
  * @param {Parameters<typeof cleanupOrphanRuntimeProcesses>[0]} opts
266
- * @returns {{ killed: number, remaining: number, killedProcesses: Array<{pid: number, ppid: number, name: string, commandLine: string, killReason: string}> }}
192
+ * @returns {ReturnType<typeof cleanupOrphanRuntimeProcesses>}
267
193
  */
268
194
  export function cleanupOrphanNodeProcesses(opts = {}) {
269
- return cleanupOrphanRuntimeProcesses({ ...opts, legacy: true });
195
+ return cleanupOrphanRuntimeProcesses(opts);
270
196
  }
271
197
 
272
198
  function normalizePowerShellJson(output) {
@@ -297,10 +223,6 @@ function normalizeName(name) {
297
223
  return String(name || "").toLowerCase();
298
224
  }
299
225
 
300
- function normalizeCommandLine(commandLine) {
301
- return String(commandLine || "").replace(/\//g, "\\");
302
- }
303
-
304
226
  function processRecordFromCim(record) {
305
227
  const pid = normalizePid(record?.ProcessId ?? record?.pid);
306
228
  if (!pid) return null;
@@ -460,16 +382,6 @@ function matchesPattern(commandLine, pattern, { exact = false } = {}) {
460
382
  return exact ? commandLine === text : commandLine.includes(text);
461
383
  }
462
384
 
463
- function hasExactGbrainServe(commandLine) {
464
- const normalized = commandLine.trim();
465
- return (
466
- /^("?[^"\s]*bun(?:\.exe)?"?\s+)?gbrain\s+serve$/i.test(normalized) ||
467
- /^("?[^"\s]*bun(?:\.exe)?"?\s+)"?[^"]*gbrain[\\/]+src[\\/]+cli\.ts"?\s+serve$/i.test(
468
- normalized,
469
- )
470
- );
471
- }
472
-
473
385
  /**
474
386
  * Return a process tree rooted at `rootPid`.
475
387
  *
@@ -651,75 +563,41 @@ export function findProcessesByCommandLine(
651
563
  }
652
564
 
653
565
  /**
654
- * Cleanup narrowly gated orphan runtime processes.
566
+ * Windows 고아 node/bash/cmd/uvx 런타임을 정리한다.
655
567
  *
656
- * Windows scans node/bash/bun and optional conhost command lines. POSIX is a
657
- * best-effort fallback. `legacy: true` preserves `cleanupOrphanNodeProcesses`
658
- * behavior by targeting scoped orphan node runtimes only.
568
+ * 조상 체인이 살아 있거나 Claude/Codex/Gemini 자손이 있으면 건드리지 않는다.
569
+ * 운영 호출자가 Windows `tfx doctor --fix` 하나라 다른 OS 에서는 아무것도 하지 않는다.
659
570
  *
660
- * @param {{legacy?: boolean, includeConhost?: boolean, sessionIds?: string[], isWindows?: boolean, spawnSyncFn?: typeof spawnSync, killFn?: typeof process.kill, protectedPids?: Set<number>}} opts
571
+ * @param {{isWindows?: boolean, spawnSyncFn?: typeof spawnSync, killFn?: typeof process.kill, protectedPids?: Set<number>}} opts
661
572
  * @returns {{killed: number, remaining: number, killedProcesses: Array<{pid: number, ppid: number, name: string, commandLine: string, killReason: string}>}}
662
573
  */
663
574
  export function cleanupOrphanRuntimeProcesses({
664
- legacy = false,
665
- includeConhost = false,
666
- sessionIds = [],
667
575
  isWindows = IS_WINDOWS,
668
576
  spawnSyncFn = spawnSync,
669
577
  killFn = process.kill,
670
578
  protectedPids,
671
579
  } = {}) {
672
- if (!isWindows) return cleanupOrphansUnix();
580
+ if (!isWindows) return { killed: 0, remaining: 0, killedProcesses: [] };
673
581
 
674
582
  const protectedSet = getProtectedPids({
675
583
  protectedPids,
676
584
  isWindows,
677
585
  spawnSyncFn,
678
- includeAncestorScan: legacy,
586
+ includeAncestorScan: true,
679
587
  });
680
588
  const processes = getProcessSnapshot({ isWindows, spawnSyncFn });
589
+ const procMap = new Map(processes.map((proc) => [proc.pid, proc]));
681
590
  let killed = 0;
682
591
  const killedProcesses = [];
683
- const procMap = legacy
684
- ? new Map(processes.map((proc) => [proc.pid, proc]))
685
- : new Map(processes.map((proc) => [proc.pid, proc]));
686
592
 
687
593
  for (const proc of processes) {
688
- if (protectedSet.has(proc.pid)) continue;
689
- const name = normalizeName(proc.name);
690
- const commandLine = normalizeCommandLine(proc.commandLine);
691
- let shouldKill = false;
692
- let killReason = null;
693
-
694
- if (legacy) {
695
- if (
696
- LEGACY_ORPHAN_KILLABLE_NAMES.has(name) &&
697
- !hasLiveAncestorChain(proc.pid, procMap, protectedSet) &&
698
- !hasLiveCliDescendant(proc.pid, procMap)
699
- ) {
700
- shouldKill = true;
701
- killReason = "legacy_orphan_ancestor_chain_dead";
702
- }
703
- } else if (name === "bun.exe") {
704
- if (
705
- hasExactGbrainServe(proc.commandLine) &&
706
- !hasLiveAncestorChain(proc.pid, procMap, protectedSet) &&
707
- !hasLiveCliDescendant(proc.pid, procMap)
708
- ) {
709
- shouldKill = true;
710
- killReason = "bun_gbrain_serve_orphan";
711
- }
712
- } else if (includeConhost && name === "conhost.exe") {
713
- if (
714
- sessionIds.length > 0 &&
715
- sessionIds.some((id) => id && commandLine.includes(id))
716
- ) {
717
- shouldKill = true;
718
- killReason = "conhost_session_match";
719
- }
720
- }
721
-
722
- if (!shouldKill) continue;
594
+ if (
595
+ protectedSet.has(proc.pid) ||
596
+ !LEGACY_ORPHAN_KILLABLE_NAMES.has(normalizeName(proc.name)) ||
597
+ hasLiveAncestorChain(proc.pid, procMap, protectedSet) ||
598
+ hasLiveCliDescendant(proc.pid, procMap)
599
+ )
600
+ continue;
723
601
  try {
724
602
  killFn(proc.pid, "SIGKILL");
725
603
  killed++;
@@ -728,7 +606,7 @@ export function cleanupOrphanRuntimeProcesses({
728
606
  ppid: proc.ppid,
729
607
  name: proc.name,
730
608
  commandLine: String(proc.commandLine || "").slice(0, 200),
731
- killReason,
609
+ killReason: "legacy_orphan_ancestor_chain_dead",
732
610
  });
733
611
  } catch {}
734
612
  }
@@ -818,82 +696,3 @@ export function cleanupStaleFsmonitorDaemons({
818
696
 
819
697
  return { killed, stale };
820
698
  }
821
-
822
- /**
823
- * Unix/macOS 고아 프로세스 정리.
824
- * `ps -eo pid,ppid,comm` 기반 프로세스 맵 → 동일한 조상 체인 판정 → SIGKILL 에스컬레이션.
825
- * @returns {{ killed: number, remaining: number }}
826
- */
827
- function cleanupOrphansUnix() {
828
- const myPid = process.pid;
829
-
830
- const protectedPids = new Set();
831
- protectedPids.add(myPid);
832
-
833
- // 현재 프로세스의 조상 트리 보호
834
- try {
835
- let current = myPid;
836
- for (let i = 0; i < 10; i++) {
837
- protectedPids.add(current);
838
- const output = execSync(`ps -o ppid= -p ${current}`, {
839
- encoding: "utf8",
840
- timeout: 3000,
841
- stdio: ["pipe", "pipe", "pipe"],
842
- });
843
- const ppid = Number.parseInt(output.trim(), 10);
844
- if (!Number.isFinite(ppid) || ppid <= 1) break;
845
- current = ppid;
846
- }
847
- } catch {}
848
-
849
- // 프로세스 맵 구축 (런타임 + CLI — 체인 추적 정확도를 위해 CLI도 포함)
850
- const procMap = new Map();
851
- try {
852
- const output = execSync("ps -eo pid,ppid,comm", {
853
- encoding: "utf8",
854
- timeout: 10000,
855
- stdio: ["pipe", "pipe", "pipe"],
856
- });
857
- for (const line of output.split("\n").slice(1)) {
858
- const parts = line.trim().split(/\s+/);
859
- if (parts.length < 3) continue;
860
- const pid = Number.parseInt(parts[0], 10);
861
- const ppid = Number.parseInt(parts[1], 10);
862
- const name = parts.slice(2).join(" ");
863
- if (
864
- Number.isFinite(pid) &&
865
- pid > 0 &&
866
- /^(node|bash|sh|python|codex|claude|gemini|uvx)/.test(name)
867
- ) {
868
- procMap.set(pid, { ppid, name });
869
- }
870
- }
871
- } catch {}
872
-
873
- // kill 대상: node, python, codex, claude, uvx — bash/sh는 사용자 인터랙티브 쉘 가능성
874
- const killableUnix = /^(node|python|codex|claude|gemini|uvx)/;
875
-
876
- // 고아 판정 + SIGKILL 에스컬레이션
877
- const orphanPids = [];
878
- for (const [pid, info] of procMap) {
879
- if (protectedPids.has(pid)) continue;
880
- if (!killableUnix.test(info.name)) continue;
881
- if (hasLiveAncestorChain(pid, procMap, protectedPids)) continue;
882
- if (hasLiveCliDescendant(pid, procMap)) continue;
883
- orphanPids.push(pid);
884
- }
885
-
886
- const killed = killWithEscalation(orphanPids, procMap);
887
-
888
- let remaining = 0;
889
- try {
890
- const output = execSync("ps -eo comm | grep -c '^node$'", {
891
- encoding: "utf8",
892
- timeout: 3000,
893
- stdio: ["pipe", "pipe", "pipe"],
894
- });
895
- remaining = Number.parseInt(output.trim(), 10) || 0;
896
- } catch {}
897
-
898
- return { killed, remaining };
899
- }
@@ -1,33 +1,15 @@
1
- // hub/lib/prompt-tmp.mjs
2
- //
3
- // macOS `codex exec` 회귀 fix 보조 helper. codex CLI v0.130.0 의 macOS hook
4
- // lifecycle (oh-my-codex/codex-native-hook.js) 이 매우 긴 argv inline prompt
5
- // (4KB+) 를 받으면 SessionStart Failed 로 즉시 exit. PR #243 의 stdin EAGAIN
6
- // async stream fix 후속 회귀로 추정.
7
- //
8
- // 대안: prompt 를 임시 파일에 쓰고 `< /tmp/...` stdin redirect 로 전달.
9
- // Manual `echo prompt | codex exec ...` (stdin pipe) 가 정상 작동하는 fact 와
10
- // 정합.
11
- //
12
- // cleanup 정책: best-effort. OS `/tmp` 가 자동 cleanup 하고, 이 모듈은
13
- // 매 호출마다 unique path 사용 (timestamp + pid + counter). orphan 파일은
14
- // 추후 doctor 가 prune.
1
+ // 프롬프트를 argv 로 넘기면 ps 에 보이므로 stdin 리다이렉트용 파일로 넘긴다.
2
+ // 파일은 매번 새 이름이고 정리는 OS 임시 디렉터리 정리에 맡긴다.
15
3
 
16
- import { mkdirSync, writeFileSync } from "node:fs";
17
- import { tmpdir } from "node:os";
4
+ import { writeFileSync } from "node:fs";
18
5
  import { join } from "node:path";
6
+ import { privateTmpDir } from "./private-tmp.mjs";
19
7
 
20
- const PROMPT_TMP_DIR = join(tmpdir(), "triflux-codex-prompt");
21
8
  let counter = 0;
22
9
 
23
10
  export function writePromptToTmpFile(prompt) {
24
- mkdirSync(PROMPT_TMP_DIR, { recursive: true });
25
11
  const id = `${Date.now()}-${process.pid}-${counter++}`;
26
- const file = join(PROMPT_TMP_DIR, `prompt-${id}.txt`);
27
- writeFileSync(file, String(prompt ?? ""), "utf8");
12
+ const file = join(privateTmpDir("triflux-codex-prompt"), `prompt-${id}.txt`);
13
+ writeFileSync(file, String(prompt ?? ""), { encoding: "utf8", mode: 0o600 });
28
14
  return file;
29
15
  }
30
-
31
- export function getPromptTmpDir() {
32
- return PROMPT_TMP_DIR;
33
- }
@@ -1,13 +1,22 @@
1
1
  import * as childProcess from "node:child_process";
2
+ import { createHash } from "node:crypto";
2
3
  import { EventEmitter } from "node:events";
3
- import { createWriteStream, mkdirSync } from "node:fs";
4
+ import {
5
+ chmodSync,
6
+ createWriteStream,
7
+ mkdirSync,
8
+ readdirSync,
9
+ rmSync,
10
+ } from "node:fs";
4
11
  import { homedir } from "node:os";
5
12
  import { join } from "node:path";
6
13
 
7
14
  const LOG_DIR = join(homedir(), ".triflux", "logs");
15
+ const RETENTION_DAYS = 7;
8
16
  const DEDUPE_WINDOW_MS = 5_000;
9
17
  const RATE_WINDOW_MS = 1_000;
10
18
  const DEFAULT_MAX_SPAWN_PER_SEC = 100;
19
+ const MAX_PLAIN_TRACE_VALUE = 64;
11
20
  // multi-worker headless dispatch 가 1 초 안에 ~25-30+ psmux/tmux 명령을 호출한다.
12
21
  // 2-worker dispatchBatch 가 default 30 limit 을 초과해 `rate_limit` throw 로 mac
13
22
  // 호환성 회귀 (smoke test 발견, 2026-05-15). 100 으로 상향 — 폭주 안전망은
@@ -54,7 +63,8 @@ function ensureLogStream() {
54
63
  return logStream;
55
64
  }
56
65
 
57
- mkdirSync(LOG_DIR, { recursive: true });
66
+ mkdirSync(LOG_DIR, { recursive: true, mode: 0o700 });
67
+ restrictMode(LOG_DIR, 0o700);
58
68
 
59
69
  if (logStream) {
60
70
  try {
@@ -65,20 +75,115 @@ function ensureLogStream() {
65
75
  }
66
76
 
67
77
  logDay = day;
68
- logStream = createWriteStream(getLogPath(day), { flags: "a" });
78
+ pruneOldTraceLogs(LOG_DIR, day);
79
+ logStream = createWriteStream(getLogPath(day), { flags: "a", mode: 0o600 });
80
+ restrictMode(getLogPath(day), 0o600);
69
81
  logStream.on("error", () => {
70
82
  /* ignore logging failures */
71
83
  });
72
84
  return logStream;
73
85
  }
74
86
 
87
+ // 날짜별 파일이 끝없이 쌓이지 않게, 그날 첫 파일을 열 때 보관 기간이 지난 파일을 지운다.
88
+ export function pruneOldTraceLogs(logDir, today) {
89
+ const cutoff = new Date(Date.parse(today) - RETENTION_DAYS * 86_400_000)
90
+ .toISOString()
91
+ .slice(0, 10);
92
+ try {
93
+ for (const name of readdirSync(logDir)) {
94
+ const day = /^spawn-trace-(\d{4}-\d{2}-\d{2})\.jsonl$/u.exec(name)?.[1];
95
+ if (day && day < cutoff) rmSync(join(logDir, name), { force: true });
96
+ }
97
+ } catch {
98
+ /* ignore */
99
+ }
100
+ }
101
+
102
+ // 예전 버전이 0644 로 만든 로그도 좁힌다. Windows 는 chmod 가 의미 없어 실패를 무시한다.
103
+ function restrictMode(path, mode) {
104
+ try {
105
+ chmodSync(path, mode);
106
+ } catch {
107
+ /* ignore */
108
+ }
109
+ }
110
+
111
+ function redacted(value) {
112
+ const digest = createHash("sha256").update(value).digest("hex").slice(0, 12);
113
+ return `<redacted len=${value.length} sha256=${digest}>`;
114
+ }
115
+
116
+ // 프롬프트가 로그에 남지 않게, 공백이 있거나 긴 값은 길이와 해시만 남긴다.
117
+ export function redactTraceValue(value) {
118
+ if (typeof value !== "string") return value;
119
+ if (value.length <= MAX_PLAIN_TRACE_VALUE && !/\s/u.test(value)) return value;
120
+ return redacted(value);
121
+ }
122
+
123
+ const PROMPT_FLAGS = new Set(["--print", "--prompt", "--message", "--seed"]);
124
+ const SEND_KEYS_VALUE_FLAGS = new Set(["t", "N", "c"]);
125
+ const NAMED_KEY =
126
+ /^(Enter|Escape|Tab|BSpace|Space|Up|Down|Left|Right|[CM]-.)$/u;
127
+
128
+ // send-keys 옵션을 getopt 처럼 읽어 키 자리의 시작과 -l(문자 그대로 입력) 여부를 찾는다.
129
+ function parseSendKeys(args, from) {
130
+ let literal = false;
131
+ let i = from;
132
+ while (i < args.length && /^-./u.test(String(args[i]))) {
133
+ const flag = String(args[i]);
134
+ i += 1;
135
+ if (flag === "--") break;
136
+ for (let j = 1; j < flag.length; j += 1) {
137
+ if (flag[j] === "l") literal = true;
138
+ if (SEND_KEYS_VALUE_FLAGS.has(flag[j])) {
139
+ if (j === flag.length - 1) i += 1;
140
+ break;
141
+ }
142
+ }
143
+ }
144
+ return { keysFrom: i, literal };
145
+ }
146
+
147
+ // 짧은 토큰도 키 입력과 프롬프트 자리에 오면 항상 가린다. 길이만 보면 짧은 비밀이 샌다.
148
+ // -l 이면 Enter 같은 이름도 글자 그대로 입력되므로 가린다.
149
+ function redactArgs(args) {
150
+ const sendKeysAt = args.indexOf("send-keys");
151
+ const { keysFrom, literal } =
152
+ sendKeysAt >= 0
153
+ ? parseSendKeys(args, sendKeysAt + 1)
154
+ : { keysFrom: -1, literal: false };
155
+ return args.map((arg, index) => {
156
+ if (typeof arg !== "string") return arg;
157
+ const promptFlag = arg.split("=")[0];
158
+ if (PROMPT_FLAGS.has(promptFlag) && arg.includes("=")) {
159
+ return `${promptFlag}=${redacted(arg.slice(promptFlag.length + 1))}`;
160
+ }
161
+ const afterPromptFlag = PROMPT_FLAGS.has(args[index - 1]);
162
+ const isKey =
163
+ keysFrom >= 0 && index >= keysFrom && (literal || !NAMED_KEY.test(arg));
164
+ return afterPromptFlag || isKey ? redacted(arg) : redactTraceValue(arg);
165
+ });
166
+ }
167
+
168
+ export function redactTraceEntry(entry) {
169
+ return {
170
+ ...entry,
171
+ command: redactTraceValue(entry.command),
172
+ args: Array.isArray(entry.args) ? redactArgs(entry.args) : entry.args,
173
+ // execFileSync 오류 메시지에는 명령과 인자 전체가 들어간다.
174
+ ...(typeof entry.error === "string"
175
+ ? { error: redactTraceValue(entry.error) }
176
+ : {}),
177
+ };
178
+ }
179
+
75
180
  function appendTrace(data, { sync = false } = {}) {
76
- const entry = {
181
+ const entry = redactTraceEntry({
77
182
  ts: nowIso(),
78
183
  session_id: process.env.TRIFLUX_SESSION_ID ?? null,
79
184
  parent_pid: process.pid,
80
185
  ...data,
81
- };
186
+ });
82
187
 
83
188
  try {
84
189
  ensureLogStream().write(`${JSON.stringify(entry)}\n`);
@@ -261,6 +366,7 @@ function trackChild(child, meta) {
261
366
  child.once("error", (error) => {
262
367
  finalize("error", {
263
368
  error: error.message,
369
+ error_code: error.code ?? null,
264
370
  });
265
371
  });
266
372
 
@@ -532,6 +638,7 @@ export function execFileSync(file, args, options) {
532
638
  signal: error?.signal ?? null,
533
639
  duration_ms: Date.now() - startedAt,
534
640
  error: error?.message,
641
+ error_code: error?.code ?? null,
535
642
  sync: true,
536
643
  },
537
644
  { sync: true },
@@ -1,4 +1,5 @@
1
1
  // hub 조율 평면 타임아웃/TTL의 단일 기준값. 환경은 호출 시점에 읽는다.
2
+ import { readMachineProfile } from "../../scripts/lib/machine-profile.mjs";
2
3
 
3
4
  export const MIN_DURATION_MS = 1_000;
4
5
  export const MAX_DURATION_MS = 86_400_000;
@@ -10,22 +11,49 @@ export const REGISTER_HEARTBEAT_GRACE_MS = 120_000;
10
11
  export const DEFAULT_STALL_INTERVENTION_SEC = 1_200;
11
12
  export const DEFAULT_HARD_CEILING_SEC = 21_600;
12
13
 
13
- function readEnvSeconds(env, name, fallbackSec) {
14
- const value = Number(env?.[name]);
15
- return Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallbackSec;
14
+ // 셸(tfx-route.sh)과 같은 우선순위: 명시 env, 그다음 machine profile, 그다음 기본값.
15
+ function readSetting(env, name) {
16
+ if (env?.[name] !== undefined) return env[name];
17
+ return readMachineProfile({ env }).values[name];
18
+ }
19
+
20
+ function readSeconds(env, name, fallbackSec, { allowZero = false } = {}) {
21
+ const raw = String(readSetting(env, name) ?? "").trim();
22
+ // 셸의 ${VAR:-기본값} 처럼 빈 값은 기본값이다. Number("") 가 0 이 되는 것을 막는다.
23
+ if (!raw) return fallbackSec;
24
+ const value = Number(raw);
25
+ const min = allowZero ? 0 : 1;
26
+ return Number.isInteger(value) && value >= min ? value : fallbackSec;
16
27
  }
17
28
 
18
29
  export function resolveStallInterventionMs(env = process.env) {
19
30
  return (
20
- readEnvSeconds(env, "TFX_STALL_THRESHOLD", DEFAULT_STALL_INTERVENTION_SEC) *
31
+ readSeconds(env, "TFX_STALL_THRESHOLD", DEFAULT_STALL_INTERVENTION_SEC) *
21
32
  1000
22
33
  );
23
34
  }
24
35
 
36
+ // 0 은 셸과 같이 상한 없음이다.
25
37
  export function resolveHardCeilingMs(env = process.env) {
26
- return (
27
- readEnvSeconds(env, "TFX_HARD_CEILING_SEC", DEFAULT_HARD_CEILING_SEC) * 1000
38
+ const sec = readSeconds(
39
+ env,
40
+ "TFX_HARD_CEILING_SEC",
41
+ DEFAULT_HARD_CEILING_SEC,
42
+ {
43
+ allowZero: true,
44
+ },
28
45
  );
46
+ return sec === 0 ? Number.POSITIVE_INFINITY : sec * 1000;
47
+ }
48
+
49
+ // 셸 heartbeat 와 같은 값 해석. classify 는 무활동이어도 끝내지 않는다.
50
+ export function resolveStallKill(env = process.env) {
51
+ const value = String(readSetting(env, "TFX_STALL_KILL") || "kill")
52
+ .trim()
53
+ .toLowerCase();
54
+ if (["1", "on", "kill"].includes(value)) return "kill";
55
+ if (["intervene", "ladder"].includes(value)) return "intervene";
56
+ return "classify";
29
57
  }
30
58
 
31
59
  export function resolveWorkerLeaseTtlMs(env = process.env) {
@@ -2,6 +2,7 @@
2
2
  import {
3
3
  resolveHardCeilingMs,
4
4
  resolveStallInterventionMs,
5
+ resolveStallKill,
5
6
  } from "./timeout-defaults.mjs";
6
7
 
7
8
  export { resolveHardCeilingMs, resolveStallInterventionMs };
@@ -19,6 +20,7 @@ export function createActivityLifecycle({
19
20
  enabled = isActivityLifecycleEnabled(),
20
21
  interventionMs = resolveStallInterventionMs(),
21
22
  hardCeilingMs = resolveHardCeilingMs(),
23
+ stallKill = resolveStallKill(),
22
24
  maxInterventions = 1,
23
25
  onIntervene,
24
26
  now = Date.now,
@@ -77,6 +79,7 @@ export function createActivityLifecycle({
77
79
  }
78
80
  if (current - lastActivityAt < interventionMs || pendingIntervention)
79
81
  return "";
82
+ if (stallKill === "classify") return "";
80
83
  if (await intervene(context)) return "";
81
84
  timeoutReason = "inactivity";
82
85
  return timeoutReason;
package/hub/platform.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { execFile, execFileSync, execSync } from "node:child_process";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { privateTmpRoot } from "./lib/private-tmp.mjs";
4
5
 
5
6
  export const IS_WINDOWS = process.platform === "win32";
6
7
  export const IS_MAC = process.platform === "darwin";
@@ -209,7 +210,8 @@ export function pipePath(name, pid = process.pid, options = {}) {
209
210
  return `\\\\.\\pipe\\${suffix}`;
210
211
  }
211
212
 
212
- const baseDir = options.tempDir || TEMP_DIR;
213
+ // 공용 /tmp 의 소켓은 다른 사용자가 접속하거나 선점할 수 있다. 짧은 경로를 위해 /tmp 를 기준으로 둔다.
214
+ const baseDir = options.tempDir || privateTmpRoot({ base: TEMP_DIR });
213
215
  return path.posix.join(baseDir, `${suffix}.sock`);
214
216
  }
215
217
 
@@ -0,0 +1,29 @@
1
+ {
2
+ "executor": "codex",
3
+ "build-fixer": "codex",
4
+ "cleanup": "codex",
5
+ "deslop": "codex",
6
+ "debugger": "codex",
7
+ "deep-executor": "codex",
8
+ "architect": "codex",
9
+ "planner": "codex",
10
+ "critic": "codex",
11
+ "analyst": "codex",
12
+ "code-reviewer": "codex",
13
+ "security-reviewer": "codex",
14
+ "quality-reviewer": "codex",
15
+ "scientist": "codex",
16
+ "scientist-deep": "codex",
17
+ "document-specialist": "codex",
18
+ "spark": "codex",
19
+ "designer": "antigravity",
20
+ "writer": "antigravity",
21
+ "explore": "claude",
22
+ "verifier": "codex",
23
+ "test-engineer": "codex",
24
+ "qa-tester": "codex",
25
+ "codex": "codex",
26
+ "antigravity": "antigravity",
27
+ "agy": "antigravity",
28
+ "claude": "claude"
29
+ }
@@ -125,10 +125,12 @@ export async function resolveCodexRolloutFile({
125
125
  return null;
126
126
  }
127
127
 
128
- export function buildResumeArgv({ cli, sessionId, prompt } = {}) {
128
+ // 프롬프트는 argv 에 두면 ps 에 보여서 넣지 않는다. 실행하는 쪽이 prompt 를 stdin 으로 넘긴다.
129
+ // claude -p 는 프롬프트 인자가 없으면, codex exec resume 은 `-` 를 받으면 stdin 을 읽는다.
130
+ export function buildResumeArgv({ cli, sessionId } = {}) {
129
131
  if (!sessionId) return null;
130
- if (cli === "claude") return ["claude", "-p", "--resume", sessionId, prompt];
131
- if (cli === "codex") return ["codex", "exec", "resume", sessionId, prompt];
132
+ if (cli === "claude") return ["claude", "-p", "--resume", sessionId];
133
+ if (cli === "codex") return ["codex", "exec", "resume", sessionId, "-"];
132
134
  return null;
133
135
  }
134
136
 
@@ -474,7 +476,7 @@ export function createInterventionLadder({
474
476
  sessionId,
475
477
  prompt: reinstructPrompt,
476
478
  target,
477
- argv: buildResumeArgv({ cli, sessionId, prompt: reinstructPrompt }),
479
+ argv: buildResumeArgv({ cli, sessionId }),
478
480
  });
479
481
  return resumed?.ok === false
480
482
  ? { ok: false, detail: "resume_dispatch_failed" }