@triflux/remote 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/hub/team/backend.mjs +10 -3
  2. package/hub/team/cli/commands/start/index.mjs +0 -2
  3. package/hub/team/handoff.mjs +1 -1
  4. package/hub/team/headless.mjs +41 -57
  5. package/hub/team/intervention.mjs +6 -4
  6. package/hub/team/nativeProxy.mjs +2 -48
  7. package/hub/team/pane.mjs +31 -18
  8. package/hub/team/psmux.mjs +36 -20
  9. package/hub/team/remote-session.mjs +66 -56
  10. package/hub/team/retry-state-machine.mjs +1 -1
  11. package/hub/team/session.mjs +80 -59
  12. package/hub/team/terminal-opener.mjs +5 -19
  13. package/hub/team/tui-viewer.mjs +2 -2
  14. package/hub/team/tui.mjs +0 -6
  15. package/hub/workers/factory.mjs +1 -10
  16. package/hub/workers/interface.mjs +0 -7
  17. package/package.json +2 -6
  18. package/scripts/lib/backup-rotation.mjs +60 -0
  19. package/scripts/lib/codex-profile-config.mjs +20 -7
  20. package/scripts/lib/codex-recovery.sh +2 -3
  21. package/scripts/lib/install-blob-history.json +3196 -0
  22. package/scripts/lib/install-retire.mjs +203 -0
  23. package/scripts/lib/legacy-hook-cleanup.mjs +148 -13
  24. package/scripts/lib/legacy-mcp-cleanup.mjs +405 -104
  25. package/scripts/lib/machine-profile.mjs +2 -2
  26. package/scripts/lib/mcp-filter.mjs +1 -42
  27. package/scripts/lib/mcp-guard-engine.mjs +198 -19
  28. package/scripts/lib/mcp-manifest.mjs +0 -40
  29. package/scripts/lib/process-utils.mjs +19 -0
  30. package/scripts/lib/stable-node.mjs +35 -0
  31. package/scripts/lib/test-env.mjs +20 -0
  32. package/cto/brief.mjs +0 -73
  33. package/cto/collect.mjs +0 -490
  34. package/cto/current.schema.json +0 -148
  35. package/cto/events.mjs +0 -208
  36. package/cto/hygiene.mjs +0 -320
  37. package/cto/lake-root.mjs +0 -127
  38. package/cto/status.mjs +0 -124
  39. package/hub/lib/cto-env.mjs +0 -1
  40. package/hub/team/notify.mjs +0 -385
  41. package/hub/team/process-cleanup.mjs +0 -386
  42. package/hub/team/routing.mjs +0 -249
  43. package/hub/team/staleState.mjs +0 -403
  44. package/hub/team/worker-signal.mjs +0 -263
  45. package/hub/team/worker-signal.types.d.ts +0 -52
  46. package/hub/workers/codex-mcp.mjs +0 -853
  47. package/scripts/lib/remote-spawn-transfer.mjs +0 -226
