triflux 3.2.0-dev.7 → 3.2.0-dev.9

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.
Files changed (42) hide show
  1. package/bin/triflux.mjs +557 -251
  2. package/hooks/keyword-rules.json +16 -0
  3. package/hub/bridge.mjs +410 -318
  4. package/hub/hitl.mjs +45 -31
  5. package/hub/pipe.mjs +457 -0
  6. package/hub/router.mjs +422 -161
  7. package/hub/server.mjs +429 -424
  8. package/hub/store.mjs +388 -314
  9. package/hub/team/cli-team-common.mjs +348 -0
  10. package/hub/team/cli-team-control.mjs +393 -0
  11. package/hub/team/cli-team-start.mjs +512 -0
  12. package/hub/team/cli-team-status.mjs +269 -0
  13. package/hub/team/cli.mjs +59 -1459
  14. package/hub/team/dashboard.mjs +1 -9
  15. package/hub/team/native.mjs +12 -80
  16. package/hub/team/nativeProxy.mjs +121 -47
  17. package/hub/team/pane.mjs +66 -43
  18. package/hub/team/psmux.mjs +297 -0
  19. package/hub/team/session.mjs +354 -291
  20. package/hub/team/shared.mjs +13 -0
  21. package/hub/team/staleState.mjs +299 -0
  22. package/hub/tools.mjs +41 -52
  23. package/hub/workers/claude-worker.mjs +446 -0
  24. package/hub/workers/codex-mcp.mjs +414 -0
  25. package/hub/workers/factory.mjs +18 -0
  26. package/hub/workers/gemini-worker.mjs +349 -0
  27. package/hub/workers/interface.mjs +41 -0
  28. package/hud/hud-qos-status.mjs +4 -2
  29. package/package.json +4 -1
  30. package/scripts/keyword-detector.mjs +15 -0
  31. package/scripts/lib/keyword-rules.mjs +4 -1
  32. package/scripts/psmux-steering-prototype.sh +368 -0
  33. package/scripts/setup.mjs +128 -70
  34. package/scripts/tfx-route-worker.mjs +161 -0
  35. package/scripts/tfx-route.sh +415 -80
  36. package/skills/tfx-auto/SKILL.md +90 -564
  37. package/skills/tfx-auto-codex/SKILL.md +1 -3
  38. package/skills/tfx-codex/SKILL.md +1 -4
  39. package/skills/tfx-doctor/SKILL.md +1 -0
  40. package/skills/tfx-gemini/SKILL.md +1 -4
  41. package/skills/tfx-setup/SKILL.md +1 -4
  42. package/skills/tfx-team/SKILL.md +53 -62
