@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
@@ -3,7 +3,15 @@
3
3
  // ============================================================================
4
4
 
5
5
  import { spawn } from "node:child_process";
6
- import { existsSync, readdirSync, readFileSync } from "node:fs";
6
+ import {
7
+ closeSync,
8
+ existsSync,
9
+ fstatSync,
10
+ openSync,
11
+ readdirSync,
12
+ readFileSync,
13
+ readSync,
14
+ } from "node:fs";
7
15
  import { join } from "node:path";
8
16
  import {
9
17
  CODEX_MIN_BUCKETS,
@@ -253,6 +261,110 @@ function mergeRateLimitSnapshots(snapshots, nowSec) {
253
261
  return merged;
254
262
  }
255
263
 
264
+ const EXTRA_BYTES_AFTER_FIRST_BUCKET = 64 * 1024;
265
+ const REVERSE_READ_CHUNK_BYTES = 64 * 1024;
266
+
267
+ // 파일 끝에서 줄 단위로 거슬러 읽는다. 호출자가 멈추는 지점까지만 디스크를 읽는다.
268
+ // 0x0A 로만 나누므로 멀티바이트 UTF-8 이 청크 경계에서 깨지지 않는다.
269
+ // 파일 양끝의 공백은 문자열 trim() 과 같게 버리고 중간의 빈 줄은 유지해,
270
+ // 옛 content.trim().split("\n").reverse() 와 같은 줄을 낸다.
271
+ export function createReverseLineReader(
272
+ path,
273
+ chunkBytes = REVERSE_READ_CHUNK_BYTES,
274
+ ) {
275
+ const fd = openSync(path, "r");
276
+ let pos;
277
+ try {
278
+ pos = fstatSync(fd).size;
279
+ } catch (err) {
280
+ closeSync(fd);
281
+ throw err;
282
+ }
283
+ let carryParts = []; // 아직 줄 시작을 못 만난 뒷부분 조각. 나중에 읽은(앞쪽) 조각이 뒤에 쌓인다
284
+ let queue = [];
285
+ let leadingBlank = -1; // 파일 맨 앞의 빈 줄 수. 끝까지 읽은 뒤에만 정해진다
286
+ let trimmingTail = true;
287
+ let yielded = false;
288
+ let emptyGiven = false;
289
+
290
+ function fill() {
291
+ const size = Math.min(chunkBytes, pos);
292
+ const chunk = Buffer.alloc(size);
293
+ pos -= size;
294
+ readSync(fd, chunk, 0, size, pos);
295
+ reader.bytesRead += size;
296
+ const pieces = [];
297
+ let start = 0;
298
+ for (
299
+ let i = chunk.indexOf(0x0a);
300
+ i !== -1;
301
+ i = chunk.indexOf(0x0a, start)
302
+ ) {
303
+ pieces.push(chunk.subarray(start, i));
304
+ start = i + 1;
305
+ }
306
+ pieces.push(chunk.subarray(start));
307
+ if (pieces.length === 1 && pos > 0) {
308
+ carryParts.push(chunk);
309
+ return;
310
+ }
311
+ const last = pieces.length - 1;
312
+ pieces[last] = Buffer.concat([pieces[last], ...carryParts.reverse()]);
313
+ carryParts = pos > 0 ? [pieces.shift()] : [];
314
+ queue = pieces.map((piece) => piece.toString("utf-8"));
315
+ if (pos === 0) {
316
+ leadingBlank = 0;
317
+ while (leadingBlank < queue.length && queue[leadingBlank].trim() === "") {
318
+ leadingBlank++;
319
+ }
320
+ }
321
+ }
322
+
323
+ // 옛 trim() 은 파일 맨 앞의 공백과 빈 줄을 지운다. 이미 읽은 줄만으로 알 수 있을 때만 적용해
324
+ // 앞선 큰 줄을 미리 읽지 않는다.
325
+ const isFileHead = () => leadingBlank >= 0 && queue.length <= leadingBlank;
326
+
327
+ const reader = {
328
+ bytesRead: 0,
329
+ // 줄이 더 없으면 null
330
+ next() {
331
+ let line = null;
332
+ while (queue.length > 0 || pos > 0) {
333
+ if (queue.length === 0) {
334
+ fill();
335
+ continue;
336
+ }
337
+ line = queue.pop();
338
+ const blank = line.trim() === "";
339
+ if (blank && (trimmingTail || isFileHead())) {
340
+ line = null;
341
+ continue;
342
+ }
343
+ break;
344
+ }
345
+ if (line === null) {
346
+ if (yielded || emptyGiven) return null;
347
+ emptyGiven = true; // 빈 파일도 옛 split 처럼 빈 줄 하나를 낸다
348
+ return "";
349
+ }
350
+ yielded = true;
351
+ if (trimmingTail) {
352
+ trimmingTail = false;
353
+ line = line.trimEnd();
354
+ }
355
+ return isFileHead() ? line.trimStart() : line;
356
+ },
357
+ close() {
358
+ try {
359
+ closeSync(fd);
360
+ } catch {
361
+ /* 이미 닫힘 */
362
+ }
363
+ },
364
+ };
365
+ return reader;
366
+ }
367
+
256
368
  // ============================================================================
257
369
  // Codex 세션 JSONL에서 실제 rate limits 추출
258
370
  // 한계: rate_limits는 세션별 스냅샷이므로 여러 세션 간 토큰 합산은 불가.
@@ -298,50 +410,65 @@ export function getCodexRateLimits({
298
410
  const daySnapshots = [];
299
411
  for (const file of files) {
300
412
  try {
301
- const content = readFileSync(join(sessDir, file), "utf-8");
302
413
  const found = {};
303
- const lines = content.trim().split("\n").reverse();
304
- let scanned = 0;
305
- for (const line of lines) {
306
- if (++scanned > maxLinesPerFile) break;
307
- try {
308
- const evt = JSON.parse(line);
309
- const rl = evt?.payload?.rate_limits;
310
- if (rl?.limit_id && !found[rl.limit_id]) {
311
- // window_minutes 기준으로 5h/1w 슬롯 정규화
312
- const { primary, secondary } = normalizeBuckets(rl);
313
- found[rl.limit_id] = {
314
- limitId: rl.limit_id,
315
- limitName: rl.limit_name,
316
- primary,
317
- secondary,
318
- credits: rl.credits,
319
- tokens: evt.payload?.info?.total_token_usage,
320
- contextWindow: evt.payload?.info?.model_context_window,
321
- timestamp: evt.timestamp,
322
- };
323
- } else if (
324
- dayOffset <= 1 &&
325
- !rl &&
326
- evt?.payload?.info?.total_token_usage &&
327
- !syntheticBucket
328
- ) {
329
- // 2일 이내 token_count: 합성 버킷 (rate_limits가 null일 때 행 활성화용, stale 방지)
330
- syntheticBucket = {
331
- limitId: "codex",
332
- limitName: "codex-session",
333
- primary: null,
334
- secondary: null,
335
- credits: null,
336
- tokens: evt.payload.info.total_token_usage,
337
- contextWindow: evt.payload.info.model_context_window,
338
- timestamp: evt.timestamp,
339
- };
414
+ const reader = createReverseLineReader(join(sessDir, file));
415
+ try {
416
+ let scanned = 0;
417
+ let bytesAfterFirstBucket = 0;
418
+ // 다음 줄을 읽기 전에 한도를 본다. 안 볼 줄을 만들려고 큰 줄을 읽지 않기 위해서다.
419
+ while (
420
+ scanned < maxLinesPerFile &&
421
+ bytesAfterFirstBucket < EXTRA_BYTES_AFTER_FIRST_BUCKET
422
+ ) {
423
+ const line = reader.next();
424
+ if (line === null) break;
425
+ scanned++;
426
+ // 같은 시점의 다른 limit_id 는 인접해 기록된다. 첫 버킷을 찾은 뒤에는 일정 바이트만 더 보고 멈춰,
427
+ // 단일 버킷 계정에서 800줄(파일 거의 전체)을 끝까지 읽지 않는다.
428
+ if (Object.keys(found).length > 0) {
429
+ bytesAfterFirstBucket += Buffer.byteLength(line) + 1;
430
+ }
431
+ try {
432
+ const evt = JSON.parse(line);
433
+ const rl = evt?.payload?.rate_limits;
434
+ if (rl?.limit_id && !found[rl.limit_id]) {
435
+ // window_minutes 기준으로 5h/1w 슬롯 정규화
436
+ const { primary, secondary } = normalizeBuckets(rl);
437
+ found[rl.limit_id] = {
438
+ limitId: rl.limit_id,
439
+ limitName: rl.limit_name,
440
+ primary,
441
+ secondary,
442
+ credits: rl.credits,
443
+ tokens: evt.payload?.info?.total_token_usage,
444
+ contextWindow: evt.payload?.info?.model_context_window,
445
+ timestamp: evt.timestamp,
446
+ };
447
+ } else if (
448
+ dayOffset <= 1 &&
449
+ !rl &&
450
+ evt?.payload?.info?.total_token_usage &&
451
+ !syntheticBucket
452
+ ) {
453
+ // 2일 이내 token_count: 합성 버킷 (rate_limits가 null일 때 행 활성화용, stale 방지)
454
+ syntheticBucket = {
455
+ limitId: "codex",
456
+ limitName: "codex-session",
457
+ primary: null,
458
+ secondary: null,
459
+ credits: null,
460
+ tokens: evt.payload.info.total_token_usage,
461
+ contextWindow: evt.payload.info.model_context_window,
462
+ timestamp: evt.timestamp,
463
+ };
464
+ }
465
+ } catch {
466
+ /* 라인 파싱 실패 무시 */
340
467
  }
341
- } catch {
342
- /* 라인 파싱 실패 무시 */
468
+ if (Object.keys(found).length >= CODEX_MIN_BUCKETS) break;
343
469
  }
344
- if (Object.keys(found).length >= CODEX_MIN_BUCKETS) break;
470
+ } finally {
471
+ reader.close();
345
472
  }
346
473
  daySnapshots.push(...Object.values(found));
347
474
  } catch {
package/hud/utils.mjs CHANGED
@@ -86,10 +86,6 @@ export function padAnsiRight(text, width) {
86
86
  return padAnsi(text, width, "right");
87
87
  }
88
88
 
89
- export function padAnsiLeft(text, width) {
90
- return padAnsi(text, width, "left");
91
- }
92
-
93
89
  export function fitText(text, width) {
94
90
  const t = String(text || "");
95
91
  if (t.length <= width) return t;
@@ -186,20 +182,6 @@ export function formatTokenCount(n) {
186
182
  return String(n);
187
183
  }
188
184
 
189
- export function getContextPercent(stdin) {
190
- const nativePercent = stdin?.context_window?.used_percentage;
191
- if (typeof nativePercent === "number" && Number.isFinite(nativePercent))
192
- return clampPercent(nativePercent);
193
- const usage = stdin?.context_window?.current_usage || {};
194
- const totalTokens =
195
- Number(usage.input_tokens || 0) +
196
- Number(usage.cache_creation_input_tokens || 0) +
197
- Number(usage.cache_read_input_tokens || 0);
198
- const capacity = Number(stdin?.context_window?.context_window_size || 0);
199
- if (!capacity || capacity <= 0) return 0;
200
- return clampPercent((totalTokens / capacity) * 100);
201
- }
202
-
203
185
  // 과거 리셋 시간 → 다음 주기로 순환하여 미래 시점 반환
204
186
  // elapsed가 cycleMs의 정수배일 때 ceil은 target=now를 반환해 diff=0이 되므로
205
187
  // floor+1로 항상 다음 사이클을 가리키도록 한다.
@@ -240,11 +222,6 @@ export function formatResetRemaining(isoOrUnix, cycleMs = 0) {
240
222
  return `${totalHours}h${String(minutes).padStart(2, "0")}m`;
241
223
  }
242
224
 
243
- export function isResetPast(isoOrUnix) {
244
- const date = parseResetDate(isoOrUnix);
245
- return date != null && date.getTime() <= Date.now();
246
- }
247
-
248
225
  export function formatResetRemainingDayHour(isoOrUnix, cycleMs = 0) {
249
226
  const diffMs = getRemainingResetMs(isoOrUnix, cycleMs);
250
227
  if (diffMs == null || diffMs <= 0) return "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/core",
3
- "version": "10.53.0",
3
+ "version": "10.55.0",
4
4
  "description": "triflux core — CLI routing, adapters. Zero native dependencies.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",
@@ -10,7 +10,7 @@
10
10
  "./scripts/*": "./scripts/*"
11
11
  },
12
12
  "engines": {
13
- "node": ">=20"
13
+ "node": ">=22"
14
14
  },
15
15
  "dependencies": {
16
16
  "@iarna/toml": "^2.2.5"
@@ -0,0 +1,60 @@
1
+ import {
2
+ existsSync,
3
+ readdirSync,
4
+ readFileSync,
5
+ rmSync,
6
+ statSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { basename, dirname, join } from "node:path";
10
+
11
+ // 설정이 바뀔 때마다 백업이 쌓여 끝없이 늘었다. 같은 대상의 백업은 최근 몇 개만 남긴다.
12
+ export const BACKUP_KEEP = 5;
13
+
14
+ // 시각 suffix 만 받는다. label 이 더 긴 다른 백업(예: <label>-manual-...)은 남긴다.
15
+ const STAMP_SUFFIX = /^[\dT]+(?:-\d+)*$/i;
16
+
17
+ // 읽는 사이 다른 프로세스가 지웠으면 같은 것으로 보고 아래에서 다시 쓴다. 다른 읽기 오류는 그대로 던진다.
18
+ function sameContent(path, content) {
19
+ try {
20
+ return readFileSync(path, "utf8") === content;
21
+ } catch (error) {
22
+ if (error?.code === "ENOENT") return true;
23
+ throw error;
24
+ }
25
+ }
26
+
27
+ // `<파일>.<label>-<시각>` 으로 백업하고, 같은 label 의 오래된 백업을 지운다.
28
+ export function writeRotatedBackup(
29
+ path,
30
+ content,
31
+ { label, suffix, mode, keep = BACKUP_KEEP },
32
+ ) {
33
+ const directory = dirname(path);
34
+ const prefix = `${basename(path)}.${label}-`;
35
+ // 같은 시각 이름에 다른 원문이 이미 있으면 번호를 붙여 따로 남긴다.
36
+ let backupPath = join(directory, `${prefix}${suffix}`);
37
+ for (let i = 1; existsSync(backupPath); i++) {
38
+ if (sameContent(backupPath, content)) break;
39
+ backupPath = join(directory, `${prefix}${suffix}-${i}`);
40
+ }
41
+ if (!existsSync(backupPath))
42
+ writeFileSync(backupPath, content, { encoding: "utf8", mode });
43
+ const backups = readdirSync(directory)
44
+ .filter(
45
+ (name) =>
46
+ name.startsWith(prefix) && STAMP_SUFFIX.test(name.slice(prefix.length)),
47
+ )
48
+ .flatMap((name) => {
49
+ // 다른 프로세스가 같은 백업을 먼저 지웠을 수 있다.
50
+ try {
51
+ return [{ name, mtime: statSync(join(directory, name)).mtimeMs }];
52
+ } catch {
53
+ return [];
54
+ }
55
+ })
56
+ .sort((a, b) => b.mtime - a.mtime || b.name.localeCompare(a.name));
57
+ for (const { name } of backups.slice(keep))
58
+ rmSync(join(directory, name), { force: true });
59
+ return backupPath;
60
+ }
@@ -3,10 +3,12 @@ import {
3
3
  mkdirSync,
4
4
  readFileSync,
5
5
  renameSync,
6
+ statSync,
6
7
  writeFileSync,
7
8
  } from "node:fs";
8
9
  import { homedir } from "node:os";
9
10
  import { dirname, join } from "node:path";
11
+ import { writeRotatedBackup } from "./backup-rotation.mjs";
10
12
 
11
13
  const EFFORT_BY_SUFFIX = {
12
14
  ultra: "ultra",
@@ -80,10 +82,6 @@ function readProfileScalar(raw, key) {
80
82
  * naming convention when the file is absent. Returns the keys that resolve, in
81
83
  * `key="value"` TOML-scalar form ready to be shell-quoted by the caller.
82
84
  *
83
- * NOTE: the Codex MCP transport does the structured-arg equivalent in
84
- * hub/workers/codex-mcp.mjs (resolveCodexProfileConfig → model + config object);
85
- * this returns the CLI `-c` string form for the same per-profile file.
86
- *
87
85
  * @param {string} profileName
88
86
  * @param {{ codexHome?: string }} [opts]
89
87
  * @returns {string[]} e.g. ['model="gpt-6.1-sol"', 'model_reasoning_effort="high"']
@@ -230,7 +228,8 @@ export function sanitizeCodexProfileConfig(
230
228
  skippedProfileFiles.push(profileName);
231
229
  continue;
232
230
  }
233
- writeFileSync(profilePath, body, "utf8");
231
+ // 옮겨 온 프로필 본문에 MCP env 키가 있을 수 있다.
232
+ writeFileSync(profilePath, body, { encoding: "utf8", mode: 0o600 });
234
233
  migratedProfiles.push(profileName);
235
234
  }
236
235
  }
@@ -254,6 +253,15 @@ function stamp(now = () => new Date()) {
254
253
  .replace("T", "-");
255
254
  }
256
255
 
256
+ // config.toml 에는 MCP env 키가 들어갈 수 있다. 다시 쓸 때 원본 권한을 지키고 없으면 0600 으로 만든다.
257
+ export function privateFileMode(path) {
258
+ try {
259
+ return statSync(path).mode & 0o777;
260
+ } catch {
261
+ return 0o600;
262
+ }
263
+ }
264
+
257
265
  export function sanitizeCodexProfileConfigFile(
258
266
  configPath,
259
267
  {
@@ -268,7 +276,12 @@ export function sanitizeCodexProfileConfigFile(
268
276
  const source = readFileSync(configPath, "utf8");
269
277
  const sanitized = sanitizeCodexProfileConfig(source, { codexHome });
270
278
  if (!sanitized.changed) return sanitized;
271
- writeFileSync(`${configPath}.${backupPrefix}-${stamp(now)}`, source, "utf8");
279
+ const mode = privateFileMode(configPath);
280
+ writeRotatedBackup(configPath, source, {
281
+ label: backupPrefix,
282
+ suffix: stamp(now),
283
+ mode,
284
+ });
272
285
  // Atomic replace. config.toml is shared global state: a codex session may be
273
286
  // reading it while another entry point (tfx-route, the codex session hook, or
274
287
  // a parallel swarm worker) sanitizes it. Write the sanitized content to a
@@ -278,7 +291,7 @@ export function sanitizeCodexProfileConfigFile(
278
291
  // without corruption (the content is identical anyway since the migration is
279
292
  // deterministic). The pid keeps temp names unique across separate processes.
280
293
  const tmpPath = `${configPath}.tmp-${process.pid}-${stamp(now)}`;
281
- writeFileSync(tmpPath, sanitized.toml, "utf8");
294
+ writeFileSync(tmpPath, sanitized.toml, { encoding: "utf8", mode });
282
295
  renameSync(tmpPath, configPath);
283
296
  return sanitized;
284
297
  }
@@ -58,9 +58,8 @@ recover_codex_stdout() {
58
58
 
59
59
  # ── MCP transport 채널 사망 판정 ──
60
60
  # Codex MCP transport 채널이 실행 중 죽으면 codex exec 가 exit 0 + 빈 stdout 으로
61
- # 끝나는 변형이 있다(래퍼 부재로 인한 pre-flight CODEX_MCP_TRANSPORT_EXIT_CODE=70
62
- # 과 구분됨 — 이건 런타임 채널 사망). stderr 의 하드 크래시 서명으로 이 사망을
63
- # 검출한다. 아우터 가드가 exit 0 이어도 결과를 실패로 승격하는 근거로 쓴다.
61
+ # 끝나는 변형이 있다. stderr 의 하드 크래시 서명으로 이 사망을 검출한다.
62
+ # 아우터 가드가 exit 0 이어도 결과를 실패로 승격하는 근거로 쓴다.
64
63
  # 인자: [stderr_log] (미지정 시 $STDERR_LOG). 반환: 0=크래시 서명 있음, 1=없음.
65
64
  codex_stdout_transport_crashed() {
66
65
  local stderr_log="${1:-${STDERR_LOG:-}}"