triflux 3.3.0-dev.3 → 3.3.0-dev.6

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/hub/pipe.mjs CHANGED
@@ -5,6 +5,19 @@ import net from 'node:net';
5
5
  import { existsSync, unlinkSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { randomUUID } from 'node:crypto';
8
+ import {
9
+ teamInfo,
10
+ teamTaskList,
11
+ teamTaskUpdate,
12
+ teamSendMessage,
13
+ } from './team/nativeProxy.mjs';
14
+ import { createPipeline } from './pipeline/index.mjs';
15
+ import {
16
+ ensurePipelineTable,
17
+ initPipelineState,
18
+ listPipelineStates,
19
+ readPipelineState,
20
+ } from './pipeline/state.mjs';
8
21
 
9
22
  const DEFAULT_HEARTBEAT_TTL_MS = 60000;
10
23
 
@@ -236,6 +249,41 @@ export function createPipeServer({
236
249
  };
237
250
  }
238
251
 
252
+ case 'team_task_update': {
253
+ const result = await teamTaskUpdate(payload);
254
+ if (client) touchClient(client);
255
+ return result;
256
+ }
257
+
258
+ case 'team_send_message': {
259
+ const result = await teamSendMessage(payload);
260
+ if (client) touchClient(client);
261
+ return result;
262
+ }
263
+
264
+ case 'pipeline_advance': {
265
+ if (client) touchClient(client);
266
+ if (!store?.db) {
267
+ return { ok: false, error: 'hub_db_not_found' };
268
+ }
269
+ ensurePipelineTable(store.db);
270
+ const pipeline = createPipeline(store.db, payload.team_name);
271
+ return pipeline.advance(payload.phase);
272
+ }
273
+
274
+ case 'pipeline_init': {
275
+ if (client) touchClient(client);
276
+ if (!store?.db) {
277
+ return { ok: false, error: 'hub_db_not_found' };
278
+ }
279
+ ensurePipelineTable(store.db);
280
+ const state = initPipelineState(store.db, payload.team_name, {
281
+ fix_max: payload.fix_max,
282
+ ralph_max: payload.ralph_max,
283
+ });
284
+ return { ok: true, data: state };
285
+ }
286
+
239
287
  default:
240
288
  return {
241
289
  ok: false,
@@ -305,6 +353,39 @@ export function createPipeServer({
305
353
  return router.getAssignStatus(payload);
306
354
  }
307
355
 
356
+ case 'team_info': {
357
+ const result = await teamInfo(payload);
358
+ if (client) touchClient(client);
359
+ return result;
360
+ }
361
+
362
+ case 'team_task_list': {
363
+ const result = await teamTaskList(payload);
364
+ if (client) touchClient(client);
365
+ return result;
366
+ }
367
+
368
+ case 'pipeline_state': {
369
+ if (client) touchClient(client);
370
+ if (!store?.db) {
371
+ return { ok: false, error: 'hub_db_not_found' };
372
+ }
373
+ ensurePipelineTable(store.db);
374
+ const state = readPipelineState(store.db, payload.team_name);
375
+ return state
376
+ ? { ok: true, data: state }
377
+ : { ok: false, error: 'pipeline_not_found' };
378
+ }
379
+
380
+ case 'pipeline_list': {
381
+ if (client) touchClient(client);
382
+ if (!store?.db) {
383
+ return { ok: false, error: 'hub_db_not_found' };
384
+ }
385
+ ensurePipelineTable(store.db);
386
+ return { ok: true, data: listPipelineStates(store.db) };
387
+ }
388
+
308
389
  default:
309
390
  return {
310
391
  ok: false,
package/hub/server.mjs CHANGED
@@ -13,22 +13,13 @@ import { createStore } from './store.mjs';
13
13
  import { createRouter } from './router.mjs';
14
14
  import { createHitlManager } from './hitl.mjs';
15
15
  import { createPipeServer } from './pipe.mjs';
16
+ import { createAssignCallbackServer } from './assign-callbacks.mjs';
16
17
  import { createTools } from './tools.mjs';
17
- import {
18
- ensurePipelineTable,
19
- createPipeline,
20
- } from './pipeline/index.mjs';
21
- import {
22
- readPipelineState,
23
- initPipelineState,
24
- listPipelineStates,
25
- } from './pipeline/state.mjs';
26
- import {
27
- teamInfo,
28
- teamTaskList,
29
- teamTaskUpdate,
30
- teamSendMessage,
31
- } from './team/nativeProxy.mjs';
18
+
19
+ const MAX_BODY_SIZE = 1024 * 1024;
20
+ const PUBLIC_PATHS = new Set(['/', '/status', '/health', '/healthz']);
21
+ const LOOPBACK_REMOTE_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
22
+ const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
32
23
 
33
24
  function isInitializeRequest(body) {
34
25
  if (body?.method === 'initialize') return true;
@@ -36,7 +27,6 @@ function isInitializeRequest(body) {
36
27
  return false;
37
28
  }
38
29
 
39
- const MAX_BODY_SIZE = 1024 * 1024;
40
30
  async function parseBody(req) {
41
31
  const chunks = [];
42
32
  let size = 0;
@@ -54,13 +44,72 @@ const PID_DIR = join(homedir(), '.claude', 'cache', 'tfx-hub');
54
44
  const PID_FILE = join(PID_DIR, 'hub.pid');
55
45
  const TOKEN_FILE = join(homedir(), '.claude', '.tfx-hub-token');
56
46
 
57
- // localhost 계열 Origin만 허용
58
- const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
59
-
60
47
  function isAllowedOrigin(origin) {
61
48
  return origin && ALLOWED_ORIGIN_RE.test(origin);
62
49
  }
63
50
 
51
+ function getRequestPath(url = '/') {
52
+ try {
53
+ return new URL(url, 'http://127.0.0.1').pathname;
54
+ } catch {
55
+ return String(url).replace(/\?.*/, '') || '/';
56
+ }
57
+ }
58
+
59
+ function isLoopbackRemoteAddress(remoteAddress) {
60
+ return typeof remoteAddress === 'string' && LOOPBACK_REMOTE_ADDRESSES.has(remoteAddress);
61
+ }
62
+
63
+ function extractBearerToken(req) {
64
+ const authHeader = typeof req.headers.authorization === 'string' ? req.headers.authorization : '';
65
+ return authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
66
+ }
67
+
68
+ function writeJson(res, statusCode, body, headers = {}) {
69
+ res.writeHead(statusCode, {
70
+ 'Content-Type': 'application/json',
71
+ ...headers,
72
+ });
73
+ res.end(JSON.stringify(body));
74
+ }
75
+
76
+ function applyCorsHeaders(req, res) {
77
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : '';
78
+ if (origin) {
79
+ res.setHeader('Vary', 'Origin');
80
+ }
81
+ if (!isAllowedOrigin(origin)) return false;
82
+
83
+ res.setHeader('Access-Control-Allow-Origin', origin);
84
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
85
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id, Last-Event-ID');
86
+ return true;
87
+ }
88
+
89
+ function isAuthorizedRequest(req, path, hubToken) {
90
+ if (!hubToken) {
91
+ return isLoopbackRemoteAddress(req.socket.remoteAddress);
92
+ }
93
+ if (PUBLIC_PATHS.has(path)) return true;
94
+ return extractBearerToken(req) === hubToken;
95
+ }
96
+
97
+ function resolveTeamStatusCode(result) {
98
+ if (result?.ok) return 200;
99
+ const code = result?.error?.code;
100
+ if (code === 'TEAM_NOT_FOUND' || code === 'TASK_NOT_FOUND' || code === 'TASKS_DIR_NOT_FOUND') return 404;
101
+ if (code === 'CLAIM_CONFLICT' || code === 'MTIME_CONFLICT') return 409;
102
+ if (code === 'INVALID_TEAM_NAME' || code === 'INVALID_TASK_ID' || code === 'INVALID_TEXT' || code === 'INVALID_FROM' || code === 'INVALID_STATUS') return 400;
103
+ return 500;
104
+ }
105
+
106
+ function resolvePipelineStatusCode(result) {
107
+ if (result?.ok) return 200;
108
+ if (result?.error === 'pipeline_not_found') return 404;
109
+ if (result?.error === 'hub_db_not_found') return 503;
110
+ return 400;
111
+ }
112
+
64
113
  /**
65
114
  * tfx-hub 시작
66
115
  * @param {object} opts
@@ -74,14 +123,18 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
74
123
  dbPath = join(PID_DIR, 'state.db');
75
124
  }
76
125
 
77
- // 인증 토큰 생성 (환경변수 우선, 없으면 자동 생성)
78
- const HUB_TOKEN = process.env.TFX_HUB_TOKEN || randomUUID();
79
- mkdirSync(join(homedir(), '.claude'), { recursive: true });
80
- writeFileSync(TOKEN_FILE, HUB_TOKEN, { mode: 0o600 });
126
+ const HUB_TOKEN = process.env.TFX_HUB_TOKEN?.trim() || null;
127
+ if (HUB_TOKEN) {
128
+ mkdirSync(join(homedir(), '.claude'), { recursive: true });
129
+ writeFileSync(TOKEN_FILE, HUB_TOKEN, { mode: 0o600 });
130
+ } else {
131
+ try { unlinkSync(TOKEN_FILE); } catch {}
132
+ }
81
133
 
82
134
  const store = createStore(dbPath);
83
135
  const router = createRouter(store);
84
136
  const pipe = createPipeServer({ router, store, sessionId });
137
+ const assignCallbacks = createAssignCallbackServer({ store, sessionId });
85
138
  const hitl = createHitlManager(store, router);
86
139
  const tools = createTools(store, router, hitl, pipe);
87
140
  const transports = new Map();
@@ -122,64 +175,61 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
122
175
  }
123
176
 
124
177
  const httpServer = createHttpServer(async (req, res) => {
125
- // CORS: localhost 계열 Origin만 허용
126
- const origin = req.headers['origin'];
127
- if (isAllowedOrigin(origin)) {
128
- res.setHeader('Access-Control-Allow-Origin', origin);
129
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
130
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id, Last-Event-ID');
131
- }
178
+ const path = getRequestPath(req.url);
179
+ const corsAllowed = applyCorsHeaders(req, res);
132
180
 
133
181
  if (req.method === 'OPTIONS') {
134
- res.writeHead(isAllowedOrigin(origin) ? 204 : 403);
182
+ const localOnlyMode = !HUB_TOKEN;
183
+ const isLoopbackRequest = isLoopbackRemoteAddress(req.socket.remoteAddress);
184
+ res.writeHead(corsAllowed && (!localOnlyMode || isLoopbackRequest) ? 204 : 403);
135
185
  return res.end();
136
186
  }
137
187
 
138
- if (req.url === '/' || req.url === '/status') {
188
+ if (!isAuthorizedRequest(req, path, HUB_TOKEN)) {
189
+ if (!HUB_TOKEN) {
190
+ return writeJson(res, 403, { ok: false, error: 'Forbidden: localhost only' });
191
+ }
192
+ return writeJson(
193
+ res,
194
+ 401,
195
+ { ok: false, error: 'Unauthorized' },
196
+ { 'WWW-Authenticate': 'Bearer realm="tfx-hub"' },
197
+ );
198
+ }
199
+
200
+ if (path === '/' || path === '/status') {
139
201
  const status = router.getStatus('hub').data;
140
- res.writeHead(200, { 'Content-Type': 'application/json' });
141
- return res.end(JSON.stringify({
202
+ return writeJson(res, 200, {
142
203
  ...status,
143
204
  sessions: transports.size,
144
205
  pid: process.pid,
145
206
  port,
207
+ auth_mode: HUB_TOKEN ? 'token-required' : 'localhost-only',
146
208
  pipe_path: pipe.path,
147
209
  pipe: pipe.getStatus(),
148
- }));
210
+ assign_callback_pipe_path: assignCallbacks.path,
211
+ assign_callback_pipe: assignCallbacks.getStatus(),
212
+ });
149
213
  }
150
214
 
151
- if (req.url === '/health' || req.url === '/healthz') {
215
+ if (path === '/health' || path === '/healthz') {
152
216
  const status = router.getStatus('hub').data;
153
217
  const healthy = status?.hub?.state === 'healthy';
154
- res.writeHead(healthy ? 200 : 503, { 'Content-Type': 'application/json' });
155
- return res.end(JSON.stringify({ ok: healthy }));
218
+ return writeJson(res, healthy ? 200 : 503, { ok: healthy });
156
219
  }
157
220
 
158
- if (req.url.startsWith('/bridge')) {
159
- res.setHeader('Content-Type', 'application/json');
160
-
161
- // Bearer 토큰 인증
162
- const authHeader = req.headers['authorization'] || '';
163
- const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
164
- if (bearerToken !== HUB_TOKEN) {
165
- res.writeHead(401);
166
- return res.end(JSON.stringify({ ok: false, error: 'Unauthorized' }));
167
- }
168
-
221
+ if (path.startsWith('/bridge')) {
169
222
  if (req.method !== 'POST' && req.method !== 'DELETE') {
170
- res.writeHead(405);
171
- return res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed' }));
223
+ return writeJson(res, 405, { ok: false, error: 'Method Not Allowed' });
172
224
  }
173
225
 
174
226
  try {
175
227
  const body = req.method === 'POST' ? await parseBody(req) : {};
176
- const path = req.url.replace(/\?.*/, '');
177
228
 
178
229
  if (path === '/bridge/register' && req.method === 'POST') {
179
230
  const { agent_id, cli, timeout_sec = 600, topics = [], capabilities = [], metadata = {} } = body;
180
231
  if (!agent_id || !cli) {
181
- res.writeHead(400);
182
- return res.end(JSON.stringify({ ok: false, error: 'agent_id, cli 필수' }));
232
+ return writeJson(res, 400, { ok: false, error: 'agent_id, cli 필수' });
183
233
  }
184
234
 
185
235
  const heartbeat_ttl_ms = (timeout_sec + 120) * 1000;
@@ -191,15 +241,13 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
191
241
  heartbeat_ttl_ms,
192
242
  metadata,
193
243
  });
194
- res.writeHead(200);
195
- return res.end(JSON.stringify(result));
244
+ return writeJson(res, 200, result);
196
245
  }
197
246
 
198
247
  if (path === '/bridge/result' && req.method === 'POST') {
199
248
  const { agent_id, topic = 'task.result', payload = {}, trace_id, correlation_id } = body;
200
249
  if (!agent_id) {
201
- res.writeHead(400);
202
- return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
250
+ return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
203
251
  }
204
252
 
205
253
  const result = await pipe.executeCommand('result', {
@@ -209,8 +257,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
209
257
  trace_id,
210
258
  correlation_id,
211
259
  });
212
- res.writeHead(200);
213
- return res.end(JSON.stringify(result));
260
+ return writeJson(res, 200, result);
214
261
  }
215
262
 
216
263
  if (path === '/bridge/control' && req.method === 'POST') {
@@ -226,8 +273,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
226
273
  } = body;
227
274
 
228
275
  if (!to_agent || !command) {
229
- res.writeHead(400);
230
- return res.end(JSON.stringify({ ok: false, error: 'to_agent, command 필수' }));
276
+ return writeJson(res, 400, { ok: false, error: 'to_agent, command 필수' });
231
277
  }
232
278
 
233
279
  const result = await pipe.executeCommand('control', {
@@ -241,8 +287,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
241
287
  correlation_id,
242
288
  });
243
289
 
244
- res.writeHead(200);
245
- return res.end(JSON.stringify(result));
290
+ return writeJson(res, 200, result);
246
291
  }
247
292
 
248
293
  if (path === '/bridge/assign/async' && req.method === 'POST') {
@@ -261,8 +306,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
261
306
  } = body;
262
307
 
263
308
  if (!supervisor_agent || !worker_agent || !task) {
264
- res.writeHead(400);
265
- return res.end(JSON.stringify({ ok: false, error: 'supervisor_agent, worker_agent, task 필수' }));
309
+ return writeJson(res, 400, { ok: false, error: 'supervisor_agent, worker_agent, task 필수' });
266
310
  }
267
311
 
268
312
  const result = await pipe.executeCommand('assign', {
@@ -278,8 +322,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
278
322
  trace_id,
279
323
  correlation_id,
280
324
  });
281
- res.writeHead(result.ok ? 200 : 400);
282
- return res.end(JSON.stringify(result));
325
+ return writeJson(res, result.ok ? 200 : 400, result);
283
326
  }
284
327
 
285
328
  if (path === '/bridge/assign/result' && req.method === 'POST') {
@@ -295,8 +338,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
295
338
  } = body;
296
339
 
297
340
  if (!job_id || !status) {
298
- res.writeHead(400);
299
- return res.end(JSON.stringify({ ok: false, error: 'job_id, status 필수' }));
341
+ return writeJson(res, 400, { ok: false, error: 'job_id, status 필수' });
300
342
  }
301
343
 
302
344
  const result = await pipe.executeCommand('assign_result', {
@@ -309,22 +351,19 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
309
351
  payload,
310
352
  metadata,
311
353
  });
312
- res.writeHead(result.ok ? 200 : 409);
313
- return res.end(JSON.stringify(result));
354
+ return writeJson(res, result.ok ? 200 : 409, result);
314
355
  }
