triflux 10.28.0 → 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.
- package/bin/tfx-live.mjs +45 -0
- package/hooks/pipeline-stop.mjs +13 -1
- package/hooks/session-end-cleanup.mjs +15 -0
- package/hooks/session-start-fast.mjs +123 -0
- package/hub/server.mjs +44 -1
- package/hub/team/synapse-http.mjs +114 -13
- package/hub/team/synapse-registry.mjs +187 -10
- package/hub/team/uds-orchestrator.mjs +259 -0
- package/hub/workers/codex-app-server-worker.mjs +4 -1
- package/hub/workers/lib/jsonrpc-core.mjs +299 -0
- package/hub/workers/lib/jsonrpc-stdio.mjs +17 -276
- package/hub/workers/lib/jsonrpc-ws-uds.mjs +479 -0
- package/hud/constants.mjs +15 -0
- package/hud/context-monitor.mjs +19 -2
- package/hud/hud-qos-status.mjs +3 -1
- package/hud/providers/claude.mjs +116 -14
- package/hud/renderers.mjs +39 -3
- package/package.json +1 -1
- package/scripts/pack.mjs +4 -0
- package/scripts/release/check-packages-mirror.mjs +47 -0
- package/scripts/release/prepare.mjs +7 -4
- package/skills/tfx-live/SKILL.md +19 -0
- package/skills/tfx-research/SKILL.md +5 -7
- package/skills/tfx-research/skill.json +1 -1
package/bin/tfx-live.mjs
CHANGED
|
@@ -42,6 +42,8 @@ function usage() {
|
|
|
42
42
|
" tfx-live converse --session NAME --prompts-file PATH [--cli codex|claude] [--remote HOST] [--cwd DIR] [--timeout 60] [--settle 1500]",
|
|
43
43
|
" tfx-live goal-driven --session NAME --goal TEXT [--cli codex|claude] [--remote HOST] [--cwd DIR] [--timeout 60] [--settle 1500] [--max-rounds 8] [--done-token DONE]",
|
|
44
44
|
" tfx-live peer [--cli-a codex] [--cli-b claude] [--session-a peerA] [--session-b peerB] [--transport-a tmux|uds|auto] [--transport-b tmux|uds|auto] [--short-a SHORT] [--short-b SHORT] [--session-id-a ID] [--session-id-b ID] [--bridge ABS] [--remote HOST] [--cwd DIR] [--rounds 4] [--mode counting|freeform] [--seed TEXT] [--timeout 60]",
|
|
45
|
+
" tfx-live orchestrate --task TEXT [--mode peer|codex-led|claude-led] [--codex-transport exec|app-server-uds] [--cwd DIR] [--timeout 120]",
|
|
46
|
+
" Runs the Claude(UDS)+Codex orchestration engine. --codex-transport app-server-uds drives a real `codex app-server` over WebSocket-over-UDS (experimental); default exec keeps the codex stdio one-shot path.",
|
|
45
47
|
].join("\n");
|
|
46
48
|
}
|
|
47
49
|
|
|
@@ -1690,6 +1692,47 @@ async function peer(flags) {
|
|
|
1690
1692
|
printJson(output);
|
|
1691
1693
|
}
|
|
1692
1694
|
|
|
1695
|
+
const ORCHESTRATION_MODES = ["peer", "codex-led", "claude-led"];
|
|
1696
|
+
const CODEX_ORCH_TRANSPORTS = ["exec", "app-server-uds"];
|
|
1697
|
+
|
|
1698
|
+
async function orchestrate(flags) {
|
|
1699
|
+
const mode = flags.mode ?? "peer";
|
|
1700
|
+
if (!ORCHESTRATION_MODES.includes(mode)) {
|
|
1701
|
+
throw new Error(`--mode must be one of: ${ORCHESTRATION_MODES.join(", ")}`);
|
|
1702
|
+
}
|
|
1703
|
+
const task = requireFlag(flags, "task");
|
|
1704
|
+
const codexTransport = flags["codex-transport"] ?? "exec";
|
|
1705
|
+
if (!CODEX_ORCH_TRANSPORTS.includes(codexTransport)) {
|
|
1706
|
+
throw new Error(
|
|
1707
|
+
`--codex-transport must be one of: ${CODEX_ORCH_TRANSPORTS.join(", ")}`,
|
|
1708
|
+
);
|
|
1709
|
+
}
|
|
1710
|
+
const cwd = flags.cwd ?? process.cwd();
|
|
1711
|
+
const timeoutMs = secondsFlag(flags, "timeout", 120_000);
|
|
1712
|
+
|
|
1713
|
+
// Lazy import keeps the orchestration engine (and its hub/team deps) off the
|
|
1714
|
+
// hot path for every other thin-CLI verb; only `orchestrate` pays the cost.
|
|
1715
|
+
const orchestratorUrl = new URL(
|
|
1716
|
+
"../hub/team/uds-orchestrator.mjs",
|
|
1717
|
+
import.meta.url,
|
|
1718
|
+
).href;
|
|
1719
|
+
const {
|
|
1720
|
+
runUdsOrchestration,
|
|
1721
|
+
createClaudeUdsEndpoint,
|
|
1722
|
+
createCodexExecEndpoint,
|
|
1723
|
+
createCodexAppServerUdsEndpoint,
|
|
1724
|
+
} = await import(orchestratorUrl);
|
|
1725
|
+
|
|
1726
|
+
const claude = createClaudeUdsEndpoint({ cwd, timeoutMs });
|
|
1727
|
+
const codex =
|
|
1728
|
+
codexTransport === "app-server-uds"
|
|
1729
|
+
? createCodexAppServerUdsEndpoint({ cwd, timeoutMs })
|
|
1730
|
+
: createCodexExecEndpoint({ workdir: cwd, timeout: timeoutMs });
|
|
1731
|
+
|
|
1732
|
+
const result = await runUdsOrchestration({ mode, task, claude, codex });
|
|
1733
|
+
printJson({ codexTransport, ...result });
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1693
1736
|
function printJson(value) {
|
|
1694
1737
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
1695
1738
|
}
|
|
@@ -1718,6 +1761,8 @@ async function main() {
|
|
|
1718
1761
|
await goalDriven(flags);
|
|
1719
1762
|
} else if (command === "peer") {
|
|
1720
1763
|
await peer(flags);
|
|
1764
|
+
} else if (command === "orchestrate") {
|
|
1765
|
+
await orchestrate(flags);
|
|
1721
1766
|
} else {
|
|
1722
1767
|
throw new Error(`Unknown subcommand: ${command}\n${usage()}`);
|
|
1723
1768
|
}
|
package/hooks/pipeline-stop.mjs
CHANGED
|
@@ -142,7 +142,7 @@ function contextPercentsFromObject(value) {
|
|
|
142
142
|
];
|
|
143
143
|
|
|
144
144
|
const currentUsage = value.current_usage ?? value.currentUsage ?? {};
|
|
145
|
-
const
|
|
145
|
+
const explicitMaxTokens =
|
|
146
146
|
value.context_window_size ??
|
|
147
147
|
value.contextWindowSize ??
|
|
148
148
|
value.max_context_tokens ??
|
|
@@ -151,6 +151,18 @@ function contextPercentsFromObject(value) {
|
|
|
151
151
|
value.maxTokens ??
|
|
152
152
|
value.total_tokens ??
|
|
153
153
|
value.totalTokens;
|
|
154
|
+
// Fall back to a 1M context window when the payload omits one — Opus 4.x
|
|
155
|
+
// [1M] and Codex gpt-5.5 both run on a 1M window, and assuming 200K there
|
|
156
|
+
// false-positives at ~15% real usage. Override via
|
|
157
|
+
// TFX_CONTEXT_DEFAULT_MAX_TOKENS for narrower setups.
|
|
158
|
+
const envFallback = Number(process.env.TFX_CONTEXT_DEFAULT_MAX_TOKENS);
|
|
159
|
+
const fallbackMaxTokens =
|
|
160
|
+
Number.isFinite(envFallback) && envFallback > 0 ? envFallback : 1_000_000;
|
|
161
|
+
const explicitNumeric = Number(explicitMaxTokens);
|
|
162
|
+
const maxTokens =
|
|
163
|
+
Number.isFinite(explicitNumeric) && explicitNumeric > 0
|
|
164
|
+
? explicitNumeric
|
|
165
|
+
: fallbackMaxTokens;
|
|
154
166
|
candidates.push(
|
|
155
167
|
tokenPercent(value.used_tokens ?? value.usedTokens, maxTokens),
|
|
156
168
|
tokenPercent(
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
152
|
+
partial:
|
|
153
|
+
partialMeta && typeof partialMeta === "object" ? partialMeta : {},
|
|
53
154
|
},
|
|
54
155
|
opts,
|
|
55
156
|
);
|