triflux 10.9.16 → 10.9.18

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.18",
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.18"
34
34
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.3.4",
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
  "author": {
6
6
  "name": "tellang"
@@ -331,6 +331,10 @@ function normalizeExecFileArgs(args, options, callback) {
331
331
  };
332
332
  }
333
333
 
334
+ function wait(ms) {
335
+ return new Promise((resolve) => setTimeout(resolve, ms));
336
+ }
337
+
334
338
  export function spawn(command, args, options) {
335
339
  const { argsList, options: normalizedOptions } = normalizeSpawnArgs(
336
340
  args,
@@ -369,6 +373,32 @@ export function spawn(command, args, options) {
369
373
  });
370
374
  }
371
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
+
372
402
  export function execFile(file, args, options, callback) {
373
403
  const normalized = normalizeExecFileArgs(args, options, callback);
374
404
  const traceId = nextTraceId();
@@ -515,6 +545,7 @@ export const spawnSync = childProcess.spawnSync;
515
545
  export default {
516
546
  ...childProcess,
517
547
  spawn,
548
+ spawnWithBackoff,
518
549
  execFile,
519
550
  execFileSync,
520
551
  get MAX_SPAWN_PER_SEC() {
package/hub/server.mjs CHANGED
@@ -719,6 +719,11 @@ export async function startHub({
719
719
  pipe: pipe.getStatus(),
720
720
  assign_callback_pipe_path: assignCallbacks.path,
721
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,
722
727
  });
723
728
  }
724
729
 
@@ -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() {
@@ -219,6 +219,14 @@ export function validateHandoff(parsed, context = {}) {
219
219
  );
220
220
  }
221
221
 
222
+ const filesChangedCount = Array.isArray(h.files_changed)
223
+ ? h.files_changed.filter(Boolean).length
224
+ : 0;
225
+ if (filesChangedCount === 0 && h.lead_action === "accept") {
226
+ h.lead_action = "needs_read";
227
+ warnings.push("files_changed empty: accept → needs_read");
228
+ }
229
+
222
230
  const missingCore = coreRequired.filter((f) => !h[f]);
223
231
  const missingRouting = routingRequired.filter((f) => !h[f]);
224
232
  const valid = missingCore.length === 0 && missingRouting.length === 0;
@@ -231,15 +239,20 @@ export function validateHandoff(parsed, context = {}) {
231
239
  * @param {number} exitCode
232
240
  * @param {string} resultFile
233
241
  * @param {string} [cli]
242
+ * @param {object} [context]
243
+ * @param {string[]} [context.filesChanged]
234
244
  * @returns {object}
235
245
  */
236
- export function buildFallbackHandoff(exitCode, resultFile, cli) {
246
+ export function buildFallbackHandoff(exitCode, resultFile, cli, context = {}) {
237
247
  const ok = exitCode === 0;
248
+ const filesChanged = Array.isArray(context.filesChanged)
249
+ ? context.filesChanged.filter(Boolean)
250
+ : [];
238
251
  return {
239
252
  status: ok ? "ok" : "failed",
240
- lead_action: ok ? "accept" : "retry",
253
+ lead_action: ok && filesChanged.length > 0 ? "accept" : ok ? "needs_read" : "retry",
241
254
  task: "unknown",
242
- files_changed: [],
255
+ files_changed: filesChanged,
243
256
  verdict: `${cli || "worker"} completed (exit ${exitCode})`,
244
257
  confidence: "low",
245
258
  risk: "low",
@@ -298,6 +311,7 @@ export function processHandoff(rawText, context = {}) {
298
311
  context.exitCode ?? 1,
299
312
  context.resultFile || "none",
300
313
  context.cli,
314
+ { filesChanged: context.gitDiffFiles },
301
315
  );
302
316
  return {
303
317
  handoff: fb,
@@ -18,10 +18,15 @@ import { createRequire } from "node:module";
18
18
  import { tmpdir } from "node:os";
19
19
  import { join } from "node:path";
20
20
  import { requestJson } from "../bridge.mjs";
21
+ import { getMaxSpawnPerSec } from "../lib/spawn-trace.mjs";
21
22
  import { escapePwshSingleQuoted } from "../cli-adapter-base.mjs";
22
23
  import { getBackend } from "./backend.mjs";
23
24
  import { resolveDashboardLayout } from "./dashboard-layout.mjs";
24
- import { HANDOFF_INSTRUCTION_SHORT, processHandoff } from "./handoff.mjs";
25
+ import {
26
+ formatHandoffForLead,
27
+ HANDOFF_INSTRUCTION_SHORT,
28
+ processHandoff,
29
+ } from "./handoff.mjs";
25
30
  import {
26
31
  capturePsmuxPane,
27
32
  createPsmuxSession,
@@ -316,7 +321,7 @@ export function createStallMonitor(paneId, resultFile, config, deps = {}) {
316
321
  * @param {string} [opts.command] — re-dispatch용 원본 명령
317
322
  * @param {string} [opts.token] — completion token
318
323
  * @param {(snapshot: string) => void} [opts.onPoll] — 폴링 콜백
319
- * @returns {Promise<{ matched: boolean, exitCode: number|null, restarts: number, stallDetected: boolean }>}
324
+ * @returns {Promise<{ matched: boolean, exitCode: number|null, restarts: number, stallDetected: boolean, paneId: string, token?: string, logPath?: string|null }>}
320
325
  */
321
326
  export async function waitForCompletionWithStallDetect(
322
327
  sessionName,
@@ -346,19 +351,23 @@ export async function waitForCompletionWithStallDetect(
346
351
  const _startCapture = deps.startCapture || startCapture;
347
352
 
348
353
  const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
349
- const completionPatterns = [
350
- token
351
- ? `${esc("__TRIFLUX_DONE__:")}${esc(token)}:(\\d+)`
352
- : `${esc("__TRIFLUX_DONE__:")}\\S+:(\\d+)`,
353
- token
354
- ? `${esc("TFX_DONE_")}${esc(token)}:(\\d+)`
355
- : `${esc("TFX_DONE_")}\\S+:(\\d+)`,
356
- ];
357
- const completionRe = new RegExp(completionPatterns.join("|"), "m");
354
+ const buildCompletionRegex = (activeToken) => {
355
+ const completionPatterns = [
356
+ activeToken
357
+ ? `${esc("__TRIFLUX_DONE__:")}${esc(activeToken)}:(\\d+)`
358
+ : `${esc("__TRIFLUX_DONE__:")}\\S+:(\\d+)`,
359
+ activeToken
360
+ ? `${esc("TFX_DONE_")}${esc(activeToken)}:(\\d+)`
361
+ : `${esc("TFX_DONE_")}\\S+:(\\d+)`,
362
+ ];
363
+ return new RegExp(completionPatterns.join("|"), "m");
364
+ };
358
365
 
359
366
  let restarts = 0;
360
367
  let currentPaneId = paneId;
361
368
  let stallDetected = false;
369
+ let currentToken = token;
370
+ let currentLogPath = opts.logPath || null;
362
371
 
363
372
  while (true) {
364
373
  let lastOutput = "";
@@ -385,6 +394,9 @@ export async function waitForCompletionWithStallDetect(
385
394
  restarts,
386
395
  stallDetected,
387
396
  timedOut: true,
397
+ paneId: currentPaneId,
398
+ token: currentToken,
399
+ logPath: currentLogPath,
388
400
  };
389
401
  }
390
402
 
@@ -399,6 +411,7 @@ export async function waitForCompletionWithStallDetect(
399
411
  }
400
412
 
401
413
  // 2) completion 토큰 감지
414
+ const completionRe = buildCompletionRegex(currentToken);
402
415
  const completionMatch = completionRe.exec(currentOutput);
403
416
  if (completionMatch) {
404
417
  return {
@@ -410,6 +423,9 @@ export async function waitForCompletionWithStallDetect(
410
423
  restarts,
411
424
  stallDetected,
412
425
  timedOut: false,
426
+ paneId: currentPaneId,
427
+ token: currentToken,
428
+ logPath: currentLogPath,
413
429
  };
414
430
  }
415
431
 
@@ -442,6 +458,9 @@ export async function waitForCompletionWithStallDetect(
442
458
  restarts,
443
459
  stallDetected,
444
460
  timedOut: false,
461
+ paneId: currentPaneId,
462
+ token: currentToken,
463
+ logPath: currentLogPath,
445
464
  };
446
465
  }
447
466
  } catch {
@@ -480,8 +499,10 @@ export async function waitForCompletionWithStallDetect(
480
499
  "#{session_name}:#{window_index}.#{pane_index}",
481
500
  ]);
482
501
  _startCapture(sessionName, newPaneId);
483
- _dispatch(sessionName, newPaneId, command);
484
- currentPaneId = newPaneId;
502
+ const redispatch = _dispatch(sessionName, newPaneId, command);
503
+ currentPaneId = redispatch?.paneId || newPaneId;
504
+ if (redispatch?.token) currentToken = redispatch.token;
505
+ if (redispatch?.logPath) currentLogPath = redispatch.logPath;
485
506
  }
486
507
 
487
508
  restarts++;
@@ -564,7 +585,11 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
564
585
  assignment.cli,
565
586
  assignment.prompt,
566
587
  resultFile,
567
- { mcp: assignment.mcp, model: assignment.model },
588
+ {
589
+ mcp: assignment.mcp,
590
+ model: assignment.model,
591
+ cwd: assignment.cwd || assignment.workdir,
592
+ },
568
593
  );
569
594
  startCapture(sessionName, newPaneId);
570
595
  // pane 간 pipe-pane EBUSY 방지 — 이벤트 루프 해방하며 순차 대기
@@ -584,6 +609,7 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
584
609
  role: assignment.role,
585
610
  command: cmd,
586
611
  workerId,
612
+ cwd: assignment.cwd || assignment.workdir,
587
613
  });
588
614
  }
589
615
 
@@ -635,7 +661,11 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
635
661
  assignment.cli,
636
662
  assignment.prompt,
637
663
  resultFile,
638
- { mcp: assignment.mcp, model: assignment.model },
664
+ {
665
+ mcp: assignment.mcp,
666
+ model: assignment.model,
667
+ cwd: assignment.cwd || assignment.workdir,
668
+ },
639
669
  );
640
670
  const scriptDir = join(RESULT_DIR, sessionName);
641
671
  await registerHeadlessWorker(sessionName, i, assignment.cli);
@@ -660,6 +690,7 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
660
690
  role: assignment.role,
661
691
  command: cmd,
662
692
  workerId,
693
+ cwd: assignment.cwd || assignment.workdir,
663
694
  };
664
695
  }),
665
696
  );
