@triflux/remote 10.0.0 → 10.1.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.
package/hub/index.mjs CHANGED
@@ -14,8 +14,8 @@ export { createPsmuxSession, startCapture } from './team/psmux.mjs';
14
14
  export { createConductor } from './team/conductor.mjs';
15
15
 
16
16
  // Swarm
17
- export { createSwarmLocks } from '../../../hub/team/swarm-locks.mjs';
18
- export { parseShards, buildFileLeaseMap, buildMcpManifest, computeMergeOrder, planSwarm } from '../../../hub/team/swarm-planner.mjs';
19
- export { createSwarmHypervisor, SWARM_STATES } from '../../../hub/team/swarm-hypervisor.mjs';
20
- export { reconcile, buildRedundantIds, shouldRunRedundant } from '../../../hub/team/swarm-reconciler.mjs';
21
- export { ensureWorktree, prepareIntegrationBranch, rebaseShardOntoIntegration, pruneWorktree } from '../../../hub/team/worktree-lifecycle.mjs';
17
+ export { createSwarmLocks } from './team/swarm-locks.mjs';
18
+ export { parseShards, buildFileLeaseMap, buildMcpManifest, computeMergeOrder, planSwarm } from './team/swarm-planner.mjs';
19
+ export { createSwarmHypervisor, SWARM_STATES } from './team/swarm-hypervisor.mjs';
20
+ export { reconcile, buildRedundantIds, shouldRunRedundant } from './team/swarm-reconciler.mjs';
21
+ export { ensureWorktree, prepareIntegrationBranch, rebaseShardOntoIntegration, pruneWorktree, fetchRemoteShard } from './team/worktree-lifecycle.mjs';
@@ -42,7 +42,7 @@ body{background:#0d1117;color:#c9d1d9;font-family:system-ui,-apple-system,sans-s
42
42
  .token-item .calls{font-size:.78rem;color:#8b949e;margin-top:2px}
43
43
 
44
44
  .chart-wrap{position:relative;width:100%;height:160px}
45
- .chart-wrap canvas{width:100%!important;height:100%!important}
45
+ .chart-wrap canvas{width:100%;height:100%}
46
46
  .chart-legend{display:flex;gap:14px;margin-top:8px;font-size:.78rem;color:#8b949e}
47
47
  .legend-dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:4px;vertical-align:middle}
48
48
 
@@ -152,7 +152,7 @@ body{background:#0d1117;color:#c9d1d9;font-family:system-ui,-apple-system,sans-s
152
152
  function remain(rt) {
153
153
  if (!rt) return '';
154
154
  var ts = typeof rt === 'number' && rt < 1e10 ? rt * 1000 : new Date(rt).getTime();
155
- if (!isFinite(ts)) return '';
155
+ if (!Number.isFinite(ts)) return '';
156
156
  var ms = ts - Date.now();
157
157
  if (ms <= 0) return '만료됨';
158
158
  var h = Math.floor(ms / 3600000);
@@ -199,7 +199,7 @@ body{background:#0d1117;color:#c9d1d9;font-family:system-ui,-apple-system,sans-s
199
199
  var s = '';
200
200
 
201
201
  // Claude
202
- var cl = d.claude && d.claude.data;
202
+ var cl = d.claude?.data;
203
203
  if (cl) {
204
204
  s += quotaBlock('Claude (c)', '#d19a66', [
205
205
  ['5시간', cl.fiveHourPercent || 0],
@@ -208,16 +208,16 @@ body{background:#0d1117;color:#c9d1d9;font-family:system-ui,-apple-system,sans-s
208
208
  }
209
209
 
210
210
  // Codex
211
- var cx2 = d.codex && d.codex.buckets && d.codex.buckets.codex;
211
+ var cx2 = d.codex?.buckets?.codex;
212
212
  if (cx2) {
213
213
  s += quotaBlock('Codex (x)', '#ffffff', [
214
214
  ['Primary', cx2.primary ? cx2.primary.used_percent || 0 : 0],
215
215
  ['Secondary', cx2.secondary ? cx2.secondary.used_percent || 0 : 0]
216
- ], cx2.primary && cx2.primary.resets_at);
216
+ ], cx2.primary?.resets_at);
217
217
  }
218
218
 
219
219
  // Gemini
220
- var gm = d.gemini && d.gemini.buckets;
220
+ var gm = d.gemini?.buckets;
221
221
  if (gm && gm.length > 0) {
222
222
  var frac = gm[0].remainingFraction;
223
223
  s += quotaBlock('Gemini (g)', '#61afef', [
@@ -330,7 +330,7 @@ body{background:#0d1117;color:#c9d1d9;font-family:system-ui,-apple-system,sans-s
330
330
  gauge(data.aimd ? data.aimd.batchSize : 0);
331
331
  renderQuota(data);
332
332
 
333
- cachedEvents = (data.aimd && data.aimd.events) || [];
333
+ cachedEvents = (data.aimd?.events) || [];
334
334
  drawEvents(cachedEvents);
335
335
 
336
336
  renderTokens(data.accumulator);
package/hub/server.mjs CHANGED
@@ -63,6 +63,53 @@ const STATIC_CONTENT_TYPES = Object.freeze({
63
63
  // Each entry is an array of request timestamps within the current window.
64
64
  const rateLimitMap = new Map();
65
65
 
66
+ function formatHostForUrl(host = '127.0.0.1') {
67
+ return String(host).includes(':') ? `[${host}]` : host;
68
+ }
69
+
70
+ function buildHubUrl(host, port) {
71
+ return `http://${formatHostForUrl(host || '127.0.0.1')}:${port}/mcp`;
72
+ }
73
+
74
+ function isPidAlive(pid, killFn = process.kill) {
75
+ const resolvedPid = Number(pid);
76
+ if (!Number.isFinite(resolvedPid) || resolvedPid <= 0) return false;
77
+ try {
78
+ killFn(resolvedPid, 0);
79
+ return true;
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ async function tryReuseExistingHub({
86
+ port,
87
+ host = '127.0.0.1',
88
+ readCurrentState = readState,
89
+ readInfo = getHubInfo,
90
+ checkHealth = isServerHealthy,
91
+ killFn = process.kill,
92
+ } = {}) {
93
+ const existing = readCurrentState();
94
+ const existingPort = Number(existing?.port);
95
+ if (!isPidAlive(existing?.pid, killFn) || !Number.isFinite(existingPort) || existingPort <= 0) {
96
+ return null;
97
+ }
98
+ if (Number.isFinite(Number(port)) && existingPort !== Number(port)) return null;
99
+ if (!(await checkHealth(existingPort))) return null;
100
+
101
+ const info = readInfo() ?? existing;
102
+ const infoHost = typeof info?.host === 'string' ? info.host : host;
103
+ return {
104
+ reused: true,
105
+ external: true,
106
+ port: existingPort,
107
+ pid: Number(existing.pid),
108
+ url: info?.url ?? buildHubUrl(infoHost, existingPort),
109
+ stop: async () => false,
110
+ };
111
+ }
112
+
66
113
  function checkRateLimit(ip) {
67
114
  const now = Date.now();
68
115
  const cutoff = now - RATE_LIMIT_WINDOW_MS;
@@ -314,24 +361,8 @@ export async function startHub({
314
361
  } = {}) {
315
362
  const port = portOpt ?? parseInt(process.env.TFX_HUB_PORT || '27888', 10);
316
363
 
317
- // Singleton reuse: if a healthy hub is already running on the target port, return a lightweight handle
318
- const existingState = readState();
319
- if (existingState?.pid && Number(existingState.port) === Number(port)) {
320
- const pidAlive = (() => {
321
- try { process.kill(Number(existingState.pid), 0); return true; } catch { return false; }
322
- })();
323
- if (pidAlive && await isServerHealthy(port)) {
324
- const existingInfo = getHubInfo();
325
- return {
326
- reused: true,
327
- external: true,
328
- port: Number(port),
329
- pid: Number(existingState.pid),
330
- url: existingInfo?.url ?? `http://${host}:${port}/mcp`,
331
- stop: async () => false,
332
- };
333
- }
334
- }
364
+ const existingHub = await tryReuseExistingHub({ port, host });
365
+ if (existingHub) return existingHub;
335
366
 
336
367
  const hubIdleTimeoutMs = parsePositiveInt(process.env.TFX_HUB_IDLE_TIMEOUT_MS, HUB_IDLE_TIMEOUT_DEFAULT_MS);
337
368
  const hubIdleSweepMs = parsePositiveInt(
@@ -365,6 +396,12 @@ export async function startHub({
365
396
  lockHeld = false;
366
397
  };
367
398
 
399
+ const lockedExistingHub = await tryReuseExistingHub({ port, host });
400
+ if (lockedExistingHub) {
401
+ releaseStartupLock();
402
+ return lockedExistingHub;
403
+ }
404
+
368
405
  const HUB_TOKEN = process.env.TFX_HUB_TOKEN?.trim() || null;
369
406
  if (HUB_TOKEN) {
370
407
  mkdirSync(join(homedir(), '.claude'), { recursive: true });
@@ -381,6 +418,29 @@ export async function startHub({
381
418
  const adaptiveEngine = createAdaptiveEngine({ repoRoot: PROJECT_ROOT, fingerprintService });
382
419
  adaptiveEngine.startSession();
383
420
 
421
+ // safety-guard → reflexion 패널티 승격 + 적응형 규칙 유지보수
422
+ const projectSlug = PROJECT_ROOT.split(/[\\/]/).pop();
423
+ try {
424
+ const { promotePenalties } = await import("./promote-penalties.mjs");
425
+ const result = promotePenalties(store, { projectSlug });
426
+ if (result.promoted > 0) {
427
+ console.log(`[reflexion] ${result.promoted} penalties promoted to adaptive rules`);
428
+ }
429
+ } catch { /* promote-penalties 실패는 Hub 시작을 막지 않음 */ }
430
+
431
+ // stale adaptive_rules 정리 (30일 초과 + confidence 0.2 미만)
432
+ try {
433
+ const pruned = store.pruneStaleRules();
434
+ if (pruned > 0) console.log(`[reflexion] ${pruned} stale adaptive rules pruned`);
435
+ } catch { /* prune 실패 무시 */ }
436
+
437
+ // adaptive rule confidence decay (7일 이상 미관측 규칙 -0.1 감소)
438
+ try {
439
+ const { decayRules } = await import("./reflexion.mjs");
440
+ const decay = decayRules(store, adaptiveEngine.sessionCount?.() || 1, projectSlug);
441
+ if (decay.deleted.length > 0) console.log(`[reflexion] ${decay.deleted.length} low-confidence rules removed`);
442
+ } catch { /* decay 실패 무시 */ }
443
+
384
444
  // Delegator MCP resident service 초기화
385
445
  const delegatorWorker = createDelegatorWorker({ cwd: PROJECT_ROOT });
386
446
  try {
@@ -1019,7 +1079,7 @@ export async function startHub({
1019
1079
  pid: process.pid,
1020
1080
  hubToken: HUB_TOKEN,
1021
1081
  authMode: HUB_TOKEN ? 'token-required' : 'localhost-only',
1022
- url: `http://${host}:${port}/mcp`,
1082
+ url: buildHubUrl(host, port),
1023
1083
  pipe_path: pipe.path,
1024
1084
  pipePath: pipe.path,
1025
1085
  assign_callback_pipe_path: assignCallbacks.path,
@@ -1029,7 +1089,7 @@ export async function startHub({
1029
1089
  idleTimeoutMs: hubIdleTimeoutMs,
1030
1090
  };
1031
1091
 
1032
- writeFileSync(PID_FILE, JSON.stringify({
1092
+ writeState({
1033
1093
  pid: process.pid,
1034
1094
  port,
1035
1095
  host,
@@ -1038,16 +1098,13 @@ export async function startHub({
1038
1098
  pipe_path: pipe.path,
1039
1099
  pipePath: pipe.path,
1040
1100
  assign_callback_pipe_path: assignCallbacks.path,
1101
+ assignCallbackPipePath: assignCallbacks.path,
1102
+ authMode: HUB_TOKEN ? 'token-required' : 'localhost-only',
1103
+ startedAt,
1041
1104
  started: startedAtMs,
1042
1105
  version,
1043
- session_id: sessionId,
1044
- }));
1045
- writeState({
1046
- pid: process.pid,
1047
- port,
1048
- version,
1049
1106
  sessionId,
1050
- startedAt,
1107
+ session_id: sessionId,
1051
1108
  });
1052
1109
  releaseStartupLock();
1053
1110
 
@@ -1128,12 +1185,7 @@ export async function startHub({
1128
1185
  }
1129
1186
 
1130
1187
  export function getHubInfo() {
1131
- if (!existsSync(PID_FILE)) return null;
1132
- try {
1133
- return JSON.parse(readFileSync(PID_FILE, 'utf8'));
1134
- } catch {
1135
- return null;
1136
- }
1188
+ return readState();
1137
1189
  }
1138
1190
 
1139
1191
  /**
@@ -1146,29 +1198,19 @@ export function getHubInfo() {
1146
1198
  */
1147
1199
  export async function getOrCreateServer(opts = {}) {
1148
1200
  const { _deps, ...startOpts } = opts;
1149
- const checkHealth = _deps?.isHealthy ?? isServerHealthy;
1150
- const readInfo = _deps?.getInfo ?? getHubInfo;
1151
- const readCurrentState = _deps?.readState ?? readState;
1201
+ const existingHub = await tryReuseExistingHub({
1202
+ port: startOpts.port,
1203
+ host: startOpts.host,
1204
+ checkHealth: _deps?.isHealthy ?? isServerHealthy,
1205
+ readInfo: _deps?.getInfo ?? getHubInfo,
1206
+ readCurrentState: _deps?.readState ?? readState,
1207
+ });
1152
1208
  const boot = _deps?.startHub ?? startHub;
1153
1209
 
1154
- const existing = readCurrentState();
1155
- if (existing?.pid && existing?.port) {
1156
- const pidAlive = (() => {
1157
- try { process.kill(Number(existing.pid), 0); return true; } catch { return false; }
1158
- })();
1159
-
1160
- if (pidAlive && await checkHealth(existing.port)) {
1161
- return {
1162
- reused: true,
1163
- port: existing.port,
1164
- pid: existing.pid,
1165
- url: readInfo()?.url ?? `http://127.0.0.1:${existing.port}/mcp`,
1166
- };
1167
- }
1168
- }
1210
+ if (existingHub) return existingHub;
1169
1211
 
1170
1212
  const server = await boot(startOpts);
1171
- return { reused: false, ...server };
1213
+ return server.reused === true ? server : { reused: false, ...server };
1172
1214
  }
1173
1215
 
1174
1216
  /**
@@ -15,23 +15,35 @@ export { createMemoryStore };
15
15
  function ensureAdaptiveRulesSchema(db) {
16
16
  const schemaKey = 'adaptive_rules_schema_version';
17
17
  const currentVersion = db.prepare('SELECT value FROM _meta WHERE key = ?').pluck().get(schemaKey);
18
- if (currentVersion === '1') return;
19
- db.exec(`
20
- CREATE TABLE IF NOT EXISTS adaptive_rules (
21
- project_slug TEXT NOT NULL,
22
- pattern TEXT NOT NULL,
23
- confidence REAL NOT NULL DEFAULT 0.5,
24
- hit_count INTEGER NOT NULL DEFAULT 1,
25
- last_seen_ms INTEGER NOT NULL,
26
- created_ms INTEGER NOT NULL,
27
- PRIMARY KEY (project_slug, pattern)
28
- );
29
- CREATE INDEX IF NOT EXISTS idx_adaptive_rules_last_seen
30
- ON adaptive_rules(project_slug, last_seen_ms DESC);
31
- CREATE INDEX IF NOT EXISTS idx_adaptive_rules_confidence
32
- ON adaptive_rules(project_slug, confidence DESC);
33
- `);
34
- db.prepare('INSERT OR REPLACE INTO _meta (key, value) VALUES (?, ?)').run(schemaKey, '1');
18
+ if (currentVersion === '2') return;
19
+ if (!currentVersion) {
20
+ // 신규 생성
21
+ db.exec(`
22
+ CREATE TABLE IF NOT EXISTS adaptive_rules (
23
+ project_slug TEXT NOT NULL,
24
+ pattern TEXT NOT NULL,
25
+ confidence REAL NOT NULL DEFAULT 0.5,
26
+ hit_count INTEGER NOT NULL DEFAULT 1,
27
+ last_seen_ms INTEGER NOT NULL,
28
+ created_ms INTEGER NOT NULL,
29
+ error_message TEXT,
30
+ solution TEXT,
31
+ context TEXT,
32
+ PRIMARY KEY (project_slug, pattern)
33
+ );
34
+ CREATE INDEX IF NOT EXISTS idx_adaptive_rules_last_seen
35
+ ON adaptive_rules(project_slug, last_seen_ms DESC);
36
+ CREATE INDEX IF NOT EXISTS idx_adaptive_rules_confidence
37
+ ON adaptive_rules(project_slug, confidence DESC);
38
+ `);
39
+ } else {
40
+ // v1 → v2 마이그레이션: 컬럼 추가
41
+ const cols = db.pragma('table_info(adaptive_rules)').map(c => c.name);
42
+ if (!cols.includes('error_message')) db.exec('ALTER TABLE adaptive_rules ADD COLUMN error_message TEXT');
43
+ if (!cols.includes('solution')) db.exec('ALTER TABLE adaptive_rules ADD COLUMN solution TEXT');
44
+ if (!cols.includes('context')) db.exec('ALTER TABLE adaptive_rules ADD COLUMN context TEXT');
45
+ }
46
+ db.prepare('INSERT OR REPLACE INTO _meta (key, value) VALUES (?, ?)').run(schemaKey, '2');
35
47
  }
36
48
 
37
49
  function attachAdaptiveRuleStore(store) {
@@ -40,15 +52,20 @@ function attachAdaptiveRuleStore(store) {
40
52
  const statements = {
41
53
  upsertRule: store.db.prepare(`
42
54
  INSERT INTO adaptive_rules (
43
- project_slug, pattern, confidence, hit_count, last_seen_ms, created_ms
55
+ project_slug, pattern, confidence, hit_count, last_seen_ms, created_ms,
56
+ error_message, solution, context
44
57
  ) VALUES (
45
- @project_slug, @pattern, @confidence, @hit_count, @last_seen_ms, @created_ms
58
+ @project_slug, @pattern, @confidence, @hit_count, @last_seen_ms, @created_ms,
59
+ @error_message, @solution, @context
46
60
  )
47
61
  ON CONFLICT(project_slug, pattern) DO UPDATE SET
48
62
  confidence = excluded.confidence,
49
63
  hit_count = excluded.hit_count,
50
64
  last_seen_ms = excluded.last_seen_ms,
51
- created_ms = MIN(adaptive_rules.created_ms, excluded.created_ms)`),
65
+ created_ms = MIN(adaptive_rules.created_ms, excluded.created_ms),
66
+ error_message = COALESCE(excluded.error_message, adaptive_rules.error_message),
67
+ solution = COALESCE(excluded.solution, adaptive_rules.solution),
68
+ context = COALESCE(excluded.context, adaptive_rules.context)`),
52
69
  getRule: store.db.prepare('SELECT * FROM adaptive_rules WHERE project_slug = ? AND pattern = ?'),
53
70
  updateRule: store.db.prepare(`
54
71
  UPDATE adaptive_rules SET
@@ -93,6 +110,20 @@ function attachAdaptiveRuleStore(store) {
93
110
  return statements.pruneRules.run(cutoff, minConfidence).changes;
94
111
  };
95
112
 
113
+ // listAdaptiveRules: decayRules/getActiveAdaptiveRules에서 사용
114
+ const listStmt = store.db.prepare('SELECT * FROM adaptive_rules WHERE project_slug = ? ORDER BY confidence DESC');
115
+ const listAllStmt = store.db.prepare('SELECT * FROM adaptive_rules ORDER BY confidence DESC');
116
+ const deleteRuleStmt = store.db.prepare('DELETE FROM adaptive_rules WHERE project_slug = ? AND pattern = ?');
117
+
118
+ store.listAdaptiveRules = function listAdaptiveRules(projectSlug) {
119
+ if (projectSlug) return listStmt.all(projectSlug).map(clone);
120
+ return listAllStmt.all().map(clone);
121
+ };
122
+
123
+ store.deleteAdaptiveRule = function deleteAdaptiveRule(projectSlug, pattern) {
124
+ return deleteRuleStmt.run(projectSlug, pattern).changes > 0;
125
+ };
126
+
96
127
  return store;
97
128
  }
98
129
 
package/hub/store.mjs CHANGED
@@ -551,7 +551,7 @@ export function createStore(dbPath, options = {}) {
551
551
  ttl_ms: clampDuration(patch.ttl_ms ?? current.ttl_ms, current.ttl_ms || nextTimeout),
552
552
  timeout_ms: nextTimeout,
553
553
  deadline_ms: (() => {
554
- if (Object.prototype.hasOwnProperty.call(patch, 'deadline_ms')) {
554
+ if (Object.hasOwn(patch, 'deadline_ms')) {
555
555
  return patch.deadline_ms == null ? null : Math.trunc(Number(patch.deadline_ms));
556
556
  }
557
557
  if (isTerminal) return null;
@@ -560,23 +560,23 @@ export function createStore(dbPath, options = {}) {
560
560
  })(),
561
561
  trace_id: patch.trace_id ?? current.trace_id,
562
562
  correlation_id: patch.correlation_id ?? current.correlation_id,
563
- last_message_id: Object.prototype.hasOwnProperty.call(patch, 'last_message_id')
563
+ last_message_id: Object.hasOwn(patch, 'last_message_id')
564
564
  ? patch.last_message_id
565
565
  : current.last_message_id,
566
- result_json: Object.prototype.hasOwnProperty.call(patch, 'result')
566
+ result_json: Object.hasOwn(patch, 'result')
567
567
  ? (patch.result == null ? null : JSON.stringify(patch.result))
568
568
  : (current.result == null ? null : JSON.stringify(current.result)),
569
- error_json: Object.prototype.hasOwnProperty.call(patch, 'error')
569
+ error_json: Object.hasOwn(patch, 'error')
570
570
  ? (patch.error == null ? null : JSON.stringify(patch.error))
571
571
  : (current.error == null ? null : JSON.stringify(current.error)),
572
572
  updated_at_ms: now,
573
- started_at_ms: Object.prototype.hasOwnProperty.call(patch, 'started_at_ms')
573
+ started_at_ms: Object.hasOwn(patch, 'started_at_ms')
574
574
  ? patch.started_at_ms
575
575
  : (nextStatus === 'running' ? (current.started_at_ms || now) : current.started_at_ms),
576
- completed_at_ms: Object.prototype.hasOwnProperty.call(patch, 'completed_at_ms')
576
+ completed_at_ms: Object.hasOwn(patch, 'completed_at_ms')
577
577
  ? patch.completed_at_ms
578
578
  : (isTerminal ? (current.completed_at_ms || now) : current.completed_at_ms),
579
- last_retry_at_ms: Object.prototype.hasOwnProperty.call(patch, 'last_retry_at_ms')
579
+ last_retry_at_ms: Object.hasOwn(patch, 'last_retry_at_ms')
580
580
  ? patch.last_retry_at_ms
581
581
  : current.last_retry_at_ms,
582
582
  };
@@ -661,7 +661,7 @@ export function createStore(dbPath, options = {}) {
661
661
  started_at_ms: null,
662
662
  last_retry_at_ms: Date.now(),
663
663
  result: patch.result ?? null,
664
- error: Object.prototype.hasOwnProperty.call(patch, 'error') ? patch.error : current.error,
664
+ error: Object.hasOwn(patch, 'error') ? patch.error : current.error,
665
665
  last_message_id: null,
666
666
  });
667
667
  },
@@ -1,6 +1,6 @@
1
- import { BOLD, DIM, GREEN, RESET, AMBER } from "../../../shared.mjs";
1
+ import { DIM, GREEN, RESET, AMBER } from "../../../shared.mjs";
2
2
  import { runHeadlessInteractive, resolveCliType } from "../../../headless.mjs";
3
- import { ok, warn } from "../../render.mjs";
3
+ import { ok, } from "../../render.mjs";
4
4
  import { buildTasks } from "../../services/task-model.mjs";
5
5
  import { clearTeamState } from "../../services/state-store.mjs";
6
6
 
@@ -10,7 +10,7 @@ export async function startHeadlessTeam({ sessionId, task, lead, agents, subtask
10
10
  ? assigns.map((a, i) => ({ cli: resolveCliType(a.cli), prompt: a.prompt, role: a.role || `worker-${i + 1}`, mcp: mcpProfile, model }))
11
11
  : subtasks.map((subtask, i) => ({ cli: resolveCliType(agents[i] || agents[0]), prompt: subtask, role: `worker-${i + 1}`, mcp: mcpProfile, model }));
12
12
 
13
- const startedAt = Date.now();
13
+ const _startedAt = Date.now();
14
14
  ok(`headless ${assignments.length}워커 시작`);
15
15
 
16
16
  const handle = await runHeadlessInteractive(sessionId, assignments, {
@@ -46,6 +46,8 @@ export async function startHeadlessTeam({ sessionId, task, lead, agents, subtask
46
46
  const failed = results.filter((r) => !r.matched || r.exitCode !== 0);
47
47
 
48
48
  ok(`헤드리스 완료: ${succeeded.length}성공 / ${failed.length}실패 / ${results.length}전체`);
49
+ // 머신 파싱용 완료 마커 — background task에서 결과 회수 시점 판단에 사용
50
+ console.log(`=== HEADLESS_COMPLETE succeeded=${succeeded.length} failed=${failed.length} total=${results.length} ===`);
49
51
 
50
52
  if (failed.length > 0) {
51
53
  for (const r of failed) console.log(` ${AMBER}✗${RESET} ${r.paneName} (${r.cli}) exit=${r.exitCode}`);
@@ -154,7 +154,7 @@ export async function ensureHubAlive(maxRetries = 3) {
154
154
  }
155
155
  // 다음 재시도 전 대기: 1초, 2초, 4초 (마지막 시도 후에는 대기 없음)
156
156
  if (i < maxRetries - 1) {
157
- const backoffMs = Math.pow(2, i) * 1000; // i=0: 1초, i=1: 2초, i=2: 4초
157
+ const backoffMs = 2 ** i * 1000; // i=0: 1초, i=1: 2초, i=2: 4초
158
158
  await new Promise((resolve) => setTimeout(resolve, backoffMs));
159
159
  }
160
160
  }
@@ -0,0 +1,121 @@
1
+ import { createMessage, MSG_TYPES } from "../../mesh/mesh-protocol.mjs";
2
+
3
+ const BRIDGE_AGENT_ID = "conductor";
4
+
5
+ /**
6
+ * Creates a bridge that converts Conductor EventEmitter events
7
+ * into Mesh protocol messages and keeps the registry in sync.
8
+ *
9
+ * @param {object} conductor - A conductor instance (with on/off)
10
+ * @param {object} registry - A mesh-registry instance
11
+ * @param {object} [opts]
12
+ * @param {string} [opts.bridgeAgentId="conductor"] - Agent ID for the bridge
13
+ * @param {function} [opts.onMessage] - Called with each generated mesh message
14
+ * @returns {object} Bridge API
15
+ */
16
+ export function createConductorMeshBridge(conductor, registry, opts = {}) {
17
+ const {
18
+ bridgeAgentId = BRIDGE_AGENT_ID,
19
+ onMessage,
20
+ } = opts;
21
+
22
+ const sessionAgentMap = new Map();
23
+ let attached = false;
24
+
25
+ function emit(message) {
26
+ if (typeof onMessage === "function") {
27
+ onMessage(message);
28
+ }
29
+ }
30
+
31
+ function agentIdForSession(sessionId) {
32
+ return `session:${sessionId}`;
33
+ }
34
+
35
+ /**
36
+ * Handle conductor stateChange events.
37
+ * - starting → register agent in registry
38
+ * - terminal states → unregister agent
39
+ * - all transitions → emit mesh EVENT message
40
+ */
41
+ function handleStateChange(event) {
42
+ const { sessionId, from, to, reason } = event;
43
+ const agentId = agentIdForSession(sessionId);
44
+
45
+ // Register on first starting transition
46
+ if (to === "starting" && !sessionAgentMap.has(sessionId)) {
47
+ registry.register(agentId, ["session"]);
48
+ sessionAgentMap.set(sessionId, agentId);
49
+ }
50
+
51
+ // Emit mesh event
52
+ const msg = createMessage(
53
+ MSG_TYPES.EVENT,
54
+ bridgeAgentId,
55
+ "*",
56
+ { event: "stateChange", sessionId, from, to, reason },
57
+ );
58
+ emit(msg);
59
+
60
+ // Unregister on terminal states
61
+ if (to === "dead" || to === "completed") {
62
+ registry.unregister(agentId);
63
+ sessionAgentMap.delete(sessionId);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Handle conductor completed events.
69
+ */
70
+ function handleCompleted(event) {
71
+ const msg = createMessage(
72
+ MSG_TYPES.EVENT,
73
+ bridgeAgentId,
74
+ "*",
75
+ { event: "completed", sessionId: event.sessionId },
76
+ );
77
+ emit(msg);
78
+ }
79
+
80
+ /**
81
+ * Handle conductor dead events.
82
+ */
83
+ function handleDead(event) {
84
+ const msg = createMessage(
85
+ MSG_TYPES.EVENT,
86
+ bridgeAgentId,
87
+ "*",
88
+ { event: "dead", sessionId: event.sessionId, reason: event.reason },
89
+ );
90
+ emit(msg);
91
+ }
92
+
93
+ /**
94
+ * Attaches event listeners to the conductor.
95
+ */
96
+ function attach() {
97
+ if (attached) return;
98
+ conductor.on("stateChange", handleStateChange);
99
+ conductor.on("completed", handleCompleted);
100
+ conductor.on("dead", handleDead);
101
+ attached = true;
102
+ }
103
+
104
+ /**
105
+ * Detaches event listeners and cleans up registry entries.
106
+ */
107
+ function detach() {
108
+ if (!attached) return;
109
+ conductor.off("stateChange", handleStateChange);
110
+ conductor.off("completed", handleCompleted);
111
+ conductor.off("dead", handleDead);
112
+
113
+ for (const [, agentId] of sessionAgentMap) {
114
+ registry.unregister(agentId);
115
+ }
116
+ sessionAgentMap.clear();
117
+ attached = false;
118
+ }
119
+
120
+ return { attach, detach, get isAttached() { return attached; } };
121
+ }