@triflux/remote 10.49.0 → 10.50.1
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/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 +1 -1
- 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/scripts/lib/cross-review-utils.mjs +0 -59
- package/scripts/lib/hook-utils.mjs +0 -16
- package/scripts/lib/keyword-rules.mjs +0 -296
- 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
|
@@ -13,6 +13,10 @@ import {
|
|
|
13
13
|
removeClaudeSessionProjection,
|
|
14
14
|
writeClaudeSessionProjection,
|
|
15
15
|
} from "./claude-session-projection.mjs";
|
|
16
|
+
import {
|
|
17
|
+
findClaudeTranscript,
|
|
18
|
+
readClaudeTranscript,
|
|
19
|
+
} from "./claude-transcript.mjs";
|
|
16
20
|
|
|
17
21
|
export function resolveClaudeConfigDir(env = process.env) {
|
|
18
22
|
if (env.CLAUDE_CONFIG_DIR) return path.resolve(env.CLAUDE_CONFIG_DIR);
|
|
@@ -223,6 +227,21 @@ function sessionsFromDaemonList(listResponse) {
|
|
|
223
227
|
return extractClaudeAgentSessions(listResponse);
|
|
224
228
|
}
|
|
225
229
|
|
|
230
|
+
async function addSessionContexts(sessions, candidate) {
|
|
231
|
+
return await Promise.all(
|
|
232
|
+
sessions.map(async (session) => {
|
|
233
|
+
const transcript = await findClaudeTranscript({
|
|
234
|
+
configDir: candidate.configDir,
|
|
235
|
+
sourceConfigDir: candidate.sourceConfigDir,
|
|
236
|
+
sessionId: session.sessionId,
|
|
237
|
+
cwd: session.cwd,
|
|
238
|
+
});
|
|
239
|
+
const snapshot = await readClaudeTranscript(transcript).catch(() => null);
|
|
240
|
+
return { ...session, context: snapshot?.context ?? null };
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
226
245
|
function errorMessage(error) {
|
|
227
246
|
return error?.message || String(error);
|
|
228
247
|
}
|
|
@@ -322,12 +341,21 @@ export async function probeClaudeDaemonCandidates({
|
|
|
322
341
|
sessionId,
|
|
323
342
|
timeoutMs = 6000,
|
|
324
343
|
tmpRoot = "/tmp",
|
|
344
|
+
includeContext = false,
|
|
345
|
+
candidateSourceConfigDir,
|
|
325
346
|
} = {}) {
|
|
326
|
-
const
|
|
347
|
+
const discovered = await buildClaudeDaemonDiscoveryCandidates({
|
|
327
348
|
configDir,
|
|
328
349
|
env,
|
|
329
350
|
tmpRoot,
|
|
330
351
|
});
|
|
352
|
+
const candidates = candidateSourceConfigDir
|
|
353
|
+
? discovered.filter(
|
|
354
|
+
(candidate) =>
|
|
355
|
+
(candidate.sourceConfigDir ?? candidate.configDir) ===
|
|
356
|
+
candidateSourceConfigDir,
|
|
357
|
+
)
|
|
358
|
+
: discovered;
|
|
331
359
|
const callerProvenance = detectCallerProvenance(env);
|
|
332
360
|
const targetRequested = Boolean(short || sessionId);
|
|
333
361
|
const results = [];
|
|
@@ -339,7 +367,10 @@ export async function probeClaudeDaemonCandidates({
|
|
|
339
367
|
{ proto: 1, op: "list" },
|
|
340
368
|
{ timeoutMs },
|
|
341
369
|
);
|
|
342
|
-
const
|
|
370
|
+
const normalized = sessionsFromDaemonList(list);
|
|
371
|
+
const sessions = includeContext
|
|
372
|
+
? await addSessionContexts(normalized, candidate)
|
|
373
|
+
: normalized;
|
|
343
374
|
const ok = list?.ok !== false;
|
|
344
375
|
results.push({
|
|
345
376
|
ok,
|
|
@@ -1038,10 +1069,10 @@ function defaultClaudeAttachCompletionMatched(
|
|
|
1038
1069
|
);
|
|
1039
1070
|
}
|
|
1040
1071
|
|
|
1041
|
-
function writeClaudeAttachInput(socket, input) {
|
|
1072
|
+
function writeClaudeAttachInput(socket, input, onSubmitted) {
|
|
1042
1073
|
socket.write("\x15");
|
|
1043
1074
|
socket.write(`\x1b[200~${String(input)}\x1b[201~`);
|
|
1044
|
-
socket.write("\r");
|
|
1075
|
+
socket.write("\r", onSubmitted);
|
|
1045
1076
|
}
|
|
1046
1077
|
|
|
1047
1078
|
function requiresPostInputTransition(handshake) {
|
|
@@ -1066,6 +1097,7 @@ export function attachClaudeDaemonSession({
|
|
|
1066
1097
|
timeoutMs = 30_000,
|
|
1067
1098
|
completionQuiescenceMs = DEFAULT_ATTACH_COMPLETION_QUIESCENCE_MS,
|
|
1068
1099
|
completionMatched = defaultClaudeAttachCompletionMatched,
|
|
1100
|
+
noWait = false,
|
|
1069
1101
|
} = {}) {
|
|
1070
1102
|
if (!controlSock) throw new Error("controlSock is required");
|
|
1071
1103
|
return new Promise((resolve, reject) => {
|
|
@@ -1198,8 +1230,24 @@ export function attachClaudeDaemonSession({
|
|
|
1198
1230
|
{ cols },
|
|
1199
1231
|
);
|
|
1200
1232
|
responseStartOffset = stream.length;
|
|
1201
|
-
|
|
1202
|
-
|
|
1233
|
+
inputSent = noWait ? null : true;
|
|
1234
|
+
writeClaudeAttachInput(
|
|
1235
|
+
socket,
|
|
1236
|
+
input,
|
|
1237
|
+
noWait
|
|
1238
|
+
? (error) => {
|
|
1239
|
+
if (error) fail(error);
|
|
1240
|
+
else {
|
|
1241
|
+
inputSent = true;
|
|
1242
|
+
finish({
|
|
1243
|
+
timedOut: false,
|
|
1244
|
+
matchedCompletion: false,
|
|
1245
|
+
closed: false,
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
: undefined,
|
|
1250
|
+
);
|
|
1203
1251
|
}, initialDrainMs);
|
|
1204
1252
|
}
|
|
1205
1253
|
} else {
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { StringDecoder } from "node:string_decoder";
|
|
5
|
+
import { modelContext } from "./session-context.mjs";
|
|
6
|
+
|
|
7
|
+
const transcriptCache = new Map();
|
|
8
|
+
|
|
9
|
+
export async function findClaudeTranscript({
|
|
10
|
+
configDir,
|
|
11
|
+
sourceConfigDir,
|
|
12
|
+
sessionId,
|
|
13
|
+
cwd,
|
|
14
|
+
}) {
|
|
15
|
+
if (!configDir || !sessionId || /[/\\]/u.test(sessionId)) return null;
|
|
16
|
+
for (const dir of new Set([configDir, sourceConfigDir].filter(Boolean))) {
|
|
17
|
+
const projectsDir = path.join(dir, "projects");
|
|
18
|
+
const preferred = cwd
|
|
19
|
+
? path.resolve(cwd).replace(/[^a-zA-Z0-9]/g, "-")
|
|
20
|
+
: null;
|
|
21
|
+
const projects = [
|
|
22
|
+
...(preferred ? [preferred] : []),
|
|
23
|
+
...(await fs.readdir(projectsDir).catch(() => [])).filter(
|
|
24
|
+
(project) => project !== preferred,
|
|
25
|
+
),
|
|
26
|
+
];
|
|
27
|
+
for (const project of projects) {
|
|
28
|
+
const candidate = path.join(projectsDir, project, `${sessionId}.jsonl`);
|
|
29
|
+
try {
|
|
30
|
+
await fs.access(candidate);
|
|
31
|
+
return candidate;
|
|
32
|
+
} catch {
|
|
33
|
+
// Continue looking for this session.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function messageText(content) {
|
|
41
|
+
if (typeof content === "string") return content;
|
|
42
|
+
if (!Array.isArray(content)) return "";
|
|
43
|
+
return content
|
|
44
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
45
|
+
.map((part) => part.text)
|
|
46
|
+
.join("\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function beginTurn(state) {
|
|
50
|
+
state.userSeen = true;
|
|
51
|
+
state.queued = false;
|
|
52
|
+
state.response = "";
|
|
53
|
+
state.error = null;
|
|
54
|
+
state.turnEnded = false;
|
|
55
|
+
state.sectionClosed = false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function acceptEntry(state, entry, marker) {
|
|
59
|
+
if (entry?.isSidechain) return;
|
|
60
|
+
const message = entry?.message;
|
|
61
|
+
if (entry?.type === "system" && entry.subtype === "compact_boundary") {
|
|
62
|
+
state.compactCount += 1;
|
|
63
|
+
state.compact = {
|
|
64
|
+
preTokens: entry.compactMetadata?.preTokens ?? null,
|
|
65
|
+
postTokens: entry.compactMetadata?.postTokens ?? null,
|
|
66
|
+
};
|
|
67
|
+
state.estimatedContextTokens = state.compact.postTokens;
|
|
68
|
+
state.measuredAt = entry.timestamp || null;
|
|
69
|
+
}
|
|
70
|
+
if (entry?.type === "assistant") {
|
|
71
|
+
if (entry.isApiErrorMessage === true || message?.model === "<synthetic>") {
|
|
72
|
+
if (state.userSeen && !state.sectionClosed && entry.isApiErrorMessage) {
|
|
73
|
+
state.error = messageText(message?.content) || "Claude API error";
|
|
74
|
+
state.turnEnded = true;
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const usage = message?.usage;
|
|
79
|
+
if (usage) {
|
|
80
|
+
state.estimatedContextTokens =
|
|
81
|
+
(usage.input_tokens || 0) +
|
|
82
|
+
(usage.cache_read_input_tokens || 0) +
|
|
83
|
+
(usage.cache_creation_input_tokens || 0);
|
|
84
|
+
state.model = message.model || state.model;
|
|
85
|
+
state.measuredAt = entry.timestamp || state.measuredAt;
|
|
86
|
+
}
|
|
87
|
+
if (state.userSeen && !state.sectionClosed) {
|
|
88
|
+
const text = messageText(message?.content);
|
|
89
|
+
if (text) state.response = text;
|
|
90
|
+
if (message?.stop_reason === "end_turn") state.turnEnded = true;
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (entry?.type === "user") {
|
|
95
|
+
const content = message?.content;
|
|
96
|
+
// Tool results are not a new human turn.
|
|
97
|
+
const humanText =
|
|
98
|
+
typeof content === "string" ||
|
|
99
|
+
(Array.isArray(content) && content.some((part) => part?.type === "text"))
|
|
100
|
+
? messageText(content)
|
|
101
|
+
: "";
|
|
102
|
+
const marked =
|
|
103
|
+
marker &&
|
|
104
|
+
(humanText.includes(marker) ||
|
|
105
|
+
JSON.stringify(
|
|
106
|
+
entry.attachments ?? message?.attachments ?? [],
|
|
107
|
+
).includes(marker));
|
|
108
|
+
if (humanText || marked) {
|
|
109
|
+
if (!marker || marked) beginTurn(state);
|
|
110
|
+
else if (state.userSeen) state.sectionClosed = true;
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (
|
|
115
|
+
entry?.type === "attachment" &&
|
|
116
|
+
entry.attachment?.type === "queued_command" &&
|
|
117
|
+
marker &&
|
|
118
|
+
JSON.stringify(entry.attachment).includes(marker)
|
|
119
|
+
) {
|
|
120
|
+
beginTurn(state);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (entry?.type === "queue-operation") {
|
|
124
|
+
const marked =
|
|
125
|
+
marker &&
|
|
126
|
+
typeof entry.content === "string" &&
|
|
127
|
+
entry.content.includes(marker);
|
|
128
|
+
if (marked && entry.operation === "enqueue") {
|
|
129
|
+
state.queued = true;
|
|
130
|
+
state.queuedContent = entry.content;
|
|
131
|
+
} else if (
|
|
132
|
+
state.queued &&
|
|
133
|
+
(marked || entry.content === state.queuedContent)
|
|
134
|
+
) {
|
|
135
|
+
if (entry.operation === "remove") beginTurn(state);
|
|
136
|
+
} else if (marked && entry.operation === "remove") {
|
|
137
|
+
beginTurn(state);
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (
|
|
142
|
+
state.userSeen &&
|
|
143
|
+
!state.sectionClosed &&
|
|
144
|
+
entry?.type === "system" &&
|
|
145
|
+
entry.subtype === "turn_duration"
|
|
146
|
+
) {
|
|
147
|
+
state.turnEnded = true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function acceptLine(state, line, marker) {
|
|
152
|
+
try {
|
|
153
|
+
acceptEntry(state, JSON.parse(line), marker);
|
|
154
|
+
return true;
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function readClaudeTranscript(transcriptPath, { requestId } = {}) {
|
|
161
|
+
if (!transcriptPath) return null;
|
|
162
|
+
const marker = requestId ? `[tfx-live req=${requestId}]` : null;
|
|
163
|
+
const key = `${transcriptPath}\0${requestId || ""}`;
|
|
164
|
+
let stat;
|
|
165
|
+
try {
|
|
166
|
+
stat = await fs.stat(transcriptPath);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error?.code === "ENOENT") return null;
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
let state = transcriptCache.get(key);
|
|
172
|
+
if (
|
|
173
|
+
!state ||
|
|
174
|
+
state.ino !== stat.ino ||
|
|
175
|
+
stat.size < state.offset ||
|
|
176
|
+
(stat.size === state.offset && stat.mtimeMs !== state.mtimeMs)
|
|
177
|
+
) {
|
|
178
|
+
state = {
|
|
179
|
+
ino: stat.ino,
|
|
180
|
+
offset: 0,
|
|
181
|
+
pending: "",
|
|
182
|
+
decoder: new StringDecoder("utf8"),
|
|
183
|
+
userSeen: false,
|
|
184
|
+
queued: false,
|
|
185
|
+
queuedContent: null,
|
|
186
|
+
response: "",
|
|
187
|
+
error: null,
|
|
188
|
+
turnEnded: false,
|
|
189
|
+
sectionClosed: false,
|
|
190
|
+
estimatedContextTokens: null,
|
|
191
|
+
model: null,
|
|
192
|
+
measuredAt: null,
|
|
193
|
+
compactCount: 0,
|
|
194
|
+
compact: null,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
if (stat.size > state.offset) {
|
|
199
|
+
const stream = createReadStream(transcriptPath, {
|
|
200
|
+
start: state.offset,
|
|
201
|
+
end: stat.size - 1,
|
|
202
|
+
});
|
|
203
|
+
for await (const chunk of stream) {
|
|
204
|
+
const lines = (state.pending + state.decoder.write(chunk)).split("\n");
|
|
205
|
+
state.pending = lines.pop();
|
|
206
|
+
for (const line of lines) acceptLine(state, line, marker);
|
|
207
|
+
}
|
|
208
|
+
if (state.pending && acceptLine(state, state.pending, marker))
|
|
209
|
+
state.pending = "";
|
|
210
|
+
}
|
|
211
|
+
} catch (error) {
|
|
212
|
+
transcriptCache.delete(key);
|
|
213
|
+
if (error?.code === "ENOENT") return null;
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
state.offset = stat.size;
|
|
217
|
+
state.mtimeMs = stat.mtimeMs;
|
|
218
|
+
transcriptCache.set(key, state);
|
|
219
|
+
return {
|
|
220
|
+
context:
|
|
221
|
+
state.estimatedContextTokens === null && state.model === null
|
|
222
|
+
? null
|
|
223
|
+
: {
|
|
224
|
+
...modelContext(
|
|
225
|
+
"claude",
|
|
226
|
+
state.model,
|
|
227
|
+
state.estimatedContextTokens,
|
|
228
|
+
),
|
|
229
|
+
model: state.model,
|
|
230
|
+
measuredAt: state.measuredAt,
|
|
231
|
+
},
|
|
232
|
+
userSeen: state.userSeen,
|
|
233
|
+
response: state.response,
|
|
234
|
+
error: state.error,
|
|
235
|
+
turnEnded: state.turnEnded,
|
|
236
|
+
sectionClosed: state.sectionClosed,
|
|
237
|
+
compactCount: state.compactCount,
|
|
238
|
+
compact: state.compact,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
@@ -72,9 +72,19 @@ function renderTmuxInstallHelp() {
|
|
|
72
72
|
|
|
73
73
|
export { parseTeamArgs };
|
|
74
74
|
|
|
75
|
+
export function resolveTeamWorkers({ agents, task, assigns = [] }) {
|
|
76
|
+
if (assigns.length > 0) {
|
|
77
|
+
return {
|
|
78
|
+
agents: assigns.map((assign) => assign.cli),
|
|
79
|
+
subtasks: assigns.map((assign) => assign.prompt),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return { agents, subtasks: decomposeTask(task, agents.length) };
|
|
83
|
+
}
|
|
84
|
+
|
|
75
85
|
export async function teamStart(args = []) {
|
|
76
86
|
const {
|
|
77
|
-
agents,
|
|
87
|
+
agents: requestedAgents,
|
|
78
88
|
lead,
|
|
79
89
|
layout,
|
|
80
90
|
teammateMode,
|
|
@@ -100,11 +110,16 @@ export async function teamStart(args = []) {
|
|
|
100
110
|
rawTask ||
|
|
101
111
|
(assigns.length > 0 ? assigns.map((a) => a.prompt).join(" + ") : "");
|
|
102
112
|
if (!task) return printStartUsage();
|
|
113
|
+
const { agents, subtasks } = resolveTeamWorkers({
|
|
114
|
+
agents: requestedAgents,
|
|
115
|
+
task,
|
|
116
|
+
assigns,
|
|
117
|
+
});
|
|
103
118
|
|
|
104
119
|
console.log(`\n ${AMBER}${BOLD}⬡ tfx multi${RESET}\n`);
|
|
105
120
|
|
|
106
121
|
// P1b: 워커 수 계산 — 단일 워커 headless에는 Hub 불필요
|
|
107
|
-
const workerCount =
|
|
122
|
+
const workerCount = agents.length;
|
|
108
123
|
const needsHub = workerCount >= 2 || teammateMode !== "headless";
|
|
109
124
|
|
|
110
125
|
let hub = null;
|
|
@@ -126,7 +141,6 @@ export async function teamStart(args = []) {
|
|
|
126
141
|
}
|
|
127
142
|
|
|
128
143
|
const sessionId = `tfx-multi-${Date.now().toString(36).slice(-4)}${Math.random().toString(36).slice(2, 6)}`;
|
|
129
|
-
const subtasks = decomposeTask(task, agents.length);
|
|
130
144
|
const hubUrl = hub?.url || getDefaultHubUrl();
|
|
131
145
|
const { mode: effectiveMode, warnings: modeWarnings } =
|
|
132
146
|
resolveEffectiveMode(teammateMode);
|
|
@@ -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
|
+
}
|