triflux 10.9.17 → 10.9.19

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.17",
12
+ "version": "10.9.19",
13
13
  "author": {
14
14
  "name": "tellang"
15
15
  },
@@ -30,5 +30,5 @@
30
30
  ]
31
31
  }
32
32
  ],
33
- "version": "10.9.17"
33
+ "version": "10.9.19"
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"
@@ -21,7 +21,14 @@
21
21
  // HOME / USERPROFILE — ${HOME} 치환용
22
22
 
23
23
  import { execFile, execFileSync } from "node:child_process";
24
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
24
+ import { createHash } from "node:crypto";
25
+ import {
26
+ existsSync,
27
+ mkdirSync,
28
+ readFileSync,
29
+ unlinkSync,
30
+ writeFileSync,
31
+ } from "node:fs";
25
32
  import { tmpdir } from "node:os";
26
33
  import { dirname, join } from "node:path";
27
34
  import { fileURLToPath } from "node:url";
@@ -30,6 +37,13 @@ import { PLUGIN_ROOT } from "./lib/resolve-root.mjs";
30
37
  const __dirname = dirname(fileURLToPath(import.meta.url));
31
38
  const REGISTRY_PATH =
32
39
  process.env.TRIFLUX_HOOK_REGISTRY || join(__dirname, "hook-registry.json");
40
+ const HOOK_CACHE_DIR =
41
+ process.env.TRIFLUX_HOOK_CACHE_DIR ||
42
+ join(tmpdir(), "triflux-hook-orchestrator-cache");
43
+ const HOOK_CACHE_TTL_MS = Math.max(
44
+ 0,
45
+ Number.parseInt(process.env.TRIFLUX_HOOK_CACHE_TTL_MS || "1500", 10) || 1500,
46
+ );
33
47
 
34
48
  // ── stdin 읽기 ──────────────────────────────────────────────
35
49
  function readStdin() {
@@ -77,6 +91,57 @@ function matchesMatcher(hookMatcher, toolName, eventInput) {
77
91
  });
78
92
  }
79
93
 
94
+ function shouldDedupePreToolUseBash(eventName, toolName) {
95
+ return eventName === "PreToolUse" && toolName === "Bash";
96
+ }
97
+
98
+ function buildHookCachePath(stdinData) {
99
+ const digest = createHash("sha1").update(stdinData).digest("hex");
100
+ return join(HOOK_CACHE_DIR, `pretool-bash-${digest}.json`);
101
+ }
102
+
103
+ function readHookCache(stdinData) {
104
+ if (HOOK_CACHE_TTL_MS <= 0) return null;
105
+
106
+ const cachePath = buildHookCachePath(stdinData);
107
+ if (!existsSync(cachePath)) return null;
108
+
109
+ try {
110
+ const cached = JSON.parse(readFileSync(cachePath, "utf8"));
111
+ const ageMs = Date.now() - Number(cached?.ts || 0);
112
+ if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > HOOK_CACHE_TTL_MS) {
113
+ unlinkSync(cachePath);
114
+ return null;
115
+ }
116
+ return cached;
117
+ } catch {
118
+ try {
119
+ unlinkSync(cachePath);
120
+ } catch {}
121
+ return null;
122
+ }
123
+ }
124
+
125
+ function writeHookCache(stdinData, payload) {
126
+ if (HOOK_CACHE_TTL_MS <= 0) return;
127
+
128
+ try {
129
+ mkdirSync(HOOK_CACHE_DIR, { recursive: true });
130
+ writeFileSync(
131
+ buildHookCachePath(stdinData),
132
+ JSON.stringify({ ts: Date.now(), ...payload }),
133
+ "utf8",
134
+ );
135
+ } catch {
136
+ // 캐시 실패는 훅 실행에 영향 주지 않음
137
+ }
138
+ }
139
+
140
+ function shouldShortCircuitPreToolUseBash(result) {
141
+ if (!result || result.code === 2) return true;
142
+ return result.code === 0 && Boolean(result.stdout?.trim());
143
+ }
144
+
80
145
  // ── 단일 훅 실행 ────────────────────────────────────────────
