@triflux/remote 10.31.0 → 10.33.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
@@ -15,35 +15,45 @@ import { homedir } from "node:os";
15
15
  import { extname, join, resolve, sep } from "node:path";
16
16
  import { performance } from "node:perf_hooks";
17
17
  import { fileURLToPath } from "node:url";
18
-
19
18
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
20
19
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
21
20
  import {
22
21
  CallToolRequestSchema,
23
22
  ListToolsRequestSchema,
24
23
  } from "@modelcontextprotocol/sdk/types.js";
24
+ import { runCollect } from "../cto/collect.mjs";
25
+ import { runStatus as runCtoStatus } from "../cto/status.mjs";
25
26
  import { createModuleLogger } from "../scripts/lib/logger.mjs";
26
- import { broker as brokerInstance, reloadBroker } from "@triflux/core/hub/account-broker.mjs";
27
- import { createAdaptiveEngine } from "@triflux/core/hub/adaptive.mjs";
28
- import { createAssignCallbackServer } from "@triflux/core/hub/assign-callbacks.mjs";
29
- import { DelegatorService } from "@triflux/core/hub/delegator/index.mjs";
30
- import { createHitlManager } from "@triflux/core/hub/hitl.mjs";
27
+ import {
28
+ inspectRegistry,
29
+ inspectRegistryStatus,
30
+ } from "../scripts/lib/mcp-guard-engine.mjs";
31
+ import { broker as brokerInstance, reloadBroker } from "./account-broker.mjs";
32
+ import { createAdaptiveEngine } from "./adaptive.mjs";
33
+ import { createAssignCallbackServer } from "./assign-callbacks.mjs";
34
+ import { DelegatorService } from "./delegator/index.mjs";
35
+ import { createHitlManager } from "./hitl.mjs";
31
36
  import {
32
37
  cleanupOrphanNodeProcesses,
33
38
  cleanupOrphanRuntimeProcesses,
34
39
  cleanupStaleFsmonitorDaemons,
35
- } from "@triflux/core/hub/lib/process-utils.mjs";
36
- import * as spawnTrace from "@triflux/core/hub/lib/spawn-trace.mjs";
40
+ } from "./lib/process-utils.mjs";
41
+ import {
42
+ reapExistingHubProcesses,
43
+ resolveHubPortForContext,
44
+ } from "./hub-lifecycle.mjs";
45
+ import * as spawnTrace from "./lib/spawn-trace.mjs";
37
46
  import {
38
47
  recordRequest,
39
48
  recordWorker,
40
49
  snapshot as traceSnapshot,
41
- } from "@triflux/core/hub/lib/trace-recorder.mjs";
42
- import { logQuotaRefreshFailures } from "@triflux/core/hub/middleware/quota-middleware.mjs";
43
- import { wrapRequestHandler } from "@triflux/core/hub/middleware/request-logger.mjs";
50
+ } from "./lib/trace-recorder.mjs";
51
+ import { focusSessionOnMac } from "./mac-focus.mjs";
52
+ import { logQuotaRefreshFailures } from "./middleware/quota-middleware.mjs";
53
+ import { wrapRequestHandler } from "./middleware/request-logger.mjs";
44
54
  import { createPipeServer } from "./pipe.mjs";
45
- import { createRouter } from "@triflux/core/hub/router.mjs";
46
- import { createAdaptiveFingerprintService } from "@triflux/core/hub/session-fingerprint.mjs";
55
+ import { createRouter } from "./router.mjs";
56
+ import { createAdaptiveFingerprintService } from "./session-fingerprint.mjs";
47
57
  import {
48
58
  acquireLock,
49
59
  getVersionHash,
@@ -51,8 +61,9 @@ import {
51
61
  readState,
52
62
  releaseLock,
53
63
  writeState,
54
- } from "@triflux/core/hub/state.mjs";
64
+ } from "./state.mjs";
55
65
  import { createStoreAdapter } from "./store-adapter.mjs";
66
+ import { createCtoAutoCollector } from "./team/cto-auto-collect.mjs";
56
67
  import { createGitPreflight } from "./team/git-preflight.mjs";
57
68
  import { nativeProxy } from "./team/nativeProxy.mjs";
58
69
  import { createSwarmLocks } from "./team/swarm-locks.mjs";