@@ -739,6 +770,9 @@ async function awaitAll(
739
770
  onPoll: stallPollCb,
740
771
  },
741
772
  );
773
+ if (stallResult.paneId) d.paneId = stallResult.paneId;
774
+ if (stallResult.token) d.token = stallResult.token;
775
+ if (stallResult.logPath) d.logPath = stallResult.logPath;
742
776
  completion = {
743
777
  matched: stallResult.matched,
744
778
  exitCode: stallResult.exitCode,
@@ -798,28 +832,27 @@ async function awaitAll(
798
832
  * @returns {Array}
799
833
  */
800
834
  async function collectResults(sessionName, results) {
801
- // B3 fix: git diff를 루프 밖에서 1회만 실행 (워커 수만큼 중복 방지)
802
- let gitDiffFiles;
803
- try {
804
- const diffOut = execSync("git diff --name-only HEAD", {
805
- encoding: "utf8",
806
- timeout: 5000,
807
- stdio: ["pipe", "pipe", "pipe"],
808
- });
809
- gitDiffFiles = diffOut.trim().split("\n").filter(Boolean);
810
- } catch {
811
- /* git 미설치 또는 non-repo — 무시 */
812
- }
813
-
814
835
  // handoff 파이프라인: parse → validate → format (각 워커 결과에 적용)
815
836
  return await Promise.all(
816
837
  results.map(async ({ d, completion, output }) => {
838
+ const workerGitDiffFiles = collectGitDiffFiles(d.cwd);
817
839
  const handoffResult = processHandoff(output, {
818
840
  exitCode: completion.exitCode,
819
841
  resultFile: d.resultFile,
820
842
  cli: d.cli,
821
- gitDiffFiles,
843
+ gitDiffFiles: workerGitDiffFiles,
822
844
  });
845
+ if (
846
+ completion.exitCode === 0 &&
847
+ workerGitDiffFiles.length === 0 &&
848
+ handoffResult.handoff?.lead_action === "accept"
849
+ ) {
850
+ handoffResult.handoff = {
851
+ ...handoffResult.handoff,
852
+ lead_action: "needs_read",
853
+ };
854
+ handoffResult.formatted = formatHandoffForLead(handoffResult.handoff);
855
+ }
823
856
  const status =
824
857
  handoffResult.handoff?.status ||
825
858
  (completion.matched && completion.exitCode === 0
@@ -842,6 +875,7 @@ async function collectResults(sessionName, results) {
842
875
  exitCode: completion.exitCode,
843
876
  output,
844
877
  resultFile: d.resultFile,
878
+ workerGitDiffFiles,
845
879
  sessionDead: completion.sessionDead || false,
846
880
  handoff: handoffResult.handoff,
847
881
  handoffFormatted: handoffResult.formatted,
@@ -852,6 +886,20 @@ async function collectResults(sessionName, results) {
852
886
  );
853
887
  }
854
888
 
889
+ function collectGitDiffFiles(cwd) {
890
+ try {
891
+ const diffOut = execSync("git diff --name-only HEAD", {
892
+ cwd,
893
+ encoding: "utf8",
894
+ timeout: 5000,
895
+ stdio: ["pipe", "pipe", "pipe"],
896
+ });
897
+ return diffOut.trim().split("\n").filter(Boolean);
898
+ } catch {
899
+ return [];
900
+ }
901
+ }
902
+
855
903
  /**
856
904
  * 헤드리스 CLI 오케스트레이션 실행
857
905
  *
@@ -880,6 +928,19 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
880
928
 
881
929
  mkdirSync(RESULT_DIR, { recursive: true });
882
930
 
931
+ // Hub version skew pre-flight (fail-open, best-effort)
932
+ requestJson("/status", { method: "GET", timeoutMs: 500 })
933
+ .then((status) => {
934
+ const hubRate = status?.spawn_trace?.max_per_sec;
935
+ const localRate = getMaxSpawnPerSec();
936
+ if (typeof hubRate === "number" && hubRate !== localRate) {
937
+ console.warn(
938
+ `[headless] Hub version skew detected: hub spawn rate=${hubRate}/s, local=${localRate}/s. Restart hub to sync.`,
939
+ );
940
+ }
941
+ })
942
+ .catch(() => {});
943
+
883
944
  // Synapse: 세션 registration (fire-and-forget, hub 미응답 시 무시)
884
945
  const synapseIds = assignments.map((_, i) => `${sessionName}-worker-${i + 1}`);
885
946
  for (let i = 0; i < assignments.length; i++) {
@@ -1273,6 +1273,7 @@ export async function waitForPattern(
1273
1273
 
1274
1274
  /**
1275
1275
  * 완료 토큰이 찍힐 때까지 대기하고 exit code를 파싱한다.
1276
+ * NOTE: 주 채널은 headless.waitForCompletionWithStallDetect이며, 본 함수는 fallback 채널이다.
1276
1277
  * @param {string} sessionName
1277
1278
  * @param {string} paneNameOrTarget
1278
1279
  * @param {string} token