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 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
  }
@@ -142,7 +142,7 @@ function contextPercentsFromObject(value) {
142
142
  ];
143
143
 
144
144
  const currentUsage = value.current_usage ?? value.currentUsage ?? {};
145
- const maxTokens =
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 { 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
  );