@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 CHANGED
@@ -22,13 +22,7 @@ import {
22
22
  ListToolsRequestSchema,
23
23
  } from "@modelcontextprotocol/sdk/types.js";
24
24
  import { runCollect } from "../cto/collect.mjs";
25
- import { resolveLakeRootDir } from "../cto/lake-root.mjs";
26
- import { runStatus as runCtoStatus } from "../cto/status.mjs";
27
25
  import { createModuleLogger } from "../scripts/lib/logger.mjs";
28
- import {
29
- inspectRegistry,
30
- inspectRegistryStatus,
31
- } from "../scripts/lib/mcp-guard-engine.mjs";
32
26
  import { broker as brokerInstance, reloadBroker } from "@triflux/core/hub/account-broker.mjs";
33
27
  import { createAdaptiveEngine } from "@triflux/core/hub/adaptive.mjs";
34
28
  import { createAssignCallbackServer } from "@triflux/core/hub/assign-callbacks.mjs";
@@ -56,7 +50,6 @@ import {
56
50
  recordWorker,
57
51
  snapshot as traceSnapshot,
58
52
  } from "@triflux/core/hub/lib/trace-recorder.mjs";
59
- import { focusSessionOnMac } from "./mac-focus.mjs";
60
53
  import { logQuotaRefreshFailures } from "@triflux/core/hub/middleware/quota-middleware.mjs";
61
54
  import { wrapRequestHandler } from "@triflux/core/hub/middleware/request-logger.mjs";
62
55
  import { createPipeServer } from "./pipe.mjs";
@@ -79,9 +72,6 @@ import {
79
72
  } from "./team/synapse-registry.mjs";
80
73
  import { registerTeamBridge } from "@triflux/core/hub/team-bridge.mjs";
81
74
  import { createTools } from "./tools.mjs";
82
- import { spawnTrayForHub } from "./tray-lifecycle.mjs";
83
- import { getRuntimeStatus } from "./tray-runtime.mjs";
84
- import { buildTrayStatePayload } from "./tray-state.mjs";
85
75
  import { createDelegatorMcpWorker } from "./workers/delegator-mcp.mjs";
86
76
 
87
77
  registerTeamBridge(nativeProxy);