@@ -0,0 +1,203 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ renameSync,
9
+ rmdirSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { dirname, join, relative } from "node:path";
14
+
15
+ // 설치본에서 triflux 가 배포하는 디렉터리. ~/.claude 기준.
16
+ const ROOTS = ["scripts", "hud", "hub"];
17
+ const RETIRED_DIR = ".tfx-retired";
18
+ const KEEP_DAYS = 30;
19
+ const MANIFEST = "scripts/.tfx-manifest.json";
20
+ // 2026-10-08 까지 저장소의 hub, hud, scripts 아래에 있었던 모든 파일 내용의 git blob id 앞 12자리.
21
+ // 이 뒤로 빠지는 파일은 매니페스트가 맡는다.
22
+ const HISTORY_FILE = new URL("./install-blob-history.json", import.meta.url);
23
+ const HUB_CACHE = "cache/tfx-hub";
24
+ // 제거된 허브와 계정 브로커만 쓰던 캐시. 지금 코드가 읽는 파일은 넣지 않는다.
25
+ const HUB_CACHE_RETIRE = [
26
+ /^state\.db(?:-wal|-shm)?$/,
27
+ /^hub\.log$/,
28
+ /^team-logs$/,
29
+ /^synapse-sessions\.json$/,
30
+ /^team-native-.+\.json$/,
31
+ ];
32
+ // 토큰 사본은 보관하지 않고 바로 지운다.
33
+ const HUB_CACHE_DELETE = [
34
+ /^accounts\.json$/,
35
+ /^broker-state\.json$/,
36
+ /^codex-auth-.+\.json$/,
37
+ /^gemini-auth-.+\.json$/,
38
+ /^gemini-accounts-.+\.json$/,
39
+ ];
40
+
41
+ export function gitBlobId(content) {
42
+ return createHash("sha1")
43
+ .update(`blob ${content.length}\0`)
44
+ .update(content)
45
+ .digest("hex");
46
+ }
47
+
48
+ function loadHistory() {
49
+ try {
50
+ return new Set(JSON.parse(readFileSync(HISTORY_FILE, "utf8")));
51
+ } catch {
52
+ return new Set();
53
+ }
54
+ }
55
+
56
+ function readManifest(claudeDir) {
57
+ try {
58
+ return (
59
+ JSON.parse(readFileSync(join(claudeDir, MANIFEST), "utf8")).files ?? {}
60
+ );
61
+ } catch {
62
+ return {};
63
+ }
64
+ }
65
+
66
+ // Windows 체크아웃에서 복사된 사본은 CRLF 일 수 있어 LF 로 맞춘 내용도 비교한다.
67
+ function isShippedContent(content, rel, history, manifest) {
68
+ const ids = [gitBlobId(content)];
69
+ const text = content.toString("utf8");
70
+ if (text.includes("\r\n"))
71
+ ids.push(gitBlobId(Buffer.from(text.replaceAll("\r\n", "\n"))));
72
+ return ids.some((id) => history.has(id.slice(0, 12)) || manifest[rel] === id);
73
+ }
74
+
75
+ function walkFiles(dir) {
76
+ if (!existsSync(dir)) return [];
77
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
78
+ const path = join(dir, entry.name);
79
+ if (entry.isDirectory())
80
+ return entry.name === "node_modules" ? [] : walkFiles(path);
81
+ return entry.isFile() ? [path] : [];
82
+ });
83
+ }
84
+
85
+ function moveTo(claudeDir, day, rel) {
86
+ let target = join(claudeDir, RETIRED_DIR, day, rel);
87
+ for (let n = 1; existsSync(target); n++)
88
+ target = join(claudeDir, RETIRED_DIR, day, `${rel}.${n}`);
89
+ mkdirSync(dirname(target), { recursive: true });
90
+ renameSync(join(claudeDir, rel), target);
91
+ }
92
+
93
+ function removeEmptyDirs(dir, keepSelf = true) {
94
+ if (!existsSync(dir) || !lstatSync(dir).isDirectory()) return;
95
+ for (const entry of readdirSync(dir, { withFileTypes: true }))
96
+ if (entry.isDirectory()) removeEmptyDirs(join(dir, entry.name), false);
97
+ if (!keepSelf && readdirSync(dir).length === 0) rmdirSync(dir);
98
+ }
99
+
100
+ function retireHubCache(claudeDir, day, result) {
101
+ const cache = join(claudeDir, HUB_CACHE);
102
+ if (!existsSync(cache)) return;
103
+ for (const name of readdirSync(cache)) {
104
+ const rel = `${HUB_CACHE}/${name}`;
105
+ if (HUB_CACHE_DELETE.some((pattern) => pattern.test(name))) {
106
+ rmSync(join(claudeDir, rel), { recursive: true, force: true });
107
+ result.deleted.push(rel);
108
+ } else if (HUB_CACHE_RETIRE.some((pattern) => pattern.test(name))) {
109
+ moveTo(claudeDir, day, rel);
110
+ result.moved.push(rel);
111
+ }
112
+ }
113
+ }
114
+
115
+ function pruneRetired(claudeDir, now, result) {
116
+ const root = join(claudeDir, RETIRED_DIR);
117
+ if (!existsSync(root)) return;
118
+ const cutoff = now.getTime() - KEEP_DAYS * 24 * 60 * 60 * 1000;
119
+ for (const name of readdirSync(root)) {
120
+ const day = Date.parse(`${name}T00:00:00Z`);
121
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(name) || !(day < cutoff)) continue;
122
+ rmSync(join(root, name), { recursive: true, force: true });
123
+ result.pruned.push(name);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * 지금 패키지가 배포하지 않는 옛 triflux 설치 파일을 ~/.claude/.tfx-retired/<날짜>/ 로 옮긴다.
129
+ * triflux 가 배포한 내용 그대로인 파일만 옮기고, 고쳤거나 처음 보는 파일은 두며, 30일 지난 보관분은 지운다.
130
+ */
131
+ export function retireInstallLeftovers({
132
+ claudeDir,
133
+ keep,
134
+ now = new Date(),
135
+ history = loadHistory(),
136
+ }) {
137
+ const result = {
138
+ moved: [],
139
+ failed: [],
140
+ deleted: [],
141
+ pruned: [],
142
+ warnings: [],
143
+ };
144
+ const day = now.toISOString().slice(0, 10);
145
+ const manifest = readManifest(claudeDir);
146
+ for (const root of ROOTS) {
147
+ // 루트가 다른 체크아웃을 가리키는 링크면 옮기기가 그 원본을 지운다.
148
+ if (
149
+ lstatSync(join(claudeDir, root), {
150
+ throwIfNoEntry: false,
151
+ })?.isSymbolicLink()
152
+ )
153
+ continue;
154
+ for (const file of walkFiles(join(claudeDir, root))) {
155
+ const rel = relative(claudeDir, file).replaceAll("\\", "/");
156
+ const name = rel.split("/").pop();
157
+ // 숨김 파일은 setup 상태 파일이고 omc-hud 는 OMC 소유다.
158
+ if (keep.has(rel) || name.startsWith(".") || name.startsWith("omc-hud"))
159
+ continue;
160
+ try {
161
+ if (!isShippedContent(readFileSync(file), rel, history, manifest))
162
+ continue;
163
+ moveTo(claudeDir, day, rel);
164
+ result.moved.push(rel);
165
+ } catch (error) {
166
+ result.failed.push(rel);
167
+ result.warnings.push(
168
+ `${rel}: 옮기기 실패 (${error.code ?? error.message})`,
169
+ );
170
+ }
171
+ }
172
+ removeEmptyDirs(join(claudeDir, root));
173
+ }
174
+ try {
175
+ retireHubCache(claudeDir, day, result);
176
+ pruneRetired(claudeDir, now, result);
177
+ } catch (error) {
178
+ result.warnings.push(
179
+ `옛 허브 캐시 정리 실패: ${error.code ?? error.message}`,
180
+ );
181
+ }
182
+ return result;
183
+ }
184
+
185
+ /**
186
+ * 이번에 배포한 파일의 blob id 를 남겨, 다음 버전에서 빠지는 파일을 같은 기준으로 옮기게 한다.
187
+ * 옮기지 못한 파일(retained)은 이전 근거를 그대로 들고 가 다음 setup 이 다시 시도하게 한다.
188
+ */
189
+ export function writeInstallManifest(claudeDir, files, retained = []) {
190
+ const previous = readManifest(claudeDir);
191
+ const entries = {};
192
+ for (const rel of retained) if (previous[rel]) entries[rel] = previous[rel];
193
+ for (const rel of files) {
194
+ try {
195
+ entries[rel] = gitBlobId(readFileSync(join(claudeDir, rel)));
196
+ } catch {
197
+ // 복사되지 않은 파일
198
+ }
199
+ }
200
+ const path = join(claudeDir, MANIFEST);
201
+ mkdirSync(dirname(path), { recursive: true });
202
+ writeFileSync(path, `${JSON.stringify({ files: entries }, null, 2)}\n`);
203
+ }
@@ -12,6 +12,7 @@ import {
12
12
  } from "node:fs";
13
13
  import { homedir } from "node:os";
14
14
  import { dirname, join } from "node:path";
15
+ import { writeRotatedBackup } from "./backup-rotation.mjs";
15
16
 
16
17
  // 설치된 Triflux 실행 대상만 식별한다.
17
18
  const MANAGED_FILENAMES = [
@@ -64,21 +65,17 @@ const packageTarget = new RegExp(
64
65
  const pluginRootTarget = new RegExp(
65
66
  String.raw`^\$\{(?:CLAUDE_)?PLUGIN_ROOT\}[/\\](?:hooks|scripts)[/\\](?:${uniqueTargetNames})$`,
66
67
  );
67
- const gateSuffix = ".claude/scripts/tfx-gate-activate.mjs";
68
-
69
- function isInstalledGateTarget(target) {
68
+ // 옛 setup 이 ~/.claude/scripts 에 복사하고 직접 등록한 스크립트.
69
+ function isInstalledScript(target, name) {
70
70
  const normalized = target.replaceAll("\\", "/");
71
+ const suffix = `.claude/scripts/${name}`;
71
72
  if (
72
- ["${HOME}", "$HOME", "~"].some(
73
- (root) => normalized === `${root}/${gateSuffix}`,
74
- )
73
+ ["${HOME}", "$HOME", "~"].some((root) => normalized === `${root}/${suffix}`)
75
74
  )
76
75
  return true;
77
- const installedTargets = [join(homedir(), gateSuffix)];
76
+ const installedTargets = [join(homedir(), suffix)];
78
77
  if (process.env.CLAUDE_CONFIG_DIR)
79
- installedTargets.push(
80
- join(process.env.CLAUDE_CONFIG_DIR, "scripts/tfx-gate-activate.mjs"),
81
- );
78
+ installedTargets.push(join(process.env.CLAUDE_CONFIG_DIR, "scripts", name));
82
79
  return installedTargets.some(
83
80
  (installed) => normalized === installed.replaceAll("\\", "/"),
84
81
  );
@@ -99,7 +96,7 @@ function matchesTarget(target) {
99
96
  return (
100
97
  packageTarget.test(resolvedFallback) ||
101
98
  pluginRootTarget.test(target) ||
102
- isInstalledGateTarget(target)
99
+ isInstalledScript(target, "tfx-gate-activate.mjs")
103
100
  );
104
101
  }
105
102
 
@@ -108,8 +105,17 @@ export function isLegacyTrifluxHook(hook) {
108
105
  return false;
109
106
  const command = hook.command;
110
107
  const executable = readCommandToken(command);
111
- if (!executable || !/(?:^|[/\\])node(?:\.exe)?$/.test(executable.value))
112
- return false;
108
+ if (!executable) return false;
109
+ // headless-guard 는 2026-09-07 에 제거됐지만 옛 setup 이 bash 로 등록한 항목이 남는다.
110
+ if (/(?:^|[/\\])bash(?:\.exe)?$/.test(executable.value)) {
111
+ const script = readCommandToken(executable.rest);
112
+ return (
113
+ !!script &&
114
+ !script.rest.trim() &&
115
+ isInstalledScript(script.value, "headless-guard-fast.sh")
116
+ );
117
+ }
118
+ if (!/(?:^|[/\\])node(?:\.exe)?$/.test(executable.value)) return false;
113
119
  const target = readCommandToken(executable.rest);
114
120
  if (!target) return false;
115
121
  // 옛 inline bootstrap만 제거한다.
@@ -228,3 +234,132 @@ export function cleanupLegacyHooks({
228
234
  }
229
235
  return result;
230
236
  }
237
+
238
+ const AGY_HOOK_GROUP = "triflux-session";
239
+
240
+ // 옛 ensure-agy-hooks 의 quoteCommandPath 와 같은 인용.
241
+ function quoteAgyCommandPath(value) {
242
+ return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
243
+ }
244
+
245
+ function unquoteAgyCommandPath(quoted) {
246
+ return quoted.slice(1, -1).replace(/\\(["\\])/g, "$1");
247
+ }
248
+
249
+ function lastSegments(path, count) {
250
+ return path.split(/[/\\]+/u).slice(-count);
251
+ }
252
+
253
+ // 옛 설치기가 만든 명령을 풀어 같은 공식으로 다시 만들었을 때 똑같아야 한다.
254
+ // 첫 버전은 node 경로를 따옴표 없이, 이후 버전은 따옴표로 감쌌다.
255
+ function isInstallerAgyCommand(command) {
256
+ const match = /^(?:("(?:[^"\\]|\\.)*")|([^"]+)) ("(?:[^"\\]|\\.)*")$/u.exec(
257
+ command,
258
+ );
259
+ if (!match) return false;
260
+ // 따옴표 없는 node 경로(첫 버전의 process.execPath)는 공백과 셸 기호가 없는 절대 경로만 인정한다.
261
+ // 공백이 있으면 "node 사용자스크립트" 와 구분할 수 없어 지우지 않고 doctor 경고로 남긴다.
262
+ if (
263
+ match[2] &&
264
+ !/^(?:\/|[A-Za-z]:[\\/]|\\\\)[^\s"';&|`$<>]*$/u.test(match[2])
265
+ )
266
+ return false;
267
+ const nodeBin = match[1] ? unquoteAgyCommandPath(match[1]) : match[2];
268
+ const script = unquoteAgyCommandPath(match[3]);
269
+ if (/[\r\n]/u.test(nodeBin + script)) return false;
270
+ const [dir, file] = lastSegments(script, 2);
271
+ if (dir !== "hooks" || file !== "agy-session-hook.mjs") return false;
272
+ if (!/^node(?:js)?(?:\.exe)?$/iu.test(lastSegments(nodeBin, 1)[0]))
273
+ return false;
274
+ const rebuilt = `${match[1] ? quoteAgyCommandPath(nodeBin) : nodeBin} ${quoteAgyCommandPath(script)}`;
275
+ return rebuilt === command;
276
+ }
277
+
278
+ // 모든 버전의 옛 설치기가 쓴 그룹 모양과 정확히 같을 때만 우리 것이다.
279
+ function isInstallerAgyGroup(group) {
280
+ if (!group || typeof group !== "object" || Array.isArray(group)) return false;
281
+ const keys = Object.keys(group).sort().join(",");
282
+ if (keys !== "PreInvocation,enabled" || group.enabled !== true) return false;
283
+ const entries = group.PreInvocation;
284
+ if (!Array.isArray(entries) || entries.length !== 1) return false;
285
+ const [entry] = entries;
286
+ return (
287
+ Object.keys(entry || {})
288
+ .sort()
289
+ .join(",") === "command,timeout,type" &&
290
+ entry.type === "command" &&
291
+ entry.timeout === 15 &&
292
+ typeof entry.command === "string" &&
293
+ isInstallerAgyCommand(entry.command)
294
+ );
295
+ }
296
+
297
+ // 자동으로 지우지 않은 그룹 중 훅 스크립트를 언급하는 것. doctor 가 경고만 한다.
298
+ function agyHookMentions(hooks) {
299
+ return Object.entries(hooks)
300
+ .filter(([, group]) =>
301
+ JSON.stringify(group ?? null).includes("agy-session-hook.mjs"),
302
+ )
303
+ .map(([name]) => name);
304
+ }
305
+
306
+ /** agy hooks.json 에서 옛 setup 이 등록한 triflux-session 훅을 지운다. */
307
+ export function cleanupAgyHooks({ geminiConfigHome, dryRun = false } = {}) {
308
+ const hooksPath = join(
309
+ geminiConfigHome || join(homedir(), ".gemini", "config"),
310
+ "hooks.json",
311
+ );
312
+ const result = {
313
+ ok: true,
314
+ changed: false,
315
+ wouldChange: false,
316
+ removed: 0,
317
+ leftover: [],
318
+ hooksPath,
319
+ backupPath: null,
320
+ error: null,
321
+ };
322
+ // 테스트 실행 중에는 명시 경로 없이 실제 HOME 을 고치지 않는다.
323
+ if (!geminiConfigHome && process.env.TEST_LOCK_PID) return result;
324
+ try {
325
+ if (!existsSync(hooksPath)) return result;
326
+ const targetPath = realpathSync(hooksPath);
327
+ const original = readFileSync(targetPath, "utf8");
328
+ const hooks = JSON.parse(original);
329
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks))
330
+ return result;
331
+ const ours = isInstallerAgyGroup(hooks[AGY_HOOK_GROUP]);
332
+ if (ours) {
333
+ result.removed = 1;
334
+ result.wouldChange = true;
335
+ }
336
+ result.leftover = agyHookMentions(hooks).filter(
337
+ (name) => !(ours && name === AGY_HOOK_GROUP),
338
+ );
339
+ if (!ours || dryRun) return result;
340
+
341
+ delete hooks[AGY_HOOK_GROUP];
342
+ const mode = statSync(targetPath).mode & 0o777;
343
+ const stamp = new Date()
344
+ .toISOString()
345
+ .replace(/[-:.TZ]/g, "")
346
+ .slice(0, 14);
347
+ result.backupPath = writeRotatedBackup(targetPath, original, {
348
+ label: "bak-tfx-agy-hooks",
349
+ suffix: stamp,
350
+ mode,
351
+ });
352
+ const temporary = `${targetPath}.tfx-${process.pid}-${Date.now()}.tmp`;
353
+ writeFileSync(temporary, `${JSON.stringify(hooks, null, 2)}\n`, {
354
+ encoding: "utf8",
355
+ flag: "wx",
356
+ mode,
357
+ });
358
+ renameSync(temporary, targetPath);
359
+ result.changed = true;
360
+ } catch (error) {
361
+ result.ok = false;
362
+ result.error = error.message;
363
+ }
364
+ return result;
365
+ }