triflux 3.3.0-dev.8 → 4.0.0

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 (91) hide show
  1. package/README.ko.md +108 -199
  2. package/README.md +108 -199
  3. package/bin/triflux.mjs +2415 -1762
  4. package/hooks/keyword-rules.json +361 -354
  5. package/hooks/pipeline-stop.mjs +5 -2
  6. package/hub/assign-callbacks.mjs +136 -136
  7. package/hub/bridge.mjs +734 -684
  8. package/hub/delegator/contracts.mjs +38 -38
  9. package/hub/delegator/index.mjs +14 -14
  10. package/hub/delegator/schema/delegator-tools.schema.json +250 -250
  11. package/hub/delegator/service.mjs +302 -118
  12. package/hub/delegator/tool-definitions.mjs +35 -35
  13. package/hub/hitl.mjs +67 -67
  14. package/hub/paths.mjs +28 -0
  15. package/hub/pipe.mjs +589 -561
  16. package/hub/pipeline/state.mjs +23 -0
  17. package/hub/public/dashboard.html +349 -0
  18. package/hub/public/tray-icon.ico +0 -0
  19. package/hub/public/tray-icon.png +0 -0
  20. package/hub/router.mjs +782 -782
  21. package/hub/schema.sql +40 -40
  22. package/hub/server.mjs +810 -637
  23. package/hub/store.mjs +706 -706
  24. package/hub/team/cli/commands/attach.mjs +37 -0
  25. package/hub/team/cli/commands/control.mjs +43 -0
  26. package/hub/team/cli/commands/debug.mjs +74 -0
  27. package/hub/team/cli/commands/focus.mjs +53 -0
  28. package/hub/team/cli/commands/interrupt.mjs +36 -0
  29. package/hub/team/cli/commands/kill.mjs +37 -0
  30. package/hub/team/cli/commands/list.mjs +24 -0
  31. package/hub/team/cli/commands/send.mjs +37 -0
  32. package/hub/team/cli/commands/start/index.mjs +87 -0
  33. package/hub/team/cli/commands/start/parse-args.mjs +32 -0
  34. package/hub/team/cli/commands/start/start-in-process.mjs +40 -0
  35. package/hub/team/cli/commands/start/start-mux.mjs +73 -0
  36. package/hub/team/cli/commands/start/start-wt.mjs +69 -0
  37. package/hub/team/cli/commands/status.mjs +87 -0
  38. package/hub/team/cli/commands/stop.mjs +31 -0
  39. package/hub/team/cli/commands/task.mjs +30 -0
  40. package/hub/team/cli/commands/tasks.mjs +13 -0
  41. package/hub/team/{cli.mjs → cli/help.mjs} +38 -99
  42. package/hub/team/cli/index.mjs +39 -0
  43. package/hub/team/cli/manifest.mjs +28 -0
  44. package/hub/team/cli/render.mjs +30 -0
  45. package/hub/team/cli/services/attach-fallback.mjs +54 -0
  46. package/hub/team/cli/services/hub-client.mjs +171 -0
  47. package/hub/team/cli/services/member-selector.mjs +30 -0
  48. package/hub/team/cli/services/native-control.mjs +115 -0
  49. package/hub/team/cli/services/runtime-mode.mjs +60 -0
  50. package/hub/team/cli/services/state-store.mjs +34 -0
  51. package/hub/team/cli/services/task-model.mjs +30 -0
  52. package/hub/team/native-supervisor.mjs +69 -63
  53. package/hub/team/native.mjs +367 -367
  54. package/hub/team/nativeProxy.mjs +217 -173
  55. package/hub/team/pane.mjs +149 -149
  56. package/hub/team/psmux.mjs +946 -946
  57. package/hub/team/session.mjs +608 -608
  58. package/hub/team/staleState.mjs +369 -299
  59. package/hub/tools.mjs +107 -107
  60. package/hub/tray.mjs +332 -0
  61. package/hub/workers/claude-worker.mjs +446 -446
  62. package/hub/workers/codex-mcp.mjs +414 -414
  63. package/hub/workers/delegator-mcp.mjs +1045 -1045
  64. package/hub/workers/factory.mjs +21 -21
  65. package/hub/workers/gemini-worker.mjs +349 -349
  66. package/hub/workers/interface.mjs +41 -41
  67. package/package.json +61 -60
  68. package/scripts/__tests__/keyword-detector.test.mjs +234 -234
  69. package/scripts/hub-ensure.mjs +102 -101
  70. package/scripts/keyword-detector.mjs +272 -272
  71. package/scripts/keyword-rules-expander.mjs +521 -521
  72. package/scripts/lib/keyword-rules.mjs +168 -168
  73. package/scripts/lib/mcp-filter.mjs +642 -642
  74. package/scripts/lib/mcp-server-catalog.mjs +118 -118
  75. package/scripts/mcp-check.mjs +126 -126
  76. package/scripts/preflight-cache.mjs +19 -0
  77. package/scripts/run.cjs +62 -62
  78. package/scripts/setup.mjs +68 -31
  79. package/scripts/test-tfx-route-no-claude-native.mjs +57 -57
  80. package/scripts/tfx-route-worker.mjs +161 -161
  81. package/scripts/tfx-route.sh +1360 -1326
  82. package/skills/tfx-auto/SKILL.md +196 -196
  83. package/skills/tfx-auto-codex/SKILL.md +77 -77
  84. package/skills/tfx-multi/SKILL.md +378 -378
  85. package/hub/team/cli-team-common.mjs +0 -348
  86. package/hub/team/cli-team-control.mjs +0 -393
  87. package/hub/team/cli-team-start.mjs +0 -516
  88. package/hub/team/cli-team-status.mjs +0 -283
  89. package/skills/auto-verify/SKILL.md +0 -145
  90. package/skills/manage-skills/SKILL.md +0 -192
  91. package/skills/verify-implementation/SKILL.md +0 -138