@@ -101,7 +91,6 @@ const LOOPBACK_REMOTE_ADDRESSES = new Set([
101
91
  const ALLOWED_ORIGIN_RE =
102
92
  /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
103
93
  const PROJECT_ROOT = fileURLToPath(new URL("..", import.meta.url));
104
- const CANONICAL_PROJECT_ROOT = resolveLakeRootDir(PROJECT_ROOT) || PROJECT_ROOT;
105
94
  const PUBLIC_DIR = resolve(join(PROJECT_ROOT, "hub", "public"));
106
95
  const CACHE_DIR = join(homedir(), ".claude", "cache");
107
96
  const HUB_DEFAULT_PORT = 27888;
@@ -498,7 +487,6 @@ function isPublicPath(path) {
498
487
  return (
499
488
  PUBLIC_PATHS.has(path) ||
500
489
  path === "/dashboard" ||
501
- path === "/tray.html" ||
502
490
  path === "/api/qos-stats" ||
503
491
  path.startsWith("/public/")
504
492
  );
@@ -861,41 +849,10 @@ function getBrokerPublicSnapshot(currentBroker = brokerInstance) {
861
849
  return currentBroker.publicSnapshot();
862
850
  }
863
851
 
864
- async function getTrayCtoStatus() {
865
- try {
866
- return await runCtoStatus(["--json"], {
867
- rootDir: CANONICAL_PROJECT_ROOT,
868
- stdout: { write() {} },
869
- });
870
- } catch (error) {
871
- return { error: error?.message || String(error) };
872
- }
873
- }
874
-
875
- function getTrayMcpStatus() {
876
- const registryState = inspectRegistry();
877
- if (!registryState.exists || !registryState.valid) {
878
- return {
879
- registry_path: registryState.path,
880
- server_count: 0,
881
- rows: [],
882
- error: registryState.errors?.join("; ") || "MCP registry unavailable",
883
- };
884
- }
885
- const status = inspectRegistryStatus(registryState.registry);
886
- return {
887
- registry_path: registryState.path,
888
- server_count: Object.keys(registryState.registry?.servers || {}).length,
889
- rows: status.rows,
890
- };
891
- }
892
-
893
852
  function resolvePublicFilePath(path) {
894
853
  let relativePath = null;
895
854
  if (path === "/dashboard") {
896
855
  relativePath = "dashboard.html";
897
- } else if (path === "/tray.html") {
898
- relativePath = "tray.html";
899
856
  } else if (path.startsWith("/public/")) {
900
857
  relativePath = path.slice("/public/".length);
901
858
  }
@@ -1441,76 +1398,6 @@ export async function startHub({
1441
1398
  });
1442
1399
  }
1443
1400
 
1444
- if (path === "/api/tray-state" && req.method === "GET") {
1445
- if (!isLoopbackRemoteAddress(req.socket.remoteAddress)) {
1446
- return writeJson(res, 403, { ok: false, error: "Loopback only" });
1447
- }
1448
-
1449
- const qos = getQosStatsPayload();
1450
- const synapseSnapshot = synapseRegistry.snapshot();
1451
- const broker = getBrokerPublicSnapshot(brokerInstance);
1452
- const runtimeStatus = getRuntimeStatus({
1453
- projectRoots: [
1454
- ...new Set([CANONICAL_PROJECT_ROOT, PROJECT_ROOT].filter(Boolean)),
1455
- ],
1456
- });
1457
- const [ctoStatus, mcpStatus, hubStatus] = await Promise.all([
1458
- getTrayCtoStatus(),
1459
- Promise.resolve(getTrayMcpStatus()),
1460
- pipe.executeQuery("status", {
1461
- scope: "hub",
1462
- include_metrics: true,
1463
- }),
1464
- ]);
1465
- const mergedCtoStatus = {
1466
- ...(ctoStatus || {}),
1467
- roles: hubStatus?.data?.roles || ctoStatus?.roles || {},
1468
- };
1469
-
1470
- return writeJson(
1471
- res,
1472
- 200,
1473
- buildTrayStatePayload({
1474
- hub: {
1475
- id: String(sessionId),
1476
- pid: process.pid,
1477
- port,
1478
- url: buildHubUrl(host, port),
1479
- projectRoot: CANONICAL_PROJECT_ROOT,
1480
- },
1481
- qos,
1482
- synapseSnapshot,
1483
- broker,
1484
- ctoStatus: mergedCtoStatus,
1485
- mcpStatus,
1486
- runtimeStatus,
1487
- }),
1488
- );
1489
- }
1490
-
1491
- if (path === "/api/focus-session" && req.method === "POST") {
1492
- if (!isLoopbackRemoteAddress(req.socket.remoteAddress)) {
1493
- return writeJson(res, 403, { ok: false, error: "Loopback only" });
1494
- }
1495
- let body;
1496
- try {
1497
- body = await parseBody(req);
1498
- } catch (_e) {
1499
- return writeJson(res, 400, {
1500
- error: "Invalid JSON or Body too large",
1501
- });
1502
- }
1503
-
1504
- const { sessionId, udsId } = body || {};
1505
- const result = await focusSessionOnMac({
1506
- sessionId,
1507
- udsId,
1508
- pid: body?.pid,
1509
- agentId: body?.agentId ?? body?.agent_id,
1510
- });
1511
- return writeJson(res, 200, { ok: result.ok !== false, focus: result });
1512
- }
1513
-
1514
1401
  // Redacted peer-discovery surface. Returns co-located live peers (same
1515
1402
  // cwd / worktree) with raw cwd/pid stripped — only label/hash + booleans.
1516
1403
  if (path === "/synapse/peers" && req.method === "GET") {
@@ -3110,15 +2997,6 @@ if (selfRun) {
3110
2997
  process.exit(0);
3111
2998
  return;
3112
2999
  }
3113
- try {
3114
- const trayPath = fileURLToPath(new URL("./tray.mjs", import.meta.url));
3115
- const tray = spawnTrayForHub({ trayPath });
3116
- if (tray.status === "started") {
3117
- hubLog.info({ pid: tray.pid }, "tray.auto_started");
3118
- }
3119
- } catch (error) {
3120
- hubLog.warn({ err: error }, "tray.auto_start_failed");
3121
- }
3122
3000
  const shutdown = async (signal) => {
3123
3001
  hubLog.info({ signal }, "hub.stopping");
3124
3002
  try {
@@ -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