@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.
- package/hub/server.mjs +0 -122
- package/hub/team/claude-daemon-control.mjs +54 -6
- package/hub/team/claude-transcript.mjs +240 -0
- package/hub/team/cli/commands/start/index.mjs +17 -3
- package/hub/team/cli/commands/start/parse-args.mjs +1 -0
- package/hub/team/session-context.mjs +203 -0
- package/hub/team/uds-orchestrator.mjs +63 -1
- package/hub/team/worktree-lifecycle.mjs +0 -1
- package/package.json +2 -3
- package/scripts/lib/doctor-env-checks.mjs +0 -85
- package/scripts/lib/legacy-hook-cleanup.mjs +230 -0
- package/scripts/lib/legacy-mcp-cleanup.mjs +772 -0
- package/scripts/lib/mcp-guard-engine.mjs +44 -271
- package/scripts/lib/mcp-manifest.mjs +2 -2
- package/hub/mac-focus.mjs +0 -94
- package/hub/mac-tray.swift +0 -144
- package/hub/public/tray-icon.ico +0 -0
- package/hub/public/tray-icon.png +0 -0
- package/hub/public/tray.html +0 -756
- package/hub/tray-lifecycle.mjs +0 -138
- package/hub/tray-runtime.mjs +0 -265
- package/hub/tray-state.mjs +0 -524
- package/hub/tray.mjs +0 -527
- package/scripts/lib/cross-review-utils.mjs +0 -59
- package/scripts/lib/hook-utils.mjs +0 -16
- package/scripts/lib/keyword-rules.mjs +0 -219
- package/scripts/lib/mcp-gateway-health-check.mjs +0 -224
- package/scripts/lib/mcp-gateway-servers.mjs +0 -70
- package/scripts/lib/mcp-gateway-wrapper-check.mjs +0 -35
|
@@ -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.
|
|
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": ">=
|
|
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
|
+
}
|