package/hub/pipe.mjs CHANGED
@@ -1,561 +1,589 @@
1
- // hub/pipe.mjs — Named Pipe/Unix socket 제어 채널
2
- // NDJSON 프로토콜로 에이전트 실시간 제어/이벤트 푸시를 처리한다.
3
-
4
- import net from 'node:net';
5
- import { existsSync, unlinkSync } from 'node:fs';
6
- import { join } from 'node:path';
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';
21
-
22
- const DEFAULT_HEARTBEAT_TTL_MS = 60000;
23
-
24
- /** 플랫폼별 pipe 경로 계산 */
25
- export function getPipePath(sessionId = process.pid) {
26
- if (process.platform === 'win32') {
27
- return `\\\\.\\pipe\\triflux-${sessionId}`;
28
- }
29
- return join('/tmp', `triflux-${sessionId}.sock`);
30
- }
31
-
32
- function safeJsonParse(line) {
33
- try {
34
- return JSON.parse(line);
35
- } catch {
36
- return null;
37
- }
38
- }
39
-
40
- function normalizeTopics(topics) {
41
- if (!Array.isArray(topics)) return [];
42
- return topics
43
- .map((topic) => String(topic || '').trim())
44
- .filter(Boolean);
45
- }
46
-
47
- /**
48
- * Named Pipe 서버 생성
49
- * @param {object} opts
50
- * @param {object} opts.router
51
- * @param {object} [opts.store]
52
- * @param {string|number} [opts.sessionId]
53
- * @param {number} [opts.heartbeatTtlMs]
54
- */
55
- export function createPipeServer({
56
- router,
57
- store = null,
58
- sessionId = process.pid,
59
- heartbeatTtlMs = DEFAULT_HEARTBEAT_TTL_MS,
60
- } = {}) {
61
- if (!router) {
62
- throw new Error('router is required');
63
- }
64
-
65
- const pipePath = getPipePath(sessionId);
66
- const clients = new Map();
67
- let server = null;
68
- let heartbeatTimer = null;
69
-
70
- function sendFrame(client, frame) {
71
- if (!client || client.closed || !client.socket.writable) return false;
72
- try {
73
- client.socket.write(`${JSON.stringify(frame)}\n`);
74
- return true;
75
- } catch {
76
- return false;
77
- }
78
- }
79
-
80
- function sendResponse(client, requestId, result) {
81
- return sendFrame(client, { type: 'response', request_id: requestId, ...result });
82
- }
83
-
84
- function closeClient(client) {
85
- if (!client || client.closed) return;
86
- client.closed = true;
87
- clients.delete(client.id);
88
- try { client.socket.destroy(); } catch {}
89
- }
90
-
91
- function touchClient(client) {
92
- client.lastHeartbeatMs = Date.now();
93
- }
94
-
95
- function resolveAgentId(client, payload) {
96
- const agentId = payload?.agent_id || client?.agentId;
97
- if (!agentId) {
98
- throw new Error('agent_id required');
99
- }
100
- return agentId;
101
- }
102
-
103
- function pushEvent(agentId, message) {
104
- let delivered = false;
105
- for (const client of clients.values()) {
106
- if (client.agentId !== agentId) continue;
107
- if (sendFrame(client, {
108
- type: 'event',
109
- event: 'message',
110
- payload: { agent_id: agentId, message },
111
- })) {
112
- delivered = true;
113
- }
114
- }
115
- return delivered;
116
- }
117
-
118
- function pushPendingMessages(agentId) {
119
- if (!agentId) return 0;
120
- const pending = router.getPendingMessages(agentId, { max_messages: 100 });
121
- let pushed = 0;
122
- for (const message of pending) {
123
- if (router.markMessagePushed(agentId, message.id)) {
124
- pushed += pushEvent(agentId, message) ? 1 : 0;
125
- } else if (pushEvent(agentId, message)) {
126
- pushed += 1;
127
- }
128
- }
129
- return pushed;
130
- }
131
-
132
- async function processCommand(client, action, payload = {}) {
133
- switch (action) {
134
- case 'register': {
135
- const result = router.registerAgent(payload);
136
- if (client) {
137
- client.agentId = payload.agent_id;
138
- client.subscriptions = new Set(router.getSubscribedTopics(client.agentId));
139
- touchClient(client);
140
- pushPendingMessages(client.agentId);
141
- }
142
- return { ok: true, data: { ...result, pipe_path: pipePath } };
143
- }
144
-
145
- case 'subscribe': {
146
- const agentId = resolveAgentId(client, payload);
147
- const topics = normalizeTopics(payload.topics);
148
- const result = router.subscribeAgent(agentId, topics, {
149
- replace: Boolean(payload.replace),
150
- });
151
- if (client) {
152
- client.agentId = agentId;
153
- client.subscriptions = new Set(result.topics);
154
- touchClient(client);
155
- }
156
- const replayed = pushPendingMessages(agentId);
157
- return {
158
- ok: true,
159
- data: { ...result, replayed_messages: replayed },
160
- };
161
- }
162
-
163
- case 'ack': {
164
- const agentId = resolveAgentId(client, payload);
165
- const acked = router.ackMessages(payload.message_ids || payload.ack_ids || [], agentId);
166
- if (client) touchClient(client);
167
- return { ok: true, data: { agent_id: agentId, acked_count: acked } };
168
- }
169
-
170
- case 'heartbeat': {
171
- const agentId = resolveAgentId(client, payload);
172
- const result = router.refreshAgentLease(agentId, payload.heartbeat_ttl_ms || heartbeatTtlMs);
173
- if (client) touchClient(client);
174
- return { ok: true, data: result };
175
- }
176
-
177
- case 'publish': {
178
- const result = router.handlePublish(payload);
179
- if (client) touchClient(client);
180
- return result;
181
- }
182
-
183
- case 'handoff': {
184
- const result = router.handleHandoff(payload);
185
- if (client) touchClient(client);
186
- return result;
187
- }
188
-
189
- case 'assign': {
190
- const result = router.assignAsync(payload);
191
- if (client) touchClient(client);
192
- return result;
193
- }
194
-
195
- case 'assign_result': {
196
- const result = router.reportAssignResult(payload);
197
- if (client) touchClient(client);
198
- return result;
199
- }
200
-
201
- case 'assign_retry': {
202
- const result = router.retryAssign(payload.job_id, payload);
203
- if (client) touchClient(client);
204
- return result;
205
- }
206
-
207
- case 'result': {
208
- const result = router.handlePublish({
209
- from: payload.agent_id,
210
- to: `topic:${payload.topic || 'task.result'}`,
211
- topic: payload.topic || 'task.result',
212
- payload: payload.payload || {},
213
- priority: 5,
214
- ttl_ms: 3600000,
215
- trace_id: payload.trace_id,
216
- correlation_id: payload.correlation_id,
217
- });
218
- if (client) touchClient(client);
219
- return result;
220
- }
221
-
222
- case 'control': {
223
- const result = router.handlePublish({
224
- from: payload.from_agent || 'lead',
225
- to: payload.to_agent,
226
- topic: 'lead.control',
227
- payload: {
228
- command: payload.command,
229
- reason: payload.reason || '',
230
- ...(payload.payload || {}),
231
- issued_at: Date.now(),
232
- },
233
- priority: 8,
234
- ttl_ms: Math.max(1000, Math.min(Number(payload.ttl_ms) || 3600000, 86400000)),
235
- trace_id: payload.trace_id,
236
- correlation_id: payload.correlation_id,
237
- });
238
- if (client) touchClient(client);
239
- return result;
240
- }
241
-
242
- case 'deregister': {
243
- const agentId = resolveAgentId(client, payload);
244
- router.updateAgentStatus(agentId, 'offline');
245
- if (client) touchClient(client);
246
- return {
247
- ok: true,
248
- data: { agent_id: agentId, status: 'offline' },
249
- };
250
- }
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
-
287
- default:
288
- return {
289
- ok: false,
290
- error: { code: 'UNKNOWN_PIPE_COMMAND', message: `지원하지 않는 command: ${action}` },
291
- };
292
- }
293
- }
294
-
295
- function buildReplayMessages(agentId, payload = {}) {
296
- const maxMessages = Math.max(1, Math.min(Number(payload.max_messages) || 20, 100));
297
- const pending = router.getPendingMessages(agentId, {
298
- max_messages: maxMessages,
299
- include_topics: payload.topics,
300
- });
301
- if (!store?.getAuditMessagesForAgent) {
302
- return pending.slice(0, maxMessages);
303
- }
304
-
305
- const audit = store.getAuditMessagesForAgent(agentId, {
306
- max_messages: maxMessages,
307
- include_topics: payload.topics,
308
- });
309
- const byId = new Map();
310
- for (const message of [...pending, ...audit]) {
311
- if (!message?.id || byId.has(message.id)) continue;
312
- byId.set(message.id, message);
313
- }
314
- return Array.from(byId.values())
315
- .sort((left, right) => right.created_at_ms - left.created_at_ms)
316
- .slice(0, maxMessages);
317
- }
318
-
319
- async function processQuery(client, action, payload = {}) {
320
- switch (action) {
321
- case 'drain': {
322
- const agentId = resolveAgentId(client, payload);
323
- const messages = router.drainAgent(agentId, {
324
- max_messages: payload.max_messages,
325
- include_topics: payload.topics,
326
- auto_ack: payload.auto_ack,
327
- });
328
- if (client) touchClient(client);
329
- return {
330
- ok: true,
331
- data: { messages, count: messages.length, server_time_ms: Date.now() },
332
- };
333
- }
334
-
335
- case 'context': {
336
- const agentId = resolveAgentId(client, payload);
337
- const messages = buildReplayMessages(agentId, payload);
338
- if (client) touchClient(client);
339
- return {
340
- ok: true,
341
- data: { messages, count: messages.length, server_time_ms: Date.now() },
342
- };
343
- }
344
-
345
- case 'status': {
346
- const scope = payload.scope || 'hub';
347
- if (client) touchClient(client);
348
- return router.getStatus(scope, payload);
349
- }
350
-
351
- case 'assign_status': {
352
- if (client) touchClient(client);
353
- return router.getAssignStatus(payload);
354
- }
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
-
389
- default:
390
- return {
391
- ok: false,
392
- error: { code: 'UNKNOWN_PIPE_QUERY', message: `지원하지 않는 query: ${action}` },
393
- };
394
- }
395
- }
396
-
397
- function onMessage(agentId, message) {
398
- if (!agentId || !message) return;
399
- if (router.markMessagePushed(agentId, message.id)) {
400
- pushEvent(agentId, message);
401
- return;
402
- }
403
- pushEvent(agentId, message);
404
- }
405
-
406
- async function handleFrame(client, frame) {
407
- if (!frame || typeof frame !== 'object') {
408
- return sendResponse(client, null, {
409
- ok: false,
410
- error: { code: 'INVALID_FRAME', message: 'JSON object frame required' },
411
- });
412
- }
413
-
414
- if (!frame.type) {
415
- return sendResponse(client, frame.request_id || null, {
416
- ok: false,
417
- error: { code: 'INVALID_FRAME', message: 'type required' },
418
- });
419
- }
420
-
421
- touchClient(client);
422
-
423
- try {
424
- if (frame.type === 'command') {
425
- const action = frame.payload?.action || frame.payload?.command;
426
- const result = await processCommand(client, action, frame.payload || {});
427
- return sendResponse(client, frame.payload?.request_id || frame.request_id || null, result);
428
- }
429
- if (frame.type === 'query') {
430
- const action = frame.payload?.action || frame.payload?.query;
431
- const result = await processQuery(client, action, frame.payload || {});
432
- return sendResponse(client, frame.payload?.request_id || frame.request_id || null, result);
433
- }
434
- return sendResponse(client, frame.request_id || null, {
435
- ok: false,
436
- error: { code: 'INVALID_FRAME_TYPE', message: `지원하지 않는 type: ${frame.type}` },
437
- });
438
- } catch (error) {
439
- return sendResponse(client, frame.request_id || null, {
440
- ok: false,
441
- error: { code: 'PIPE_REQUEST_FAILED', message: error.message },
442
- });
443
- }
444
- }
445
-
446
- function attachSocket(socket) {
447
- const client = {
448
- id: randomUUID(),
449
- socket,
450
- buffer: '',
451
- agentId: null,
452
- subscriptions: new Set(),
453
- lastHeartbeatMs: Date.now(),
454
- closed: false,
455
- };
456
- clients.set(client.id, client);
457
-
458
- socket.setEncoding('utf8');
459
- socket.on('data', async (chunk) => {
460
- client.buffer += chunk;
461
- let newlineIndex = client.buffer.indexOf('\n');
462
- while (newlineIndex >= 0) {
463
- const line = client.buffer.slice(0, newlineIndex).trim();
464
- client.buffer = client.buffer.slice(newlineIndex + 1);
465
- if (line) {
466
- const frame = safeJsonParse(line);
467
- await handleFrame(client, frame);
468
- }
469
- newlineIndex = client.buffer.indexOf('\n');
470
- }
471
- });
472
-
473
- socket.on('close', () => closeClient(client));
474
- socket.on('error', () => closeClient(client));
475
- }
476
-
477
- function startHeartbeatMonitor() {
478
- heartbeatTimer = setInterval(() => {
479
- const now = Date.now();
480
- for (const client of clients.values()) {
481
- if (now - client.lastHeartbeatMs <= heartbeatTtlMs) continue;
482
- sendFrame(client, {
483
- type: 'event',
484
- event: 'disconnect',
485
- payload: { reason: 'heartbeat_timeout' },
486
- });
487
- closeClient(client);
488
- }
489
- }, Math.max(1000, Math.floor(heartbeatTtlMs / 2)));
490
- heartbeatTimer.unref();
491
- }
492
-
493
- return {
494
- path: pipePath,
495
-
496
- async start() {
497
- if (server) return { path: pipePath };
498
-
499
- if (process.platform !== 'win32' && existsSync(pipePath)) {
500
- try { unlinkSync(pipePath); } catch {}
501
- }
502
-
503
- server = net.createServer(attachSocket);
504
- router.deliveryEmitter.on('message', onMessage);
505
-
506
- await new Promise((resolve, reject) => {
507
- server.once('error', reject);
508
- server.listen(pipePath, () => {
509
- server.off('error', reject);
510
- resolve();
511
- });
512
- });
513
-
514
- startHeartbeatMonitor();
515
- return { path: pipePath };
516
- },
517
-
518
- async stop() {
519
- if (heartbeatTimer) {
520
- clearInterval(heartbeatTimer);
521
- heartbeatTimer = null;
522
- }
523
-
524
- router.deliveryEmitter.off('message', onMessage);
525
-
526
- for (const client of clients.values()) {
527
- closeClient(client);
528
- }
529
-
530
- if (server) {
531
- const current = server;
532
- server = null;
533
- await new Promise((resolve) => current.close(resolve));
534
- }
535
-
536
- if (process.platform !== 'win32' && existsSync(pipePath)) {
537
- try { unlinkSync(pipePath); } catch {}
538
- }
539
- },
540
-
541
- getStatus() {
542
- return {
543
- path: pipePath,
544
- protocol: 'ndjson',
545
- clients: clients.size,
546
- pending_messages: Array.from(clients.values()).reduce((sum, client) => {
547
- if (!client.agentId) return sum;
548
- return sum + router.getPendingMessages(client.agentId, { max_messages: 1000 }).length;
549
- }, 0),
550
- };
551
- },
552
-
553
- async executeCommand(action, payload) {
554
- return await processCommand(null, action, payload);
555
- },
556
-
557
- async executeQuery(action, payload) {
558
- return await processQuery(null, action, payload);
559
- },
560
- };
561
- }
1
+ // hub/pipe.mjs — Named Pipe/Unix socket 제어 채널
2
+ // NDJSON 프로토콜로 에이전트 실시간 제어/이벤트 푸시를 처리한다.
3
+
4
+ import net from 'node:net';
5
+ import { existsSync, unlinkSync } from 'node:fs';
6
+ import { join } from 'node:path';
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';
21
+
22
+ const DEFAULT_HEARTBEAT_TTL_MS = 60000;
23
+
24
+ /** 플랫폼별 pipe 경로 계산 */
25
+ export function getPipePath(sessionId = process.pid) {
26
+ if (process.platform === 'win32') {
27
+ return `\\\\.\\pipe\\triflux-${sessionId}`;
28
+ }
29
+ return join('/tmp', `triflux-${sessionId}.sock`);
30
+ }
31
+
32
+ function safeJsonParse(line) {
33
+ try {
34
+ return JSON.parse(line);
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function normalizeTopics(topics) {
41
+ if (!Array.isArray(topics)) return [];
42
+ return topics
43
+ .map((topic) => String(topic || '').trim())
44
+ .filter(Boolean);
45
+ }
46
+
47
+ /**
48
+ * Named Pipe 서버 생성
49
+ * @param {object} opts
50
+ * @param {object} opts.router
51
+ * @param {object} [opts.store]
52
+ * @param {string|number} [opts.sessionId]
53
+ * @param {number} [opts.heartbeatTtlMs]
54
+ */
55
+ export function createPipeServer({
56
+ router,
57
+ store = null,
58
+ sessionId = process.pid,
59
+ heartbeatTtlMs = DEFAULT_HEARTBEAT_TTL_MS,
60
+ delegatorService = null,
61
+ } = {}) {
62
+ if (!router) {
63
+ throw new Error('router is required');
64
+ }
65
+
66
+ const pipePath = getPipePath(sessionId);
67
+ const clients = new Map();
68
+ let server = null;
69
+ let heartbeatTimer = null;
70
+
71
+ function sendFrame(client, frame) {
72
+ if (!client || client.closed || !client.socket.writable) return false;
73
+ try {
74
+ client.socket.write(`${JSON.stringify(frame)}\n`);
75
+ return true;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ function sendResponse(client, requestId, result) {
82
+ return sendFrame(client, { type: 'response', request_id: requestId, ...result });
83
+ }
84
+
85
+ function closeClient(client) {
86
+ if (!client || client.closed) return;
87
+ client.closed = true;
88
+ clients.delete(client.id);
89
+ try { client.socket.destroy(); } catch {}
90
+ }
91
+
92
+ function touchClient(client) {
93
+ client.lastHeartbeatMs = Date.now();
94
+ }
95
+
96
+ function resolveAgentId(client, payload) {
97
+ const agentId = payload?.agent_id || client?.agentId;
98
+ if (!agentId) {
99
+ throw new Error('agent_id required');
100
+ }
101
+ return agentId;
102
+ }
103
+
104
+ function pushEvent(agentId, message) {
105
+ let delivered = false;
106
+ for (const client of clients.values()) {
107
+ if (client.agentId !== agentId) continue;
108
+ if (sendFrame(client, {
109
+ type: 'event',
110
+ event: 'message',
111
+ payload: { agent_id: agentId, message },
112
+ })) {
113
+ delivered = true;
114
+ }
115
+ }
116
+ return delivered;
117
+ }
118
+
119
+ function pushPendingMessages(agentId) {
120
+ if (!agentId) return 0;
121
+ const pending = router.getPendingMessages(agentId, { max_messages: 100 });
122
+ let pushed = 0;
123
+ for (const message of pending) {
124
+ if (router.markMessagePushed(agentId, message.id)) {
125
+ pushed += pushEvent(agentId, message) ? 1 : 0;
126
+ } else if (pushEvent(agentId, message)) {
127
+ pushed += 1;
128
+ }
129
+ }
130
+ return pushed;
131
+ }
132
+
133
+ async function processCommand(client, action, payload = {}) {
134
+ switch (action) {
135
+ case 'register': {
136
+ const result = router.registerAgent(payload);
137
+ if (client) {
138
+ client.agentId = payload.agent_id;
139
+ client.subscriptions = new Set(router.getSubscribedTopics(client.agentId));
140
+ touchClient(client);
141
+ pushPendingMessages(client.agentId);
142
+ }
143
+ return { ok: true, data: { ...result, pipe_path: pipePath } };
144
+ }
145
+
146
+ case 'subscribe': {
147
+ const agentId = resolveAgentId(client, payload);
148
+ const topics = normalizeTopics(payload.topics);
149
+ const result = router.subscribeAgent(agentId, topics, {
150
+ replace: Boolean(payload.replace),
151
+ });
152
+ if (client) {
153
+ client.agentId = agentId;
154
+ client.subscriptions = new Set(result.topics);
155
+ touchClient(client);
156
+ }
157
+ const replayed = pushPendingMessages(agentId);
158
+ return {
159
+ ok: true,
160
+ data: { ...result, replayed_messages: replayed },
161
+ };
162
+ }
163
+
164
+ case 'ack': {
165
+ const agentId = resolveAgentId(client, payload);
166
+ const acked = router.ackMessages(payload.message_ids || payload.ack_ids || [], agentId);
167
+ if (client) touchClient(client);
168
+ return { ok: true, data: { agent_id: agentId, acked_count: acked } };
169
+ }
170
+
171
+ case 'heartbeat': {
172
+ const agentId = resolveAgentId(client, payload);
173
+ const result = router.refreshAgentLease(agentId, payload.heartbeat_ttl_ms || heartbeatTtlMs);
174
+ if (client) touchClient(client);
175
+ return { ok: true, data: result };
176
+ }
177
+
178
+ case 'publish': {
179
+ const result = router.handlePublish(payload);
180
+ if (client) touchClient(client);
181
+ return result;
182
+ }
183
+
184
+ case 'handoff': {
185
+ const result = router.handleHandoff(payload);
186
+ if (client) touchClient(client);
187
+ return result;
188
+ }
189
+
190
+ case 'assign': {
191
+ const result = router.assignAsync(payload);
192
+ if (client) touchClient(client);
193
+ return result;
194
+ }
195
+
196
+ case 'assign_result': {
197
+ const result = router.reportAssignResult(payload);
198
+ if (client) touchClient(client);
199
+ return result;
200
+ }
201
+
202
+ case 'assign_retry': {
203
+ const result = router.retryAssign(payload.job_id, payload);
204
+ if (client) touchClient(client);
205
+ return result;
206
+ }
207
+
208
+ case 'result': {
209
+ const result = router.handlePublish({
210
+ from: payload.agent_id,
211
+ to: `topic:${payload.topic || 'task.result'}`,
212
+ topic: payload.topic || 'task.result',
213
+ payload: payload.payload || {},
214
+ priority: 5,
215
+ ttl_ms: 3600000,
216
+ trace_id: payload.trace_id,
217
+ correlation_id: payload.correlation_id,
218
+ });
219
+ if (client) touchClient(client);
220
+ return result;
221
+ }
222
+
223
+ case 'control': {
224
+ const result = router.handlePublish({
225
+ from: payload.from_agent || 'lead',
226
+ to: payload.to_agent,
227
+ topic: 'lead.control',
228
+ payload: {
229
+ command: payload.command,
230
+ reason: payload.reason || '',
231
+ ...(payload.payload || {}),
232
+ issued_at: Date.now(),
233
+ },
234
+ priority: 8,
235
+ ttl_ms: Math.max(1000, Math.min(Number(payload.ttl_ms) || 3600000, 86400000)),
236
+ trace_id: payload.trace_id,
237
+ correlation_id: payload.correlation_id,
238
+ });
239
+ if (client) touchClient(client);
240
+ return result;
241
+ }
242
+
243
+ case 'deregister': {
244
+ const agentId = resolveAgentId(client, payload);
245
+ router.updateAgentStatus(agentId, 'offline');
246
+ if (client) touchClient(client);
247
+ return {
248
+ ok: true,
249
+ data: { agent_id: agentId, status: 'offline' },
250
+ };
251
+ }
252
+
253
+ case 'team_task_update': {
254
+ const result = await teamTaskUpdate(payload);
255
+ if (client) touchClient(client);
256
+ return result;
257
+ }
258
+
259
+ case 'team_send_message': {
260
+ const result = await teamSendMessage(payload);
261
+ if (client) touchClient(client);
262
+ return result;
263
+ }
264
+
265
+ case 'pipeline_advance': {
266
+ if (client) touchClient(client);
267
+ if (!store?.db) {
268
+ return { ok: false, error: 'hub_db_not_found' };
269
+ }
270
+ ensurePipelineTable(store.db);
271
+ const pipeline = createPipeline(store.db, payload.team_name);
272
+ return pipeline.advance(payload.phase);
273
+ }
274
+
275
+ case 'pipeline_init': {
276
+ if (client) touchClient(client);
277
+ if (!store?.db) {
278
+ return { ok: false, error: 'hub_db_not_found' };
279
+ }
280
+ ensurePipelineTable(store.db);
281
+ const state = initPipelineState(store.db, payload.team_name, {
282
+ fix_max: payload.fix_max,
283
+ ralph_max: payload.ralph_max,
284
+ });
285
+ return { ok: true, data: state };
286
+ }
287
+
288
+ case 'delegator_delegate': {
289
+ if (!delegatorService) {
290
+ return { ok: false, error: { code: 'DELEGATOR_NOT_AVAILABLE', message: 'Delegator service가 초기화되지 않았습니다' } };
291
+ }
292
+ if (client) touchClient(client);
293
+ const result = await delegatorService.delegate(payload);
294
+ return { ok: result?.ok !== false, data: result };
295
+ }
296
+
297
+ case 'delegator_reply': {
298
+ if (!delegatorService) {
299
+ return { ok: false, error: { code: 'DELEGATOR_NOT_AVAILABLE', message: 'Delegator service가 초기화되지 않았습니다' } };
300
+ }
301
+ if (client) touchClient(client);
302
+ const result = await delegatorService.reply(payload);
303
+ return { ok: result?.ok !== false, data: result };
304
+ }
305
+
306
+ default:
307
+ return {
308
+ ok: false,
309
+ error: { code: 'UNKNOWN_PIPE_COMMAND', message: `지원하지 않는 command: ${action}` },
310
+ };
311
+ }
312
+ }
313
+
314
+ function buildReplayMessages(agentId, payload = {}) {
315
+ const maxMessages = Math.max(1, Math.min(Number(payload.max_messages) || 20, 100));
316
+ const pending = router.getPendingMessages(agentId, {
317
+ max_messages: maxMessages,
318
+ include_topics: payload.topics,
319
+ });
320
+ if (!store?.getAuditMessagesForAgent) {
321
+ return pending.slice(0, maxMessages);
322
+ }
323
+
324
+ const audit = store.getAuditMessagesForAgent(agentId, {
325
+ max_messages: maxMessages,
326
+ include_topics: payload.topics,
327
+ });
328
+ const byId = new Map();
329
+ for (const message of [...pending, ...audit]) {
330
+ if (!message?.id || byId.has(message.id)) continue;
331
+ byId.set(message.id, message);
332
+ }
333
+ return Array.from(byId.values())
334
+ .sort((left, right) => right.created_at_ms - left.created_at_ms)
335
+ .slice(0, maxMessages);
336
+ }
337
+
338
+ async function processQuery(client, action, payload = {}) {
339
+ switch (action) {
340
+ case 'drain': {
341
+ const agentId = resolveAgentId(client, payload);
342
+ const messages = router.drainAgent(agentId, {
343
+ max_messages: payload.max_messages,
344
+ include_topics: payload.topics,
345
+ auto_ack: payload.auto_ack,
346
+ });
347
+ if (client) touchClient(client);
348
+ return {
349
+ ok: true,
350
+ data: { messages, count: messages.length, server_time_ms: Date.now() },
351
+ };
352
+ }
353
+
354
+ case 'context': {
355
+ const agentId = resolveAgentId(client, payload);
356
+ const messages = buildReplayMessages(agentId, payload);
357
+ if (client) touchClient(client);
358
+ return {
359
+ ok: true,
360
+ data: { messages, count: messages.length, server_time_ms: Date.now() },
361
+ };
362
+ }
363
+
364
+ case 'status': {
365
+ const scope = payload.scope || 'hub';
366
+ if (client) touchClient(client);
367
+ return router.getStatus(scope, payload);
368
+ }
369
+
370
+ case 'assign_status': {
371
+ if (client) touchClient(client);
372
+ return router.getAssignStatus(payload);
373
+ }
374
+
375
+ case 'team_info': {
376
+ const result = await teamInfo(payload);
377
+ if (client) touchClient(client);
378
+ return result;
379
+ }
380
+
381
+ case 'team_task_list': {
382
+ const result = await teamTaskList(payload);
383
+ if (client) touchClient(client);
384
+ return result;
385
+ }
386
+
387
+ case 'pipeline_state': {
388
+ if (client) touchClient(client);
389
+ if (!store?.db) {
390
+ return { ok: false, error: 'hub_db_not_found' };
391
+ }
392
+ ensurePipelineTable(store.db);
393
+ const state = readPipelineState(store.db, payload.team_name);
394
+ return state
395
+ ? { ok: true, data: state }
396
+ : { ok: false, error: 'pipeline_not_found' };
397
+ }
398
+
399
+ case 'pipeline_list': {
400
+ if (client) touchClient(client);
401
+ if (!store?.db) {
402
+ return { ok: false, error: 'hub_db_not_found' };
403
+ }
404
+ ensurePipelineTable(store.db);
405
+ return { ok: true, data: listPipelineStates(store.db) };
406
+ }
407
+
408
+ case 'delegator_status': {
409
+ if (!delegatorService) {
410
+ return { ok: false, error: { code: 'DELEGATOR_NOT_AVAILABLE', message: 'Delegator service가 초기화되지 않았습니다' } };
411
+ }
412
+ if (client) touchClient(client);
413
+ const result = await delegatorService.status(payload);
414
+ return { ok: result?.ok !== false, data: result };
415
+ }
416
+
417
+ default:
418
+ return {
419
+ ok: false,
420
+ error: { code: 'UNKNOWN_PIPE_QUERY', message: `지원하지 않는 query: ${action}` },
421
+ };
422
+ }
423
+ }
424
+
425
+ function onMessage(agentId, message) {
426
+ if (!agentId || !message) return;
427
+ if (router.markMessagePushed(agentId, message.id)) {
428
+ pushEvent(agentId, message);
429
+ return;
430
+ }
431
+ pushEvent(agentId, message);
432
+ }
433
+
434
+ async function handleFrame(client, frame) {
435
+ if (!frame || typeof frame !== 'object') {
436
+ return sendResponse(client, null, {
437
+ ok: false,
438
+ error: { code: 'INVALID_FRAME', message: 'JSON object frame required' },
439
+ });
440
+ }
441
+
442
+ if (!frame.type) {
443
+ return sendResponse(client, frame.request_id || null, {
444
+ ok: false,
445
+ error: { code: 'INVALID_FRAME', message: 'type required' },
446
+ });
447
+ }
448
+
449
+ touchClient(client);
450
+
451
+ try {
452
+ if (frame.type === 'command') {
453
+ const action = frame.payload?.action || frame.payload?.command;
454
+ const result = await processCommand(client, action, frame.payload || {});
455
+ return sendResponse(client, frame.payload?.request_id || frame.request_id || null, result);
456
+ }
457
+ if (frame.type === 'query') {
458
+ const action = frame.payload?.action || frame.payload?.query;
459
+ const result = await processQuery(client, action, frame.payload || {});
460
+ return sendResponse(client, frame.payload?.request_id || frame.request_id || null, result);
461
+ }
462
+ return sendResponse(client, frame.request_id || null, {
463
+ ok: false,
464
+ error: { code: 'INVALID_FRAME_TYPE', message: `지원하지 않는 type: ${frame.type}` },
465
+ });
466
+ } catch (error) {
467
+ return sendResponse(client, frame.request_id || null, {
468
+ ok: false,
469
+ error: { code: 'PIPE_REQUEST_FAILED', message: error.message },
470
+ });
471
+ }
472
+ }
473
+
474
+ function attachSocket(socket) {
475
+ const client = {
476
+ id: randomUUID(),
477
+ socket,
478
+ buffer: '',
479
+ agentId: null,
480
+ subscriptions: new Set(),
481
+ lastHeartbeatMs: Date.now(),
482
+ closed: false,
483
+ };
484
+ clients.set(client.id, client);
485
+
486
+ socket.setEncoding('utf8');
487
+ socket.on('data', async (chunk) => {
488
+ client.buffer += chunk;
489
+ let newlineIndex = client.buffer.indexOf('\n');
490
+ while (newlineIndex >= 0) {
491
+ const line = client.buffer.slice(0, newlineIndex).trim();
492
+ client.buffer = client.buffer.slice(newlineIndex + 1);
493
+ if (line) {
494
+ const frame = safeJsonParse(line);
495
+ await handleFrame(client, frame);
496
+ }
497
+ newlineIndex = client.buffer.indexOf('\n');
498
+ }
499
+ });
500
+
501
+ socket.on('close', () => closeClient(client));
502
+ socket.on('error', () => closeClient(client));
503
+ }
504
+
505
+ function startHeartbeatMonitor() {
506
+ heartbeatTimer = setInterval(() => {
507
+ const now = Date.now();
508
+ for (const client of clients.values()) {
509
+ if (now - client.lastHeartbeatMs <= heartbeatTtlMs) continue;
510
+ sendFrame(client, {
511
+ type: 'event',
512
+ event: 'disconnect',
513
+ payload: { reason: 'heartbeat_timeout' },
514
+ });
515
+ closeClient(client);
516
+ }
517
+ }, Math.max(1000, Math.floor(heartbeatTtlMs / 2)));
518
+ heartbeatTimer.unref();
519
+ }
520
+
521
+ return {
522
+ path: pipePath,
523
+
524
+ async start() {
525
+ if (server) return { path: pipePath };
526
+
527
+ if (process.platform !== 'win32' && existsSync(pipePath)) {
528
+ try { unlinkSync(pipePath); } catch {}
529
+ }
530
+
531
+ server = net.createServer(attachSocket);
532
+ router.deliveryEmitter.on('message', onMessage);
533
+
534
+ await new Promise((resolve, reject) => {
535
+ server.once('error', reject);
536
+ server.listen(pipePath, () => {
537
+ server.off('error', reject);
538
+ resolve();
539
+ });
540
+ });
541
+
542
+ startHeartbeatMonitor();
543
+ return { path: pipePath };
544
+ },
545
+
546
+ async stop() {
547
+ if (heartbeatTimer) {
548
+ clearInterval(heartbeatTimer);
549
+ heartbeatTimer = null;
550
+ }
551
+
552
+ router.deliveryEmitter.off('message', onMessage);
553
+
554
+ for (const client of clients.values()) {
555
+ closeClient(client);
556
+ }
557
+
558
+ if (server) {
559
+ const current = server;
560
+ server = null;
561
+ await new Promise((resolve) => current.close(resolve));
562
+ }
563
+
564
+ if (process.platform !== 'win32' && existsSync(pipePath)) {
565
+ try { unlinkSync(pipePath); } catch {}
566
+ }
567
+ },
568
+
569
+ getStatus() {
570
+ return {
571
+ path: pipePath,
572
+ protocol: 'ndjson',
573
+ clients: clients.size,
574
+ pending_messages: Array.from(clients.values()).reduce((sum, client) => {
575
+ if (!client.agentId) return sum;
576
+ return sum + router.getPendingMessages(client.agentId, { max_messages: 1000 }).length;
577
+ }, 0),
578
+ };
579
+ },
580
+
581
+ async executeCommand(action, payload) {
582
+ return await processCommand(null, action, payload);
583
+ },
584
+
585
+ async executeQuery(action, payload) {
586
+ return await processQuery(null, action, payload);
587
+ },
588
+ };
589
+ }