@triflux/core 10.0.3 → 10.25.1

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 (143) hide show
  1. package/hooks/agent-route-guard.mjs +14 -10
  2. package/hooks/cross-review-tracker.mjs +21 -8
  3. package/hooks/error-context.mjs +47 -7
  4. package/hooks/hook-adaptive-collector.mjs +18 -16
  5. package/hooks/hook-manager.mjs +93 -32
  6. package/hooks/hook-orchestrator.mjs +279 -33
  7. package/hooks/hook-registry.json +104 -7
  8. package/hooks/hooks.json +49 -1
  9. package/hooks/keyword-rules.json +183 -23
  10. package/hooks/lib/resolve-root.mjs +1 -1
  11. package/hooks/mcp-config-watcher.mjs +6 -2
  12. package/hooks/permission-safe-allow.mjs +233 -0
  13. package/hooks/pipeline-stop.mjs +369 -22
  14. package/hooks/post-tool-tips.mjs +306 -0
  15. package/hooks/pre-compact-snapshot.mjs +181 -0
  16. package/hooks/safety-guard.mjs +308 -18
  17. package/hooks/session-end-cleanup.mjs +345 -0
  18. package/hooks/session-start-fast.mjs +214 -0
  19. package/hooks/subagent-tracker.mjs +267 -0
  20. package/hooks/subagent-verifier.mjs +9 -4
  21. package/hub/account-broker.mjs +1036 -66
  22. package/hub/adaptive-diagnostic.mjs +75 -48
  23. package/hub/adaptive-inject.mjs +96 -58
  24. package/hub/adaptive-memory.mjs +156 -42
  25. package/hub/adaptive.mjs +60 -31
  26. package/hub/assign-callbacks.mjs +163 -126
  27. package/hub/bridge.mjs +832 -291
  28. package/hub/cli-adapter-base.mjs +321 -97
  29. package/hub/codex-adapter.mjs +179 -93
  30. package/hub/codex-compat.mjs +4 -3
  31. package/hub/codex-preflight.mjs +113 -37
  32. package/hub/delegator/contracts.mjs +19 -23
  33. package/hub/delegator/index.mjs +14 -14
  34. package/hub/delegator/service.mjs +88 -64
  35. package/hub/delegator/tool-definitions.mjs +35 -35
  36. package/hub/dynamic-routing-engine.mjs +511 -0
  37. package/hub/fullcycle.mjs +34 -17
  38. package/hub/gemini-adapter.mjs +77 -95
  39. package/hub/hitl.mjs +202 -143
  40. package/hub/intent.mjs +171 -39
  41. package/hub/lib/bash-path.mjs +73 -0
  42. package/hub/lib/cache-guard.mjs +43 -17
  43. package/hub/lib/env-detect.mjs +145 -0
  44. package/hub/lib/hosts-compat.mjs +319 -0
  45. package/hub/lib/mcp-response-cache.mjs +239 -0
  46. package/hub/lib/memory-store.mjs +303 -107
  47. package/hub/lib/path-utils.mjs +165 -0
  48. package/hub/lib/phase-manager.mjs +493 -0
  49. package/hub/lib/process-utils.mjs +797 -104
  50. package/hub/lib/prompt-tmp.mjs +33 -0
  51. package/hub/lib/risk-tier.mjs +53 -0
  52. package/hub/lib/spawn-trace.mjs +561 -0
  53. package/hub/lib/ssh-command.mjs +44 -24
  54. package/hub/lib/ssh-retry.mjs +5 -1
  55. package/hub/lib/staged-file-detect.mjs +43 -0
  56. package/hub/lib/state-snapshot.mjs +293 -0
  57. package/hub/lib/tfx-route-args.mjs +309 -0
  58. package/hub/lib/timeline-adapter.mjs +190 -0
  59. package/hub/lib/trace-recorder.mjs +153 -0
  60. package/hub/lib/uuidv7.mjs +4 -3
  61. package/hub/memory-doctor.mjs +266 -106
  62. package/hub/middleware/quota-middleware.mjs +70 -0
  63. package/hub/middleware/request-logger.mjs +61 -34
  64. package/hub/paths.mjs +9 -9
  65. package/hub/pipeline/gates/confidence.mjs +34 -15
  66. package/hub/pipeline/gates/consensus.mjs +27 -15
  67. package/hub/pipeline/gates/index.mjs +7 -3
  68. package/hub/pipeline/gates/selfcheck.mjs +57 -19
  69. package/hub/pipeline/index.mjs +77 -42
  70. package/hub/pipeline/state.mjs +10 -10
  71. package/hub/pipeline/transitions.mjs +40 -23
  72. package/hub/platform.mjs +65 -48
  73. package/hub/quality/deslop.mjs +70 -49
  74. package/hub/reflexion.mjs +116 -58
  75. package/hub/research.mjs +34 -25
  76. package/hub/router.mjs +941 -791
  77. package/hub/routing/complexity.mjs +132 -29
  78. package/hub/routing/index.mjs +17 -12
  79. package/hub/routing/q-learning.mjs +76 -28
  80. package/hub/routing-snapshot.mjs +261 -0
  81. package/hub/session-fingerprint.mjs +129 -63
  82. package/hub/state.mjs +86 -44
  83. package/hub/team/retry-state-machine.mjs +267 -0
  84. package/hub/token-mode.mjs +76 -72
  85. package/hub/workers/worker-utils.mjs +22 -16
  86. package/hud/colors.mjs +27 -9
  87. package/hud/constants.mjs +184 -27
  88. package/hud/context-monitor.mjs +133 -44
  89. package/hud/hud-qos-status.mjs +153 -50
  90. package/hud/mission-board.mjs +56 -0
  91. package/hud/providers/claude.mjs +398 -114
  92. package/hud/providers/codex.mjs +96 -21
  93. package/hud/providers/gemini.mjs +184 -51
  94. package/hud/renderers.mjs +413 -123
  95. package/hud/terminal.mjs +101 -31
  96. package/hud/utils.mjs +81 -39
  97. package/mesh/index.mjs +69 -0
  98. package/mesh/mesh-budget.mjs +137 -0
  99. package/mesh/mesh-heartbeat.mjs +100 -0
  100. package/mesh/mesh-protocol.mjs +96 -0
  101. package/mesh/mesh-queue.mjs +163 -0
  102. package/mesh/mesh-registry.mjs +78 -0
  103. package/mesh/mesh-router.mjs +77 -0
  104. package/package.json +5 -1
  105. package/scripts/lib/agent-json.mjs +27 -0
  106. package/scripts/lib/async.mjs +174 -0
  107. package/scripts/lib/claudemd-scanner.mjs +6 -1
  108. package/scripts/lib/cli-agy.mjs +62 -0
  109. package/scripts/lib/cli-claude.mjs +78 -0
  110. package/scripts/lib/cli-codex.mjs +199 -0
  111. package/scripts/lib/cli-gemini.mjs +67 -0
  112. package/scripts/lib/codex-recovery.sh +50 -0
  113. package/scripts/lib/context.mjs +3 -3
  114. package/scripts/lib/cross-review-utils.mjs +13 -5
  115. package/scripts/lib/doctor-env-checks.mjs +121 -0
  116. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  117. package/scripts/lib/env-probe.mjs +58 -30
  118. package/scripts/lib/env.mjs +14 -0
  119. package/scripts/lib/gemini-profiles.mjs +44 -10
  120. package/scripts/lib/handoff.mjs +33 -17
  121. package/scripts/lib/hook-utils.mjs +8 -6
  122. package/scripts/lib/hub.mjs +48 -0
  123. package/scripts/lib/keyword-rules.mjs +48 -19
  124. package/scripts/lib/logger.mjs +24 -24
  125. package/scripts/lib/mcp-filter.mjs +393 -249
  126. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  127. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  128. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  129. package/scripts/lib/mcp-guard-engine.mjs +1282 -116
  130. package/scripts/lib/mcp-health.mjs +773 -0
  131. package/scripts/lib/mcp-manifest.mjs +24 -14
  132. package/scripts/lib/mcp-server-catalog.mjs +355 -118
  133. package/scripts/lib/pid.mjs +63 -0
  134. package/scripts/lib/process-utils.mjs +26 -0
  135. package/scripts/lib/psmux-info.mjs +72 -12
  136. package/scripts/lib/quota.mjs +47 -0
  137. package/scripts/lib/remote-spawn-transfer.mjs +44 -14
  138. package/scripts/lib/skill-state.mjs +220 -0
  139. package/scripts/lib/skill-template.mjs +36 -11
  140. package/scripts/lib/team.mjs +75 -0
  141. package/scripts/lib/timeout.mjs +66 -0
  142. package/scripts/lib/tmp.mjs +26 -0
  143. package/scripts/lib/toml.mjs +113 -0
