triflux 10.28.1 → 10.29.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.
@@ -9,6 +9,7 @@
9
9
  import { execFileSync } from "node:child_process";
10
10
  import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
+ import { unregisterSynapseSession } from "../hub/team/synapse-http.mjs";
12
13
 
13
14
  const MARKER = "[session-end-cleanup]";
14
15
  const STALE_MS = 30 * 60 * 1000;
@@ -292,6 +293,20 @@ function buildOutput(summary, cleanup, tmuxReport) {
292
293
  export function run(input, env = process.env) {
293
294
  if (input?.hook_event_name !== "SessionEnd") return "";
294
295
 
296
+ // Best-effort Synapse unregister (fire-and-forget). Crash-safe: if this hook
297
+ // never fires, the 5-min interactive TTL eventually marks the row stale, so
298
+ // unregister is an optimization, not a correctness dependency. Never throws,
299
+ // never changes the report-only stdout contract below.
300
+ //
301
+ // Bounded timeout (unref AbortController inside synapse-http) so a stalled hub
302
+ // cannot keep this short-lived SessionEnd hook process alive past its budget.
303
+ try {
304
+ const sessionId = String(input?.session_id || "").trim();
305
+ if (sessionId) unregisterSynapseSession(sessionId, { timeoutMs: 1500 });
306
+ } catch {
307
+ /* swallow — report-only charter preserved */
308
+ }
309
+
295
310
  const root = env.CLAUDE_CWD || process.cwd();
296
311
  const statePath = join(root, ".triflux", "subagents", "subagents.json");
297
312
  if (!existsSync(statePath)) return "";
@@ -10,8 +10,14 @@
10
10
  //
11
11
  // external source 훅 (session-vault 등)은 여전히 execFile로 실행된다.
12
12
 
13
+ import { execFile } from "node:child_process";
13
14
  import { dirname, join } from "node:path";
14
15
  import { fileURLToPath, pathToFileURL } from "node:url";
16
+ import {
17
+ drainPendingSynapse,
18
+ heartbeatSynapseSession,
19
+ registerSynapseSession,
20
+ } from "../hub/team/synapse-http.mjs";
15
21
  import { createModuleLogger } from "../scripts/lib/logger.mjs";
16
22
 
17
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -20,6 +26,111 @@ const importMod = (p) => import(pathToFileURL(p).href);
20
26
 
21
27
  const log = createModuleLogger("session-start-fast");
22
28
 
29
+ /**
30
+ * SessionStart 페이로드(JSON 문자열)에서 session_id / cwd 를 추출한다.
31
+ * 파싱 실패는 빈 객체로 흡수한다 (BLOCKING path 에 절대 영향 없음).
32
+ */
33
+ function parseStartPayload(stdinData) {
34
+ try {
35
+ const raw = typeof stdinData === "string" ? stdinData : "";
36
+ return raw.trim() ? JSON.parse(raw) : {};
37
+ } catch {
38
+ return {};
39
+ }
40
+ }
41
+
42
+ /**
43
+ * cwd 기준 git 컨텍스트(worktree root / branch)를 best-effort, 비동기로 수집.
44
+ * execFileSync 와 달리 호출자(BACKGROUND)를 블로킹하지 않는다. 각 git 호출은
45
+ * tight timeout(1.5s) + 강제 kill 로 묶여 hub/디스크 stall 시에도 잔류하지 않는다.
46
+ * @param {string} cwd
47
+ * @param {(args:string[], cb:(out:string)=>void)=>void} [gitRunner] 테스트용 seam
48
+ * @returns {Promise<{ worktreePath: string, branch: string }>}
49
+ */
50
+ function gitContextAsync(cwd, gitRunner = defaultGitRunner) {
51
+ const run = (args) =>
52
+ new Promise((resolve) => {
53
+ try {
54
+ gitRunner(cwd, args, (out) => resolve(out));
55
+ } catch {
56
+ resolve("");
57
+ }
58
+ });
59
+ return Promise.all([
60
+ run(["rev-parse", "--show-toplevel"]),
61
+ run(["rev-parse", "--abbrev-ref", "HEAD"]),
62
+ ]).then(([toplevel, branch]) => ({
63
+ worktreePath: toplevel || cwd,
64
+ branch,
65
+ }));
66
+ }
67
+
68
+ /** 기본 git runner: execFile(async) + tight timeout + kill. 실패는 빈 문자열. */
69
+ function defaultGitRunner(cwd, args, cb) {
70
+ execFile(
71
+ "git",
72
+ args,
73
+ {
74
+ cwd,
75
+ encoding: "utf8",
76
+ timeout: 1500,
77
+ killSignal: "SIGKILL",
78
+ windowsHide: true,
79
+ },
80
+ (err, stdout) => {
81
+ cb(err ? "" : String(stdout || "").trim());
82
+ },
83
+ );
84
+ }
85
+
86
+ /**
87
+ * 인터랙티브 세션을 Synapse 레지스트리에 self-register (fire-and-forget).
88
+ * hub 미응답이면 silent no-op. BLOCKING path 에 latency 0 — git 컨텍스트는
89
+ * 블로킹 경로 밖에서 비동기로 enrich 한다 (cwd 만으로 즉시 minimal register).
90
+ *
91
+ * pid 는 의도적으로 보내지 않는다: 이 훅 프로세스의 process.pid 는 short-lived
92
+ * 훅 PID 일 뿐 세션 PID 가 아니므로 오기재가 된다. liveness 는 TTL 이 담당한다.
93
+ *
94
+ * @param {string} stdinData
95
+ * @param {object} [seams] 테스트용 injectable seam
96
+ * @param {Function} [seams.register] registerSynapseSession 대체
97
+ * @param {Function} [seams.heartbeat] heartbeatSynapseSession 대체
98
+ * @param {Function} [seams.gitRunner] git runner 대체
99
+ * @returns {void} (enrich 는 백그라운드에서 완료)
100
+ */
101
+ export function registerInteractiveSession(stdinData, seams = {}) {
102
+ const register = seams.register || registerSynapseSession;
103
+ const heartbeat = seams.heartbeat || heartbeatSynapseSession;
104
+ const gitRunner = seams.gitRunner || defaultGitRunner;
105
+ try {
106
+ const payload = parseStartPayload(stdinData);
107
+ const sessionId = String(payload?.session_id || "").trim();
108
+ if (!sessionId) return;
109
+ const cwd = typeof payload?.cwd === "string" ? payload.cwd : process.cwd();
110
+
111
+ // 1) cwd 만으로 즉시 minimal register (블로킹 git 없음, latency 0).
112
+ register({
113
+ sessionId,
114
+ cwd,
115
+ worktreePath: cwd,
116
+ branch: "",
117
+ host: "local",
118
+ sessionKind: "interactive",
119
+ isRemote: false,
120
+ });
121
+
122
+ // 2) worktree/branch 는 블로킹 경로 밖에서 비동기 enrich → heartbeat partial.
123
+ gitContextAsync(cwd, gitRunner)
124
+ .then(({ worktreePath, branch }) => {
125
+ if (worktreePath === cwd && !branch) return; // 추가 정보 없음
126
+ heartbeat(sessionId, { worktreePath, branch });
127
+ })
128
+ .catch(() => {});
129
+ } catch {
130
+ /* best-effort — never affects session start */
131
+ }
132
+ }
133
+
23
134
  /**
24
135
  * BLOCKING 훅을 순차 실행. 하나라도 throw하면 로그만 남기고 계속.
25
136
  * @param {string} stdinData
@@ -174,6 +285,9 @@ function runBackground(stdinData) {
174
285
  .then((mod) => mod.run(stdinData))
175
286
  .catch(() => {}); // 완전 무시
176
287
 
288
+ // Synapse: 인터랙티브 세션 self-register (fire-and-forget, hub 미응답 무시)
289
+ registerInteractiveSession(stdinData);
290
+
177
291
  // session-vault은 external source — hook-orchestrator가 execFile로 실행
178
292
  }
179
293
 
@@ -195,6 +309,15 @@ export async function execute(stdinData, externalHooks = []) {
195
309
  runDeferred(stdinData);
196
310
  runBackground(stdinData);
197
311
 
312
+ // hook-orchestrator process.exit(0)s immediately after we return, which would
313
+ // drop the just-fired Synapse register POST before it flushes to the loopback
314
+ // socket (Node's exit abandons pending I/O). Drain it with a tight bound: the
315
+ // register is a fast localhost POST (hub was ensured up in the BLOCKING phase)
316
+ // and the ceiling caps a hub stall so SessionStart never hangs. The async
317
+ // git-enrich heartbeat stays best-effort (it may not have fired yet) — losing
318
+ // it only drops worktree/branch enrichment, not the core registration.
319
+ await drainPendingSynapse(1000);
320
+
198
321
  const totalDur = performance.now() - totalStart;
199
322
  log.info(
200
323
  {
package/hub/server.mjs CHANGED
@@ -56,7 +56,10 @@ import { createStoreAdapter } from "./store-adapter.mjs";
56
56
  import { createGitPreflight } from "./team/git-preflight.mjs";
57
57
  import { nativeProxy } from "./team/nativeProxy.mjs";
58
58
  import { createSwarmLocks } from "./team/swarm-locks.mjs";
59
- import { createSynapseRegistry } from "./team/synapse-registry.mjs";
59
+ import {
60
+ createSynapseRegistry,
61
+ projectPeer,
62
+ } from "./team/synapse-registry.mjs";
60
63
  import { registerTeamBridge } from "./team-bridge.mjs";
61
64
  import { createTools } from "./tools.mjs";
62
65
  import { createDelegatorMcpWorker } from "./workers/delegator-mcp.mjs";
@@ -1299,7 +1302,20 @@ export async function startHub({
1299
1302
  }
1300
1303
 
1301
1304
  // ── Synapse Layer 5: session registry + locks + preflight routes ──
1305
+ // Admin/raw snapshot (loopback-only). Returns raw cwd/pid for local
1306
+ // admin/HUD use; the redacted peer surface is GET /synapse/peers.
1302
1307
  if (path === "/synapse/sessions" && req.method === "GET") {
1308
+ // Enforce the loopback boundary independent of token mode: the global
1309
+ // token gate admits any token-bearing remote client, but this raw
1310
+ // snapshot leaks absolute cwd/pid/worktreePath/dirtyFiles for every
1311
+ // session, so it must never be reachable off 127.0.0.1 (LOCKED #3: raw
1312
+ // cwd is admin-only via loopback). Off-loopback peers use /synapse/peers.
1313
+ if (!isLoopbackRemoteAddress(req.socket.remoteAddress)) {
1314
+ return writeJson(res, 403, {
1315
+ ok: false,
1316
+ error: "Forbidden: /synapse/sessions is loopback-only",
1317
+ });
1318
+ }
1303
1319
  return writeJson(res, 200, {
1304
1320
  ok: true,
1305
1321
  ...synapseRegistry.snapshot(),
@@ -1307,6 +1323,33 @@ export async function startHub({
1307
1323
  });
1308
1324
  }
1309
1325
 
1326
+ // Redacted peer-discovery surface. Returns co-located live peers (same
1327
+ // cwd / worktree) with raw cwd/pid stripped — only label/hash + booleans.
1328
+ if (path === "/synapse/peers" && req.method === "GET") {
1329
+ // formatHostForUrl wraps IPv6 hosts in [...]; a bare `::1` would make
1330
+ // `new URL(req.url, "http://::1")` throw (invalid authority).
1331
+ const query = new URL(req.url, `http://${formatHostForUrl(host)}`)
1332
+ .searchParams;
1333
+ const cwd = query.get("cwd") || "";
1334
+ const worktree = query.get("worktree") || "";
1335
+ const excludeSessionId = query.get("excludeSessionId") || "";
1336
+ // Require at least one non-empty locator. Without it querySessions
1337
+ // already returns [], but short-circuiting keeps the contract explicit
1338
+ // and avoids ever enumerating the registry over the redacted surface.
1339
+ if (!cwd && !worktree) {
1340
+ return writeJson(res, 200, { ok: true, peers: [], ts: Date.now() });
1341
+ }
1342
+ const matches = synapseRegistry.querySessions({
1343
+ cwd,
1344
+ worktree,
1345
+ excludeSessionId,
1346
+ });
1347
+ const peers = matches.map((session) =>
1348
+ projectPeer(session, { cwd, worktree }),
1349
+ );
1350
+ return writeJson(res, 200, { ok: true, peers, ts: Date.now() });
1351
+ }
1352
+
1310
1353
  if (path === "/synapse/register" && req.method === "POST") {
1311
1354
  try {
1312
1355
  const body = await parseBody(req);
@@ -1,4 +1,56 @@
1
- const DEFAULT_SYNAPSE_BASE_URL = "http://127.0.0.1:27888";
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const HUB_DEFAULT_PORT = 27888;
6
+ // Same token file as hub/bridge.mjs (HUB_TOKEN_FILE). Kept inline rather than
7
+ // imported so this helper stays dependency-free and byte-identical across the
8
+ // packages/core mirror (which cannot carry relative cross-package imports).
9
+ const HUB_TOKEN_FILE = join(homedir(), ".claude", ".tfx-hub-token");
10
+
11
+ // In-flight fire-and-forget POSTs. A short-lived caller (the SessionStart hook,
12
+ // which process.exit(0)s immediately after firing register) must be able to
13
+ // flush these before the process dies: Node's process.exit() abandons pending
14
+ // socket I/O, so an un-awaited loopback POST is created but never written.
15
+ // Callers on an exit budget drain this set via drainPendingSynapse() first.
16
+ const inFlightSynapse = new Set();
17
+
18
+ function normalizeToken(raw) {
19
+ if (raw == null) return null;
20
+ const token = String(raw).trim();
21
+ return token || null;
22
+ }
23
+
24
+ // Mirrors hub/bridge.mjs:readHubToken — env override first, then the token file.
25
+ // Missing token → null (backward-compatible with token-less hubs).
26
+ function readHubToken() {
27
+ const envToken = normalizeToken(process.env.TFX_HUB_TOKEN);
28
+ if (envToken) return envToken;
29
+ try {
30
+ return normalizeToken(readFileSync(HUB_TOKEN_FILE, "utf8"));
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ // Wrap IPv6 hosts in [...] so they form a valid URL authority (cf. bridge.mjs).
37
+ function formatHostForUrl(host) {
38
+ return String(host).includes(":") ? `[${host}]` : host;
39
+ }
40
+
41
+ // Resolve the hub base URL from env (TFX_HUB_URL / TFX_HUB_PORT) before falling
42
+ // back to the loopback default, so a cascaded / non-default hub port is hit
43
+ // instead of silently posting to 127.0.0.1:27888 and missing the live hub.
44
+ function resolveSynapseBaseUrl() {
45
+ const envUrl = normalizeToken(process.env.TFX_HUB_URL);
46
+ if (envUrl) return envUrl.replace(/\/mcp$/, "");
47
+
48
+ const envPort = Number.parseInt(String(process.env.TFX_HUB_PORT ?? ""), 10);
49
+ if (Number.isFinite(envPort) && envPort > 0) {
50
+ return `http://${formatHostForUrl("127.0.0.1")}:${envPort}`;
51
+ }
52
+ return `http://${formatHostForUrl("127.0.0.1")}:${HUB_DEFAULT_PORT}`;
53
+ }
2
54
 
3
55
  function resolveSynapseFetch(fetchImpl) {
4
56
  if (fetchImpl === null) return null;
@@ -19,23 +71,68 @@ export function fireAndForgetSynapse(path, payload, opts = {}) {
19
71
  if (!fetchImpl) return false;
20
72
 
21
73
  try {
22
- const url = new URL(
23
- path,
24
- opts.baseUrl || DEFAULT_SYNAPSE_BASE_URL,
25
- ).toString();
26
- Promise.resolve(
27
- fetchImpl(url, {
28
- method: "POST",
29
- headers: { "content-type": "application/json" },
30
- body: JSON.stringify(payload),
31
- }),
32
- ).catch(() => {});
74
+ const baseUrl = opts.baseUrl || resolveSynapseBaseUrl();
75
+ const url = new URL(path, baseUrl).toString();
76
+ const headers = { "content-type": "application/json" };
77
+ // token: false explicitly skips auth; otherwise reuse the hub token so a
78
+ // token-required hub does not 401 (the failure would be swallowed silently).
79
+ if (opts.token !== false) {
80
+ const token =
81
+ typeof opts.token === "string" && opts.token.trim()
82
+ ? opts.token.trim()
83
+ : readHubToken();
84
+ if (token) headers.Authorization = `Bearer ${token}`;
85
+ }
86
+ const init = {
87
+ method: "POST",
88
+ headers,
89
+ body: JSON.stringify(payload),
90
+ };
91
+ // Optional bounded timeout so callers on a teardown budget (e.g. SessionEnd)
92
+ // cannot hang the host process if the hub stalls. The timer is unref'd so it
93
+ // never keeps the event loop (and thus the hook process) alive on its own.
94
+ let timer = null;
95
+ const timeoutMs = Number(opts.timeoutMs);
96
+ if (
97
+ Number.isFinite(timeoutMs) &&
98
+ timeoutMs > 0 &&
99
+ typeof AbortController === "function"
100
+ ) {
101
+ const controller = new AbortController();
102
+ init.signal = controller.signal;
103
+ timer = setTimeout(() => controller.abort(), timeoutMs);
104
+ if (typeof timer.unref === "function") timer.unref();
105
+ }
106
+ const tracked = Promise.resolve(fetchImpl(url, init))
107
+ .catch(() => {})
108
+ .finally(() => {
109
+ if (timer) clearTimeout(timer);
110
+ inFlightSynapse.delete(tracked);
111
+ });
112
+ inFlightSynapse.add(tracked);
33
113
  return true;
34
114
  } catch {
35
115
  return false;
36
116
  }
37
117
  }
38
118
 
119
+ // Await any in-flight fire-and-forget Synapse POSTs, bounded by timeoutMs so a
120
+ // stalled hub can never hang the caller. The SessionStart hook calls this right
121
+ // before process.exit(0) so the register POST actually flushes to the socket.
122
+ export function drainPendingSynapse(timeoutMs = 1000) {
123
+ if (inFlightSynapse.size === 0) return Promise.resolve();
124
+ const settled = Promise.allSettled([...inFlightSynapse]);
125
+ const ms = Number(timeoutMs);
126
+ if (!Number.isFinite(ms) || ms <= 0) return settled;
127
+ return Promise.race([
128
+ settled,
129
+ new Promise((resolve) => {
130
+ const t = setTimeout(resolve, ms);
131
+ if (typeof t.unref === "function") t.unref();
132
+ }),
133
+ ]);
134
+ }
135
+
39
136
  export function registerSynapseSession(meta, opts = {}) {
40
137
  return fireAndForgetSynapse("/synapse/register", meta, opts);
41
138
  }
@@ -45,11 +142,15 @@ export function heartbeatSynapseSession(
45
142
  partialMeta = {},
46
143
  opts = {},
47
144
  ) {
145
+ // The live route reads `{ sessionId, partial }` (hub/server.mjs
146
+ // /synapse/heartbeat → synapseRegistry.heartbeat(sessionId, partial)), so the
147
+ // helper must nest the meta under `partial` rather than spreading it top-level.
48
148
  return fireAndForgetSynapse(
49
149
  "/synapse/heartbeat",
50
150
  {
51
151
  sessionId,
52
- ...(partialMeta && typeof partialMeta === "object" ? partialMeta : {}),
152
+ partial:
153
+ partialMeta && typeof partialMeta === "object" ? partialMeta : {},
53
154
  },
54
155
  opts,
55
156
  );