@@ -0,0 +1,349 @@
1
+ // hub/workers/gemini-worker.mjs — Gemini headless subprocess 래퍼
2
+ // ADR-006: --output-format stream-json 기반 단발 실행 워커.
3
+
4
+ import { spawn } from 'node:child_process';
5
+ import readline from 'node:readline';
6
+
7
+ const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
8
+ const DEFAULT_KILL_GRACE_MS = 1000;
9
+
10
+ function toStringList(value) {
11
+ if (!Array.isArray(value)) return [];
12
+ return value
13
+ .map((item) => String(item ?? '').trim())
14
+ .filter(Boolean);
15
+ }
16
+
17
+ function safeJsonParse(line) {
18
+ try {
19
+ return JSON.parse(line);
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ function appendTextFragments(value, parts) {
26
+ if (value == null) return;
27
+ if (typeof value === 'string') {
28
+ const trimmed = value.trim();
29
+ if (trimmed) parts.push(trimmed);
30
+ return;
31
+ }
32
+ if (Array.isArray(value)) {
33
+ for (const item of value) appendTextFragments(item, parts);
34
+ return;
35
+ }
36
+ if (typeof value === 'object') {
37
+ if (typeof value.text === 'string') appendTextFragments(value.text, parts);
38
+ if (typeof value.response === 'string') appendTextFragments(value.response, parts);
39
+ if (typeof value.result === 'string') appendTextFragments(value.result, parts);
40
+ if (typeof value.content === 'string' || Array.isArray(value.content) || value.content) {
41
+ appendTextFragments(value.content, parts);
42
+ }
43
+ if (value.message) appendTextFragments(value.message, parts);
44
+ }
45
+ }
46
+
47
+ function extractText(event) {
48
+ const parts = [];
49
+ appendTextFragments(event, parts);
50
+ return parts.join('\n').trim();
51
+ }
52
+
53
+ function findLastEvent(events, predicate) {
54
+ for (let index = events.length - 1; index >= 0; index -= 1) {
55
+ if (predicate(events[index])) return events[index];
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function buildGeminiArgs(options) {
61
+ const args = [];
62
+
63
+ if (options.model) {
64
+ args.push('--model', options.model);
65
+ }
66
+
67
+ if (options.approvalMode) {
68
+ args.push('--approval-mode', options.approvalMode);
69
+ } else if (options.yolo !== false) {
70
+ args.push('--yolo');
71
+ }
72
+
73
+ const allowedMcpServers = toStringList(options.allowedMcpServerNames);
74
+ if (allowedMcpServers.length) {
75
+ args.push('--allowed-mcp-server-names', ...allowedMcpServers);
76
+ }
77
+
78
+ const extraArgs = toStringList(options.extraArgs);
79
+ if (extraArgs.length) args.push(...extraArgs);
80
+
81
+ args.push('--prompt', options.promptArgument ?? '');
82
+ args.push('--output-format', 'stream-json');
83
+
84
+ return args;
85
+ }
86
+
87
+ function createWorkerError(message, details = {}) {
88
+ const error = new Error(message);
89
+ Object.assign(error, details);
90
+ return error;
91
+ }
92
+
93
+ /**
94
+ * Gemini stream-json 래퍼
95
+ */
96
+ export class GeminiWorker {
97
+ type = 'gemini';
98
+
99
+ constructor(options = {}) {
100
+ this.command = options.command || 'gemini';
101
+ this.commandArgs = toStringList(options.commandArgs || options.args);
102
+ this.cwd = options.cwd || process.cwd();
103
+ this.env = { ...process.env, ...(options.env || {}) };
104
+ this.model = options.model || null;
105
+ this.approvalMode = options.approvalMode || null;
106
+ this.yolo = options.yolo !== false;
107
+ this.allowedMcpServerNames = toStringList(options.allowedMcpServerNames);
108
+ this.extraArgs = toStringList(options.extraArgs);
109
+ this.timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : DEFAULT_TIMEOUT_MS;
110
+ this.killGraceMs = Number(options.killGraceMs) > 0 ? Number(options.killGraceMs) : DEFAULT_KILL_GRACE_MS;
111
+ this.onEvent = typeof options.onEvent === 'function' ? options.onEvent : null;
112
+
113
+ this.state = 'idle';
114
+ this.child = null;
115
+ this.lastRun = null;
116
+ }
117
+
118
+ getStatus() {
119
+ return {
120
+ type: 'gemini',
121
+ state: this.state,
122
+ pid: this.child?.pid || null,
123
+ last_run_at_ms: this.lastRun?.finishedAtMs || null,
124
+ last_exit_code: this.lastRun?.exitCode ?? null,
125
+ };
126
+ }
127
+
128
+ async start() {
129
+ if (this.state === 'stopped') {
130
+ this.state = 'idle';
131
+ }
132
+ return this.getStatus();
133
+ }
134
+
135
+ async stop() {
136
+ if (!this.child) {
137
+ this.state = 'stopped';
138
+ return this.getStatus();
139
+ }
140
+ const child = this.child;
141
+ this._terminateChild(child);
142
+ await new Promise((resolve) => {
143
+ child.once('close', resolve);
144
+ setTimeout(resolve, this.killGraceMs + 50).unref?.();
145
+ });
146
+ this.child = null;
147
+ this.state = 'stopped';
148
+ return this.getStatus();
149
+ }
150
+
151
+ async restart() {
152
+ await this.stop();
153
+ this.state = 'idle';
154
+ return this.getStatus();
155
+ }
156
+
157
+ _terminateChild(child) {
158
+ if (!child || child.exitCode !== null || child.killed) return;
159
+ try { child.stdin.end(); } catch {}
160
+ try { child.kill(); } catch {}
161
+
162
+ const timer = setTimeout(() => {
163
+ if (child.exitCode === null) {
164
+ try { child.kill('SIGKILL'); } catch {}
165
+ }
166
+ }, this.killGraceMs);
167
+ timer.unref?.();
168
+ }
169
+
170
+ async run(prompt, options = {}) {
171
+ if (this.child) {
172
+ throw createWorkerError('GeminiWorker is already running', { code: 'WORKER_BUSY' });
173
+ }
174
+
175
+ await this.start();
176
+
177
+ const timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : this.timeoutMs;
178
+ const startedAtMs = Date.now();
179
+ const args = [
180
+ ...this.commandArgs,
181
+ ...buildGeminiArgs({
182
+ model: options.model || this.model,
183
+ approvalMode: options.approvalMode || this.approvalMode,
184
+ yolo: options.yolo ?? this.yolo,
185
+ allowedMcpServerNames: options.allowedMcpServerNames || this.allowedMcpServerNames,
186
+ extraArgs: options.extraArgs || this.extraArgs,
187
+ promptArgument: options.promptArgument ?? '',
188
+ }),
189
+ ];
190
+
191
+ const child = spawn(this.command, args, {
192
+ cwd: options.cwd || this.cwd,
193
+ env: { ...this.env, ...(options.env || {}) },
194
+ stdio: ['pipe', 'pipe', 'pipe'],
195
+ windowsHide: true,
196
+ });
197
+
198
+ this.child = child;
199
+ this.state = 'running';
200
+
201
+ const events = [];
202
+ const stdoutLines = [];
203
+ const stderrLines = [];
204
+ let lastErrorEvent = null;
205
+ let timedOut = false;
206
+ let exitCode = null;
207
+ let exitSignal = null;
208
+
209
+ const stdoutReader = readline.createInterface({
210
+ input: child.stdout,
211
+ crlfDelay: Infinity,
212
+ });
213
+ const stderrReader = readline.createInterface({
214
+ input: child.stderr,
215
+ crlfDelay: Infinity,
216
+ });
217
+
218
+ stdoutReader.on('line', (line) => {
219
+ if (!line) return;
220
+ const event = safeJsonParse(line);
221
+ if (!event) {
222
+ stdoutLines.push(line);
223
+ return;
224
+ }
225
+
226
+ events.push(event);
227
+ if (event.type === 'error') lastErrorEvent = event;
228
+ if (this.onEvent) {
229
+ try { this.onEvent(event); } catch {}
230
+ }
231
+ });
232
+
233
+ stderrReader.on('line', (line) => {
234
+ if (!line) return;
235
+ stderrLines.push(line);
236
+ });
237
+
238
+ const closePromise = new Promise((resolve, reject) => {
239
+ child.once('error', reject);
240
+ child.once('close', (code, signal) => {
241
+ exitCode = code;
242
+ exitSignal = signal;
243
+ resolve();
244
+ });
245
+ });
246
+
247
+ const timeout = setTimeout(() => {
248
+ timedOut = true;
249
+ this._terminateChild(child);
250
+ }, timeoutMs);
251
+ timeout.unref?.();
252
+
253
+ child.stdin.on('error', () => {});
254
+ child.stdin.end(String(prompt ?? ''));
255
+
256
+ try {
257
+ await closePromise;
258
+ } finally {
259
+ clearTimeout(timeout);
260
+ stdoutReader.close();
261
+ stderrReader.close();
262
+ if (this.child === child) {
263
+ this.child = null;
264
+ }
265
+ this.state = 'idle';
266
+ }
267
+
268
+ const resultEvent = findLastEvent(events, (event) => event?.type === 'result');
269
+ const response = [
270
+ extractText(resultEvent),
271
+ ...events
272
+ .filter((event) => event?.type === 'message' || event?.type === 'assistant')
273
+ .map((event) => extractText(event))
274
+ .filter(Boolean),
275
+ ...stdoutLines,
276
+ ]
277
+ .filter(Boolean)
278
+ .join('\n')
279
+ .trim();
280
+
281
+ const result = {
282
+ type: 'gemini',
283
+ command: this.command,
284
+ args,
285
+ response,
286
+ events,
287
+ resultEvent,
288
+ usage: resultEvent?.usage || null,
289
+ stdout: stdoutLines.join('\n').trim(),
290
+ stderr: stderrLines.join('\n').trim(),
291
+ exitCode,
292
+ exitSignal,
293
+ timedOut,
294
+ startedAtMs,
295
+ finishedAtMs: Date.now(),
296
+ };
297
+
298
+ this.lastRun = result;
299
+
300
+ if (timedOut) {
301
+ throw createWorkerError(`Gemini worker timed out after ${timeoutMs}ms`, {
302
+ code: 'ETIMEDOUT',
303
+ result,
304
+ stderr: result.stderr,
305
+ });
306
+ }
307
+
308
+ if (exitCode !== 0) {
309
+ throw createWorkerError(`Gemini worker exited with code ${exitCode}`, {
310
+ code: 'WORKER_EXIT',
311
+ result,
312
+ stderr: result.stderr,
313
+ });
314
+ }
315
+
316
+ if (lastErrorEvent) {
317
+ throw createWorkerError('Gemini worker emitted an error event', {
318
+ code: 'WORKER_EVENT_ERROR',
319
+ result,
320
+ stderr: result.stderr,
321
+ });
322
+ }
323
+
324
+ return result;
325
+ }
326
+
327
+ isReady() {
328
+ return this.state !== 'stopped';
329
+ }
330
+
331
+ async execute(prompt, options = {}) {
332
+ try {
333
+ const result = await this.run(prompt, options);
334
+ return {
335
+ output: result.response,
336
+ exitCode: 0,
337
+ sessionKey: options.sessionKey || null,
338
+ raw: result,
339
+ };
340
+ } catch (error) {
341
+ return {
342
+ output: error.stderr || error.message || 'Gemini worker failed',
343
+ exitCode: error.code === 'ETIMEDOUT' ? 124 : 1,
344
+ sessionKey: options.sessionKey || null,
345
+ raw: error.result || null,
346
+ };
347
+ }
348
+ }
349
+ }
@@ -0,0 +1,41 @@
1
+ // hub/workers/interface.mjs — Worker 공통 인터페이스 정의
2
+
3
+ /**
4
+ * 워커 실행 옵션
5
+ * @typedef {object} WorkerExecuteOptions
6
+ * @property {string} [cwd] - 워커 작업 디렉터리
7
+ * @property {string} [sessionKey] - 내부 세션 키
8
+ * @property {string} [threadId] - 외부에서 지정한 Codex threadId
9
+ * @property {boolean} [resetSession] - 기존 세션을 무시하고 새 세션 시작 여부
10
+ * @property {string} [model] - Codex 모델 이름
11
+ * @property {string} [profile] - Codex 프로필 이름
12
+ * @property {'untrusted'|'on-failure'|'on-request'|'never'} [approvalPolicy] - 승인 정책
13
+ * @property {'read-only'|'workspace-write'|'danger-full-access'} [sandbox] - 샌드박스 정책
14
+ * @property {Record<string, unknown>} [config] - 추가 Codex 설정
15
+ * @property {string} [baseInstructions] - 기본 시스템 지침
16
+ * @property {string} [developerInstructions] - 개발자 지침
17
+ * @property {string} [compactPrompt] - 컴팩션 프롬프트
18
+ * @property {number} [timeoutMs] - MCP 요청 타임아웃(ms)
19
+ */
20
+
21
+ /**
22
+ * 워커 실행 결과
23
+ * @typedef {object} WorkerResult
24
+ * @property {string} output - 최종 텍스트 출력
25
+ * @property {number} exitCode - 종료 코드(0=성공)
26
+ * @property {string | null} [threadId] - Codex 세션 threadId
27
+ * @property {string | null} [sessionKey] - 내부 세션 키
28
+ * @property {unknown} [raw] - 원본 tool call 결과
29
+ */
30
+
31
+ /**
32
+ * 공통 워커 인터페이스
33
+ * @typedef {object} IWorker
34
+ * @property {(prompt: string, opts?: WorkerExecuteOptions) => Promise<WorkerResult>} execute
35
+ * @property {() => Promise<void>} start
36
+ * @property {() => Promise<void>} stop
37
+ * @property {() => boolean} isReady
38
+ * @property {string} type - 'codex' | 'gemini' | 'claude'
39
+ */
40
+
41
+ export const WORKER_TYPES = Object.freeze(['codex', 'gemini', 'claude']);
@@ -313,8 +313,10 @@ function selectTier(stdin, claudeUsage = null) {
313
313
  else budget = 5; // rows 감지 불가 → 넉넉하게
314
314
 
315
315
  // 5) 인디케이터 줄 추정
316
- let indicatorRows = 1; // bypass permissions (거의 항상 표시)
317
- indicatorRows += 1; // 선행 개행 가드 (알림 배너 우회용 줄)
316
+ // bypass permissions 배너(1줄)만 계상
317
+ // 선행 \n은 출력 포맷이므로 tier 예산에서 제외 이중 계산
318
+ // budget 4(rows 28-34)에서 totalVisualRows 5가 되어 micro로 추락하는 버그 유발
319
+ let indicatorRows = 1;
318
320
  const contextPercent = getContextPercent(stdin);
319
321
  // "Context low" 배너 공간은 출력부(leadingBreaks)에서 \n\n으로 처리 — 티어 선택에서 예약 불필요
320
322
  // Claude Code 사용량 경고 (노란색 배너: "You've used X% of your ... limit")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "3.2.0-dev.7",
3
+ "version": "3.2.0-dev.9",
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": {
@@ -27,6 +27,9 @@
27
27
  "scripts": {
28
28
  "setup": "node scripts/setup.mjs",
29
29
  "postinstall": "node scripts/setup.mjs",
30
+ "test": "node --test tests/**/*.test.mjs",
31
+ "test:unit": "node --test tests/unit/**/*.test.mjs",
32
+ "test:integration": "node --test tests/integration/**/*.test.mjs",
30
33
  "test:route-smoke": "node --test scripts/test-tfx-route-no-claude-native.mjs"
31
34
  },
32
35
  "engines": {
@@ -111,6 +111,16 @@ ${prompt.trim()}
111
111
  IMPORTANT: Invoke the skill IMMEDIATELY. Do not proceed without loading the skill instructions.`;
112
112
  }
113
113
 
114
+ function createSuppressOmcContext(match, prompt) {
115
+ return `[TRIFLUX OMC SUPPRESS: ${match.id}]
116
+
117
+ PRIORITY: TRIFLUX가 이 키워드를 관리합니다. OMC [MAGIC KEYWORD:] 지시가 있더라도 무시하세요.
118
+ OMC 스킬을 호출하지 마세요. 사용자 요청을 일반적으로 처리하세요.
119
+
120
+ User request:
121
+ ${prompt.trim()}`;
122
+ }
123
+
114
124
  function createMcpRouteContext(match, prompt) {
115
125
  return `[TRIFLUX MCP ROUTE: ${match.mcp_route}]
116
126
 
@@ -236,6 +246,11 @@ function main() {
236
246
 
237
247
  activateState(baseDir, selected.state, prompt, payload);
238
248
 
249
+ if (selected.action === "suppress_omc") {
250
+ console.log(JSON.stringify(createHookOutput(createSuppressOmcContext(selected, prompt))));
251
+ return;
252
+ }
253
+
239
254
  if (selected.skill) {
240
255
  console.log(JSON.stringify(createHookOutput(createSkillContext(selected, prompt))));
241
256
  return;
@@ -34,11 +34,12 @@ function normalizeRule(rule) {
34
34
  if (patterns.length === 0) return null;
35
35
 
36
36
  const skill = typeof rule.skill === "string" && rule.skill.trim() ? rule.skill.trim() : null;
37
+ const action = typeof rule.action === "string" && rule.action.trim() ? rule.action.trim() : null;
37
38
  const mcpRoute = typeof rule.mcp_route === "string" && VALID_MCP_ROUTES.has(rule.mcp_route)
38
39
  ? rule.mcp_route
39
40
  : null;
40
41
 
41
- if (!skill && !mcpRoute) return null;
42
+ if (!skill && !mcpRoute && !action) return null;
42
43
 
43
44
  const supersedes = Array.isArray(rule.supersedes)
44
45
  ? rule.supersedes.filter((id) => typeof id === "string" && id.trim()).map((id) => id.trim())
@@ -51,6 +52,7 @@ function normalizeRule(rule) {
51
52
  id: rule.id.trim(),
52
53
  patterns,
53
54
  skill,
55
+ action: rule.action || null,
54
56
  priority: rule.priority,
55
57
  supersedes,
56
58
  exclusive: rule.exclusive === true,
@@ -114,6 +116,7 @@ export function matchRules(compiledRules, cleanText) {
114
116
  matches.push({
115
117
  id: rule.id,
116
118
  skill: rule.skill,
119
+ action: rule.action || null,
117
120
  priority: rule.priority,
118
121
  supersedes: rule.supersedes || [],
119
122
  exclusive: rule.exclusive === true,