@triflux/remote 10.65.0 → 10.65.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/codex-queue.mjs +539 -0
- package/package.json +1 -1
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { open, readdir, readFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { createInterface } from "node:readline";
|
|
8
|
+
import { StringDecoder } from "node:string_decoder";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { codexThreadNames } from "@triflux/core/hub/lib/codex-session-registry.mjs";
|
|
11
|
+
import { findCodexRollout } from "./session-context.mjs";
|
|
12
|
+
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
const THREAD_ID_RE =
|
|
15
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
16
|
+
const FIRST_LINE_LIMIT = 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
export function isCodexThreadId(value) {
|
|
19
|
+
return THREAD_ID_RE.test(String(value ?? ""));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function codexHome(env) {
|
|
23
|
+
return env.CODEX_HOME || join(env.HOME || homedir(), ".codex");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function realCwd(value) {
|
|
27
|
+
if (!value) return null;
|
|
28
|
+
try {
|
|
29
|
+
return realpathSync(value);
|
|
30
|
+
} catch {
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sleep(ms) {
|
|
36
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 같은 이름이 여러 번 등록될 수 있어 sinceMs 이후 마지막 항목을 쓴다.
|
|
40
|
+
export async function codexThreadIdByName(
|
|
41
|
+
name,
|
|
42
|
+
{ env = process.env, sinceMs = 0 } = {},
|
|
43
|
+
) {
|
|
44
|
+
let data;
|
|
45
|
+
try {
|
|
46
|
+
data = await readFile(join(codexHome(env), "session_index.jsonl"), "utf8");
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
let threadId = null;
|
|
51
|
+
for (const line of data.split("\n")) {
|
|
52
|
+
try {
|
|
53
|
+
const entry = JSON.parse(line);
|
|
54
|
+
if (
|
|
55
|
+
entry.thread_name === name &&
|
|
56
|
+
isCodexThreadId(entry.id) &&
|
|
57
|
+
Date.parse(entry.updated_at) >= sinceMs
|
|
58
|
+
)
|
|
59
|
+
threadId = entry.id;
|
|
60
|
+
} catch {
|
|
61
|
+
// 기록 중인 마지막 줄은 건너뛴다.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return threadId;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function readFirstLine(path) {
|
|
68
|
+
const handle = await open(path, "r");
|
|
69
|
+
try {
|
|
70
|
+
const decoder = new StringDecoder("utf8");
|
|
71
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
72
|
+
let text = "";
|
|
73
|
+
let position = 0;
|
|
74
|
+
while (position < FIRST_LINE_LIMIT) {
|
|
75
|
+
const { bytesRead } = await handle.read(
|
|
76
|
+
buffer,
|
|
77
|
+
0,
|
|
78
|
+
buffer.length,
|
|
79
|
+
position,
|
|
80
|
+
);
|
|
81
|
+
if (!bytesRead) break;
|
|
82
|
+
position += bytesRead;
|
|
83
|
+
text += decoder.write(buffer.subarray(0, bytesRead));
|
|
84
|
+
const end = text.indexOf("\n");
|
|
85
|
+
if (end >= 0) return text.slice(0, end);
|
|
86
|
+
}
|
|
87
|
+
return text;
|
|
88
|
+
} finally {
|
|
89
|
+
await handle.close();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// rollout 첫 줄 session_meta 의 cwd 가 같은 thread 가 정확히 하나일 때만 돌려준다.
|
|
94
|
+
export async function findCodexThreadByCwd(
|
|
95
|
+
cwd,
|
|
96
|
+
{ env = process.env, sinceMs = 0, days = 2 } = {},
|
|
97
|
+
) {
|
|
98
|
+
const target = realCwd(cwd);
|
|
99
|
+
if (!target) return { threadId: null, reason: "cwd-unknown" };
|
|
100
|
+
const root = join(codexHome(env), "sessions");
|
|
101
|
+
const dayDirs = [];
|
|
102
|
+
for (const year of (await readdir(root).catch(() => [])).sort().reverse()) {
|
|
103
|
+
for (const month of (await readdir(join(root, year)).catch(() => []))
|
|
104
|
+
.sort()
|
|
105
|
+
.reverse()) {
|
|
106
|
+
for (const day of (await readdir(join(root, year, month)).catch(() => []))
|
|
107
|
+
.sort()
|
|
108
|
+
.reverse()) {
|
|
109
|
+
dayDirs.push(join(root, year, month, day));
|
|
110
|
+
if (dayDirs.length >= days) break;
|
|
111
|
+
}
|
|
112
|
+
if (dayDirs.length >= days) break;
|
|
113
|
+
}
|
|
114
|
+
if (dayDirs.length >= days) break;
|
|
115
|
+
}
|
|
116
|
+
const matches = new Set();
|
|
117
|
+
for (const dir of dayDirs) {
|
|
118
|
+
for (const file of await readdir(dir).catch(() => [])) {
|
|
119
|
+
if (!file.startsWith("rollout-") || !file.endsWith(".jsonl")) continue;
|
|
120
|
+
try {
|
|
121
|
+
const { payload } = JSON.parse(await readFirstLine(join(dir, file)));
|
|
122
|
+
// 같은 cwd 의 exec 워커와 서브에이전트 thread 는 TUI 대상이 아니다.
|
|
123
|
+
if (
|
|
124
|
+
isCodexThreadId(payload?.id) &&
|
|
125
|
+
(payload.source ?? "cli") === "cli" &&
|
|
126
|
+
realCwd(payload.cwd) === target &&
|
|
127
|
+
Date.parse(payload.timestamp) >= sinceMs
|
|
128
|
+
)
|
|
129
|
+
matches.add(payload.id);
|
|
130
|
+
} catch {
|
|
131
|
+
// session_meta 가 아닌 파일은 무시한다.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (matches.size === 1) return { threadId: [...matches][0] };
|
|
136
|
+
return {
|
|
137
|
+
threadId: null,
|
|
138
|
+
reason: matches.size ? "thread-ambiguous" : "thread-not-found",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 같은 cwd 에서 도는 Codex TUI 수. 확인할 수 없으면 null.
|
|
143
|
+
export async function countCodexTuiInCwd(cwd, { execFn = execFileAsync } = {}) {
|
|
144
|
+
const target = realCwd(cwd);
|
|
145
|
+
let listed = "";
|
|
146
|
+
try {
|
|
147
|
+
({ stdout: listed } = await execFn(
|
|
148
|
+
"lsof",
|
|
149
|
+
["-a", "-c", "codex", "-d", "cwd", "-Fpn"],
|
|
150
|
+
{ timeout: 5000, maxBuffer: 1024 * 1024 },
|
|
151
|
+
));
|
|
152
|
+
} catch (error) {
|
|
153
|
+
// lsof 는 일치하는 프로세스가 없어도 1 로 끝난다.
|
|
154
|
+
if (error.code !== 1) return null;
|
|
155
|
+
listed = error.stdout ?? "";
|
|
156
|
+
}
|
|
157
|
+
const pids = [];
|
|
158
|
+
let pid = null;
|
|
159
|
+
for (const line of String(listed).split("\n")) {
|
|
160
|
+
if (line.startsWith("p")) pid = line.slice(1);
|
|
161
|
+
else if (line.startsWith("n") && pid && realCwd(line.slice(1)) === target)
|
|
162
|
+
pids.push(pid);
|
|
163
|
+
}
|
|
164
|
+
if (!pids.length) return 0;
|
|
165
|
+
try {
|
|
166
|
+
const { stdout } = await execFn(
|
|
167
|
+
"ps",
|
|
168
|
+
["-o", "args=", "-p", pids.join(",")],
|
|
169
|
+
{ timeout: 5000, maxBuffer: 1024 * 1024 },
|
|
170
|
+
);
|
|
171
|
+
return String(stdout)
|
|
172
|
+
.split("\n")
|
|
173
|
+
.filter((line) => line.trim() && !/\bapp-server\b/.test(line)).length;
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// app-server 의 thread/queue/add 로 쌓아 프롬프트가 ps 에 보이지 않게 한다.
|
|
180
|
+
export async function queueCodexMessage({
|
|
181
|
+
threadId,
|
|
182
|
+
message,
|
|
183
|
+
env = process.env,
|
|
184
|
+
execFn = execFileAsync,
|
|
185
|
+
spawnFn = spawn,
|
|
186
|
+
}) {
|
|
187
|
+
let addSent = false;
|
|
188
|
+
try {
|
|
189
|
+
const result = await withCodexAppServer(
|
|
190
|
+
(request) => {
|
|
191
|
+
addSent = true;
|
|
192
|
+
return request("thread/queue/add", {
|
|
193
|
+
threadId,
|
|
194
|
+
input: [{ type: "text", text: message, text_elements: [] }],
|
|
195
|
+
clientUserMessageId: randomUUID(),
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
{ env, spawnFn },
|
|
199
|
+
);
|
|
200
|
+
const id = result?.queuedSubmission?.id;
|
|
201
|
+
if (id) return { queuedMessageId: id, via: "app-server" };
|
|
202
|
+
throw new Error(
|
|
203
|
+
`unexpected thread/queue/add result: ${JSON.stringify(result)}`,
|
|
204
|
+
);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
// 오류 응답이 아니면 add 가 이미 쌓였을 수 있어 재전송하지 않는다.
|
|
207
|
+
if (addSent && !error.rpcError) {
|
|
208
|
+
error.maybeQueued = true;
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
const queued = await queueCodexMessageByArgv({
|
|
212
|
+
threadId,
|
|
213
|
+
message,
|
|
214
|
+
env,
|
|
215
|
+
execFn,
|
|
216
|
+
});
|
|
217
|
+
return {
|
|
218
|
+
...queued,
|
|
219
|
+
via: "codex-queue-argv",
|
|
220
|
+
fallbackReason: error.message,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// add 를 못 쓰는 Codex(app-server 없음, 메서드 없음)만 이 경로로 와서 프롬프트가 argv 에 보인다.
|
|
226
|
+
async function queueCodexMessageByArgv({ threadId, message, env, execFn }) {
|
|
227
|
+
let stdout;
|
|
228
|
+
try {
|
|
229
|
+
// cwd 의 프로젝트 .codex/config.toml 이 깨져 있으면 queue 가 실패하므로 HOME 에서 실행한다.
|
|
230
|
+
({ stdout } = await execFn(
|
|
231
|
+
"codex",
|
|
232
|
+
["queue", `--thread=${threadId}`, `--message=${message}`],
|
|
233
|
+
{
|
|
234
|
+
env,
|
|
235
|
+
cwd: env.HOME || homedir(),
|
|
236
|
+
timeout: 30_000,
|
|
237
|
+
maxBuffer: 1024 * 1024,
|
|
238
|
+
},
|
|
239
|
+
));
|
|
240
|
+
} catch (error) {
|
|
241
|
+
// 비정상 종료라도 이미 쌓였을 수 있으면 폴백 재전송을 막는다.
|
|
242
|
+
if (!/Queued message/.test(String(error.stdout ?? ""))) {
|
|
243
|
+
if (error.killed || String(error.stdout ?? "").trim())
|
|
244
|
+
error.maybeQueued = true;
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
stdout = error.stdout;
|
|
248
|
+
}
|
|
249
|
+
const match = String(stdout).match(/Queued message (\S+) for thread/);
|
|
250
|
+
if (!match) {
|
|
251
|
+
const error = new Error(
|
|
252
|
+
`unexpected codex queue output: ${String(stdout).trim()}`,
|
|
253
|
+
);
|
|
254
|
+
// 종료 코드 0 이면 이미 쌓였을 수 있어 폴백 재전송을 막는다.
|
|
255
|
+
error.maybeQueued = true;
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
return { queuedMessageId: match[1] };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 공식 app-server 를 stdio 로 잠깐 띄워 요청을 보낸다. 큐 API 가 실험 API 라 experimentalApi 를 켠다.
|
|
262
|
+
export async function withCodexAppServer(
|
|
263
|
+
run,
|
|
264
|
+
{ env = process.env, timeoutMs = 30_000, spawnFn = spawn } = {},
|
|
265
|
+
) {
|
|
266
|
+
const child = spawnFn("codex", ["app-server"], {
|
|
267
|
+
env,
|
|
268
|
+
cwd: env.HOME || homedir(),
|
|
269
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
270
|
+
});
|
|
271
|
+
const pending = new Map();
|
|
272
|
+
let nextId = 0;
|
|
273
|
+
let closed = null;
|
|
274
|
+
const settleAll = (error) => {
|
|
275
|
+
closed ??= error;
|
|
276
|
+
for (const { reject } of pending.values()) reject(closed);
|
|
277
|
+
pending.clear();
|
|
278
|
+
};
|
|
279
|
+
child.on("error", settleAll);
|
|
280
|
+
// 먼저 끝난 app-server 에 쓰면 EPIPE 가 stdin 에서 나므로 요청 실패로 돌린다.
|
|
281
|
+
child.stdin.on("error", settleAll);
|
|
282
|
+
child.on("exit", (code) =>
|
|
283
|
+
settleAll(new Error(`codex app-server exited (${code})`)),
|
|
284
|
+
);
|
|
285
|
+
createInterface({ input: child.stdout }).on("line", (line) => {
|
|
286
|
+
let message;
|
|
287
|
+
try {
|
|
288
|
+
message = JSON.parse(line);
|
|
289
|
+
} catch {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const waiter = pending.get(message.id);
|
|
293
|
+
if (!waiter) return;
|
|
294
|
+
pending.delete(message.id);
|
|
295
|
+
if (message.error)
|
|
296
|
+
waiter.reject(
|
|
297
|
+
Object.assign(new Error(`${waiter.method}: ${message.error.message}`), {
|
|
298
|
+
rpcError: message.error,
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
else waiter.resolve(message.result);
|
|
302
|
+
});
|
|
303
|
+
const send = (message) =>
|
|
304
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);
|
|
305
|
+
const request = (method, params) => {
|
|
306
|
+
if (closed) return Promise.reject(closed);
|
|
307
|
+
const id = ++nextId;
|
|
308
|
+
return new Promise((resolve, reject) => {
|
|
309
|
+
pending.set(id, { method, resolve, reject });
|
|
310
|
+
send({ id, method, params });
|
|
311
|
+
});
|
|
312
|
+
};
|
|
313
|
+
const timer = setTimeout(
|
|
314
|
+
() => settleAll(new Error("codex app-server timed out")),
|
|
315
|
+
timeoutMs,
|
|
316
|
+
);
|
|
317
|
+
try {
|
|
318
|
+
await request("initialize", {
|
|
319
|
+
clientInfo: { name: "triflux-tfx-live", version: "1.0.0" },
|
|
320
|
+
capabilities: { experimentalApi: true },
|
|
321
|
+
});
|
|
322
|
+
send({ method: "initialized", params: {} });
|
|
323
|
+
return await run(request);
|
|
324
|
+
} finally {
|
|
325
|
+
clearTimeout(timer);
|
|
326
|
+
child.kill();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function queuedRequestId(texts) {
|
|
331
|
+
return texts.join("\n").match(/\[tfx-live req=([^\]\s]+)\]/)?.[1] ?? null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// TUI 가 가져가기 전의 항목만 보인다. 가져간 뒤의 처리 여부는 wait 가 rollout 으로 본다.
|
|
335
|
+
export function listCodexQueue(threadId, options = {}) {
|
|
336
|
+
return withCodexAppServer(async (request) => {
|
|
337
|
+
const items = [];
|
|
338
|
+
let cursor = null;
|
|
339
|
+
do {
|
|
340
|
+
const page = await request("thread/queue/list", { threadId, cursor });
|
|
341
|
+
for (const item of page?.data ?? []) {
|
|
342
|
+
const texts = (item.input ?? []).map((part) => part.text ?? "");
|
|
343
|
+
items.push({
|
|
344
|
+
id: item.id,
|
|
345
|
+
requestId: queuedRequestId(texts),
|
|
346
|
+
text: texts.join("\n"),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
cursor = page?.nextCursor ?? null;
|
|
350
|
+
} while (cursor);
|
|
351
|
+
return items;
|
|
352
|
+
}, options);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// 지우기 전에 TUI 가 가져간 항목은 deleted: false 라 missing 으로 돌려준다.
|
|
356
|
+
export function deleteCodexQueueItems(threadId, ids, options = {}) {
|
|
357
|
+
return withCodexAppServer(async (request) => {
|
|
358
|
+
const deleted = [];
|
|
359
|
+
const missing = [];
|
|
360
|
+
for (const id of ids) {
|
|
361
|
+
const result = await request("thread/queue/delete", {
|
|
362
|
+
threadId,
|
|
363
|
+
queuedSubmissionId: id,
|
|
364
|
+
});
|
|
365
|
+
(result?.deleted === true ? deleted : missing).push(id);
|
|
366
|
+
}
|
|
367
|
+
return { deleted, missing };
|
|
368
|
+
}, options);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function userTexts(entry) {
|
|
372
|
+
const payload = entry.payload ?? {};
|
|
373
|
+
if (
|
|
374
|
+
entry.type === "response_item" &&
|
|
375
|
+
payload.type === "message" &&
|
|
376
|
+
payload.role === "user"
|
|
377
|
+
)
|
|
378
|
+
return {
|
|
379
|
+
turnId: payload.internal_chat_message_metadata_passthrough?.turn_id,
|
|
380
|
+
texts: (payload.content ?? []).map((item) => item.text ?? ""),
|
|
381
|
+
};
|
|
382
|
+
if (
|
|
383
|
+
entry.type === "event_msg" &&
|
|
384
|
+
payload.type === "item_completed" &&
|
|
385
|
+
payload.item?.type === "UserMessage"
|
|
386
|
+
)
|
|
387
|
+
return {
|
|
388
|
+
turnId: payload.turn_id,
|
|
389
|
+
texts: (payload.item.content ?? []).map((item) => item.text ?? ""),
|
|
390
|
+
};
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function createCodexTurnTracker(requestId) {
|
|
395
|
+
const tag = `[tfx-live req=${requestId}]`;
|
|
396
|
+
const finished = new Map();
|
|
397
|
+
let turnId = null;
|
|
398
|
+
let deliveredAt = null;
|
|
399
|
+
return {
|
|
400
|
+
accept(line) {
|
|
401
|
+
let entry;
|
|
402
|
+
try {
|
|
403
|
+
entry = JSON.parse(line);
|
|
404
|
+
} catch {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const payload = entry.payload ?? {};
|
|
408
|
+
if (entry.type === "event_msg" && payload.type === "task_complete")
|
|
409
|
+
finished.set(payload.turn_id, {
|
|
410
|
+
status: "completed",
|
|
411
|
+
response: payload.last_agent_message ?? "",
|
|
412
|
+
completedAt: entry.timestamp,
|
|
413
|
+
});
|
|
414
|
+
else if (entry.type === "event_msg" && payload.type === "turn_aborted")
|
|
415
|
+
finished.set(payload.turn_id, {
|
|
416
|
+
status: "failed",
|
|
417
|
+
error: `turn aborted: ${payload.reason ?? "unknown"}`,
|
|
418
|
+
completedAt: entry.timestamp,
|
|
419
|
+
});
|
|
420
|
+
if (turnId) return;
|
|
421
|
+
const user = userTexts(entry);
|
|
422
|
+
if (user?.turnId && user.texts.some((text) => text.includes(tag))) {
|
|
423
|
+
turnId = user.turnId;
|
|
424
|
+
deliveredAt = entry.timestamp;
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
state() {
|
|
428
|
+
if (!turnId) return { status: "queued", delivered: false, done: false };
|
|
429
|
+
const end = finished.get(turnId);
|
|
430
|
+
if (!end)
|
|
431
|
+
return {
|
|
432
|
+
status: "working",
|
|
433
|
+
delivered: true,
|
|
434
|
+
deliveredAt,
|
|
435
|
+
turnId,
|
|
436
|
+
done: false,
|
|
437
|
+
};
|
|
438
|
+
return {
|
|
439
|
+
...end,
|
|
440
|
+
delivered: true,
|
|
441
|
+
deliveredAt,
|
|
442
|
+
turnId,
|
|
443
|
+
done: end.status === "completed",
|
|
444
|
+
};
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function readNewLines(reader, onLine) {
|
|
450
|
+
const handle = await open(reader.path, "r");
|
|
451
|
+
try {
|
|
452
|
+
const buffer = Buffer.alloc(256 * 1024);
|
|
453
|
+
while (true) {
|
|
454
|
+
const { bytesRead } = await handle.read(
|
|
455
|
+
buffer,
|
|
456
|
+
0,
|
|
457
|
+
buffer.length,
|
|
458
|
+
reader.offset,
|
|
459
|
+
);
|
|
460
|
+
if (!bytesRead) break;
|
|
461
|
+
reader.offset += bytesRead;
|
|
462
|
+
const lines = (
|
|
463
|
+
reader.carry + reader.decoder.write(buffer.subarray(0, bytesRead))
|
|
464
|
+
).split("\n");
|
|
465
|
+
reader.carry = lines.pop();
|
|
466
|
+
for (const line of lines) if (line) onLine(line);
|
|
467
|
+
}
|
|
468
|
+
} finally {
|
|
469
|
+
await handle.close();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// rollout 은 첫 턴에 생기므로 파일이 없으면 아직 배달 전으로 본다.
|
|
474
|
+
export async function waitCodexRequest({
|
|
475
|
+
threadId,
|
|
476
|
+
requestId,
|
|
477
|
+
timeoutMs = 0,
|
|
478
|
+
pollIntervalMs = 1000,
|
|
479
|
+
findRollout = findCodexRollout,
|
|
480
|
+
}) {
|
|
481
|
+
const tracker = createCodexTurnTracker(requestId);
|
|
482
|
+
const reader = {
|
|
483
|
+
path: null,
|
|
484
|
+
offset: 0,
|
|
485
|
+
carry: "",
|
|
486
|
+
decoder: new StringDecoder("utf8"),
|
|
487
|
+
};
|
|
488
|
+
const deadline = Date.now() + timeoutMs;
|
|
489
|
+
while (true) {
|
|
490
|
+
reader.path ||= await findRollout(threadId);
|
|
491
|
+
if (reader.path) await readNewLines(reader, (line) => tracker.accept(line));
|
|
492
|
+
const state = tracker.state();
|
|
493
|
+
const final = state.done || state.status === "failed";
|
|
494
|
+
if (final || Date.now() >= deadline)
|
|
495
|
+
return {
|
|
496
|
+
cli: "codex",
|
|
497
|
+
threadId,
|
|
498
|
+
requestId,
|
|
499
|
+
rolloutPath: reader.path,
|
|
500
|
+
...state,
|
|
501
|
+
...(final ? {} : { timedOut: true }),
|
|
502
|
+
};
|
|
503
|
+
await sleep(Math.min(pollIntervalMs, Math.max(1, deadline - Date.now())));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function oneLine(value) {
|
|
508
|
+
const line = String(value ?? "")
|
|
509
|
+
.split(/\r?\n/)[0]
|
|
510
|
+
.trim();
|
|
511
|
+
return line ? line.slice(0, 80) : null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// 보낸 쪽 세션 이름. Claude 는 세션 기록, Codex 는 session_index 에서 찾는다.
|
|
515
|
+
export async function resolveSenderName({ env = process.env } = {}) {
|
|
516
|
+
if (env.TFX_LIVE_FROM) return oneLine(env.TFX_LIVE_FROM);
|
|
517
|
+
if (env.CLAUDE_PID) {
|
|
518
|
+
const dir = join(
|
|
519
|
+
env.CLAUDE_CONFIG_DIR || join(env.HOME || homedir(), ".claude"),
|
|
520
|
+
"sessions",
|
|
521
|
+
);
|
|
522
|
+
try {
|
|
523
|
+
const name = oneLine(
|
|
524
|
+
JSON.parse(await readFile(join(dir, `${env.CLAUDE_PID}.json`), "utf8"))
|
|
525
|
+
.name,
|
|
526
|
+
);
|
|
527
|
+
if (name) return name;
|
|
528
|
+
} catch {
|
|
529
|
+
// 세션 기록이 없으면 다음 출처로 넘어간다.
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (isCodexThreadId(env.CODEX_THREAD_ID)) {
|
|
533
|
+
const name = codexThreadNames({
|
|
534
|
+
indexPath: join(codexHome(env), "session_index.jsonl"),
|
|
535
|
+
}).get(env.CODEX_THREAD_ID);
|
|
536
|
+
if (oneLine(name)) return oneLine(name);
|
|
537
|
+
}
|
|
538
|
+
return null;
|
|
539
|
+
}
|