triflux 10.9.15 → 10.9.17

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,7 +9,7 @@
9
9
  {
10
10
  "name": "triflux",
11
11
  "description": "Tri-CLI orchestrator for Claude Code. Routes tasks across Claude + Codex + Gemini with consensus intelligence, natural language routing, 42 skills, and cross-model review.",
12
- "version": "10.9.6",
12
+ "version": "10.9.17",
13
13
  "author": {
14
14
  "name": "tellang"
15
15
  },
@@ -30,5 +30,5 @@
30
30
  ]
31
31
  }
32
32
  ],
33
- "version": "10.9.14"
33
+ "version": "10.9.17"
34
34
  }
@@ -7,7 +7,7 @@ import { join } from "node:path";
7
7
  const LOG_DIR = join(homedir(), ".triflux", "logs");
8
8
  const DEDUPE_WINDOW_MS = 5_000;
9
9
  const RATE_WINDOW_MS = 1_000;
10
- export const MAX_SPAWN_PER_SEC = resolvePositiveInteger(
10
+ export let MAX_SPAWN_PER_SEC = resolvePositiveInteger(
11
11
  process.env.TRIFLUX_MAX_SPAWN_RATE,
12
12
  30,
13
13
  );
@@ -149,6 +149,18 @@ function createPolicyError(reasonCode, message, meta = {}) {
149
149
  return error;
150
150
  }
151
151
 
152
+ export function getMaxSpawnPerSec() {
153
+ return MAX_SPAWN_PER_SEC;
154
+ }
155
+
156
+ export function reload() {
157
+ MAX_SPAWN_PER_SEC = resolvePositiveInteger(
158
+ process.env.TRIFLUX_MAX_SPAWN_RATE,
159
+ 30,
160
+ );
161
+ return getMaxSpawnPerSec();
162
+ }
163
+
152
164
  function logBlocked(traceId, command, args, options, error, extra = {}) {
153
165
  appendTrace({
154
166
  event: "blocked",
@@ -167,6 +179,7 @@ function enforceGuards(command, args, options) {
167
179
  const now = Date.now();
168
180
  trimRecentSpawnTimes(now);
169
181
  trimDedupeEntries(now);
182
+ const maxSpawnPerSec = getMaxSpawnPerSec();
170
183
 
171
184
  const dedupeKey = getDedupeKey(options);
172
185
  if (dedupeKey) {
@@ -180,11 +193,11 @@ function enforceGuards(command, args, options) {
180
193
  }
181
194
  }
182
195
 
183
- if (recentSpawnTimes.length >= MAX_SPAWN_PER_SEC) {
196
+ if (recentSpawnTimes.length >= maxSpawnPerSec) {
184
197
  return createPolicyError(
185
198
  "rate_limit",
186
- `spawn-trace rate limit exceeded (${MAX_SPAWN_PER_SEC}/sec)`,
187
- { maxPerSec: MAX_SPAWN_PER_SEC },
199
+ `spawn-trace rate limit exceeded (${maxSpawnPerSec}/sec)`,
200
+ { maxPerSec: maxSpawnPerSec },
188
201
  );
189
202
  }
190
203
 
@@ -318,6 +331,10 @@ function normalizeExecFileArgs(args, options, callback) {
318
331
  };
319
332
  }
320
333
 
334
+ function wait(ms) {
335
+ return new Promise((resolve) => setTimeout(resolve, ms));
336
+ }
337
+
321
338
  export function spawn(command, args, options) {
322
339
  const { argsList, options: normalizedOptions } = normalizeSpawnArgs(
323
340
  args,
@@ -356,6 +373,32 @@ export function spawn(command, args, options) {
356
373
  });
357
374
  }
358
375
 
376
+ export async function spawnWithBackoff(command, args, options, maxRetries = 1) {
377
+ const retryLimit =
378
+ Number.isInteger(maxRetries) && maxRetries >= 0 ? maxRetries : 1;
379
+ let originalRateLimitError = null;
380
+
381
+ for (let attempt = 0; attempt <= retryLimit; attempt += 1) {
382
+ try {
383
+ return spawn(command, args, options);
384
+ } catch (error) {
385
+ if (error?.reasonCode !== "rate_limit") {
386
+ throw error;
387
+ }
388
+
389
+ originalRateLimitError ??= error;
390
+
391
+ if (attempt >= retryLimit) {
392
+ throw originalRateLimitError;
393
+ }
394
+
395
+ await wait(RATE_WINDOW_MS);
396
+ }
397
+ }
398
+
399
+ throw originalRateLimitError;
400
+ }
401
+
359
402
  export function execFile(file, args, options, callback) {
360
403
  const normalized = normalizeExecFileArgs(args, options, callback);
361
404
  const traceId = nextTraceId();
@@ -365,7 +408,13 @@ export function execFile(file, args, options, callback) {
365
408
  normalized.options,
366
409
  );
367
410
  if (blockedError) {
368
- logBlocked(traceId, file, normalized.argsList, normalized.options, blockedError);
411
+ logBlocked(
412
+ traceId,
413
+ file,
414
+ normalized.argsList,
415
+ normalized.options,
416
+ blockedError,
417
+ );
369
418
  if (typeof normalized.callback === "function") {
370
419
  queueMicrotask(() => normalized.callback(blockedError, "", ""));
371
420
  return createRejectedChild(file, normalized.argsList, blockedError);
@@ -417,9 +466,16 @@ export function execFileSync(file, args, options) {
417
466
  normalized.options,
418
467
  );
419
468
  if (blockedError) {
420
- logBlocked(traceId, file, normalized.argsList, normalized.options, blockedError, {
421
- sync: true,
422
- });
469
+ logBlocked(
470
+ traceId,
471
+ file,
472
+ normalized.argsList,
473
+ normalized.options,
474
+ blockedError,
475
+ {
476
+ sync: true,
477
+ },
478
+ );
423
479
  throw blockedError;
424
480
  }
425
481
 
@@ -489,8 +545,13 @@ export const spawnSync = childProcess.spawnSync;
489
545
  export default {
490
546
  ...childProcess,
491
547
  spawn,
548
+ spawnWithBackoff,
492
549
  execFile,
493
550
  execFileSync,
494
- MAX_SPAWN_PER_SEC,
551
+ get MAX_SPAWN_PER_SEC() {
552
+ return MAX_SPAWN_PER_SEC;
553
+ },
495
554
  MAX_TOTAL_DESCENDANTS,
555
+ getMaxSpawnPerSec,
556
+ reload,
496
557
  };
package/hub/server.mjs CHANGED
@@ -28,6 +28,7 @@ import { createAssignCallbackServer } from "./assign-callbacks.mjs";
28
28
  import { DelegatorService } from "./delegator/index.mjs";
29
29
  import { createHitlManager } from "./hitl.mjs";
30
30
  import { cleanupOrphanNodeProcesses } from "./lib/process-utils.mjs";
31
+ import * as spawnTrace from "./lib/spawn-trace.mjs";
31
32
  import { wrapRequestHandler } from "./middleware/request-logger.mjs";
32
33
  import { createPipeServer } from "./pipe.mjs";
33
34
  import { createRouter } from "./router.mjs";
@@ -718,6 +719,11 @@ export async function startHub({
718
719
  pipe: pipe.getStatus(),
719
720
  assign_callback_pipe_path: assignCallbacks.path,
720
721
  assign_callback_pipe: assignCallbacks.getStatus(),
722
+ spawn_trace: {
723
+ max_per_sec: spawnTrace.getMaxSpawnPerSec(),
724
+ max_total_descendants: spawnTrace.MAX_TOTAL_DESCENDANTS,
725
+ },
726
+ version,
721
727
  });
722
728
  }
723
729
 
@@ -774,11 +780,60 @@ export async function startHub({
774
780
  return writeJson(res, 200, { ok: true, accounts });
775
781
  }
776
782
 
783
+ if (path === "/spawn-trace/reload" && req.method === "POST") {
784
+ return writeJson(res, 200, {
785
+ ok: true,
786
+ max_spawn_per_sec: spawnTrace.reload(),
787
+ });
788
+ }
789
+
777
790
  // ── Synapse Layer 5: session registry + locks + preflight routes ──
778
791
  if (path === "/synapse/sessions" && req.method === "GET") {
779
792
  return writeJson(res, 200, { ok: true, ...synapseRegistry.snapshot(), ts: Date.now() });
780
793
  }
781
794
 
795
+ if (path === "/synapse/register" && req.method === "POST") {
796
+ try {
797
+ const body = await parseBody(req);
798
+ const { sessionId } = body || {};
799
+ const result = synapseRegistry.register(sessionId, body);
800
+ if (!result?.ok) {
801
+ throw new Error(result?.reason || "register failed");
802
+ }
803
+ return writeJson(res, 200, { ok: true, sessionId: result.sessionId || sessionId });
804
+ } catch (err) {
805
+ return writeJson(res, 400, { ok: false, error: String(err?.message || err) });
806
+ }
807
+ }
808
+
809
+ if (path === "/synapse/heartbeat" && req.method === "POST") {
810
+ try {
811
+ const body = await parseBody(req);
812
+ const { sessionId, partial } = body || {};
813
+ const ok = synapseRegistry.heartbeat(sessionId, partial);
814
+ if (!ok) {
815
+ throw new Error("heartbeat failed");
816
+ }
817
+ return writeJson(res, 200, { ok: true });
818
+ } catch (err) {
819
+ return writeJson(res, 400, { ok: false, error: String(err?.message || err) });
820
+ }
821
+ }
822
+
823
+ if (path === "/synapse/unregister" && req.method === "POST") {
824
+ try {
825
+ const body = await parseBody(req);
826
+ const { sessionId } = body || {};
827
+ const ok = synapseRegistry.unregister(sessionId);
828
+ if (!ok) {
829
+ throw new Error("unregister failed");
830
+ }
831
+ return writeJson(res, 200, { ok: true });
832
+ } catch (err) {
833
+ return writeJson(res, 400, { ok: false, error: String(err?.message || err) });
834
+ }
835
+ }
836
+
782
837
  if (path === "/synapse/locks" && req.method === "GET") {
783
838
  return writeJson(res, 200, { ok: true, locks: swarmLocks.snapshot(), ts: Date.now() });
784
839
  }
@@ -41,7 +41,7 @@ export class GeminiBackend {
41
41
  }
42
42
 
43
43
  buildArgs(prompt, resultFile, opts = {}) {
44
- return `gemini --prompt ${prompt} --output-format text > '${resultFile}' 2>'${resultFile}.err'`;
44
+ return `$null | gemini --prompt ${prompt} --output-format text > '${resultFile}' 2>'${resultFile}.err'`;
45
45
  }
46
46
 
47
47
  env() {
@@ -8,7 +8,6 @@
8
8
  // 3. Auto-restart (maxRestarts=3)
9
9
  // 4. JSONL event log (블랙박스 리코더)
10
10
 
11
- import { execFile, spawn } from "../lib/spawn-trace.mjs";
12
11
  import { EventEmitter } from "node:events";
13
12
  import {
14
13
  copyFileSync,
@@ -20,6 +19,7 @@ import { homedir } from "node:os";
20
19
  import { dirname, join } from "node:path";
21
20
  import { createRegistry } from "../../mesh/mesh-registry.mjs";
22
21
  import { broker } from "../account-broker.mjs";
22
+ import { execFile, spawn } from "../lib/spawn-trace.mjs";
23
23
  import { killProcess } from "../platform.mjs";
24
24
  import { createConductorMeshBridge } from "./conductor-mesh-bridge.mjs";
25
25
  import {
@@ -28,8 +28,13 @@ import {
28
28
  } from "./conductor-registry.mjs";
29
29
  import { createEventLog } from "./event-log.mjs";
30
30
  import { createHealthProbe } from "./health-probe.mjs";
31
- import { buildLauncher, getAdapter } from "./launcher-template.mjs";
32
- import { createRemoteProbe } from "./remote-probe.mjs";
31
+ import { buildLauncher } from "./launcher-template.mjs";
32
+ import {
33
+ buildSynapseTaskSummary,
34
+ heartbeatSynapseSession,
35
+ registerSynapseSession,
36
+ unregisterSynapseSession,
37
+ } from "./synapse-http.mjs";
33
38
 
34
39
  /** 세션 상태 */
35
40
  export const STATES = Object.freeze({
@@ -126,6 +131,25 @@ export function createConductor(opts = {}) {
126
131
  const sessions = new Map();
127
132
  let shuttingDown = false;
128
133
  const publicApi = null;
134
+ const synapseOpts = {
135
+ baseUrl: opts.synapseBaseUrl,
136
+ fetchImpl: opts.synapseFetch,
137
+ };
138
+
139
+ function buildSynapseMeta(session, state = session.state, reason = "") {
140
+ return {
141
+ sessionId: session.id,
142
+ host:
143
+ typeof session.config.host === "string" &&
144
+ session.config.host.length > 0
145
+ ? session.config.host
146
+ : "local",
147
+ taskSummary: buildSynapseTaskSummary(session.config.prompt),
148
+ status: state,
149
+ reason,
150
+ isRemote: Boolean(session.config.remote),
151
+ };
152
+ }
129
153
 
130
154
  // 공유 event log (모든 세션 이벤트를 하나의 JSONL에)
131
155
  const eventLog = createEventLog(join(logsDir, "conductor-events.jsonl"));
@@ -166,6 +190,21 @@ export function createConductor(opts = {}) {
166
190
  reason,
167
191
  });
168
192
 
193
+ if (nextState === STATES.HEALTHY) {
194
+ registerSynapseSession(
195
+ buildSynapseMeta(session, nextState, reason),
196
+ synapseOpts,
197
+ );
198
+ }
199
+ heartbeatSynapseSession(
200
+ session.id,
201
+ buildSynapseMeta(session, nextState, reason),
202
+ synapseOpts,
203
+ );
204
+ if (nextState === STATES.COMPLETED || nextState === STATES.DEAD) {
205
+ unregisterSynapseSession(session.id, synapseOpts);
206
+ }
207
+
169
208
  // Terminal state cleanup
170
209
  if (TERMINAL_STATES.has(nextState)) {
171
210
  session.probe?.stop();
@@ -572,13 +611,16 @@ export function createConductor(opts = {}) {
572
611
  } else if (agent === "gemini") {
573
612
  remoteBin = "gemini -y";
574
613
  } else {
575
- remoteBin = "codex exec -s danger-full-access --dangerously-bypass-approvals-and-sandbox";
614
+ remoteBin =
615
+ "codex exec -s danger-full-access --dangerously-bypass-approvals-and-sandbox";
576
616
  }
577
617
 
578
618
  // prompt는 stdin으로 전달 — 셸 이스케이프 문제 완전 회피
579
619
  const sshArgs = [
580
- "-o", "ConnectTimeout=30",
581
- "-o", "BatchMode=yes",
620
+ "-o",
621
+ "ConnectTimeout=30",
622
+ "-o",
623
+ "BatchMode=yes",
582
624
  host,
583
625
  `${cdPrefix}${remoteBin}`,
584
626
  ];
@@ -5,7 +5,6 @@
5
5
  // 의존성: psmux.mjs (Node.js 내장 모듈만 사용)
6
6
 
7
7
  import { execSync } from "node:child_process";
8
- import { spawn } from "../lib/spawn-trace.mjs";
9
8
  import { randomUUID } from "node:crypto";
10
9
  import {
11
10
  existsSync,
@@ -19,6 +18,7 @@ import { createRequire } from "node:module";
19
18
  import { tmpdir } from "node:os";
20
19
  import { join } from "node:path";
21
20
  import { requestJson } from "../bridge.mjs";
21
+ import { getMaxSpawnPerSec } from "../lib/spawn-trace.mjs";
22
22
  import { escapePwshSingleQuoted } from "../cli-adapter-base.mjs";
23
23
  import { getBackend } from "./backend.mjs";
24
24
  import { resolveDashboardLayout } from "./dashboard-layout.mjs";
@@ -33,6 +33,11 @@ import {
33
33
  startCapture,
34
34
  waitForCompletion,
35
35
  } from "./psmux.mjs";
36
+ import {
37
+ buildSynapseTaskSummary,
38
+ registerSynapseSession,
39
+ unregisterSynapseSession,
40
+ } from "./synapse-http.mjs";
36
41
  import { createLogDashboard } from "./tui.mjs";
37
42
  import { createWtManager } from "./wt-manager.mjs";
38
43
 
@@ -119,6 +124,18 @@ export async function deregisterHeadlessWorkers(
119
124
  );
120
125
  }
121
126
 
127
+ function registerHeadlessSynapseWorker(workerId, prompt) {
128
+ registerSynapseSession({
129
+ sessionId: workerId,
130
+ host: "local",
131
+ taskSummary: buildSynapseTaskSummary(prompt),
132
+ });
133
+ }
134
+
135
+ function unregisterHeadlessSynapseWorker(workerId) {
136
+ unregisterSynapseSession(workerId);
137
+ }
138
+
122
139
  /** MCP 프로필별 프롬프트 힌트 (tfx-route.sh resolve_mcp_policy의 경량 미러) */
123
140
  const MCP_PROFILE_HINTS = {
124
141
  implement:
@@ -554,6 +571,7 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
554
571
  // pane 간 pipe-pane EBUSY 방지 — 이벤트 루프 해방하며 순차 대기
555
572
  if (i > 0) await new Promise((r) => setTimeout(r, 300));
556
573
  const dispatch = dispatchCommand(sessionName, newPaneId, cmd);
574
+ registerHeadlessSynapseWorker(workerId, assignment.prompt);
557
575
 
558
576
  if (safeProgress)
559
577
  safeProgress({ type: "dispatched", paneName, cli: assignment.cli });
@@ -626,6 +644,7 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
626
644
  scriptDir,
627
645
  scriptName: paneName,
628
646
  });
647
+ registerHeadlessSynapseWorker(workerId, assignment.prompt);
629
648
 
630
649
  // P1 fix: 비-progressive에서는 pane 리네임 금지 — 캡처 로그 경로가 타이틀 기반이므로
631
650
  // 리네임하면 waitForCompletion이 "codex (role).log"를 찾지만 실제는 "worker-N.log"로 불일치
@@ -754,6 +773,7 @@ async function awaitAll(
754
773
  const output = completion.matched
755
774
  ? readResult(d.resultFile, d.paneId)
756
775
  : "";
776
+ unregisterHeadlessSynapseWorker(d.workerId);
757
777
 
758
778
  if (safeProgress) {
759
779
  safeProgress({
@@ -861,6 +881,35 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
861
881
 
862
882
  mkdirSync(RESULT_DIR, { recursive: true });
863
883
 
884
+ // Hub version skew pre-flight (fail-open, best-effort)
885
+ requestJson("/status", { method: "GET", timeoutMs: 500 })
886
+ .then((status) => {
887
+ const hubRate = status?.spawn_trace?.max_per_sec;
888
+ const localRate = getMaxSpawnPerSec();
889
+ if (typeof hubRate === "number" && hubRate !== localRate) {
890
+ console.warn(
891
+ `[headless] Hub version skew detected: hub spawn rate=${hubRate}/s, local=${localRate}/s. Restart hub to sync.`,
892
+ );
893
+ }
894
+ })
895
+ .catch(() => {});
896
+
897
+ // Synapse: 세션 registration (fire-and-forget, hub 미응답 시 무시)
898
+ const synapseIds = assignments.map((_, i) => `${sessionName}-worker-${i + 1}`);
899
+ for (let i = 0; i < assignments.length; i++) {
900
+ const a = assignments[i];
901
+ requestJson("/synapse/register", {
902
+ method: "POST",
903
+ body: {
904
+ sessionId: synapseIds[i],
905
+ host: "local",
906
+ taskSummary: String(a.prompt || "").slice(0, 100),
907
+ isRemote: false,
908
+ },
909
+ timeoutMs: 1000,
910
+ }).catch(() => {});
911
+ }
912
+
864
913
  // in-process TUI: dashboard=true이고 stdout이 TTY일 때 직접 구동
865
914
  let tui = null;
866
915
  const resolvedLayout = resolveDashboardLayout(
@@ -918,9 +967,25 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
918
967
  }
919
968
  }
920
969
 
970
+ // Synapse heartbeat: progress 이벤트마다 해당 워커의 세션 갱신
971
+ const feedSynapse = (event) => {
972
+ if (!event?.paneName) return;
973
+ const match = event.paneName.match(/worker-(\d+)/);
974
+ if (!match) return;
975
+ const idx = parseInt(match[1], 10) - 1;
976
+ const sid = synapseIds[idx];
977
+ if (!sid) return;
978
+ requestJson("/synapse/heartbeat", {
979
+ method: "POST",
980
+ body: { sessionId: sid, partial: { taskSummary: (event.snapshot || "").slice(0, 100) } },
981
+ timeoutMs: 500,
982
+ }).catch(() => {});
983
+ };
984
+
921
985
  // onProgress 예외를 삼켜 실행 흐름 보호 (onPoll과 동일 패턴)
922
986
  const combinedProgress = (event) => {
923
987
  feedTui(event);
988
+ feedSynapse(event);
924
989
  if (onProgress) {
925
990
  try {
926
991
  onProgress(event);
@@ -981,6 +1046,15 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
981
1046
  tui.close();
982
1047
  }
983
1048
 
1049
+ // Synapse: 세션 unregister (fire-and-forget)
1050
+ for (const sid of synapseIds) {
1051
+ requestJson("/synapse/unregister", {
1052
+ method: "POST",
1053
+ body: { sessionId: sid },
1054
+ timeoutMs: 1000,
1055
+ }).catch(() => {});
1056
+ }
1057
+
984
1058
  return { sessionName, results: collected };
985
1059
  }
986
1060
 
@@ -999,6 +1073,11 @@ export async function runHeadlessWithCleanup(assignments, opts = {}) {
999
1073
  try {
1000
1074
  return await runHeadless(sessionName, assignments, runOpts);
1001
1075
  } finally {
1076
+ for (let index = 0; index < assignments.length; index++) {
1077
+ unregisterHeadlessSynapseWorker(
1078
+ getHeadlessWorkerAgentId(sessionName, index),
1079
+ );
1080
+ }
1002
1081
  await deregisterHeadlessWorkers(sessionName, assignments.length);
1003
1082
  try {
1004
1083
  killPsmuxSession(sessionName);
@@ -1056,7 +1135,7 @@ export function applyTrifluxTheme(sessionName) {
1056
1135
  * WT 기본 프로필의 폰트 크기를 읽는다.
1057
1136
  * @returns {number} 기본 폰트 크기 (못 읽으면 12)
1058
1137
  */
1059
- function getWtDefaultFontSize() {
1138
+ function _getWtDefaultFontSize() {
1060
1139
  const settingsPaths = [
1061
1140
  join(
1062
1141
  process.env.LOCALAPPDATA || "",
@@ -1096,7 +1175,7 @@ function getWtDefaultFontSize() {
1096
1175
  * @param {string} filePath — 대상 파일 경로
1097
1176
  * @param {string} data — 쓸 내용
1098
1177
  */
1099
- function atomicWriteSync(filePath, data) {
1178
+ function _atomicWriteSync(filePath, data) {
1100
1179
  const tmpPath = `${filePath}.${process.pid}.tmp`;
1101
1180
  try {
1102
1181
  writeFileSync(tmpPath, data, "utf8");
@@ -1131,7 +1210,11 @@ function buildAttachTitle(sessionName, suffix = "") {
1131
1210
  * @param {number} [workerCount=2]
1132
1211
  * @returns {Promise<boolean>} 성공 여부
1133
1212
  */
1134
- export async function autoAttachTerminal(sessionName, opts = {}, workerCount = 2) {
1213
+ export async function autoAttachTerminal(
1214
+ sessionName,
1215
+ opts = {},
1216
+ workerCount = 2,
1217
+ ) {
1135
1218
  if (!process.env.WT_SESSION) return false;
1136
1219
  try {
1137
1220
  execSync("where wt.exe", { stdio: "ignore" });
@@ -1145,8 +1228,14 @@ export async function autoAttachTerminal(sessionName, opts = {}, workerCount = 2
1145
1228
  try {
1146
1229
  const safeSession = sanitizeSessionName(sessionName);
1147
1230
  if (workerCount >= 5) {
1148
- const resolvedLayout = resolveDashboardLayout(opts.dashboardLayout || "single", workerCount);
1149
- const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(/\\/g, "/");
1231
+ const resolvedLayout = resolveDashboardLayout(
1232
+ opts.dashboardLayout || "single",
1233
+ workerCount,
1234
+ );
1235
+ const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(
1236
+ /\\/g,
1237
+ "/",
1238
+ );
1150
1239
  await wt.createTab({
1151
1240
  title: buildAttachTitle(safeSession, "dashboard"),
1152
1241
  profile: "triflux",
@@ -1220,7 +1309,10 @@ export async function attachDashboardTab(
1220
1309
  try {
1221
1310
  const safeSession = sanitizeSessionName(sessionName);
1222
1311
  const resolvedLayout = resolveDashboardLayout(dashboardLayout, workerCount);
1223
- const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(/\\/g, "/");
1312
+ const viewerPath = join(import.meta.dirname, "tui-viewer.mjs").replace(
1313
+ /\\/g,
1314
+ "/",
1315
+ );
1224
1316
 
1225
1317
  await wt.createTab({
1226
1318
  title: buildAttachTitle(safeSession, "dashboard"),
@@ -1233,7 +1325,6 @@ export async function attachDashboardTab(
1233
1325
  }
1234
1326
  }
1235
1327
 
1236
-
1237
1328
  /**
1238
1329
  * 모든 워커 pane의 현재 스냅샷을 수집한다.
1239
1330
  *
@@ -1415,6 +1506,11 @@ export async function runHeadlessInteractive(
1415
1506
  kill() {
1416
1507
  if (this._killed) return;
1417
1508
  this._killed = true;
1509
+ for (let index = 0; index < assignments.length; index++) {
1510
+ unregisterHeadlessSynapseWorker(
1511
+ getHeadlessWorkerAgentId(sessionName, index),
1512
+ );
1513
+ }
1418
1514
  void deregisterHeadlessWorkers(sessionName, assignments.length);
1419
1515
  try {
1420
1516
  killPsmuxSession(sessionName);
@@ -0,0 +1,59 @@
1
+ const DEFAULT_SYNAPSE_BASE_URL = "http://127.0.0.1:27888";
2
+
3
+ function resolveSynapseFetch(fetchImpl) {
4
+ if (typeof fetchImpl === "function") return fetchImpl;
5
+ if (typeof globalThis.fetch === "function") {
6
+ return globalThis.fetch.bind(globalThis);
7
+ }
8
+ return null;
9
+ }
10
+
11
+ export function buildSynapseTaskSummary(prompt, maxLength = 100) {
12
+ if (maxLength <= 0) return "";
13
+ return String(prompt ?? "").slice(0, maxLength);
14
+ }
15
+
16
+ export function fireAndForgetSynapse(path, payload, opts = {}) {
17
+ const fetchImpl = resolveSynapseFetch(opts.fetchImpl);
18
+ if (!fetchImpl) return false;
19
+
20
+ try {
21
+ const url = new URL(
22
+ path,
23
+ opts.baseUrl || DEFAULT_SYNAPSE_BASE_URL,
24
+ ).toString();
25
+ Promise.resolve(
26
+ fetchImpl(url, {
27
+ method: "POST",
28
+ headers: { "content-type": "application/json" },
29
+ body: JSON.stringify(payload),
30
+ }),
31
+ ).catch(() => {});
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ export function registerSynapseSession(meta, opts = {}) {
39
+ return fireAndForgetSynapse("/synapse/register", meta, opts);
40
+ }
41
+
42
+ export function heartbeatSynapseSession(
43
+ sessionId,
44
+ partialMeta = {},
45
+ opts = {},
46
+ ) {
47
+ return fireAndForgetSynapse(
48
+ "/synapse/heartbeat",
49
+ {
50
+ sessionId,
51
+ ...(partialMeta && typeof partialMeta === "object" ? partialMeta : {}),
52
+ },
53
+ opts,
54
+ );
55
+ }
56
+
57
+ export function unregisterSynapseSession(sessionId, opts = {}) {
58
+ return fireAndForgetSynapse("/synapse/unregister", { sessionId }, opts);
59
+ }
@@ -165,8 +165,12 @@ export function createSynapseRegistry(opts = {}) {
165
165
  startMonitor(sessionId);
166
166
  }
167
167
 
168
- function register(meta) {
169
- const sessionId = normalizeSessionId(meta?.sessionId);
168
+ function register(sessionIdOrMeta, meta = null) {
169
+ const normalizedMeta =
170
+ meta && typeof meta === "object"
171
+ ? { ...meta, sessionId: sessionIdOrMeta }
172
+ : sessionIdOrMeta;
173
+ const sessionId = normalizeSessionId(normalizedMeta?.sessionId);
170
174
  if (!sessionId) {
171
175
  return { ok: false, sessionId, reason: "invalid_id" };
172
176
  }
@@ -178,7 +182,7 @@ export function createSynapseRegistry(opts = {}) {
178
182
 
179
183
  const session = sanitizeSession(
180
184
  {
181
- ...meta,
185
+ ...normalizedMeta,
182
186
  sessionId,
183
187
  status: "active",
184
188
  lastHeartbeat: now(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.9.15",
3
+ "version": "10.9.17",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,10 +1,21 @@
1
1
  import assert from "node:assert/strict";
2
- import { after, before, describe, it, mock } from "node:test";
3
- import { join } from "node:path";
4
- import { mkdirSync, rmSync, readFileSync, existsSync } from "node:fs";
2
+ import { mkdirSync, rmSync } from "node:fs";
5
3
  import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { performance } from "node:perf_hooks";
6
+ import { after, before, describe, it } from "node:test";
6
7
 
7
8
  const TEST_LOG_DIR = join(tmpdir(), `spawn-trace-test-${Date.now()}`);
9
+ let importSequence = 0;
10
+
11
+ async function loadSpawnTraceModule() {
12
+ importSequence += 1;
13
+ return import(`../../hub/lib/spawn-trace.mjs?test=${importSequence}`);
14
+ }
15
+
16
+ function waitForClose(child) {
17
+ return new Promise((resolve) => child.once("close", resolve));
18
+ }
8
19
 
9
20
  describe("spawn-trace", () => {
10
21
  before(() => {
@@ -12,12 +23,17 @@ describe("spawn-trace", () => {
12
23
  });
13
24
 
14
25
  after(() => {
15
- try { rmSync(TEST_LOG_DIR, { recursive: true, force: true }); } catch { /* ignore */ }
26
+ try {
27
+ rmSync(TEST_LOG_DIR, { recursive: true, force: true });
28
+ } catch {
29
+ /* ignore */
30
+ }
16
31
  });
17
32
 
18
33
  it("exports child_process-compatible API surface", async () => {
19
- const mod = await import("../../hub/lib/spawn-trace.mjs");
34
+ const mod = await loadSpawnTraceModule();
20
35
  assert.equal(typeof mod.spawn, "function");
36
+ assert.equal(typeof mod.spawnWithBackoff, "function");
21
37
  assert.equal(typeof mod.execFile, "function");
22
38
  assert.equal(typeof mod.execFileSync, "function");
23
39
  assert.equal(typeof mod.exec, "function");
@@ -27,13 +43,35 @@ describe("spawn-trace", () => {
27
43
  });
28
44
 
29
45
  it("exports guard constants", async () => {
30
- const mod = await import("../../hub/lib/spawn-trace.mjs");
46
+ const mod = await loadSpawnTraceModule();
31
47
  assert.equal(typeof mod.MAX_SPAWN_PER_SEC, "number");
32
48
  assert.equal(typeof mod.MAX_TOTAL_DESCENDANTS, "number");
49
+ assert.equal(typeof mod.getMaxSpawnPerSec, "function");
50
+ assert.equal(typeof mod.reload, "function");
51
+ });
52
+
53
+ it("reload re-evaluates TRIFLUX_MAX_SPAWN_RATE", async () => {
54
+ const mod = await loadSpawnTraceModule();
55
+ const original = process.env.TRIFLUX_MAX_SPAWN_RATE;
56
+
57
+ try {
58
+ process.env.TRIFLUX_MAX_SPAWN_RATE = "7";
59
+ assert.equal(mod.reload(), 7);
60
+ assert.equal(mod.getMaxSpawnPerSec(), 7);
61
+ assert.equal(mod.MAX_SPAWN_PER_SEC, 7);
62
+ assert.equal(mod.default.MAX_SPAWN_PER_SEC, 7);
63
+ } finally {
64
+ if (original == null) {
65
+ delete process.env.TRIFLUX_MAX_SPAWN_RATE;
66
+ } else {
67
+ process.env.TRIFLUX_MAX_SPAWN_RATE = original;
68
+ }
69
+ mod.reload();
70
+ }
33
71
  });
34
72
 
35
73
  it("spawn returns a ChildProcess-like object", async () => {
36
- const mod = await import("../../hub/lib/spawn-trace.mjs");
74
+ const mod = await loadSpawnTraceModule();
37
75
  const child = mod.spawn("node", ["-e", "process.exit(0)"], {
38
76
  windowsHide: true,
39
77
  });
@@ -45,16 +83,20 @@ describe("spawn-trace", () => {
45
83
  });
46
84
 
47
85
  it("execFileSync returns stdout buffer", async () => {
48
- const mod = await import("../../hub/lib/spawn-trace.mjs");
49
- const result = mod.execFileSync("node", ["-e", 'process.stdout.write("hello")'], {
50
- encoding: "utf8",
51
- windowsHide: true,
52
- });
86
+ const mod = await loadSpawnTraceModule();
87
+ const result = mod.execFileSync(
88
+ "node",
89
+ ["-e", 'process.stdout.write("hello")'],
90
+ {
91
+ encoding: "utf8",
92
+ windowsHide: true,
93
+ },
94
+ );
53
95
  assert.equal(result.trim(), "hello");
54
96
  });
55
97
 
56
98
  it("execFileSync throws on non-zero exit", async () => {
57
- const mod = await import("../../hub/lib/spawn-trace.mjs");
99
+ const mod = await loadSpawnTraceModule();
58
100
  assert.throws(() => {
59
101
  mod.execFileSync("node", ["-e", "process.exit(1)"], {
60
102
  windowsHide: true,
@@ -63,7 +105,7 @@ describe("spawn-trace", () => {
63
105
  });
64
106
 
65
107
  it("execFile with callback receives stdout", async () => {
66
- const mod = await import("../../hub/lib/spawn-trace.mjs");
108
+ const mod = await loadSpawnTraceModule();
67
109
  const result = await new Promise((resolve, reject) => {
68
110
  mod.execFile(
69
111
  "node",
@@ -79,22 +121,133 @@ describe("spawn-trace", () => {
79
121
  });
80
122
 
81
123
  it("strips trace-specific options before passing to child_process", async () => {
82
- const mod = await import("../../hub/lib/spawn-trace.mjs");
124
+ const mod = await loadSpawnTraceModule();
83
125
  // reason and dedupe should not cause child_process to error
84
- const result = mod.execFileSync("node", ["-e", 'process.stdout.write("ok")'], {
85
- encoding: "utf8",
86
- windowsHide: true,
87
- reason: "test:strip-options",
88
- dedupe: "test-key",
89
- });
126
+ const result = mod.execFileSync(
127
+ "node",
128
+ ["-e", 'process.stdout.write("ok")'],
129
+ {
130
+ encoding: "utf8",
131
+ windowsHide: true,
132
+ reason: "test:strip-options",
133
+ dedupe: "test-key",
134
+ },
135
+ );
90
136
  assert.equal(result.trim(), "ok");
91
137
  });
92
138
 
93
139
  it("default export includes spawn/execFile/execFileSync", async () => {
94
- const mod = await import("../../hub/lib/spawn-trace.mjs");
140
+ const mod = await loadSpawnTraceModule();
95
141
  assert.equal(typeof mod.default.spawn, "function");
142
+ assert.equal(typeof mod.default.spawnWithBackoff, "function");
96
143
  assert.equal(typeof mod.default.execFile, "function");
97
144
  assert.equal(typeof mod.default.execFileSync, "function");
98
145
  assert.equal(typeof mod.default.MAX_SPAWN_PER_SEC, "number");
146
+ assert.equal(typeof mod.default.getMaxSpawnPerSec, "function");
147
+ assert.equal(typeof mod.default.reload, "function");
148
+ });
149
+
150
+ it("waits for RATE_WINDOW_MS and retries once after a rate limit error", async () => {
151
+ const original = process.env.TRIFLUX_MAX_SPAWN_RATE;
152
+ process.env.TRIFLUX_MAX_SPAWN_RATE = "1";
153
+
154
+ try {
155
+ const mod = await loadSpawnTraceModule();
156
+ const blocker = mod.spawn(
157
+ "node",
158
+ ["-e", "setTimeout(() => process.exit(0), 1500)"],
159
+ { windowsHide: true },
160
+ );
161
+
162
+ const startedAt = performance.now();
163
+ const child = await mod.spawnWithBackoff(
164
+ "node",
165
+ ["-e", "process.exit(0)"],
166
+ { windowsHide: true },
167
+ );
168
+ const elapsedMs = performance.now() - startedAt;
169
+
170
+ assert.ok(elapsedMs >= 900, `expected retry delay, got ${elapsedMs}ms`);
171
+ assert.equal(typeof child.pid, "number");
172
+
173
+ await waitForClose(child);
174
+ await waitForClose(blocker);
175
+ } finally {
176
+ if (original == null) {
177
+ delete process.env.TRIFLUX_MAX_SPAWN_RATE;
178
+ } else {
179
+ process.env.TRIFLUX_MAX_SPAWN_RATE = original;
180
+ }
181
+ }
182
+ });
183
+
184
+ it("rethrows the original rate limit error when the retry also hits the limit", async () => {
185
+ const originalEnv = process.env.TRIFLUX_MAX_SPAWN_RATE;
186
+ const originalDateNow = Date.now;
187
+ process.env.TRIFLUX_MAX_SPAWN_RATE = "1";
188
+ Date.now = () => 1_000;
189
+
190
+ try {
191
+ const mod = await loadSpawnTraceModule();
192
+ const blocker = mod.spawn(
193
+ "node",
194
+ ["-e", "setTimeout(() => process.exit(0), 1500)"],
195
+ { windowsHide: true },
196
+ );
197
+
198
+ const startedAt = performance.now();
199
+ await assert.rejects(
200
+ () =>
201
+ mod.spawnWithBackoff("node", ["-e", "process.exit(0)"], {
202
+ windowsHide: true,
203
+ }),
204
+ (error) => {
205
+ assert.equal(error?.reasonCode, "rate_limit");
206
+ assert.equal(error?.maxPerSec, 1);
207
+ return true;
208
+ },
209
+ );
210
+ const elapsedMs = performance.now() - startedAt;
211
+ assert.ok(elapsedMs >= 900, `expected retry delay, got ${elapsedMs}ms`);
212
+
213
+ blocker.kill();
214
+ await waitForClose(blocker);
215
+ } finally {
216
+ Date.now = originalDateNow;
217
+ if (originalEnv == null) {
218
+ delete process.env.TRIFLUX_MAX_SPAWN_RATE;
219
+ } else {
220
+ process.env.TRIFLUX_MAX_SPAWN_RATE = originalEnv;
221
+ }
222
+ }
223
+ });
224
+
225
+ it("throws non-rate-limit guard errors immediately", async () => {
226
+ const mod = await loadSpawnTraceModule();
227
+ const blocker = mod.spawn(
228
+ "node",
229
+ ["-e", "setTimeout(() => process.exit(0), 250)"],
230
+ { dedupe: "same-key", windowsHide: true },
231
+ );
232
+
233
+ const startedAt = performance.now();
234
+ await assert.rejects(
235
+ () =>
236
+ mod.spawnWithBackoff("node", ["-e", "process.exit(0)"], {
237
+ dedupe: "same-key",
238
+ windowsHide: true,
239
+ }),
240
+ (error) => {
241
+ assert.equal(error?.reasonCode, "dedupe");
242
+ return true;
243
+ },
244
+ );
245
+ const elapsedMs = performance.now() - startedAt;
246
+ assert.ok(
247
+ elapsedMs < 500,
248
+ `expected immediate failure, got ${elapsedMs}ms`,
249
+ );
250
+
251
+ await waitForClose(blocker);
99
252
  });
100
253
  });
@@ -1795,6 +1795,12 @@ EOF
1795
1795
  team_complete_task "success" "$output_preview"
1796
1796
  elif [[ "$exit_code" -eq 124 ]]; then
1797
1797
  team_complete_task "timeout" "타임아웃 (${TIMEOUT_SEC}초)"
1798
+ elif [[ "$exit_code" -eq 143 ]]; then
1799
+ team_complete_task "timeout" "외부 시그널로 종료 (SIGTERM, ${TIMEOUT_SEC}초)"
1800
+ elif [[ "$exit_code" -eq 137 ]]; then
1801
+ team_complete_task "timeout" "외부 시그널로 종료 (SIGKILL, ${TIMEOUT_SEC}초)"
1802
+ elif [[ "$exit_code" -eq 130 ]]; then
1803
+ team_complete_task "failed" "사용자 인터럽트 (SIGINT)"
1798
1804
  else
1799
1805
  local err_preview
1800
1806
  err_preview=$(tail -c 1024 "$STDERR_LOG" 2>/dev/null || echo "에러 정보 없음")