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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/hooks/hook-orchestrator.mjs +155 -24
- package/hub/team/handoff.mjs +17 -3
- package/hub/team/headless.mjs +76 -29
- package/hub/team/process-cleanup.mjs +27 -5
- package/hub/team/psmux.mjs +1 -0
- package/hub/team/swarm-hypervisor.mjs +423 -149
- package/hub/team/swarm-planner.mjs +2 -1
- package/hub/team/tui-viewer.mjs +3 -0
- package/package.json +10 -4
- package/scripts/__tests__/keyword-detector.test.mjs +4 -4
- package/scripts/__tests__/release-governance.test.mjs +148 -0
- package/scripts/headless-guard.mjs +1 -1
- package/scripts/release/bump-version.mjs +77 -0
- package/scripts/release/check-sync.mjs +51 -0
- package/scripts/release/lib.mjs +303 -0
- package/scripts/release/prepare.mjs +85 -0
- package/scripts/release/publish.mjs +87 -0
- package/scripts/release/verify.mjs +81 -0
- package/scripts/release/version-manifest.json +26 -0
- package/scripts/tfx-route.sh +1 -1
|
@@ -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.
|
|
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.
|
|
33
|
+
"version": "10.9.19"
|
|
34
34
|
}
|
|
@@ -21,7 +21,14 @@
|
|
|
21
21
|
// HOME / USERPROFILE — ${HOME} 치환용
|
|
22
22
|
|
|
23
23
|
import { execFile, execFileSync } from "node:child_process";
|
|
24
|
-
import {
|
|
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 (
|
|
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(
|
|
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(
|
|
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
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
}
|
package/hub/team/handoff.mjs
CHANGED
|
@@ -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,
|
package/hub/team/headless.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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초 대기 후 살아있으면
|
|
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
|
-
|
|
343
|
+
// 여전히 살아있음 → Windows는 taskkill/process.kill, 그 외는 SIGKILL
|
|
344
|
+
forceKillPid(p.pid);
|
|
323
345
|
} catch {
|
|
324
346
|
// ESRCH: 이미 종료됨 — 정상
|
|
325
347
|
}
|
package/hub/team/psmux.mjs
CHANGED
|
@@ -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
|