@@ -60,8 +71,11 @@ import {
60
71
  createSynapseRegistry,
61
72
  projectPeer,
62
73
  } from "./team/synapse-registry.mjs";
63
- import { registerTeamBridge } from "@triflux/core/hub/team-bridge.mjs";
74
+ import { registerTeamBridge } from "./team-bridge.mjs";
64
75
  import { createTools } from "./tools.mjs";
76
+ import { spawnTrayForHub } from "./tray-lifecycle.mjs";
77
+ import { getRuntimeStatus } from "./tray-runtime.mjs";
78
+ import { buildTrayStatePayload } from "./tray-state.mjs";
65
79
  import { createDelegatorMcpWorker } from "./workers/delegator-mcp.mjs";
66
80
 
67
81
  registerTeamBridge(nativeProxy);
@@ -104,7 +118,7 @@ const SYNAPSE_VALID_OPS = new Set([
104
118
  "stash-pop",
105
119
  "worktree-remove",
106
120
  ]);
107
- const HUB_IDLE_TIMEOUT_DEFAULT_MS = 0; // 0 = 영구 실행 (idle shutdown 비활성). TFX_HUB_IDLE_TIMEOUT_MS 환경변수로 오버라이드 가능
121
+ const HUB_IDLE_TIMEOUT_DEFAULT_MS = 30 * 60 * 1000;
108
122
  const HUB_IDLE_SWEEP_DEFAULT_MS = 60 * 1000;