@@ -1,41 +1,42 @@
1
1
  // hub/gemini-adapter.mjs — Gemini CLI 방어 계층
2
2
  // codex-adapter.mjs와 동일 패턴, cli-adapter-base 공통 인터페이스 사용
3
3
 
4
- import { mkdirSync } from 'node:fs';
5
- import { join } from 'node:path';
6
- import { tmpdir } from 'node:os';
7
-
8
- import { withRetry } from './workers/worker-utils.mjs';
9
- import { whichCommandAsync } from './platform.mjs';
4
+ import { mkdirSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
10
7
  import {
11
- createCircuitBreaker,
12
- createResult,
13
- appendWarnings,
8
+ executeWithCircuitBroker,
14
9
  normalizePathForShell,
15
- shellQuote,
16
10
  runProcess,
17
- } from './cli-adapter-base.mjs';
18
-
19
- const breaker = createCircuitBreaker();
11
+ shellQuote,
12
+ } from "./cli-adapter-base.mjs";
13
+ import { whichCommandAsync } from "./platform.mjs";
20
14
 
21
15
  // ── Gemini-specific stall inference ─────────────────────────────
22
16
 
23
17
  function inferStallMode(stdout, stderr) {
24
18
  const text = `${stdout}\n${stderr}`.toLowerCase();
25
- if (/(rate.?limit|quota|resource.?exhaust|429)/u.test(text)) return 'rate_limited';
26
- if (/(unauthorized|forbidden|auth|login|token|credential|api.?key)/u.test(text)) return 'auth_stall';
27
- if (/\bmcp\b|playwright|tavily|brave|sequential|server/u.test(text)) return 'mcp_stall';
28
- return 'timeout';
19
+ if (/(rate.?limit|quota|resource.?exhaust|429)/u.test(text))
20
+ return "rate_limited";
21
+ if (
22
+ /(unauthorized|forbidden|auth|login|token|credential|api.?key)/u.test(text)
23
+ )
24
+ return "auth_stall";
25
+ if (/\bmcp\b|playwright|tavily|brave|sequential|server/u.test(text))
26
+ return "mcp_stall";
27
+ return "timeout";
29
28
  }
