@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.
@@ -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 candidates = await buildClaudeDaemonDiscoveryCandidates({
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 sessions = sessionsFromDaemonList(list);
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
- writeClaudeAttachInput(socket, input);
1202
- inputSent = true;
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 = assigns.length > 0 ? assigns.length : agents.length;
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);
@@ -25,6 +25,7 @@ const KNOWN_ROLES = new Set([
25
25
  "test-engineer",
26
26
  "designer",
27
27
  "writer",
28
+ "researcher",
28
29
  "scientist",
29
30
  ]);
30
31
 
@@ -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
+ }