109
123
  const STATIC_CONTENT_TYPES = Object.freeze({
110
124
  ".html": "text/html",
@@ -256,10 +270,11 @@ function readHubPidFile(
256
270
  }
257
271
 
258
272
  export function resolveHubPort(env = process.env, opts = {}) {
259
- void opts;
260
- const envPort = parseHubPort(env?.TFX_HUB_PORT);
261
- if (envPort) return envPort;
262
- return HUB_DEFAULT_PORT;
273
+ return resolveHubPortForContext({
274
+ env,
275
+ cwd: opts.cwd ?? process.cwd(),
276
+ defaultPort: HUB_DEFAULT_PORT,
277
+ });
263
278
  }
264
279
 
265
280
  export function detectLivePeer(
@@ -482,6 +497,7 @@ function isPublicPath(path) {
482
497
  return (
483
498
  PUBLIC_PATHS.has(path) ||
484
499
  path === "/dashboard" ||
500
+ path === "/tray.html" ||
485
501
  path === "/api/qos-stats" ||
486
502
  path.startsWith("/public/")
487
503
  );
@@ -638,6 +654,68 @@ function parsePositiveInt(value, fallback) {
638
654
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
639
655
  }
640
656
 
657
+ export function resolveHubIdleTimeoutMs({
658
+ port,
659
+ env = process.env,
660
+ defaultPort = HUB_DEFAULT_PORT,
661
+ } = {}) {
662
+ const parsed = Number.parseInt(String(env?.TFX_HUB_IDLE_TIMEOUT_MS ?? ""), 10);
663
+ if (Number.isFinite(parsed) && parsed >= 0) return parsed;
664
+ return Number(port) === defaultPort ? 0 : HUB_IDLE_TIMEOUT_DEFAULT_MS;
665
+ }
666
+
667
+ export function cleanupOwnedPidFile({
668
+ pidFilePath = PID_FILE,
669
+ currentPid = process.pid,
670
+ readFile = readFileSync,
671
+ unlink = unlinkSync,
672
+ } = {}) {
673
+ try {
674
+ const info = JSON.parse(readFile(pidFilePath, "utf8"));
675
+ if (Number(info?.pid) !== Number(currentPid)) return false;
676
+ unlink(pidFilePath);
677
+ return true;
678
+ } catch {
679
+ return false;
680
+ }
681
+ }
682
+
683
+ export function cleanupOwnedTokenFile({
684
+ tokenFilePath = TOKEN_FILE,
685
+ token,
686
+ port = HUB_DEFAULT_PORT,
687
+ readFile = readFileSync,
688
+ unlink = unlinkSync,
689
+ defaultPort = HUB_DEFAULT_PORT,
690
+ } = {}) {
691
+ if (Number(port) !== Number(defaultPort)) return false;
692
+ const normalizedToken = String(token ?? "").trim();
693
+ if (!normalizedToken) return false;
694
+ try {
695
+ if (String(readFile(tokenFilePath, "utf8")).trim() !== normalizedToken) {
696
+ return false;
697
+ }
698
+ unlink(tokenFilePath);
699
+ return true;
700
+ } catch {
701
+ return false;
702
+ }
703
+ }
704
+
705
+ export function writeOwnedTokenFile({
706
+ tokenFilePath = TOKEN_FILE,
707
+ tokenDir = join(homedir(), ".claude"),
708
+ token,
709
+ mkdir = mkdirSync,
710
+ writeFile = writeFileSync,
711
+ } = {}) {
712
+ const normalizedToken = String(token ?? "").trim();
713
+ if (!normalizedToken) return false;
714
+ mkdir(tokenDir, { recursive: true });
715
+ writeFile(tokenFilePath, normalizedToken, { mode: 0o600 });
716
+ return true;
717
+ }
718
+
641
719
  function readRecentAimdEvents(now = Date.now()) {
642
720
  try {
643
721
  if (!existsSync(BATCH_EVENTS_PATH)) return [];
@@ -779,10 +857,41 @@ function getBrokerPublicSnapshot(currentBroker = brokerInstance) {
779
857
  return currentBroker.publicSnapshot();
780
858
  }
781
859
 
860
+ async function getTrayCtoStatus() {
861
+ try {
862
+ return await runCtoStatus(["--json"], {
863
+ rootDir: PROJECT_ROOT,
864
+ stdout: { write() {} },
865
+ });
866
+ } catch (error) {
867
+ return { error: error?.message || String(error) };
868
+ }
869
+ }
870
+
871
+ function getTrayMcpStatus() {
872
+ const registryState = inspectRegistry();
873
+ if (!registryState.exists || !registryState.valid) {
874
+ return {
875
+ registry_path: registryState.path,
876
+ server_count: 0,
877
+ rows: [],
878
+ error: registryState.errors?.join("; ") || "MCP registry unavailable",
879
+ };
880
+ }
881
+ const status = inspectRegistryStatus(registryState.registry);
882
+ return {
883
+ registry_path: registryState.path,
884
+ server_count: Object.keys(registryState.registry?.servers || {}).length,
885
+ rows: status.rows,
886
+ };
887
+ }
888
+
782
889
  function resolvePublicFilePath(path) {
783
890
  let relativePath = null;
784
891
  if (path === "/dashboard") {
785
892
  relativePath = "dashboard.html";
893
+ } else if (path === "/tray.html") {
894
+ relativePath = "tray.html";
786
895
  } else if (path.startsWith("/public/")) {
787
896
  relativePath = path.slice("/public/".length);
788
897
  }
@@ -878,10 +987,7 @@ export async function startHub({
878
987
  attachBrokerDiagnostics(brokerInstance);
879
988
  syncBrokerAuthCache(brokerInstance);
880
989
 
881
- const hubIdleTimeoutMs = parsePositiveInt(
882
- process.env.TFX_HUB_IDLE_TIMEOUT_MS,
883
- HUB_IDLE_TIMEOUT_DEFAULT_MS,
884
- );
990
+ const hubIdleTimeoutMs = resolveHubIdleTimeoutMs({ port, env: process.env });
885
991
  const hubIdleSweepMs = parsePositiveInt(
886
992
  process.env.TFX_HUB_IDLE_SWEEP_MS,
887
993
  Math.min(HUB_IDLE_SWEEP_DEFAULT_MS, hubIdleTimeoutMs),
@@ -926,12 +1032,9 @@ export async function startHub({
926
1032
 
927
1033
  const HUB_TOKEN = process.env.TFX_HUB_TOKEN?.trim() || null;
928
1034
  if (HUB_TOKEN) {
929
- mkdirSync(join(homedir(), ".claude"), { recursive: true });
930
- writeFileSync(TOKEN_FILE, HUB_TOKEN, { mode: 0o600 });
1035
+ writeOwnedTokenFile({ token: HUB_TOKEN, port });
931
1036
  } else {
932
- try {
933
- unlinkSync(TOKEN_FILE);
934
- } catch {}
1037
+ cleanupOwnedTokenFile({ token: HUB_TOKEN, port });
935
1038
  }
936
1039
 
937
1040
  const store = await createStoreAdapter(dbPath);
@@ -970,7 +1073,7 @@ export async function startHub({
970
1073
 
971
1074
  // adaptive rule confidence decay (7일 이상 미관측 규칙 -0.1 감소)
972
1075
  try {
973
- const { decayRules } = await import("@triflux/core/hub/reflexion.mjs");
1076
+ const { decayRules } = await import("./reflexion.mjs");
974
1077
  const decay = decayRules(
975
1078
  store,
976
1079
  adaptiveEngine.sessionCount?.() || 1,
@@ -1009,10 +1112,16 @@ export async function startHub({
1009
1112
  registry: synapseRegistry,
1010
1113
  locks: swarmLocks,
1011
1114
  });
1115
+ const ctoAutoCollector = createCtoAutoCollector({
1116
+ registry: synapseRegistry,
1117
+ runCollect,
1118
+ logger: hubLog,
1119
+ });
1012
1120
 
1013
1121
  // Synapse Layer 5: emitter subscribers — bridge events to hub logging
1014
- synapseEmitter.on("synapse.session.started", ({ sessionId }) => {
1122
+ synapseEmitter.on("synapse.session.started", ({ sessionId, session }) => {
1015
1123
  hubLog.info({ sessionId }, "synapse.session.started");
1124
+ ctoAutoCollector.handleSessionStarted({ sessionId, session });
1016
1125
  });
1017
1126
  synapseEmitter.on("synapse.session.heartbeat", ({ sessionId }) => {
1018
1127
  hubLog.debug({ sessionId }, "synapse.session.heartbeat");
@@ -1323,6 +1432,66 @@ export async function startHub({
1323
1432
  });
1324
1433
  }
1325
1434
 
1435
+ if (path === "/api/tray-state" && req.method === "GET") {
1436
+ if (!isLoopbackRemoteAddress(req.socket.remoteAddress)) {
1437
+ return writeJson(res, 403, { ok: false, error: "Loopback only" });
1438
+ }
1439
+
1440
+ const qos = getQosStatsPayload();
1441
+ const synapseSnapshot = synapseRegistry.snapshot();
1442
+ const broker = getBrokerPublicSnapshot(brokerInstance);
1443
+ const runtimeStatus = getRuntimeStatus({
1444
+ projectRoots: [PROJECT_ROOT],
1445
+ });
1446
+ const [ctoStatus, mcpStatus] = await Promise.all([
1447
+ getTrayCtoStatus(),
1448
+ Promise.resolve(getTrayMcpStatus()),
1449
+ ]);
1450
+
1451
+ return writeJson(
1452
+ res,
1453
+ 200,
1454
+ buildTrayStatePayload({
1455
+ hub: {
1456
+ id: String(sessionId),
1457
+ pid: process.pid,
1458
+ port,
1459
+ url: buildHubUrl(host, port),
1460
+ projectRoot: PROJECT_ROOT,
1461
+ },
1462
+ qos,
1463
+ synapseSnapshot,
1464
+ broker,
1465
+ ctoStatus,
1466
+ mcpStatus,
1467
+ runtimeStatus,
1468
+ }),
1469
+ );
1470
+ }
1471
+
1472
+ if (path === "/api/focus-session" && req.method === "POST") {
1473
+ if (!isLoopbackRemoteAddress(req.socket.remoteAddress)) {
1474
+ return writeJson(res, 403, { ok: false, error: "Loopback only" });
1475
+ }
1476
+ let body;
1477
+ try {
1478
+ body = await parseBody(req);
1479
+ } catch (_e) {
1480
+ return writeJson(res, 400, {
1481
+ error: "Invalid JSON or Body too large",
1482
+ });
1483
+ }
1484
+
1485
+ const { sessionId, udsId } = body || {};
1486
+ const result = await focusSessionOnMac({
1487
+ sessionId,
1488
+ udsId,
1489
+ pid: body?.pid,
1490
+ agentId: body?.agentId ?? body?.agent_id,
1491
+ });
1492
+ return writeJson(res, 200, { ok: result.ok !== false, focus: result });
1493
+ }
1494
+
1326
1495
  // Redacted peer-discovery surface. Returns co-located live peers (same
1327
1496
  // cwd / worktree) with raw cwd/pid stripped — only label/hash + booleans.
1328
1497
  if (path === "/synapse/peers" && req.method === "GET") {
@@ -2145,9 +2314,7 @@ export async function startHub({
2145
2314
  store.close();
2146
2315
  } catch {}
2147
2316
  if (!preserveTokenFile) {
2148
- try {
2149
- unlinkSync(TOKEN_FILE);
2150
- } catch {}
2317
+ cleanupOwnedTokenFile({ token: HUB_TOKEN, port });
2151
2318
  }
2152
2319
  releaseStartupLock();
2153
2320
  };
@@ -2271,6 +2438,22 @@ export async function startHub({
2271
2438
  },
2272
2439
  "hub.started",
2273
2440
  );
2441
+ if (port === HUB_DEFAULT_PORT) {
2442
+ void reapExistingHubProcesses({ currentPid: process.pid }).then(
2443
+ ({ reaped }) => {
2444
+ if (reaped.length > 0) {
2445
+ hubLog.info(
2446
+ {
2447
+ killed: reaped.length,
2448
+ processes: reaped,
2449
+ caller: "startup",
2450
+ },
2451
+ "hub.startup_reaper",
2452
+ );
2453
+ }
2454
+ },
2455
+ );
2456
+ }
2274
2457
  hubLog.debug(
2275
2458
  {
2276
2459
  publicDir: PUBLIC_DIR,
@@ -2315,12 +2498,8 @@ export async function startHub({
2315
2498
  synapseRegistry.destroy();
2316
2499
  } catch {}
2317
2500
  store.close();
2318
- try {
2319
- unlinkSync(PID_FILE);
2320
- } catch {}
2321
- try {
2322
- unlinkSync(TOKEN_FILE);
2323
- } catch {}
2501
+ cleanupOwnedPidFile();
2502
+ cleanupOwnedTokenFile({ token: HUB_TOKEN, port });
2324
2503
  httpServer.closeAllConnections();
2325
2504
  await new Promise((resolveClose) =>
2326
2505
  httpServer.close(resolveClose),
@@ -2730,13 +2909,14 @@ refresh();setInterval(refresh,10000);
2730
2909
 
2731
2910
  const selfRun = process.argv[1]?.replace(/\\/g, "/").endsWith("hub/server.mjs");
2732
2911
  if (selfRun) {
2733
- const port = resolveHubPort(process.env);
2912
+ const port = resolveHubPortForContext({
2913
+ env: process.env,
2914
+ cwd: process.cwd(),
2915
+ });
2734
2916
  const dbPath = process.env.TFX_HUB_DB || undefined;
2735
2917
 
2736
2918
  const cleanupPidFile = () => {
2737
- try {
2738
- unlinkSync(PID_FILE);
2739
- } catch {}
2919
+ cleanupOwnedPidFile();
2740
2920
  };
2741
2921
 
2742
2922
  process.on("unhandledRejection", (err) => {
@@ -2760,6 +2940,15 @@ if (selfRun) {
2760
2940
  process.exit(0);
2761
2941
  return;
2762
2942
  }
2943
+ try {
2944
+ const trayPath = fileURLToPath(new URL("./tray.mjs", import.meta.url));
2945
+ const tray = spawnTrayForHub({ trayPath });
2946
+ if (tray.status === "started") {
2947
+ hubLog.info({ pid: tray.pid }, "tray.auto_started");
2948
+ }
2949
+ } catch (error) {
2950
+ hubLog.warn({ err: error }, "tray.auto_start_failed");
2951
+ }
2763
2952
  const shutdown = async (signal) => {
2764
2953
  hubLog.info({ signal }, "hub.stopping");
2765
2954
  try {
@@ -10,52 +10,6 @@ import {
10
10
  writeClaudeSessionProjection,
11
11
  } from "./claude-session-projection.mjs";
12
12
 
13
- export function buildClaudePromptDispatchPayload({
14
- short = crypto.randomBytes(4).toString("hex"),
15
- sessionId,
16
- cwd = process.cwd(),
17
- prompt,
18
- name = "tfx uds claude prompt",
19
- createdAt = Date.now(),
20
- cols = 120,
21
- rows = 40,
22
- } = {}) {
23
- if (!prompt) throw new Error("prompt is required");
24
- const uuid = crypto.randomUUID();
25
- const resolvedSessionId = sessionId || `${short}${uuid.slice(8)}`;
26
- return {
27
- proto: 1,
28
- short,
29
- sessionId: resolvedSessionId,
30
- createdAt,
31
- source: "shell",
32
- cwd,
33
- agent: "claude",
34
- launch: {
35
- mode: "prompt",
36
- args: [
37
- "--session-id",
38
- resolvedSessionId,
39
- "--agent",
40
- "claude",
41
- "--permission-mode",
42
- "auto",
43
- "--",
44
- prompt,
45
- ],
46
- },
47
- env: {},
48
- isolation: "none",
49
- respawnFlags: [],
50
- seed: {
51
- intent: "[redacted uds orchestration prompt]",
52
- name,
53
- },
54
- cols,
55
- rows,
56
- };
57
- }
58
-
59
13
  export function resolveClaudeConfigDir(env = process.env) {
60
14
  if (env.CLAUDE_CONFIG_DIR) return path.resolve(env.CLAUDE_CONFIG_DIR);
61
15
  return path.join(os.homedir(), ".claude");
@@ -1054,6 +1008,52 @@ export function buildDaemonExecDispatchPayload({
1054
1008
  };
1055
1009
  }
1056
1010
 
1011
+ export function buildClaudePromptDispatchPayload({
1012
+ short = crypto.randomBytes(4).toString("hex"),
1013
+ sessionId,
1014
+ cwd = process.cwd(),
1015
+ prompt,
1016
+ name = "tfx uds claude prompt",
1017
+ createdAt = Date.now(),
1018
+ cols = 120,
1019
+ rows = 40,
1020
+ } = {}) {
1021
+ if (!prompt) throw new Error("prompt is required");
1022
+ const uuid = crypto.randomUUID();
1023
+ const resolvedSessionId = sessionId || `${short}${uuid.slice(8)}`;
1024
+ return {
1025
+ proto: 1,
1026
+ short,
1027
+ sessionId: resolvedSessionId,
1028
+ createdAt,
1029
+ source: "shell",
1030
+ cwd,
1031
+ agent: "claude",
1032
+ launch: {
1033
+ mode: "prompt",
1034
+ args: [
1035
+ "--session-id",
1036
+ resolvedSessionId,
1037
+ "--agent",
1038
+ "claude",
1039
+ "--permission-mode",
1040
+ "auto",
1041
+ "--",
1042
+ prompt,
1043
+ ],
1044
+ },
1045
+ env: {},
1046
+ isolation: "none",
1047
+ respawnFlags: [],
1048
+ seed: {
1049
+ intent: "[redacted uds orchestration prompt]",
1050
+ name,
1051
+ },
1052
+ cols,
1053
+ rows,
1054
+ };
1055
+ }
1056
+
1057
1057
  export function findDaemonJobByShort(listResponse, short) {
1058
1058
  if (!Array.isArray(listResponse?.jobs)) return null;
1059
1059
  return listResponse.jobs.find((job) => job?.short === short) || null;
@@ -39,6 +39,7 @@ import {
39
39
  ensureConductorRegistry,
40
40
  getConductorRegistry,
41
41
  } from "./conductor-registry.mjs";
42
+ import { readCtoSnapshot } from "./cto-pull.mjs";
42
43
  import { createEventLog } from "./event-log.mjs";
43
44
  import { buildSpawnSpecForMode, MODES } from "./execution-mode.mjs";
44
45
  import { extractCompletionPayload } from "./extract-completion-payload.mjs";
@@ -219,6 +220,22 @@ export function createConductor(opts = {}) {
219
220
  // 공유 event log (모든 세션 이벤트를 하나의 JSONL에)
220
221
  const eventLog = createEventLog(join(logsDir, "conductor-events.jsonl"));
221
222
 
223
+ function logCtoContext(sessionId) {
224
+ const snapshot = readCtoSnapshot({ lakeRoot: opts.ctoLakeRoot });
225
+ if (!snapshot) return;
226
+ eventLog.append("cto_context", {
227
+ session: sessionId,
228
+ snapshot: {
229
+ schema_version: snapshot.schema_version,
230
+ repo: snapshot.repo,
231
+ sources: snapshot.sources,
232
+ ledger_tail: snapshot.ledger_tail,
233
+ live_sessions: snapshot.live_sessions,
234
+ active_shards: snapshot.active_shards,
235
+ },
236
+ });
237
+ }
238
+
222
239
  /**
223
240
  * 세션 상태 전이.
224
241
  * @param {object} session
@@ -1172,6 +1189,7 @@ export function createConductor(opts = {}) {
1172
1189
 
1173
1190
  sessions.set(resolvedConfig.id, session);
1174
1191
  getConductorRegistry()?.register?.(resolvedConfig.id, publicApi);
1192
+ logCtoContext(resolvedConfig.id);
1175
1193
 
1176
1194
  if (resolvedConfig.remote) {
1177
1195
  startRemoteSession(session);
@@ -0,0 +1,109 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ const DEFAULT_DEBOUNCE_MS = 120_000;
5
+ const OFF_VALUES = new Set(["0", "false", "off", "no"]);
6
+
7
+ export function isCtoAutoCollectDisabled(env = process.env) {
8
+ return OFF_VALUES.has(
9
+ String(env?.TFX_CTO_AUTO_COLLECT ?? "")
10
+ .trim()
11
+ .toLowerCase(),
12
+ );
13
+ }
14
+
15
+ function freshCurrentMd(projectRoot, now, debounceMs) {
16
+ try {
17
+ const currentMd = join(projectRoot, ".triflux", "lake", "current.md");
18
+ if (!existsSync(currentMd)) return false;
19
+ return now - statSync(currentMd).mtimeMs < debounceMs;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ export function createCtoAutoCollector(opts = {}) {
26
+ const registry = opts.registry;
27
+ const runCollect = opts.runCollect;
28
+ const logger = opts.logger || null;
29
+ const debounceMs = Number(opts.debounceMs || DEFAULT_DEBOUNCE_MS);
30
+ const now = opts.now || (() => Date.now());
31
+ const env = opts.env || process.env;
32
+ const lastCollectMs = opts.lastCollectMs || new Map();
33
+ const inFlight = new Set();
34
+
35
+ function handleSessionStarted({ sessionId, session } = {}) {
36
+ if (isCtoAutoCollectDisabled(env))
37
+ return { triggered: false, reason: "disabled" };
38
+ if (!registry || typeof registry.querySessions !== "function") {
39
+ return { triggered: false, reason: "missing-registry" };
40
+ }
41
+ if (typeof runCollect !== "function") {
42
+ return { triggered: false, reason: "missing-runCollect" };
43
+ }
44
+
45
+ const cwd = typeof session?.cwd === "string" ? session.cwd : "";
46
+ const worktree =
47
+ typeof session?.worktreePath === "string" ? session.worktreePath : "";
48
+ const projectRoot = worktree || cwd;
49
+ if (!projectRoot)
50
+ return { triggered: false, reason: "missing-project-root" };
51
+
52
+ let peers = [];
53
+ try {
54
+ peers = registry.querySessions({
55
+ cwd,
56
+ worktree,
57
+ excludeSessionId: sessionId,
58
+ });
59
+ } catch (error) {
60
+ logger?.warn?.(
61
+ { sessionId, err: String(error?.message || error) },
62
+ "cto.auto_collect.query_failed",
63
+ );
64
+ return { triggered: false, reason: "query-failed" };
65
+ }
66
+ if (!Array.isArray(peers) || peers.length < 1) {
67
+ return { triggered: false, reason: "no-peers" };
68
+ }
69
+
70
+ const currentTime = now();
71
+ const last = lastCollectMs.get(projectRoot) || 0;
72
+ if (currentTime - last < debounceMs) {
73
+ return { triggered: false, reason: "debounced" };
74
+ }
75
+ if (freshCurrentMd(projectRoot, currentTime, debounceMs)) {
76
+ lastCollectMs.set(projectRoot, currentTime);
77
+ return { triggered: false, reason: "fresh-lake" };
78
+ }
79
+
80
+ lastCollectMs.set(projectRoot, currentTime);
81
+ const promise = Promise.resolve()
82
+ .then(() => runCollect([], { rootDir: projectRoot }))
83
+ .catch((error) => {
84
+ logger?.warn?.(
85
+ { projectRoot, err: String(error?.message || error) },
86
+ "cto.auto_collect.failed",
87
+ );
88
+ })
89
+ .finally(() => {
90
+ inFlight.delete(promise);
91
+ });
92
+ inFlight.add(promise);
93
+ return {
94
+ triggered: true,
95
+ reason: "triggered",
96
+ projectRoot,
97
+ peerCount: peers.length,
98
+ };
99
+ }
100
+
101
+ async function drain() {
102
+ await Promise.allSettled([...inFlight]);
103
+ }
104
+
105
+ return Object.freeze({
106
+ handleSessionStarted,
107
+ drain,
108
+ });
109
+ }