30
29
 
31
30
  // ── Preflight ───────────────────────────────────────────────────
32
31
 
33
32
  async function runPreflight(opts = {}) {
34
- const geminiPath = await whichCommandAsync('gemini');
33
+ const geminiPath = await whichCommandAsync("gemini");
35
34
  if (!geminiPath) {
36
35
  return {
37
36
  geminiPath: null,
38
- warnings: ['Gemini CLI not found. Install Gemini and ensure `gemini` is available on PATH.'],
37
+ warnings: [
38
+ "Gemini CLI not found. Install Gemini and ensure `gemini` is available on PATH.",
39
+ ],
39
40
  excludeMcpServers: [],
40
41
  ok: false,
41
42
  };
@@ -45,7 +46,7 @@ async function runPreflight(opts = {}) {
45
46
  const excludeMcpServers = [];
46
47
 
47
48
  for (const name of Array.isArray(opts.mcpServers) ? opts.mcpServers : []) {
48
- const server = String(name ?? '').trim();
49
+ const server = String(name ?? "").trim();
49
50
  if (!server) continue;
50
51
  // Gemini MCP health는 best-effort: 실행 시점에 --allowed-mcp-server-names로 필터링
51
52
  // 사전 probe는 수행하지 않음 (gemini가 자체적으로 graceful degrade)
@@ -57,26 +58,33 @@ async function runPreflight(opts = {}) {
57
58
  // ── Command building ────────────────────────────────────────────
58
59
 
59
60
  function buildGeminiCommand(prompt, resultFile, opts = {}) {
60
- const parts = ['gemini'];
61
+ const parts = ["gemini"];
61
62
 
62
- if (opts.model) parts.push('--model', shellQuote(opts.model));
63
- parts.push('--yolo');
63
+ if (opts.model) parts.push("--model", shellQuote(opts.model));
64
+ parts.push("--yolo");
64
65
 
65
- const allowed = Array.isArray(opts.allowedMcpServers) ? opts.allowedMcpServers : [];
66
- const excluded = Array.isArray(opts.excludeMcpServers) ? opts.excludeMcpServers : [];
66
+ const allowed = Array.isArray(opts.allowedMcpServers)
67
+ ? opts.allowedMcpServers
68
+ : [];
69
+ const excluded = Array.isArray(opts.excludeMcpServers)
70
+ ? opts.excludeMcpServers
71
+ : [];
67
72
  const filtered = allowed.filter((name) => !excluded.includes(name));
68
73
  if (filtered.length) {
69
- parts.push('--allowed-mcp-server-names', ...filtered.map((n) => shellQuote(n)));
74
+ parts.push(
75
+ "--allowed-mcp-server-names",
76
+ ...filtered.map((n) => shellQuote(n)),
77
+ );
70
78
  }
71
79
 
72
- parts.push('--prompt', shellQuote(prompt));
73
- parts.push('--output-format', 'text');
80
+ parts.push("--prompt", shellQuote(prompt));
81
+ parts.push("--output-format", "text");
74
82
 
75
83
  if (resultFile) {
76
- return `${parts.join(' ')} > ${shellQuote(normalizePathForShell(resultFile))} 2>${shellQuote(normalizePathForShell(resultFile + '.err'))}`;
84
+ return `${parts.join(" ")} > ${shellQuote(normalizePathForShell(resultFile))} 2>${shellQuote(normalizePathForShell(resultFile + ".err"))}`;
77
85
  }
78
86
 
79
- return parts.join(' ');
87
+ return parts.join(" ");
80
88
  }
81
89
 
82
90
  function buildAttempts(opts, preflight) {
@@ -84,20 +92,19 @@ function buildAttempts(opts, preflight) {
84
92
  const base = {
85
93
  timeout,
86
94
  model: opts.model,
87
- allowedMcpServers: Array.isArray(opts.mcpServers) ? [...opts.mcpServers] : [],
95
+ allowedMcpServers: Array.isArray(opts.mcpServers)
96
+ ? [...opts.mcpServers]
97
+ : [],
88
98
  excludeMcpServers: [...(preflight.excludeMcpServers || [])],
89
99
  };
90
100
  if (opts.retryOnFail === false) return [base];
91
- return [
92
- base,
93
- { ...base, timeout: timeout * 2, allowedMcpServers: [] },
94
- ];
101
+ return [base, { ...base, timeout: timeout * 2, allowedMcpServers: [] }];
95
102
  }
96
103
 
97
104
  // ── Public: buildExecArgs ───────────────────────────────────────
98
105
 
99
106
  export function buildExecArgs(opts = {}) {
100
- const prompt = typeof opts.prompt === 'string' ? opts.prompt : '';
107
+ const prompt = typeof opts.prompt === "string" ? opts.prompt : "";
101
108
  return buildGeminiCommand(prompt, opts.resultFile || null, {
102
109
  model: opts.model,
103
110
  allowedMcpServers: opts.mcpServers,
@@ -106,75 +113,50 @@ export function buildExecArgs(opts = {}) {
106
113
 
107
114
  // ── Execution ───────────────────────────────────────────────────
108
115
 
109
- async function runGemini(prompt, workdir, preflight, attempt) {
110
- const dir = join(tmpdir(), 'triflux-gemini-exec');
116
+ async function runGemini(prompt, workdir, preflight, attempt, lease) {
117
+ const dir = join(tmpdir(), "triflux-gemini-exec");
111
118
  mkdirSync(dir, { recursive: true });
112
- const resultFile = join(dir, `gemini-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
119
+ const resultFile = join(
120
+ dir,
121
+ `gemini-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`,
122
+ );
113
123
  const command = buildGeminiCommand(prompt, resultFile, {
114
124
  model: attempt.model,
115
125
  allowedMcpServers: attempt.allowedMcpServers,
116
126
  excludeMcpServers: attempt.excludeMcpServers,
117
127
  });
118
- return runProcess(command, workdir, attempt.timeout, { resultFile, inferStallMode });
128
+ // PRD A1: lease.env 가 있으면 spawn env 에 적용 (예: GOOGLE_API_KEY 등 account-specific
129
+ // 환경변수). lease 가 null 이면 default 동작 유지 (부모 env inherit).
130
+ const spawnEnv =
131
+ lease?.env && typeof lease.env === "object"
132
+ ? { ...process.env, ...lease.env }
133
+ : undefined;
134
+ return runProcess(command, workdir, attempt.timeout, {
135
+ resultFile,
136
+ inferStallMode,
137
+ spawnEnv,
138
+ });
119
139
  }
120
140
 
121
141
  // ── Public API ──────────────────────────────────────────────────
122
142
 
123
- export function getCircuitState(now) {
124
- return breaker.getState(now);
143
+ export async function getCircuitState() {
144
+ const brokerMod = await import("./account-broker.mjs");
145
+ if (!brokerMod.broker) return { state: "closed", failures: [] };
146
+ const snap = brokerMod.broker
147
+ .snapshot()
148
+ .filter((a) => a.provider === "gemini");
149
+ return snap.length
150
+ ? { state: snap[0].circuitState, accounts: snap }
151
+ : { state: "closed", failures: [] };
125
152
  }
126
153
 
127
- export async function execute(opts = {}) {
128
- const entry = breaker.canExecute();
129
- if (!entry.allowed) {
130
- return createResult(false, { fellBack: true, failureMode: 'circuit_open' });
131
- }
132
-
133
- const preflight = await runPreflight({ mcpServers: opts.mcpServers });
134
- if (!preflight.ok) {
135
- breaker.clearTrial();
136
- breaker.recordFailure(entry.halfOpen);
137
- return createResult(false, {
138
- stderr: appendWarnings('', preflight.warnings),
139
- fellBack: opts.fallbackToClaude !== false,
140
- failureMode: 'crash',
141
- });
142
- }
143
-
144
- const attempts = buildAttempts(opts, preflight);
145
- let attemptIndex = 0;
146
- let lastResult = createResult(false);
147
-
148
- try {
149
- lastResult = await withRetry(async () => {
150
- const result = await runGemini(opts.prompt || '', opts.workdir || process.cwd(), preflight, attempts[attemptIndex]);
151
- const current = { ...result, stderr: appendWarnings(result.stderr, preflight.warnings), retried: attemptIndex > 0 };
152
- const canRetry = !current.ok && attemptIndex < attempts.length - 1;
153
- attemptIndex += 1;
154
- if (!canRetry) return current;
155
- const error = new Error('retry');
156
- error.retryable = true;
157
- error.result = current;
158
- throw error;
159
- }, {
160
- maxAttempts: attempts.length,
161
- baseDelayMs: 250,
162
- maxDelayMs: 750,
163
- shouldRetry: (error) => error?.retryable === true,
164
- });
165
- } catch (error) {
166
- lastResult = error?.result || createResult(false, { stderr: String(error?.message || error) });
167
- }
168
-
169
- if (lastResult.ok) {
170
- breaker.reset();
171
- return lastResult;
172
- }
173
-
174
- breaker.recordFailure(entry.halfOpen);
175
- return {
176
- ...lastResult,
177
- retried: attempts.length > 1,
178
- fellBack: opts.fallbackToClaude !== false,
179
- };
154
+ export function execute(opts = {}) {
155
+ return executeWithCircuitBroker({
156
+ provider: "gemini",
157
+ runFn: runGemini,
158
+ preflightFn: (o) => runPreflight({ mcpServers: o.mcpServers }),
159
+ buildAttemptsFn: buildAttempts,
160
+ opts,
161
+ });
180
162
  }
package/hub/hitl.mjs CHANGED
@@ -1,143 +1,202 @@
1
- // hub/hitl.mjs — Human-in-the-Loop 매니저
2
- // 사용자 입력 요청/응답, 타임아웃 자동 처리
3
-
4
- /**
5
- * HITL 매니저 생성
6
- * @param {object} store — createStore() 반환 객체
7
- * @param {object} router — createRouter() 반환 객체
8
- */
9
- export function createHitlManager(store, router = null) {
10
- function forwardHumanResponse({ requesterAgent, requestId, action, content, submittedBy, correlationId, traceId, priority }) {
11
- if (!router?.handlePublish) {
12
- throw new Error('router.handlePublish is required for HITL forwarding');
13
- }
14
- return router.handlePublish({
15
- from: 'hub:hitl',
16
- to: requesterAgent,
17
- topic: 'human.response',
18
- priority,
19
- ttl_ms: 300000,
20
- payload: { request_id: requestId, action, content, submitted_by: submittedBy },
21
- correlation_id: correlationId,
22
- trace_id: traceId,
23
- message_type: 'human_response',
24
- });
25
- }
26
-
27
- return {
28
- /**
29
- * 사용자에게 입력 요청 생성
30
- * 터미널에 알림 출력 후 pending 상태로 저장
31
- */
32
- requestHumanInput({
33
- requester_agent, kind, prompt, requested_schema = {},
34
- deadline_ms, default_action, channel_preference = 'terminal',
35
- correlation_id, trace_id,
36
- }) {
37
- const result = store.insertHumanRequest({
38
- requester_agent, kind, prompt, requested_schema,
39
- deadline_ms, default_action,
40
- correlation_id, trace_id,
41
- });
42
-
43
- // 터미널 알림 (stderr — stdout은 MCP 용)
44
- const kindLabel = { captcha: 'CAPTCHA', approval: '승인', credential: '자격증명', choice: '선택', text: '텍스트' };
45
- process.stderr.write(
46
- `\n[tfx-hub] 사용자 입력 요청 (${kindLabel[kind] || kind})\n` +
47
- ` 요청자: ${requester_agent}\n` +
48
- ` 내용: ${prompt}\n` +
49
- ` ID: ${result.request_id}\n` +
50
- ` 제한: ${Math.round(deadline_ms / 1000)}초\n\n`,
51
- );
52
-
53
- return { ok: true, data: result };
54
- },
55
-
56
- /**
57
- * 사용자 입력 응답 제출
58
- * 유효성 검증 → 상태 업데이트 → 요청자에게 응답 메시지 전달
59
- */
60
- submitHumanInput({ request_id, action, content = null, submitted_by = 'human' }) {
61
- // 요청 조회
62
- const hr = store.getHumanRequest(request_id);
63
- if (!hr) {
64
- return { ok: false, error: { code: 'NOT_FOUND', message: `요청 없음: ${request_id}` } };
65
- }
66
- if (hr.state !== 'pending') {
67
- return { ok: false, error: { code: 'ALREADY_HANDLED', message: `이미 처리됨: ${hr.state}` } };
68
- }
69
-
70
- // 상태 매핑
71
- const stateMap = { accept: 'accepted', decline: 'declined', cancel: 'cancelled' };
72
- const newState = stateMap[action];
73
- if (!newState) {
74
- return { ok: false, error: { code: 'INVALID_ACTION', message: `잘못된 action: ${action}` } };
75
- }
76
-
77
- // DB 업데이트
78
- store.updateHumanRequest(request_id, newState, content);
79
-
80
- // 요청자에게 응답 메시지 전달
81
- let forwardedMessageId = null;
82
- if (action === 'accept' || action === 'decline') {
83
- const published = forwardHumanResponse({
84
- requesterAgent: hr.requester_agent,
85
- requestId: request_id,
86
- action,
87
- content,
88
- submittedBy: submitted_by,
89
- correlationId: hr.correlation_id,
90
- traceId: hr.trace_id,
91
- priority: 7,
92
- });
93
- forwardedMessageId = published.data?.message_id || null;
94
- }
95
-
96
- return {
97
- ok: true,
98
- data: { request_id, new_state: newState, forwarded_message_id: forwardedMessageId },
99
- };
100
- },
101
-
102
- /**
103
- * 만료된 요청 자동 처리
104
- * deadline 초과 시 default_action 적용
105
- */
106
- checkTimeouts() {
107
- const pending = store.getPendingHumanRequests();
108
- const now = Date.now();
109
- const expired = pending.filter(hr => hr.deadline_ms <= now);
110
- if (!expired.length) return 0;
111
-
112
- const expireRequests = () => {
113
- for (const hr of expired) {
114
- store.updateHumanRequest(hr.request_id, 'timed_out', null);
115
- if (hr.default_action === 'timeout_continue') {
116
- forwardHumanResponse({
117
- requesterAgent: hr.requester_agent,
118
- requestId: hr.request_id,
119
- action: 'timeout_continue',
120
- content: null,
121
- submittedBy: 'system',
122
- correlationId: hr.correlation_id,
123
- traceId: hr.trace_id,
124
- priority: 5,
125
- });
126
- }
127
- }
128
- return expired.length;
129
- };
130
-
131
- const processExpired = store.db?.transaction
132
- ? store.db.transaction(expireRequests)
133
- : expireRequests;
134
-
135
- return processExpired();
136
- },
137
-
138
- /** 대기 중인 요청 목록 */
139
- getPendingRequests() {
140
- return store.getPendingHumanRequests();
141
- },
142
- };
143
- }
1
+ // hub/hitl.mjs — Human-in-the-Loop 매니저
2
+ // 사용자 입력 요청/응답, 타임아웃 자동 처리
3
+
4
+ /**
5
+ * HITL 매니저 생성
6
+ * @param {object} store — createStore() 반환 객체
7
+ * @param {object} router — createRouter() 반환 객체
8
+ */
9
+ export function createHitlManager(store, router = null) {
10
+ function forwardHumanResponse({
11
+ requesterAgent,
12
+ requestId,
13
+ action,
14
+ content,
15
+ submittedBy,
16
+ correlationId,
17
+ traceId,
18
+ priority,
19
+ }) {
20
+ if (!router?.handlePublish) {
21
+ throw new Error("router.handlePublish is required for HITL forwarding");
22
+ }
23
+ return router.handlePublish({
24
+ from: "hub:hitl",
25
+ to: requesterAgent,
26
+ topic: "human.response",
27
+ priority,
28
+ ttl_ms: 300000,
29
+ payload: {
30
+ request_id: requestId,
31
+ action,
32
+ content,
33
+ submitted_by: submittedBy,
34
+ },
35
+ correlation_id: correlationId,
36
+ trace_id: traceId,
37
+ message_type: "human_response",
38
+ });
39
+ }
40
+
41
+ return {
42
+ /**
43
+ * 사용자에게 입력 요청 생성
44
+ * 터미널에 알림 출력 후 pending 상태로 저장
45
+ */
46
+ requestHumanInput({
47
+ requester_agent,
48
+ kind,
49
+ prompt,
50
+ requested_schema = {},
51
+ deadline_ms,
52
+ default_action,
53
+ channel_preference = "terminal",
54
+ correlation_id,
55
+ trace_id,
56
+ }) {
57
+ const result = store.insertHumanRequest({
58
+ requester_agent,
59
+ kind,
60
+ prompt,
61
+ requested_schema,
62
+ deadline_ms,
63
+ default_action,
64
+ correlation_id,
65
+ trace_id,
66
+ });
67
+
68
+ // 터미널 알림 (stderr — stdout은 MCP 용)
69
+ const kindLabel = {
70
+ captcha: "CAPTCHA",
71
+ approval: "승인",
72
+ credential: "자격증명",
73
+ choice: "선택",
74
+ text: "텍스트",
75
+ };
76
+ process.stderr.write(
77
+ `\n[tfx-hub] 사용자 입력 요청 (${kindLabel[kind] || kind})\n` +
78
+ ` 요청자: ${requester_agent}\n` +
79
+ ` 내용: ${prompt}\n` +
80
+ ` ID: ${result.request_id}\n` +
81
+ ` 제한: ${Math.round(deadline_ms / 1000)}초\n\n`,
82
+ );
83
+
84
+ return { ok: true, data: result };
85
+ },
86
+
87
+ /**
88
+ * 사용자 입력 응답 제출
89
+ * 유효성 검증 → 상태 업데이트 → 요청자에게 응답 메시지 전달
90
+ */
91
+ submitHumanInput({
92
+ request_id,
93
+ action,
94
+ content = null,
95
+ submitted_by = "human",
96
+ }) {
97
+ // 요청 조회
98
+ const hr = store.getHumanRequest(request_id);
99
+ if (!hr) {
100
+ return {
101
+ ok: false,
102
+ error: { code: "NOT_FOUND", message: `요청 없음: ${request_id}` },
103
+ };
104
+ }
105
+ if (hr.state !== "pending") {
106
+ return {
107
+ ok: false,
108
+ error: {
109
+ code: "ALREADY_HANDLED",
110
+ message: `이미 처리됨: ${hr.state}`,
111
+ },
112
+ };
113
+ }
114
+
115
+ // 상태 매핑
116
+ const stateMap = {
117
+ accept: "accepted",
118
+ decline: "declined",
119
+ cancel: "cancelled",
120
+ };
121
+ const newState = stateMap[action];
122
+ if (!newState) {
123
+ return {
124
+ ok: false,
125
+ error: {
126
+ code: "INVALID_ACTION",
127
+ message: `잘못된 action: ${action}`,
128
+ },
129
+ };
130
+ }
131
+
132
+ // DB 업데이트
133
+ store.updateHumanRequest(request_id, newState, content);
134
+
135
+ // 요청자에게 응답 메시지 전달
136
+ let forwardedMessageId = null;
137
+ if (action === "accept" || action === "decline") {
138
+ const published = forwardHumanResponse({
139
+ requesterAgent: hr.requester_agent,
140
+ requestId: request_id,
141
+ action,
142
+ content,
143
+ submittedBy: submitted_by,
144
+ correlationId: hr.correlation_id,
145
+ traceId: hr.trace_id,
146
+ priority: 7,
147
+ });
148
+ forwardedMessageId = published.data?.message_id || null;
149
+ }
150
+
151
+ return {
152
+ ok: true,
153
+ data: {
154
+ request_id,
155
+ new_state: newState,
156
+ forwarded_message_id: forwardedMessageId,
157
+ },
158
+ };
159
+ },
160
+
161
+ /**
162
+ * 만료된 요청 자동 처리
163
+ * deadline 초과 시 default_action 적용
164
+ */
165
+ checkTimeouts() {
166
+ const pending = store.getPendingHumanRequests();
167
+ const now = Date.now();
168
+ const expired = pending.filter((hr) => hr.deadline_ms <= now);
169
+ if (!expired.length) return 0;
170
+
171
+ const expireRequests = () => {
172
+ for (const hr of expired) {
173
+ store.updateHumanRequest(hr.request_id, "timed_out", null);
174
+ if (hr.default_action === "timeout_continue") {
175
+ forwardHumanResponse({
176
+ requesterAgent: hr.requester_agent,
177
+ requestId: hr.request_id,
178
+ action: "timeout_continue",
179
+ content: null,
180
+ submittedBy: "system",
181
+ correlationId: hr.correlation_id,
182
+ traceId: hr.trace_id,
183
+ priority: 5,
184
+ });
185
+ }
186
+ }
187
+ return expired.length;
188
+ };
189
+
190
+ const processExpired = store.db?.transaction
191
+ ? store.db.transaction(expireRequests)
192
+ : expireRequests;
193
+
194
+ return processExpired();
195
+ },
196
+
197
+ /** 대기 중인 요청 목록 */
198
+ getPendingRequests() {
199
+ return store.getPendingHumanRequests();
200
+ },
201
+ };
202
+ }