315
356
 
316
357
  if (path === '/bridge/assign/status' && req.method === 'POST') {
317
358
  const result = await pipe.executeQuery('assign_status', body);
318
359
  const statusCode = result.ok ? 200 : (result.error?.code === 'ASSIGN_NOT_FOUND' ? 404 : 400);
319
- res.writeHead(statusCode);
320
- return res.end(JSON.stringify(result));
360
+ return writeJson(res, statusCode, result);
321
361
  }
322
362
 
323
363
  if (path === '/bridge/assign/retry' && req.method === 'POST') {
324
364
  const { job_id, reason, requested_by } = body;
325
365
  if (!job_id) {
326
- res.writeHead(400);
327
- return res.end(JSON.stringify({ ok: false, error: 'job_id 필수' }));
366
+ return writeJson(res, 400, { ok: false, error: 'job_id 필수' });
328
367
  }
329
368
 
330
369
  const result = await pipe.executeCommand('assign_retry', {
@@ -336,115 +375,85 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
336
375
  : result.error?.code === 'ASSIGN_NOT_FOUND' ? 404
337
376
  : result.error?.code === 'ASSIGN_RETRY_EXHAUSTED' ? 409
338
377
  : 400;
339
- res.writeHead(statusCode);
340
- return res.end(JSON.stringify(result));
378
+ return writeJson(res, statusCode, result);
341
379
  }
342
380
 
343
381
  if (req.method === 'POST') {
344
382
  let teamResult = null;
345
383
  if (path === '/bridge/team/info' || path === '/bridge/team-info') {
346
- teamResult = await teamInfo(body);
384
+ teamResult = await pipe.executeQuery('team_info', body);
347
385
  } else if (path === '/bridge/team/task-list' || path === '/bridge/team-task-list') {
348
- teamResult = await teamTaskList(body);
386
+ teamResult = await pipe.executeQuery('team_task_list', body);
349
387
  } else if (path === '/bridge/team/task-update' || path === '/bridge/team-task-update') {
350
- teamResult = await teamTaskUpdate(body);
388
+ teamResult = await pipe.executeCommand('team_task_update', body);
351
389
  } else if (path === '/bridge/team/send-message' || path === '/bridge/team-send-message') {
352
- teamResult = await teamSendMessage(body);
390
+ teamResult = await pipe.executeCommand('team_send_message', body);
353
391
  }
354
392
 
355
393
  if (teamResult) {
356
- let status = 200;
357
- const code = teamResult?.error?.code;
358
- if (!teamResult.ok) {
359
- if (code === 'TEAM_NOT_FOUND' || code === 'TASK_NOT_FOUND' || code === 'TASKS_DIR_NOT_FOUND') status = 404;
360
- else if (code === 'CLAIM_CONFLICT' || code === 'MTIME_CONFLICT') status = 409;
361
- else if (code === 'INVALID_TEAM_NAME' || code === 'INVALID_TASK_ID' || code === 'INVALID_TEXT' || code === 'INVALID_FROM' || code === 'INVALID_STATUS') status = 400;
362
- else status = 500;
363
- }
364
- res.writeHead(status);
365
- return res.end(JSON.stringify(teamResult));
394
+ return writeJson(res, resolveTeamStatusCode(teamResult), teamResult);
366
395
  }
367
396
 
368
397
  if (path.startsWith('/bridge/team')) {
369
- res.writeHead(404);
370
- return res.end(JSON.stringify({ ok: false, error: `Unknown team endpoint: ${path}` }));
398
+ return writeJson(res, 404, { ok: false, error: `Unknown team endpoint: ${path}` });
371
399
  }
372
400
 
373
401
  // ── 파이프라인 엔드포인트 ──
374
402
  if (path === '/bridge/pipeline/state' && req.method === 'POST') {
375
- ensurePipelineTable(store.db);
376
- const { team_name } = body;
377
- const state = readPipelineState(store.db, team_name);
378
- res.writeHead(state ? 200 : 404);
379
- return res.end(JSON.stringify(state
380
- ? { ok: true, data: state }
381
- : { ok: false, error: 'pipeline_not_found' }));
403
+ const result = await pipe.executeQuery('pipeline_state', body);
404
+ return writeJson(res, resolvePipelineStatusCode(result), result);
382
405
  }
383
406
 
384
407
  if (path === '/bridge/pipeline/advance' && req.method === 'POST') {
385
- ensurePipelineTable(store.db);
386
- const { team_name, phase } = body;
387
- const pipeline = createPipeline(store.db, team_name);
388
- const result = pipeline.advance(phase);
389
- res.writeHead(result.ok ? 200 : 400);
390
- return res.end(JSON.stringify(result));
408
+ const result = await pipe.executeCommand('pipeline_advance', body);
409
+ return writeJson(res, resolvePipelineStatusCode(result), result);
391
410
  }
392
411
 
393
412
  if (path === '/bridge/pipeline/init' && req.method === 'POST') {
394
- ensurePipelineTable(store.db);
395
- const { team_name, fix_max, ralph_max } = body;
396
- const state = initPipelineState(store.db, team_name, { fix_max, ralph_max });
397
- res.writeHead(200);
398
- return res.end(JSON.stringify({ ok: true, data: state }));
413
+ const result = await pipe.executeCommand('pipeline_init', body);
414
+ return writeJson(res, resolvePipelineStatusCode(result), result);
399
415
  }
400
416
 
401
417
  if (path === '/bridge/pipeline/list' && req.method === 'POST') {
402
- ensurePipelineTable(store.db);
403
- const states = listPipelineStates(store.db);
404
- res.writeHead(200);
405
- return res.end(JSON.stringify({ ok: true, data: states }));
418
+ const result = await pipe.executeQuery('pipeline_list', body);
419
+ return writeJson(res, resolvePipelineStatusCode(result), result);
406
420
  }
407
421
  }
408
422
 
409
423
  if (path === '/bridge/context' && req.method === 'POST') {
410
- const { agent_id, topics, max_messages = 10 } = body;
424
+ const { agent_id, topics, max_messages = 10, auto_ack = true } = body;
411
425
  if (!agent_id) {
412
- res.writeHead(400);
413
- return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
426
+ return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
414
427
  }
415
428
 
416
- const result = await pipe.executeQuery('context', {
429
+ const result = await pipe.executeQuery('drain', {
417
430
  agent_id,
418
431
  topics,
419
432
  max_messages,
433
+ auto_ack,
420
434
  });
421
- res.writeHead(200);
422
- return res.end(JSON.stringify(result));
435
+ return writeJson(res, 200, result);
423
436
  }
424
437
 
425
438
  if (path === '/bridge/deregister' && req.method === 'POST') {
426
439
  const { agent_id } = body;
427
440
  if (!agent_id) {
428
- res.writeHead(400);
429
- return res.end(JSON.stringify({ ok: false, error: 'agent_id 필수' }));
441
+ return writeJson(res, 400, { ok: false, error: 'agent_id 필수' });
430
442
  }
431
443
  const result = await pipe.executeCommand('deregister', { agent_id });
432
- res.writeHead(200);
433
- return res.end(JSON.stringify(result));
444
+ return writeJson(res, 200, result);
434
445
  }
435
446
 
436
- res.writeHead(404);
437
- return res.end(JSON.stringify({ ok: false, error: 'Unknown bridge endpoint' }));
447
+ return writeJson(res, 404, { ok: false, error: 'Unknown bridge endpoint' });
438
448
  } catch (error) {
439
449
  if (!res.headersSent) {
440
- res.writeHead(500);
441
- res.end(JSON.stringify({ ok: false, error: error.message }));
450
+ writeJson(res, 500, { ok: false, error: error.message });
442
451
  }
443
452
  return;
444
453
  }
445
454
  }
446
455
 
447
- if (req.url !== '/mcp') {
456
+ if (path !== '/mcp') {
448
457
  res.writeHead(404);
449
458
  return res.end('Not Found');
450
459
  }
@@ -536,6 +545,7 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
536
545
 
537
546
  mkdirSync(PID_DIR, { recursive: true });
538
547
  await pipe.start();
548
+ await assignCallbacks.start();
539
549
 
540
550
  return new Promise((resolve, reject) => {
541
551
  httpServer.listen(port, host, () => {
@@ -544,22 +554,28 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
544
554
  host,
545
555
  dbPath,
546
556
  pid: process.pid,
557
+ hubToken: HUB_TOKEN,
558
+ authMode: HUB_TOKEN ? 'token-required' : 'localhost-only',
547
559
  url: `http://${host}:${port}/mcp`,
548
560
  pipe_path: pipe.path,
549
561
  pipePath: pipe.path,
562
+ assign_callback_pipe_path: assignCallbacks.path,
563
+ assignCallbackPipePath: assignCallbacks.path,
550
564
  };
551
565
 
552
566
  writeFileSync(PID_FILE, JSON.stringify({
553
567
  pid: process.pid,
554
568
  port,
555
569
  host,
570
+ auth_mode: HUB_TOKEN ? 'token-required' : 'localhost-only',
556
571
  url: info.url,
557
572
  pipe_path: pipe.path,
558
573
  pipePath: pipe.path,
574
+ assign_callback_pipe_path: assignCallbacks.path,
559
575
  started: Date.now(),
560
576
  }));
561
577
 
562
- console.log(`[tfx-hub] MCP 서버 시작: ${info.url} / pipe ${pipe.path} (PID ${process.pid})`);
578
+ console.log(`[tfx-hub] MCP 서버 시작: ${info.url} / pipe ${pipe.path} / assign-callback ${assignCallbacks.path} (PID ${process.pid})`);
563
579
 
564
580
  const stopFn = async () => {
565
581
  router.stopSweeper();
@@ -570,13 +586,23 @@ export async function startHub({ port = 27888, dbPath, host = '127.0.0.1', sessi
570
586
  }
571
587
  transports.clear();
572
588
  await pipe.stop();
589
+ await assignCallbacks.stop();
573
590
  store.close();
574
591
  try { unlinkSync(PID_FILE); } catch {}
575
592
  try { unlinkSync(TOKEN_FILE); } catch {}
576
593
  await new Promise((resolveClose) => httpServer.close(resolveClose));
577
594
  };
578
595
 
579
- resolve({ ...info, httpServer, store, router, hitl, pipe, stop: stopFn });
596
+ resolve({
597
+ ...info,
598
+ httpServer,
599
+ store,
600
+ router,
601
+ hitl,
602
+ pipe,
603
+ assignCallbacks,
604
+ stop: stopFn,
605
+ });
580
606
  });
581
607
  httpServer.on('error', reject);
582
608
  });