81
146
  function executeHook(hook, stdinData) {
82
147
  const cmd = resolveCommand(hook.command);
@@ -315,9 +380,23 @@ async function main() {
315
380
 
316
381
  if (!eventName) process.exit(0);
317
382
 
383
+ const dedupePreToolUseBash = shouldDedupePreToolUseBash(eventName, toolName);
384
+ if (dedupePreToolUseBash) {
385
+ const cached = readHookCache(stdinRaw);
386
+ if (cached?.stderr) process.stderr.write(cached.stderr);
387
+ if (cached?.blocked) process.exit(2);
388
+ if (cached?.mergedOutput) {
389
+ process.stdout.write(JSON.stringify(cached.mergedOutput));
390
+ }
391
+ if (cached) process.exit(0);
392
+ }
393
+
318
394
  // ── SessionStart fast-path ──
319
395
  // TRIFLUX_HOOK_FAST_PATH=false로 비활성화 가능 (rollback)
320
- if (eventName === "SessionStart" && process.env.TRIFLUX_HOOK_FAST_PATH !== "false") {
396
+ if (
397
+ eventName === "SessionStart" &&
398
+ process.env.TRIFLUX_HOOK_FAST_PATH !== "false"
399
+ ) {
321
400
  try {
322
401
  const { execute } = await import("./session-start-fast.mjs");
323
402
  const result = await execute(stdinRaw);
@@ -328,7 +407,9 @@ async function main() {
328
407
 
329
408
  // external source 훅 (session-vault 등)은 기존 방식으로 실행
330
409
  const allHooks = registry.events.SessionStart || [];
331
- const externalHooks = allHooks.filter((h) => h.enabled !== false && h.source !== "triflux");
410
+ const externalHooks = allHooks.filter(
411
+ (h) => h.enabled !== false && h.source !== "triflux",
412
+ );
332
413
  for (const hook of externalHooks) {
333
414
  const hookResult = executeHookAsync(hook, stdinRaw);
334
415
  hookResult.catch(() => {}); // fire-and-forget for external hooks
@@ -337,7 +418,9 @@ async function main() {
337
418
  process.exit(0);
338
419
  } catch (err) {
339
420
  // fast-path 실패 시 기존 방식으로 폴백
340
- process.stderr.write(`[orchestrator] fast-path failed, falling back: ${err.message}\n`);
421
+ process.stderr.write(
422
+ `[orchestrator] fast-path failed, falling back: ${err.message}\n`,
423
+ );
341
424
  }
342
425
  }
343
426
 
@@ -366,35 +449,56 @@ async function main() {
366
449
 
367
450
  let mergedOutput = null;
368
451
  let blocked = false;
452
+ let blockingStderr = "";
369
453
 
370
- for (const group of groups) {
371
- if (blocked) break;
372
-
373
- if (group.hooks.length === 1) {
374
- // 단일 훅 — 기존 동기 실행
375
- const result = executeHook(group.hooks[0], stdinRaw);
454
+ if (dedupePreToolUseBash) {
455
+ for (const hook of matched) {
456
+ const result = executeHook(hook, stdinRaw);
376
457
  if (result.code === 2) {
377
- if (result.stderr) process.stderr.write(result.stderr);
458
+ blockingStderr = result.stderr || "";
459
+ if (blockingStderr) process.stderr.write(blockingStderr);
378
460
  blocked = true;
379
461
  break;
380
462
  }
381
463
  if (result.code === 0 && result.stdout.trim()) {
382
464
  mergedOutput = mergeOutputs(mergedOutput, result.stdout.trim());
383
465
  }
384
- } else {
385
- // 같은 priority 다중 훅 — 비동기 병렬 실행
386
- const results = await Promise.all(
387
- group.hooks.map((h) => executeHookAsync(h, stdinRaw)),
388
- );
389
- for (const result of results) {
466
+ if (shouldShortCircuitPreToolUseBash(result)) {
467
+ break;
468
+ }
469
+ }
470
+ } else {
471
+ for (const group of groups) {
472
+ if (blocked) break;
473
+
474
+ if (group.hooks.length === 1) {
475
+ // 단일 훅 — 기존 동기 실행
476
+ const result = executeHook(group.hooks[0], stdinRaw);
390
477
  if (result.code === 2) {
391
- if (result.stderr) process.stderr.write(result.stderr);
478
+ blockingStderr = result.stderr || "";
479
+ if (blockingStderr) process.stderr.write(blockingStderr);
392
480
  blocked = true;
393
481
  break;
394
482
  }
395
483
  if (result.code === 0 && result.stdout.trim()) {
396
484
  mergedOutput = mergeOutputs(mergedOutput, result.stdout.trim());
397
485
  }
486
+ } else {
487
+ // 같은 priority 다중 훅 — 비동기 병렬 실행
488
+ const results = await Promise.all(
489
+ group.hooks.map((h) => executeHookAsync(h, stdinRaw)),
490
+ );
491
+ for (const result of results) {
492
+ if (result.code === 2) {
493
+ blockingStderr = result.stderr || "";
494
+ if (blockingStderr) process.stderr.write(blockingStderr);
495
+ blocked = true;
496
+ break;
497
+ }
498
+ if (result.code === 0 && result.stdout.trim()) {
499
+ mergedOutput = mergeOutputs(mergedOutput, result.stdout.trim());
500
+ }
501
+ }
398
502
  }
399
503
  }
400
504
  }
@@ -403,23 +507,35 @@ async function main() {
403
507
  if (eventName === "PostToolUse" && !blocked) {
404
508
  try {
405
509
  const home = process.env.HOME || process.env.USERPROFILE || "";
406
- const snapshotPath = join(home, ".claude", "cache", "tfx-hub", "context-monitor.json");
510
+ const snapshotPath = join(
511
+ home,
512
+ ".claude",
513
+ "cache",
514
+ "tfx-hub",
515
+ "context-monitor.json",
516
+ );
407
517
  const nudgeMarker = join(tmpdir(), "tfx-compact-nudge-sent");
408
518
  if (existsSync(snapshotPath) && !existsSync(nudgeMarker)) {
409
519
  const snap = JSON.parse(readFileSync(snapshotPath, "utf8"));
410
520
  const percent = Number(snap.percent || 0);
411
521
  if (percent >= 80) {
412
522
  const level = percent >= 90 ? "critical" : "warn";
413
- const msg = level === "critical"
414
- ? `[context ${percent}%] 컨텍스트 ${percent}% 사용. /compact 또는 에이전트 분할을 강력 권장합니다.`
415
- : `[context ${percent}%] 컨텍스트 ${percent}% 사용. 마일스톤이면 /compact 권장합니다.`;
416
- mergedOutput = mergeOutputs(mergedOutput, JSON.stringify({ systemMessage: msg }));
523
+ const msg =
524
+ level === "critical"
525
+ ? `[context ${percent}%] 컨텍스트 ${percent}% 사용. /compact 또는 에이전트 분할을 강력 권장합니다.`
526
+ : `[context ${percent}%] 컨텍스트 ${percent}% 사용. 마일스톤이면 /compact를 권장합니다.`;
527
+ mergedOutput = mergeOutputs(
528
+ mergedOutput,
529
+ JSON.stringify({ systemMessage: msg }),
530
+ );
417
531
  if (level === "warn") {
418
532
  writeFileSync(nudgeMarker, new Date().toISOString());
419
533
  }
420
534
  }
421
535
  }
422
- } catch { /* 컨텍스트 모니터 읽기 실패 무시 */ }
536
+ } catch {
537
+ /* 컨텍스트 모니터 읽기 실패 무시 */
538
+ }
423
539
  }
424
540
 
425
541
  // ── PostToolUse:Skill 완료 시 라우팅 가중치 기록 ──
@@ -440,9 +556,24 @@ async function main() {
440
556
 
441
557
  // 결과 출력
442
558
  if (blocked) {
559
+ if (dedupePreToolUseBash) {
560
+ writeHookCache(stdinRaw, {
561
+ blocked: true,
562
+ mergedOutput: null,
563
+ stderr: blockingStderr,
564
+ });
565
+ }
443
566
  process.exit(2);
444
567
  }
445
568
 
569
+ if (dedupePreToolUseBash) {
570
+ writeHookCache(stdinRaw, {
571
+ blocked: false,
572
+ mergedOutput,
573
+ stderr: "",
574
+ });
575
+ }
576
+
446
577
  if (mergedOutput) {
447
578
  process.stdout.write(JSON.stringify(mergedOutput));
448
579
  }
@@ -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,
@@ -22,7 +22,11 @@ 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";
25
- import { HANDOFF_INSTRUCTION_SHORT, processHandoff } from "./handoff.mjs";
25
+ import {
26
+ formatHandoffForLead,
27
+ HANDOFF_INSTRUCTION_SHORT,
28
+ processHandoff,
29
+ } from "./handoff.mjs";
26
30
  import {
27
31
  capturePsmuxPane,
28
32
  createPsmuxSession,
@@ -317,7 +321,7 @@ export function createStallMonitor(paneId, resultFile, config, deps = {}) {
317
321
  * @param {string} [opts.command] — re-dispatch용 원본 명령
318
322
  * @param {string} [opts.token] — completion token
319
323
  * @param {(snapshot: string) => void} [opts.onPoll] — 폴링 콜백
320
- * @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 }>}
321
325
  */
322
326
  export async function waitForCompletionWithStallDetect(
323
327
  sessionName,
@@ -347,19 +351,23 @@ export async function waitForCompletionWithStallDetect(
347
351
  const _startCapture = deps.startCapture || startCapture;
348
352
 
349
353
  const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
350
- const completionPatterns = [
351
- token
352
- ? `${esc("__TRIFLUX_DONE__:")}${esc(token)}:(\\d+)`
353
- : `${esc("__TRIFLUX_DONE__:")}\\S+:(\\d+)`,
354
- token
355
- ? `${esc("TFX_DONE_")}${esc(token)}:(\\d+)`
356
- : `${esc("TFX_DONE_")}\\S+:(\\d+)`,
357
- ];
358
- 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
+ };
359
365
 
360
366
  let restarts = 0;
361
367
  let currentPaneId = paneId;
362
368
  let stallDetected = false;
369
+ let currentToken = token;
370
+ let currentLogPath = opts.logPath || null;
363
371
 
364
372
  while (true) {
365
373
  let lastOutput = "";
@@ -386,6 +394,9 @@ export async function waitForCompletionWithStallDetect(
386
394
  restarts,
387
395
  stallDetected,
388
396
  timedOut: true,
397
+ paneId: currentPaneId,
398
+ token: currentToken,
399
+ logPath: currentLogPath,
389
400
  };
390
401
  }
391
402
 
@@ -400,6 +411,7 @@ export async function waitForCompletionWithStallDetect(
400
411
  }
401
412
 
402
413
  // 2) completion 토큰 감지
414
+ const completionRe = buildCompletionRegex(currentToken);
403
415
  const completionMatch = completionRe.exec(currentOutput);
404
416
  if (completionMatch) {
405
417
  return {
@@ -411,6 +423,9 @@ export async function waitForCompletionWithStallDetect(
411
423
  restarts,
412
424
  stallDetected,
413
425
  timedOut: false,
426
+ paneId: currentPaneId,
427
+ token: currentToken,
428
+ logPath: currentLogPath,
414
429
  };
415
430
  }
416
431
 
@@ -443,6 +458,9 @@ export async function waitForCompletionWithStallDetect(
443
458
  restarts,
444
459
  stallDetected,
445
460
  timedOut: false,
461
+ paneId: currentPaneId,
462
+ token: currentToken,
463
+ logPath: currentLogPath,
446
464
  };
447
465
  }
448
466
  } catch {
@@ -481,8 +499,10 @@ export async function waitForCompletionWithStallDetect(
481
499
  "#{session_name}:#{window_index}.#{pane_index}",
482
500
  ]);
483
501
  _startCapture(sessionName, newPaneId);
484
- _dispatch(sessionName, newPaneId, command);
485
- 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;
486
506
  }
487
507
 
488
508
  restarts++;
@@ -565,7 +585,11 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
565
585
  assignment.cli,
566
586
  assignment.prompt,
567
587
  resultFile,
568
- { mcp: assignment.mcp, model: assignment.model },
588
+ {
589
+ mcp: assignment.mcp,
590
+ model: assignment.model,
591
+ cwd: assignment.cwd || assignment.workdir,
592
+ },
569
593
  );
570
594
  startCapture(sessionName, newPaneId);
571
595
  // pane 간 pipe-pane EBUSY 방지 — 이벤트 루프 해방하며 순차 대기
@@ -585,6 +609,7 @@ async function dispatchProgressive(sessionName, assignments, opts = {}) {
585
609
  role: assignment.role,
586
610
  command: cmd,
587
611
  workerId,
612
+ cwd: assignment.cwd || assignment.workdir,
588
613
  });
589
614
  }
590
615
 
@@ -636,7 +661,11 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
636
661
  assignment.cli,
637
662
  assignment.prompt,
638
663
  resultFile,
639
- { mcp: assignment.mcp, model: assignment.model },
664
+ {
665
+ mcp: assignment.mcp,
666
+ model: assignment.model,
667
+ cwd: assignment.cwd || assignment.workdir,
668
+ },
640
669
  );
641
670
  const scriptDir = join(RESULT_DIR, sessionName);
642
671
  await registerHeadlessWorker(sessionName, i, assignment.cli);
@@ -661,6 +690,7 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
661
690
  role: assignment.role,
662
691
  command: cmd,
663
692
  workerId,
693
+ cwd: assignment.cwd || assignment.workdir,
664
694
  };
665
695
  }),
666
696
  );
@@ -740,6 +770,9 @@ async function awaitAll(
740
770
  onPoll: stallPollCb,
741
771
  },
742
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;
743
776
  completion = {
744
777
  matched: stallResult.matched,
745
778
  exitCode: stallResult.exitCode,
@@ -799,28 +832,27 @@ async function awaitAll(
799
832
  * @returns {Array}
800
833
  */
801
834
  async function collectResults(sessionName, results) {
802
- // B3 fix: git diff를 루프 밖에서 1회만 실행 (워커 수만큼 중복 방지)
803
- let gitDiffFiles;
804
- try {
805
- const diffOut = execSync("git diff --name-only HEAD", {
806
- encoding: "utf8",
807
- timeout: 5000,
808
- stdio: ["pipe", "pipe", "pipe"],
809
- });
810
- gitDiffFiles = diffOut.trim().split("\n").filter(Boolean);
811
- } catch {
812
- /* git 미설치 또는 non-repo — 무시 */
813
- }
814
-
815
835
  // handoff 파이프라인: parse → validate → format (각 워커 결과에 적용)
816
836
  return await Promise.all(
817
837
  results.map(async ({ d, completion, output }) => {
838
+ const workerGitDiffFiles = collectGitDiffFiles(d.cwd);
818
839
  const handoffResult = processHandoff(output, {
819
840
  exitCode: completion.exitCode,
820
841
  resultFile: d.resultFile,
821
842
  cli: d.cli,
822
- gitDiffFiles,
843
+ gitDiffFiles: workerGitDiffFiles,
823
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
+ }
824
856
  const status =
825
857
  handoffResult.handoff?.status ||
826
858
  (completion.matched && completion.exitCode === 0
@@ -843,6 +875,7 @@ async function collectResults(sessionName, results) {
843
875
  exitCode: completion.exitCode,
844
876
  output,
845
877
  resultFile: d.resultFile,
878
+ workerGitDiffFiles,
846
879
  sessionDead: completion.sessionDead || false,
847
880
  handoff: handoffResult.handoff,
848
881
  handoffFormatted: handoffResult.formatted,
@@ -853,6 +886,20 @@ async function collectResults(sessionName, results) {
853
886
  );
854
887
  }
855
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
+
856
903
  /**
857
904
  * 헤드리스 CLI 오케스트레이션 실행
858
905
  *
@@ -1,7 +1,7 @@
1
1
  // hub/team/process-cleanup.mjs — 고아 node/python 프로세스 감지 및 정리
2
2
  // Windows: Get-CimInstance Win32_Process로 parent PID + cmdLine 접근
3
3
  // Unix: ps aux 파싱
4
- import { execFile as nodeExecFile } from "node:child_process";
4
+ import { execFile as nodeExecFile, execFileSync } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
6
  import { IS_WINDOWS } from "../platform.mjs";
7
7
 
@@ -12,6 +12,28 @@ const execFileAsync = promisify(nodeExecFile);
12
12
  const TARGET_PROCESS_NAMES = ["node", "python", "python3"];
13
13
  const SIGTERM_GRACE_MS = 5000;
14
14
 
15
+ function forceKillPid(pid) {
16
+ if (IS_WINDOWS) {
17
+ try {
18
+ execFileSync("taskkill", ["/F", "/PID", String(pid)], {
19
+ stdio: "ignore",
20
+ timeout: 5000,
21
+ windowsHide: true,
22
+ });
23
+ return;
24
+ } catch (taskkillError) {
25
+ try {
26
+ process.kill(pid);
27
+ return;
28
+ } catch {
29
+ throw taskkillError;
30
+ }
31
+ }
32
+ }
33
+
34
+ process.kill(pid, "SIGKILL");
35
+ }
36
+
15
37
  // cmdLine 패턴 기반 화이트리스트 (고아 후보에서 제외)
16
38
  const WHITELIST_CMDLINE = [/oh-my-claudecode/i, /triflux[\\/]hub[\\/]s/i];
17
39
 
@@ -293,7 +315,7 @@ export function createProcessCleanup(opts = {}) {
293
315
  /**
294
316
  * 마지막 scan 결과의 프로세스를 kill한다.
295
317
  * dryRun=true이면 kill 없이 목록만 반환한다.
296
- * SIGTERM → 5s 대기 → SIGKILL 순서.
318
+ * SIGTERM → 5s 대기 → 강제 종료(taskkill/SIGKILL) 순서.
297
319
  * @returns {Promise<Array<{pid,name,killed,error}>>}
298
320
  */
299
321
  async function kill() {
@@ -312,14 +334,14 @@ export function createProcessCleanup(opts = {}) {
312
334
  // SIGTERM
313
335
  process.kill(p.pid, "SIGTERM");
314
336
 
315
- // 5초 대기 후 살아있으면 SIGKILL
337
+ // 5초 대기 후 살아있으면 강제 종료
316
338
  await new Promise((resolve) => setTimeout(resolve, SIGTERM_GRACE_MS));
317
339
 
318
340
  try {
319
341
  // 프로세스가 아직 살아있는지 확인 (signal 0)
320
342
  process.kill(p.pid, 0);
321
- // 여전히 살아있음 → SIGKILL
322
- process.kill(p.pid, "SIGKILL");
343
+ // 여전히 살아있음 → Windows는 taskkill/process.kill, 그 외는 SIGKILL
344
+ forceKillPid(p.pid);
323
345
  } catch {
324
346
  // ESRCH: 이미 종료됨 — 정상
325
347
  }
@@ -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