@triflux/remote 10.48.1 → 10.50.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.
@@ -0,0 +1,203 @@
1
+ import { createReadStream } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import { StringDecoder } from "node:string_decoder";
6
+
7
+ // https://platform.claude.com/docs/en/build-with-claude/context-windows 2026-10-08
8
+ // https://developers.openai.com/api/docs/models 2026-10-08
9
+ const MODEL_CONTEXT = [
10
+ {
11
+ cli: "claude",
12
+ model: /^(?:claude-)?(?:fable-5[.-]1|opus-5[.-]5|sonnet-5[.-]5)(?:$|[-[])/i,
13
+ tokens: 1_000_000,
14
+ source:
15
+ "https://platform.claude.com/docs/en/build-with-claude/context-windows",
16
+ },
17
+ {
18
+ cli: "codex",
19
+ model: /^gpt-6(?:\.\d+)?(?:$|[-_])/i,
20
+ tokens: 1_050_000,
21
+ source: "https://developers.openai.com/api/docs/models",
22
+ },
23
+ ];
24
+
25
+ export const CONTEXT_THRESHOLDS = {
26
+ claude: { warnContextPct: 60, maxContextPct: 90 },
27
+ codex: { warnContextPct: 15, maxContextPct: 22 },
28
+ };
29
+
30
+ const rolloutCache = new Map();
31
+
32
+ export function modelContext(cli, model, estimatedContextTokens = null) {
33
+ const entry = MODEL_CONTEXT.find(
34
+ (item) => item.cli === cli && item.model.test(model || ""),
35
+ );
36
+ const contextLimitTokens = entry?.tokens ?? null;
37
+ const executionContextLimitTokens =
38
+ cli === "claude" && process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT === "1"
39
+ ? 200_000
40
+ : null;
41
+ const effectiveLimit = Math.min(
42
+ contextLimitTokens ?? Infinity,
43
+ executionContextLimitTokens ?? Infinity,
44
+ );
45
+ return {
46
+ estimatedContextTokens,
47
+ contextLimitTokens,
48
+ contextLimitSource: entry?.source ?? null,
49
+ ...(executionContextLimitTokens ? { executionContextLimitTokens } : {}),
50
+ contextPct:
51
+ contextLimitTokens && Number.isFinite(estimatedContextTokens)
52
+ ? (estimatedContextTokens / effectiveLimit) * 100
53
+ : null,
54
+ };
55
+ }
56
+
57
+ export function contextGuard(
58
+ cli,
59
+ context,
60
+ { warnContextPct, maxContextPct } = {},
61
+ ) {
62
+ const defaults = CONTEXT_THRESHOLDS[cli];
63
+ const warn = warnContextPct ?? defaults?.warnContextPct ?? 0;
64
+ const max = maxContextPct ?? defaults?.maxContextPct ?? 0;
65
+ const fields = {
66
+ estimatedContextTokens: context?.estimatedContextTokens ?? null,
67
+ contextLimitTokens: context?.contextLimitTokens ?? null,
68
+ contextLimitSource: context?.contextLimitSource ?? null,
69
+ executionContextLimitTokens: context?.executionContextLimitTokens ?? null,
70
+ contextPct: context?.contextPct ?? null,
71
+ };
72
+ if (fields.contextPct === null) {
73
+ return warn > 0
74
+ ? {
75
+ contextWarning: {
76
+ ...fields,
77
+ reason:
78
+ fields.contextLimitTokens === null
79
+ ? "unknown-model"
80
+ : "usage-unavailable",
81
+ },
82
+ }
83
+ : {};
84
+ }
85
+ if (max > 0 && fields.contextPct >= max) {
86
+ return {
87
+ ok: false,
88
+ errorCode: "context-limit",
89
+ inputSent: false,
90
+ hint: "세션을 승계한 뒤 새 세션에 요청하라.",
91
+ ...fields,
92
+ };
93
+ }
94
+ return warn > 0 && fields.contextPct >= warn
95
+ ? { contextWarning: { ...fields, thresholdPct: warn } }
96
+ : {};
97
+ }
98
+
99
+ async function findCodexRollout(threadId) {
100
+ if (!/^[a-zA-Z0-9-]+$/u.test(threadId || "")) return null;
101
+ const root = path.join(
102
+ process.env.CODEX_HOME || path.join(homedir(), ".codex"),
103
+ "sessions",
104
+ );
105
+ for (const year of (await fs.readdir(root).catch(() => []))
106
+ .sort()
107
+ .reverse()) {
108
+ if (!/^\d{4}$/u.test(year)) continue;
109
+ for (const month of (
110
+ await fs.readdir(path.join(root, year)).catch(() => [])
111
+ )
112
+ .sort()
113
+ .reverse()) {
114
+ if (!/^\d{2}$/u.test(month)) continue;
115
+ for (const day of (
116
+ await fs.readdir(path.join(root, year, month)).catch(() => [])
117
+ )
118
+ .sort()
119
+ .reverse()) {
120
+ if (!/^\d{2}$/u.test(day)) continue;
121
+ const dir = path.join(root, year, month, day);
122
+ const file = (await fs.readdir(dir).catch(() => [])).find((name) =>
123
+ name.endsWith(`-${threadId}.jsonl`),
124
+ );
125
+ if (file) return path.join(dir, file);
126
+ }
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+
132
+ function acceptCodexLine(state, line) {
133
+ let entry;
134
+ try {
135
+ entry = JSON.parse(line);
136
+ } catch {
137
+ return false;
138
+ }
139
+ if (entry?.type === "turn_context")
140
+ state.model = entry.payload?.model || state.model;
141
+ if (entry?.type === "event_msg" && entry.payload?.type === "token_count") {
142
+ const info = entry.payload.info;
143
+ if (Number.isFinite(info?.last_token_usage?.total_tokens))
144
+ state.tokens = info.last_token_usage.total_tokens;
145
+ if (Number.isFinite(info?.model_context_window))
146
+ state.executionContextLimitTokens = info.model_context_window;
147
+ }
148
+ return true;
149
+ }
150
+
151
+ export async function readCodexContext(rolloutPath, threadId) {
152
+ rolloutPath ||= await findCodexRollout(threadId);
153
+ if (!rolloutPath) return { ...modelContext("codex", null), model: null };
154
+ let stat;
155
+ try {
156
+ stat = await fs.stat(rolloutPath);
157
+ } catch {
158
+ return { ...modelContext("codex", null), model: null };
159
+ }
160
+ let state = rolloutCache.get(rolloutPath);
161
+ if (
162
+ !state ||
163
+ state.ino !== stat.ino ||
164
+ stat.size < state.offset ||
165
+ (stat.size === state.offset && stat.mtimeMs !== state.mtimeMs)
166
+ ) {
167
+ state = {
168
+ ino: stat.ino,
169
+ offset: 0,
170
+ pending: "",
171
+ decoder: new StringDecoder("utf8"),
172
+ model: null,
173
+ tokens: null,
174
+ executionContextLimitTokens: null,
175
+ };
176
+ }
177
+ try {
178
+ if (stat.size > state.offset) {
179
+ const stream = createReadStream(rolloutPath, {
180
+ start: state.offset,
181
+ end: stat.size - 1,
182
+ });
183
+ for await (const chunk of stream) {
184
+ const lines = (state.pending + state.decoder.write(chunk)).split("\n");
185
+ state.pending = lines.pop();
186
+ for (const line of lines) acceptCodexLine(state, line);
187
+ }
188
+ if (state.pending && acceptCodexLine(state, state.pending))
189
+ state.pending = "";
190
+ }
191
+ } catch {
192
+ rolloutCache.delete(rolloutPath);
193
+ return { ...modelContext("codex", null), model: null };
194
+ }
195
+ state.offset = stat.size;
196
+ state.mtimeMs = stat.mtimeMs;
197
+ rolloutCache.set(rolloutPath, state);
198
+ return {
199
+ ...modelContext("codex", state.model, state.tokens),
200
+ model: state.model,
201
+ executionContextLimitTokens: state.executionContextLimitTokens,
202
+ };
203
+ }
@@ -18,6 +18,7 @@ import {
18
18
  dispatchClaudeDaemonJob,
19
19
  teardownClaudeDaemonJob,
20
20
  } from "./claude-daemon-control.mjs";
21
+ import { contextGuard, readCodexContext } from "./session-context.mjs";
21
22
 
22
23
  const DEFAULT_TIMEOUT_MS = 90_000;
23
24
  const DEFAULT_CODEX_APP_SERVER_UDS_TIMEOUT_MS = 120_000;
@@ -44,6 +45,7 @@ async function runCodexAppServerTurn({
44
45
  timeoutMs,
45
46
  maxTurnMs,
46
47
  expectedTurnId,
48
+ noWait = false,
47
49
  }) {
48
50
  const messages = new Map();
49
51
  const pending = [];
@@ -159,6 +161,16 @@ async function runCodexAppServerTurn({
159
161
  turnId = started?.turn?.id || started?.turnId;
160
162
  if (!turnId)
161
163
  throw new Error("codex app-server turn/start returned no turn id");
164
+ if (noWait) {
165
+ return {
166
+ status: "submitted",
167
+ inputSent: true,
168
+ done: false,
169
+ turnId,
170
+ submittedAt: new Date().toISOString(),
171
+ target: threadId,
172
+ };
173
+ }
162
174
  for (const [kind, params] of pending) receive(kind, params);
163
175
  const result = await completion;
164
176
  const entries = [...messages.values()].map((item) => ({
@@ -264,6 +276,30 @@ export async function listCodexAppServerThreads({
264
276
  }
265
277
  }
266
278
 
279
+ export async function renameCodexAppServerThread({
280
+ socketPath,
281
+ threadId,
282
+ name,
283
+ clientFactory = (opts) => new JsonRpcWsUdsClient(opts),
284
+ } = {}) {
285
+ const timeoutMs = DEFAULT_CODEX_APP_SERVER_UDS_BOOTSTRAP_MS;
286
+ const client = codexClient(socketPath, timeoutMs, clientFactory);
287
+ try {
288
+ await initializeCodexClient(client, timeoutMs);
289
+ await client.request("thread/name/set", { threadId, name }, timeoutMs);
290
+ return {
291
+ ok: true,
292
+ cli: "codex",
293
+ transport: "uds",
294
+ threadId,
295
+ name,
296
+ nameApplied: true,
297
+ };
298
+ } finally {
299
+ client.close();
300
+ }
301
+ }
302
+
267
303
  export async function askCodexAppServerThread({
268
304
  socketPath,
269
305
  threadId,
@@ -274,6 +310,9 @@ export async function askCodexAppServerThread({
274
310
  ifBusy = "wait",
275
311
  busyTimeoutMs = timeoutMs,
276
312
  pollIntervalMs = 200,
313
+ noWait = false,
314
+ warnContextPct,
315
+ maxContextPct,
277
316
  clientFactory = (opts) => new JsonRpcWsUdsClient(opts),
278
317
  } = {}) {
279
318
  if (typeof prompt !== "string" || !prompt.trim())
@@ -353,6 +392,24 @@ export async function askCodexAppServerThread({
353
392
  { threadId: selectedId, excludeTurns: true },
354
393
  DEFAULT_CODEX_APP_SERVER_UDS_BOOTSTRAP_MS,
355
394
  );
395
+ const context = await readCodexContext(
396
+ resumed?.thread?.path || thread.path,
397
+ selectedId,
398
+ );
399
+ const guard = contextGuard("codex", context, {
400
+ warnContextPct,
401
+ maxContextPct,
402
+ });
403
+ if (guard.ok === false) {
404
+ return {
405
+ ...guard,
406
+ status: "failed",
407
+ done: false,
408
+ cli: "codex",
409
+ transport: "uds",
410
+ threadId: selectedId,
411
+ };
412
+ }
356
413
  let activeTurns = null;
357
414
  if (resumed?.thread?.status?.type === "active")
358
415
  activeTurns = resumed.thread.turns?.length
@@ -371,9 +428,13 @@ export async function askCodexAppServerThread({
371
428
  timeoutMs,
372
429
  maxTurnMs,
373
430
  expectedTurnId,
431
+ noWait,
374
432
  });
375
433
  if (turn.message) throw new Error(turn.message);
376
434
  return {
435
+ ...turn,
436
+ ...context,
437
+ ...guard,
377
438
  cli: "codex",
378
439
  transport: "uds",
379
440
  threadId: selectedId,
@@ -381,7 +442,8 @@ export async function askCodexAppServerThread({
381
442
  response: turn.response,
382
443
  done: turn.status === "completed",
383
444
  matchedCompletion: turn.matchedCompletion,
384
- timedOut: turn.timedOut,
445
+ ...(noWait ? {} : { timedOut: turn.timedOut }),
446
+ ...(noWait ? { ok: true } : {}),
385
447
  status: turn.status,
386
448
  commentary: turn.commentary,
387
449
  meta: { retries: turn.retries },
@@ -1,5 +1,4 @@
1
1
  // hub/team/worktree-lifecycle.mjs — Git worktree lifecycle management
2
- // Replaces shell commands in tfx-codex-swarm Step 5 + merge-worktree Phase 6.
3
2
  // Convention: .codex-swarm/wt-{slug} paths, swarm/{runId}/{slug} branches.
4
3
  // Remote support: host option → SSH-based git operations via remote-session.mjs.
5
4
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/remote",
3
- "version": "10.48.1",
3
+ "version": "10.50.0",
4
4
  "description": "triflux remote — team mode, psmux, MCP workers, SQLite store.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",
@@ -9,7 +9,7 @@
9
9
  "./hub/*": "./hub/*"
10
10
  },
11
11
  "engines": {
12
- "node": ">=18.0.0"
12
+ "node": ">=20"
13
13
  },
14
14
  "peerDependencies": {
15
15
  "@triflux/core": "^10.0.0-alpha.1"
@@ -20,7 +20,6 @@
20
20
  "better-sqlite3": "^12.10.0",
21
21
  "pino": "^10.3.1",
22
22
  "pino-pretty": "^13.1.3",
23
- "systray2": "^2.1.4",
24
23
  "zod": "^4.0.0"
25
24
  },
26
25
  "files": [
@@ -34,88 +34,3 @@ export function inspectMacTimeoutDependency({
34
34
  fix: "brew install coreutils",
35
35
  };
36
36
  }
37
-
38
- export function addPluginRootFallbackToCommand(command, pluginRoot) {
39
- if (typeof command !== "string") return command;
40
- const fallbackRoot = String(pluginRoot || "").replace(/\\/g, "/");
41
- if (!fallbackRoot) return command;
42
- return command
43
- .replaceAll("${PLUGIN_ROOT}", `\${PLUGIN_ROOT:-${fallbackRoot}}`)
44
- .replaceAll(
45
- "${CLAUDE_PLUGIN_ROOT}",
46
- `\${CLAUDE_PLUGIN_ROOT:-${fallbackRoot}}`,
47
- )
48
- .replace(
49
- /(^|[\s"'])\/(hooks|scripts)\//g,
50
- `$1\${PLUGIN_ROOT:-${fallbackRoot}}/$2/`,
51
- );
52
- }
53
-
54
- function hasBarePluginRootReference(command) {
55
- if (typeof command !== "string") return false;
56
- return (
57
- command.includes("${PLUGIN_ROOT}/") ||
58
- command.includes("${CLAUDE_PLUGIN_ROOT}/")
59
- );
60
- }
61
-
62
- function hasCollapsedRootHookPath(command) {
63
- if (typeof command !== "string") return false;
64
- return /(^|[\s"'])\/(?:hooks|scripts)\//.test(command);
65
- }
66
-
67
- export function findPluginRootHookIssues(settings) {
68
- const hooksByEvent =
69
- settings?.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
70
- const issues = [];
71
-
72
- for (const [event, entries] of Object.entries(hooksByEvent)) {
73
- if (!Array.isArray(entries)) continue;
74
- for (const entry of entries) {
75
- const hooks = Array.isArray(entry?.hooks) ? entry.hooks : [];
76
- for (const hook of hooks) {
77
- const command = hook?.command;
78
- if (
79
- !hasBarePluginRootReference(command) &&
80
- !hasCollapsedRootHookPath(command)
81
- ) {
82
- continue;
83
- }
84
- issues.push({
85
- event,
86
- matcher: entry?.matcher || "*",
87
- command,
88
- reason: hasCollapsedRootHookPath(command)
89
- ? "collapsed-root-path"
90
- : "missing-plugin-root-fallback",
91
- });
92
- }
93
- }
94
- }
95
-
96
- return issues;
97
- }
98
-
99
- export function applyPluginRootHookFallbacks(settings, { pluginRoot } = {}) {
100
- if (!settings?.hooks || typeof settings.hooks !== "object") {
101
- return { changed: false, count: 0, settings };
102
- }
103
-
104
- let count = 0;
105
- for (const entries of Object.values(settings.hooks)) {
106
- if (!Array.isArray(entries)) continue;
107
- for (const entry of entries) {
108
- const hooks = Array.isArray(entry?.hooks) ? entry.hooks : [];
109
- for (const hook of hooks) {
110
- if (typeof hook?.command !== "string") continue;
111
- const next = addPluginRootFallbackToCommand(hook.command, pluginRoot);
112
- if (next !== hook.command) {
113
- hook.command = next;
114
- count++;
115
- }
116
- }
117
- }
118
- }
119
-
120
- return { changed: count > 0, count, settings };
121
- }
@@ -0,0 +1,230 @@
1
+ import {
2
+ constants,
3
+ copyFileSync,
4
+ existsSync,
5
+ lstatSync,
6
+ readFileSync,
7
+ realpathSync,
8
+ renameSync,
9
+ statSync,
10
+ unlinkSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+
16
+ // 설치된 Triflux 실행 대상만 식별한다.
17
+ const MANAGED_FILENAMES = [
18
+ "hook-orchestrator.mjs",
19
+ "claude-cwd-projection-refresh.mjs",
20
+ "permission-safe-allow.mjs",
21
+ "safety-guard.mjs",
22
+ "pipeline-stop.mjs",
23
+ "agent-route-guard.mjs",
24
+ "cross-review-tracker.mjs",
25
+ "cross-review-gate.mjs",
26
+ "tfx-gate-activate.mjs",
27
+ "subagent-tracker.mjs",
28
+ "subagent-verifier.mjs",
29
+ "error-context.mjs",
30
+ "hook-adaptive-collector.mjs",
31
+ "post-tool-tips.mjs",
32
+ "pre-compact-snapshot.mjs",
33
+ "cto-north-star-brief.mjs",
34
+ "session-start-lake.mjs",
35
+ "session-start-fast.mjs",
36
+ "session-end-cleanup.mjs",
37
+ "mcp-config-watcher.mjs",
38
+ "keyword-detector.mjs",
39
+ "setup.mjs",
40
+ "hub-ensure.mjs",
41
+ "preflight-cache.mjs",
42
+ "mcp-gateway-ensure.mjs",
43
+ "mcp-safety-guard.mjs",
44
+ "session-stale-cleanup.mjs",
45
+ ];
46
+
47
+ const targetNames = MANAGED_FILENAMES.join("|").replaceAll(".", "\\.");
48
+ const uniqueTargetNames = MANAGED_FILENAMES.filter(
49
+ (name) =>
50
+ ![
51
+ "setup.mjs",
52
+ "hub-ensure.mjs",
53
+ "preflight-cache.mjs",
54
+ "mcp-gateway-ensure.mjs",
55
+ "mcp-safety-guard.mjs",
56
+ "session-stale-cleanup.mjs",
57
+ ].includes(name),
58
+ )
59
+ .join("|")
60
+ .replaceAll(".", "\\.");
61
+ const packageTarget = new RegExp(
62
+ String.raw`[/\\]triflux(?:[/\\](?:\.worktrees[/\\][^/\\]+|v?\d+\.\d+\.\d+[^/\\]*))?[/\\](?:hooks|scripts)[/\\](?:${targetNames})$`,
63
+ );
64
+ const pluginRootTarget = new RegExp(
65
+ String.raw`^\$\{(?:CLAUDE_)?PLUGIN_ROOT\}[/\\](?:hooks|scripts)[/\\](?:${uniqueTargetNames})$`,
66
+ );
67
+ const gateSuffix = ".claude/scripts/tfx-gate-activate.mjs";
68
+
69
+ function isInstalledGateTarget(target) {
70
+ const normalized = target.replaceAll("\\", "/");
71
+ if (
72
+ ["${HOME}", "$HOME", "~"].some(
73
+ (root) => normalized === `${root}/${gateSuffix}`,
74
+ )
75
+ )
76
+ return true;
77
+ const installedTargets = [join(homedir(), gateSuffix)];
78
+ if (process.env.CLAUDE_CONFIG_DIR)
79
+ installedTargets.push(
80
+ join(process.env.CLAUDE_CONFIG_DIR, "scripts/tfx-gate-activate.mjs"),
81
+ );
82
+ return installedTargets.some(
83
+ (installed) => normalized === installed.replaceAll("\\", "/"),
84
+ );
85
+ }
86
+
87
+ function readCommandToken(command) {
88
+ const match = command.match(/^\s*(?:"([^"]*)"|'([^']*)'|(\S+))(.*)$/s);
89
+ return match
90
+ ? { value: match[1] ?? match[2] ?? match[3], rest: match[4] }
91
+ : null;
92
+ }
93
+
94
+ function matchesTarget(target) {
95
+ const resolvedFallback = target.replace(
96
+ /^\$\{(?:CLAUDE_)?PLUGIN_ROOT:-([^}]+)\}/,
97
+ "$1",
98
+ );
99
+ return (
100
+ packageTarget.test(resolvedFallback) ||
101
+ pluginRootTarget.test(target) ||
102
+ isInstalledGateTarget(target)
103
+ );
104
+ }
105
+
106
+ export function isLegacyTrifluxHook(hook) {
107
+ if (hook?.type !== "command" || typeof hook.command !== "string")
108
+ return false;
109
+ const command = hook.command;
110
+ const executable = readCommandToken(command);
111
+ if (!executable || !/(?:^|[/\\])node(?:\.exe)?$/.test(executable.value))
112
+ return false;
113
+ const target = readCommandToken(executable.rest);
114
+ if (!target) return false;
115
+ // 옛 inline bootstrap만 제거한다.
116
+ if (target.value === "-e" || target.value === "--eval")
117
+ return (
118
+ command.includes(".tfx-pkg-root") &&
119
+ command.includes("hook-orchestrator.mjs") &&
120
+ command.includes("spawnSync") &&
121
+ command.includes("path.join(root")
122
+ );
123
+ if (matchesTarget(target.value)) return true;
124
+ // run.cjs의 keyword-detector 인자를 확인한다.
125
+ if (/[/\\]scripts[/\\]run\.cjs$/.test(target.value)) {
126
+ const script = readCommandToken(target.rest);
127
+ return (
128
+ script?.value.endsWith("/scripts/keyword-detector.mjs") &&
129
+ (matchesTarget(script.value) || pluginRootTarget.test(script.value))
130
+ );
131
+ }
132
+ return false;
133
+ }
134
+
135
+ function removeManagedHooks(settings) {
136
+ let removed = 0;
137
+ const events = settings?.hooks;
138
+ if (!events || typeof events !== "object" || Array.isArray(events))
139
+ return removed;
140
+ for (const [event, entries] of Object.entries(events)) {
141
+ if (!Array.isArray(entries)) continue;
142
+ const kept = [];
143
+ for (const entry of entries) {
144
+ if (!Array.isArray(entry?.hooks)) {
145
+ kept.push(entry);
146
+ continue;
147
+ }
148
+ const hooks = entry.hooks.filter((hook) => {
149
+ if (!isLegacyTrifluxHook(hook)) return true;
150
+ removed++;
151
+ return false;
152
+ });
153
+ if (hooks.length)
154
+ kept.push(
155
+ hooks.length === entry.hooks.length ? entry : { ...entry, hooks },
156
+ );
157
+ }
158
+ if (kept.length) events[event] = kept;
159
+ else delete events[event];
160
+ }
161
+ if (Object.keys(events).length === 0) delete settings.hooks;
162
+ return removed;
163
+ }
164
+
165
+ /** Claude 설정의 이전 Triflux command hook을 정리한다. */
166
+ export function cleanupLegacyHooks({
167
+ settingsPath = join(
168
+ process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"),
169
+ "settings.json",
170
+ ),
171
+ dryRun = false,
172
+ } = {}) {
173
+ const result = {
174
+ ok: true,
175
+ changed: false,
176
+ wouldChange: false,
177
+ removed: 0,
178
+ backupPath: null,
179
+ error: null,
180
+ };
181
+ let original;
182
+ let settings;
183
+ try {
184
+ const pathInfo = lstatSync(settingsPath, { throwIfNoEntry: false });
185
+ if (!pathInfo) return result;
186
+ // symlink는 유지하고 실제 파일을 교체한다.
187
+ const targetPath = pathInfo.isSymbolicLink()
188
+ ? realpathSync(settingsPath)
189
+ : settingsPath;
190
+ original = readFileSync(targetPath, "utf8");
191
+ settings = JSON.parse(original);
192
+ if (!settings || typeof settings !== "object" || Array.isArray(settings))
193
+ throw new Error("settings.json must contain an object");
194
+ result.removed = removeManagedHooks(settings);
195
+ result.wouldChange = result.removed > 0;
196
+ if (!result.wouldChange || dryRun) return result;
197
+
198
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
199
+ let backupPath = `${settingsPath}.tfx-bak-${stamp}`;
200
+ for (let suffix = 1; existsSync(backupPath); suffix++)
201
+ backupPath = `${settingsPath}.tfx-bak-${stamp}-${suffix}`;
202
+ copyFileSync(settingsPath, backupPath, constants.COPYFILE_EXCL);
203
+ result.backupPath = backupPath;
204
+
205
+ const temporary = join(
206
+ dirname(targetPath),
207
+ `.settings.json.tfx-${process.pid}-${Date.now()}.tmp`,
208
+ );
209
+ try {
210
+ writeFileSync(temporary, `${JSON.stringify(settings, null, 2)}\n`, {
211
+ encoding: "utf8",
212
+ flag: "wx",
213
+ mode: statSync(targetPath).mode,
214
+ });
215
+ renameSync(temporary, targetPath);
216
+ } catch (error) {
217
+ try {
218
+ unlinkSync(temporary);
219
+ } catch {
220
+ /* 임시 파일 없음 */
221
+ }
222
+ throw error;
223
+ }
224
+ result.changed = true;
225
+ } catch (error) {
226
+ result.ok = false;
227
+ result.error = error.message;
228
+ }
229
+ return result;
230
+ }