@triflux/remote 10.41.1 → 10.43.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/pipe.mjs CHANGED
@@ -222,6 +222,7 @@ export function createPipeServer({
222
222
  switch (action) {
223
223
  case "register": {
224
224
  const result = router.registerAgent(payload);
225
+ if (!result?.ok) return result;
225
226
  if (client) {
226
227
  client.agentId = payload.agent_id;
227
228
  client.subscriptions = new Set(
@@ -230,7 +231,7 @@ export function createPipeServer({
230
231
  touchClient(client);
231
232
  pushPendingMessages(client.agentId);
232
233
  }
233
- return { ok: true, data: { ...result, pipe_path: pipePath } };
234
+ return { ok: true, data: { ...result.data, pipe_path: pipePath } };
234
235
  }
235
236
 
236
237
  case "subscribe": {
@@ -268,6 +269,9 @@ export function createPipeServer({
268
269
  payload.heartbeat_ttl_ms || heartbeatTtlMs,
269
270
  );
270
271
  if (client) touchClient(client);
272
+ if (result?.effective?.updated === false) {
273
+ return { ok: false, error: { code: "AGENT_NOT_FOUND", message: `heartbeat target not registered: ${agentId}` } };
274
+ }
271
275
  return { ok: true, data: result };
272
276
  }
273
277
 
@@ -62,8 +62,8 @@
62
62
  .mini-head, .mcp-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; min-width: 0; }
63
63
  .mini-title, .mcp-name { font-size: 12px; font-weight: 650; line-height: 1.25; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
64
64
  .path-text { font-size: 10px; color: var(--text-muted); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
65
- .focus-btn { background: transparent; border: 1px solid rgba(10,132,255,0.45); color: #64b5ff; font-size: 10px; line-height: 1; padding: 4px 7px; cursor: pointer; border-radius: 6px; flex: 0 0 auto; }
66
- .focus-btn:hover { background: var(--accent); color: #fff; }
65
+ .copy-btn { background: transparent; border: 1px solid rgba(10,132,255,0.45); color: #64b5ff; font-size: 10px; line-height: 1; padding: 4px 7px; cursor: pointer; border-radius: 6px; flex: 0 0 auto; }
66
+ .copy-btn:hover { background: var(--accent); color: #fff; }
67
67
  .agent-view { display: flex; flex-direction: column; gap: 8px; }
68
68
  .project-group { min-width: 0; border: 1px solid rgba(255,255,255,0.06); background: rgba(0,0,0,0.13); border-radius: 9px; overflow: hidden; }
69
69
  .project-group > summary, .session-card > summary, .bubble-wrap > summary { list-style: none; }
@@ -115,6 +115,8 @@
115
115
  .chip { display: inline-flex; align-items: center; gap: 5px; min-height: 20px; border-radius: 999px; padding: 3px 7px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.07); font-size: 10px; color: rgba(255,255,255,0.76); }
116
116
  .copy-chip { cursor: pointer; }
117
117
  .copy-chip:hover { border-color: rgba(10,132,255,0.42); color: #8ec9ff; }
118
+ .copy-chip, .copy-btn { -webkit-user-drag: none; }
119
+ .chat-session-id, .bubble-preview, .bubble-full, .runtime-sub, .path-text, .session-summary-title, .session-summary-sub, .project-path { -webkit-user-select: text; user-select: text; }
118
120
  .chip.on { color: var(--success); border-color: rgba(50,215,75,0.25); background: rgba(50,215,75,0.08); }
119
121
  .chip.partial { color: var(--warning); border-color: rgba(255,214,10,0.25); background: rgba(255,214,10,0.08); }
120
122
  .chip.off { color: var(--danger); border-color: rgba(255,69,58,0.25); background: rgba(255,69,58,0.08); }
@@ -190,7 +192,45 @@
190
192
  }
191
193
  function renderCopyChip(label, value) {
192
194
  if (!value) return '';
193
- return `<span class="chip copy-chip" title="Copy ${escapeHtml(label)}" data-copy-text="${dataAttr(value)}">${escapeHtml(label)} ${escapeHtml(shortId(value))}</span>`;
195
+ return `<span class="chip copy-chip" draggable="false" title="Copy ${escapeHtml(label)}" data-copy-text="${dataAttr(value)}">${escapeHtml(label)} ${escapeHtml(shortId(value))}</span>`;
196
+ }
197
+
198
+ function shellQuote(value) {
199
+ const text = String(value ?? '').trim();
200
+ return `'${text.replace(/'/g, `'\\''`)}'`;
201
+ }
202
+
203
+ function commandCwd(row) {
204
+ const cwd = String(row?.cwd || row?.worktreePath || '').trim();
205
+ if (!cwd || cwd === 'local' || cwd === 'local runtime') return '';
206
+ if (/^https?:\/\//iu.test(cwd)) return '';
207
+ return cwd;
208
+ }
209
+
210
+ function sessionAttachCommand(row) {
211
+ const sid = String(row?.sessionId || '').trim();
212
+ if (!sid) return '';
213
+ const agentId = String(row?.agent_id || row?.agent?.id || '').trim().toLowerCase();
214
+ const cwd = commandCwd(row);
215
+ const prefix = cwd ? `cd ${shellQuote(cwd)} && ` : '';
216
+ if (agentId === 'codex') return `${prefix}codex resume ${shellQuote(sid)}`;
217
+ if (agentId === 'claude') return `${prefix}claude --resume ${shellQuote(sid)}`;
218
+ if (agentId === 'antigravity') {
219
+ const conversationId = String(row?.conversationId || '').trim();
220
+ const token = conversationId || sid;
221
+ const suffix = token === sid ? '' : ` # session ${sid}`;
222
+ return `${prefix}agy --conversation ${shellQuote(token)}${suffix}`;
223
+ }
224
+ return `${prefix}tfx-live start --session ${shellQuote(sid)} --resume ${shellQuote(sid)}`;
225
+ }
226
+
227
+ function renderSessionCopyButton(row) {
228
+ const sid = String(row?.sessionId || '').trim();
229
+ const command = sessionAttachCommand(row);
230
+ if (!sid || !command) return '';
231
+ const pid = numericPid(row?.pid);
232
+ const agentId = row?.agent_id || row?.agent?.id || '';
233
+ return `<button class="copy-btn" draggable="false" data-copy-text="${dataAttr(command)}" data-session-id="${dataAttr(sid)}" data-pid="${dataAttr(pid)}" data-agent-id="${dataAttr(agentId)}" title="Copy launch command">Copy</button>`;
194
234
  }
195
235
 
196
236
  function collectOpenDetailKeys(container) {
@@ -471,10 +511,8 @@
471
511
 
472
512
  function renderChatBubble(row, { side = 'worker' } = {}) {
473
513
  const sid = row.sessionId || '';
474
- const pid = numericPid(row.pid);
475
- const agentId = row.agent_id || row.agent?.id || '';
476
514
  const text = bubbleText(row, side === 'cto' ? 'CTO prompt unavailable' : 'Worker prompt unavailable');
477
- const focusDisabled = row.synthetic || !sid;
515
+ const copyButton = row.synthetic ? '' : renderSessionCopyButton(row);
478
516
  const badge = side === 'cto'
479
517
  ? '<span class="agent-badge">T</span>'
480
518
  : renderAgentTag(row.agent) || '<span class="agent-badge">A</span>';
@@ -482,7 +520,7 @@
482
520
  <div class="bubble-card ${escapeHtml(side)}">
483
521
  <div class="chat-session-title">
484
522
  <span style="display:flex;align-items:center;gap:6px;min-width:0;">${badge}<span class="chat-session-id">${escapeHtml(sid || side)}</span></span>
485
- ${focusDisabled ? '' : `<button class="focus-btn" data-focus-session="1" data-session-id="${dataAttr(sid)}" data-pid="${dataAttr(pid)}" data-agent-id="${dataAttr(agentId)}">Focus</button>`}
523
+ ${copyButton}
486
524
  </div>
487
525
  <details class="bubble-wrap" data-open-key="bubble:${escapeHtml(side)}:${dataAttr(sid || text.slice(0, 24))}">
488
526
  <summary class="bubble-preview">${escapeHtml(compactText(text, 'prompt'))}</summary>
@@ -494,10 +532,8 @@
494
532
 
495
533
  function renderRuntimeCard(row) {
496
534
  const sid = row.sessionId || '';
497
- const pid = numericPid(row.pid);
498
- const agentId = row.agent_id || row.agent?.id || '';
499
535
  const status = row.status || row.phase || 'active';
500
- const focusDisabled = row.focusable === false || !sid || !pid;
536
+ const copyButton = row.focusable === false ? '' : renderSessionCopyButton(row);
501
537
  return `
502
538
  <div class="runtime-card">
503
539
  ${renderAgentTag(row.agent)}
@@ -505,7 +541,7 @@
505
541
  <div class="runtime-title"><span class="runtime-command">${escapeHtml(commandLabel(row))}</span><span class="agent-state ${escapeHtml(status)}">${escapeHtml(status)}</span></div>
506
542
  <div class="runtime-sub">${escapeHtml(displayPath(row.cwd || row.host || 'local runtime'))}${row.elapsed ? ` · ${escapeHtml(row.elapsed)}` : ''}</div>
507
543
  </div>
508
- ${focusDisabled ? '' : `<button class="focus-btn" data-focus-session="1" data-session-id="${dataAttr(sid)}" data-pid="${dataAttr(pid)}" data-agent-id="${dataAttr(agentId)}">Focus</button>`}
544
+ ${copyButton}
509
545
  </div>
510
546
  `;
511
547
  }
@@ -703,54 +739,15 @@
703
739
  setTimeout(() => { element.textContent = previous; }, 700);
704
740
  }
705
741
  }
706
- function postNativeTrayMessage(message) {
707
- try {
708
- window?.webkit?.messageHandlers?.tray?.postMessage(message);
709
- } catch (_err) {
710
- // Browser fallback: the web tray can run without a native wrapper.
711
- }
712
- }
713
-
714
- async function focusSession(sid, pid, agentId, button) {
715
- if (!sid) return;
716
- const previous = button?.textContent || 'Focus';
717
- if (button) {
718
- button.disabled = true;
719
- button.textContent = 'Focusing…';
720
- }
721
- try {
722
- const res = await fetch('/api/focus-session', {
723
- method: 'POST',
724
- headers: { 'Content-Type': 'application/json' },
725
- body: JSON.stringify({ sessionId: sid, pid: pid || undefined, agentId: agentId || undefined })
726
- });
727
- const body = await res.json().catch(() => ({}));
728
- if (!res.ok || body?.ok === false || body?.focus?.ok === false) throw new Error(body?.error || body?.focus?.error || 'focus failed');
729
- if (button) button.textContent = 'Focused';
730
- postNativeTrayMessage({ type: 'focus-complete' });
731
- } catch (err) {
732
- console.error(err);
733
- if (button) button.textContent = 'Failed';
734
- } finally {
735
- setTimeout(() => {
736
- if (button) {
737
- button.textContent = previous;
738
- button.disabled = false;
739
- }
740
- }, 650);
741
- }
742
- }
743
742
  document.addEventListener('click', event => {
744
743
  const copyTarget = event.target.closest('[data-copy-text]');
745
744
  if (copyTarget) {
746
745
  copyText(copyTarget.dataset.copyText, copyTarget);
747
- return;
748
- }
749
- const focusTarget = event.target.closest('[data-focus-session]');
750
- if (focusTarget) {
751
- focusSession(focusTarget.dataset.sessionId, Number(focusTarget.dataset.pid || 0), focusTarget.dataset.agentId || '', focusTarget);
752
746
  }
753
747
  });
748
+ document.addEventListener('dragstart', event => {
749
+ if (event.target.closest('[data-copy-text]')) event.preventDefault();
750
+ });
754
751
 
755
752
  setInterval(fetchState, 3000);
756
753
  fetchState();
package/hub/schema.sql ADDED
@@ -0,0 +1,149 @@
1
+ -- tfx-hub 상태 저장소 스키마
2
+ -- SQLite WAL 모드 기반 메시지 버스
3
+
4
+ -- 에이전트 등록 테이블
5
+ CREATE TABLE IF NOT EXISTS agents (
6
+ agent_id TEXT PRIMARY KEY,
7
+ cli TEXT NOT NULL CHECK (cli IN ('codex','gemini','claude','other')),
8
+ pid INTEGER,
9
+ capabilities_json TEXT NOT NULL DEFAULT '[]',
10
+ topics_json TEXT NOT NULL DEFAULT '[]',
11
+ last_seen_ms INTEGER NOT NULL,
12
+ lease_expires_ms INTEGER NOT NULL,
13
+ status TEXT NOT NULL CHECK (status IN ('online','stale','offline')),
14
+ metadata_json TEXT NOT NULL DEFAULT '{}'
15
+ );
16
+
17
+ -- 메시지 테이블
18
+ CREATE TABLE IF NOT EXISTS messages (
19
+ id TEXT PRIMARY KEY,
20
+ type TEXT NOT NULL CHECK (type IN ('request','response','event','handoff','human_request','human_response','system')),
21
+ from_agent TEXT NOT NULL,
22
+ to_agent TEXT NOT NULL,
23
+ topic TEXT NOT NULL,
24
+ priority INTEGER NOT NULL CHECK (priority BETWEEN 1 AND 9),
25
+ ttl_ms INTEGER NOT NULL,
26
+ created_at_ms INTEGER NOT NULL,
27
+ expires_at_ms INTEGER NOT NULL,
28
+ correlation_id TEXT NOT NULL,
29
+ trace_id TEXT NOT NULL,
30
+ payload_json TEXT NOT NULL DEFAULT '{}',
31
+ status TEXT NOT NULL CHECK (status IN ('queued','delivered','acked','expired','dead_letter'))
32
+ );
33
+
34
+ -- 메시지 수신함 (배달 추적)
35
+ CREATE TABLE IF NOT EXISTS message_inbox (
36
+ delivery_id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ message_id TEXT NOT NULL,
38
+ agent_id TEXT NOT NULL,
39
+ delivered_at_ms INTEGER,
40
+ acked_at_ms INTEGER,
41
+ attempts INTEGER NOT NULL DEFAULT 0,
42
+ UNIQUE(message_id, agent_id),
43
+ FOREIGN KEY(message_id) REFERENCES messages(id) ON DELETE CASCADE
44
+ );
45
+
46
+ -- 사용자 입력 요청 테이블
47
+ CREATE TABLE IF NOT EXISTS human_requests (
48
+ request_id TEXT PRIMARY KEY,
49
+ requester_agent TEXT NOT NULL,
50
+ kind TEXT NOT NULL CHECK (kind IN ('captcha','approval','credential','choice','text')),
51
+ prompt TEXT NOT NULL,
52
+ schema_json TEXT NOT NULL DEFAULT '{}',
53
+ state TEXT NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','timed_out')),
54
+ deadline_ms INTEGER NOT NULL,
55
+ default_action TEXT NOT NULL CHECK (default_action IN ('decline','cancel','timeout_continue')),
56
+ correlation_id TEXT NOT NULL,
57
+ trace_id TEXT NOT NULL,
58
+ response_json TEXT
59
+ );
60
+
61
+ -- 데드 레터 큐
62
+ CREATE TABLE IF NOT EXISTS dead_letters (
63
+ message_id TEXT PRIMARY KEY,
64
+ reason TEXT NOT NULL,
65
+ failed_at_ms INTEGER NOT NULL,
66
+ last_error TEXT
67
+ );
68
+
69
+ -- 인덱스
70
+ CREATE INDEX IF NOT EXISTS idx_messages_status ON messages(status);
71
+ CREATE INDEX IF NOT EXISTS idx_messages_to_agent ON messages(to_agent, status);
72
+ CREATE INDEX IF NOT EXISTS idx_messages_correlation ON messages(correlation_id);
73
+ CREATE INDEX IF NOT EXISTS idx_messages_trace ON messages(trace_id);
74
+ CREATE INDEX IF NOT EXISTS idx_messages_expires ON messages(expires_at_ms);
75
+ CREATE INDEX IF NOT EXISTS idx_messages_priority ON messages(priority DESC, created_at_ms ASC);
76
+ CREATE INDEX IF NOT EXISTS idx_inbox_agent ON message_inbox(agent_id, delivered_at_ms);
77
+ CREATE INDEX IF NOT EXISTS idx_inbox_message ON message_inbox(message_id);
78
+ CREATE INDEX IF NOT EXISTS idx_human_requests_state ON human_requests(state);
79
+ CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status);
80
+ CREATE INDEX IF NOT EXISTS idx_agents_lease ON agents(lease_expires_ms);
81
+
82
+ -- Assign Job 테이블
83
+ CREATE TABLE IF NOT EXISTS assign_jobs (
84
+ job_id TEXT PRIMARY KEY,
85
+ supervisor_agent TEXT NOT NULL,
86
+ worker_agent TEXT NOT NULL,
87
+ topic TEXT NOT NULL DEFAULT 'assign.job',
88
+ task TEXT NOT NULL DEFAULT '',
89
+ payload_json TEXT NOT NULL DEFAULT '{}',
90
+ status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed','timed_out')),
91
+ attempt INTEGER NOT NULL DEFAULT 1,
92
+ retry_count INTEGER NOT NULL DEFAULT 0,
93
+ max_retries INTEGER NOT NULL DEFAULT 0,
94
+ priority INTEGER NOT NULL DEFAULT 5 CHECK (priority BETWEEN 1 AND 9),
95
+ ttl_ms INTEGER NOT NULL DEFAULT 600000,
96
+ timeout_ms INTEGER NOT NULL DEFAULT 600000,
97
+ deadline_ms INTEGER,
98
+ trace_id TEXT NOT NULL,
99
+ correlation_id TEXT NOT NULL,
100
+ last_message_id TEXT,
101
+ result_json TEXT,
102
+ error_json TEXT,
103
+ created_at_ms INTEGER NOT NULL,
104
+ updated_at_ms INTEGER NOT NULL,
105
+ started_at_ms INTEGER,
106
+ dispatched_at_ms INTEGER,
107
+ completed_at_ms INTEGER,
108
+ last_retry_at_ms INTEGER
109
+ );
110
+
111
+ CREATE INDEX IF NOT EXISTS idx_assign_jobs_status ON assign_jobs(status, updated_at_ms DESC);
112
+ CREATE INDEX IF NOT EXISTS idx_assign_jobs_supervisor ON assign_jobs(supervisor_agent, updated_at_ms DESC);
113
+ CREATE INDEX IF NOT EXISTS idx_assign_jobs_worker ON assign_jobs(worker_agent, updated_at_ms DESC);
114
+ CREATE INDEX IF NOT EXISTS idx_assign_jobs_deadline ON assign_jobs(deadline_ms, status);
115
+
116
+ -- 파이프라인 상태 테이블 (Phase 2)
117
+ CREATE TABLE IF NOT EXISTS pipeline_state (
118
+ team_name TEXT PRIMARY KEY,
119
+ phase TEXT NOT NULL DEFAULT 'plan',
120
+ fix_attempt INTEGER DEFAULT 0,
121
+ fix_max INTEGER DEFAULT 3,
122
+ ralph_iteration INTEGER DEFAULT 0,
123
+ ralph_max INTEGER DEFAULT 10,
124
+ artifacts TEXT DEFAULT '{}',
125
+ phase_history TEXT DEFAULT '[]',
126
+ created_at INTEGER,
127
+ updated_at INTEGER
128
+ );
129
+
130
+ -- Reflexion 에러 학습 테이블
131
+ CREATE TABLE IF NOT EXISTS reflexion_entries (
132
+ id TEXT PRIMARY KEY,
133
+ type TEXT NOT NULL DEFAULT 'reflexion', -- reflexion | adaptive
134
+ error_pattern TEXT NOT NULL, -- 에러 시그니처 (정규화)
135
+ error_message TEXT NOT NULL, -- 원본 에러 메시지
136
+ context_json TEXT NOT NULL DEFAULT '{}', -- { file, function, cli, agent }
137
+ solution TEXT NOT NULL, -- 해결책 설명
138
+ solution_code TEXT, -- 해결 코드 스니펫 (있으면)
139
+ adaptive_state_json TEXT NOT NULL DEFAULT '{}', -- adaptive rule metadata
140
+ confidence REAL NOT NULL DEFAULT 0.5, -- 솔루션 신뢰도 (0-1)
141
+ hit_count INTEGER NOT NULL DEFAULT 1, -- 매칭 횟수
142
+ success_count INTEGER NOT NULL DEFAULT 0, -- 성공 횟수
143
+ last_hit_ms INTEGER NOT NULL,
144
+ created_at_ms INTEGER NOT NULL,
145
+ updated_at_ms INTEGER NOT NULL
146
+ );
147
+
148
+ CREATE INDEX IF NOT EXISTS idx_reflexion_pattern ON reflexion_entries(error_pattern);
149
+ CREATE INDEX IF NOT EXISTS idx_reflexion_confidence ON reflexion_entries(confidence DESC);
package/hub/server.mjs CHANGED
@@ -32,6 +32,13 @@ import {
32
32
  import { broker as brokerInstance, reloadBroker } from "@triflux/core/hub/account-broker.mjs";
33
33
  import { createAdaptiveEngine } from "@triflux/core/hub/adaptive.mjs";
34
34
  import { createAssignCallbackServer } from "@triflux/core/hub/assign-callbacks.mjs";
35
+ import {
36
+ clampDurationMs,
37
+ DEFAULT_ASSIGN_TIMEOUT_MS,
38
+ DEFAULT_ASSIGN_TTL_MS,
39
+ DEFAULT_REGISTER_TIMEOUT_SEC,
40
+ resolveWorkerLeaseTtlMs,
41
+ } from "@triflux/core/hub/lib/timeout-defaults.mjs";
35
42
  import { DelegatorService } from "@triflux/core/hub/delegator/index.mjs";
36
43
  import { createHitlManager } from "@triflux/core/hub/hitl.mjs";
37
44
  import {
@@ -143,7 +150,7 @@ function buildHubUrl(host, port) {
143
150
 
144
151
  function parseHubPort(value) {
145
152
  const parsed = Number.parseInt(String(value ?? ""), 10);
146
- return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
153
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
147
154
  }
148
155
 
149
156
  function isPidAlive(pid, killFn = process.kill) {
@@ -963,16 +970,17 @@ export async function startHub({
963
970
  createDelegatorWorker = createDelegatorMcpWorker,
964
971
  } = {}) {
965
972
  const resolvedPort = parseHubPort(portOpt);
966
- const portSpecified = Number.isFinite(resolvedPort) && resolvedPort > 0;
973
+ const portSpecified = Number.isFinite(resolvedPort);
974
+ const ephemeralPort = resolvedPort === 0;
967
975
  const livePeer = detectLivePeer();
968
976
  const livePidPort = parseHubPort(livePeer?.port);
969
- const port = portSpecified
977
+ let port = portSpecified
970
978
  ? resolvedPort
971
979
  : resolveHubPort(process.env, {
972
980
  detectPeer: () => livePeer,
973
981
  });
974
982
 
975
- if (portSpecified && livePeer.alive && livePidPort && port !== livePidPort) {
983
+ if (portSpecified && !ephemeralPort && livePeer.alive && livePidPort && port !== livePidPort) {
976
984
  hubLog.warn(
977
985
  {
978
986
  requestedPort: port,
@@ -983,11 +991,8 @@ export async function startHub({
983
991
  );
984
992
  }
985
993
 
986
- const existingHub = await tryReuseExistingHub({
987
- port,
988
- portSpecified,
989
- host,
990
- detectPeer: () => livePeer,
994
+ const existingHub = ephemeralPort ? null : await tryReuseExistingHub({
995
+ port, portSpecified, host, detectPeer: () => livePeer,
991
996
  });
992
997
  if (existingHub) return existingHub;
993
998
 
@@ -1026,7 +1031,7 @@ export async function startHub({
1026
1031
  lockHeld = false;
1027
1032
  };
1028
1033
 
1029
- const lockedExistingHub = await tryReuseExistingHub({
1034
+ const lockedExistingHub = ephemeralPort ? null : await tryReuseExistingHub({
1030
1035
  port,
1031
1036
  portSpecified,
1032
1037
  host,
@@ -1038,9 +1043,9 @@ export async function startHub({
1038
1043
  }
1039
1044
 
1040
1045
  const HUB_TOKEN = process.env.TFX_HUB_TOKEN?.trim() || null;
1041
- if (HUB_TOKEN) {
1046
+ if (!ephemeralPort && HUB_TOKEN) {
1042
1047
  writeOwnedTokenFile({ token: HUB_TOKEN, port });
1043
- } else {
1048
+ } else if (!ephemeralPort) {
1044
1049
  cleanupOwnedTokenFile({ token: HUB_TOKEN, port });
1045
1050
  }
1046
1051
 
@@ -1690,7 +1695,7 @@ export async function startHub({
1690
1695
  const {
1691
1696
  agent_id,
1692
1697
  cli,
1693
- timeout_sec = 600,
1698
+ timeout_sec = DEFAULT_REGISTER_TIMEOUT_SEC,
1694
1699
  topics = [],
1695
1700
  capabilities = [],
1696
1701
  metadata = {},
@@ -1709,7 +1714,10 @@ export async function startHub({
1709
1714
  metadata?.task ||
1710
1715
  cli;
1711
1716
  const workerStartedAt = performance.now();
1712
- const heartbeat_ttl_ms = (timeout_sec + 120) * 1000;
1717
+ const hasExplicitTimeout = Number.isFinite(Number(body.timeout_sec));
1718
+ const heartbeat_ttl_ms = hasExplicitTimeout
1719
+ ? (timeout_sec + 120) * 1000
1720
+ : Math.max((timeout_sec + 120) * 1000, resolveWorkerLeaseTtlMs());
1713
1721
  const result = await pipe.executeCommand("register", {
1714
1722
  agent_id,
1715
1723
  cli,
@@ -1725,7 +1733,7 @@ export async function startHub({
1725
1733
  });
1726
1734
  recordWorker(agent_id, "spawned", command, 0);
1727
1735
  }
1728
- return writeJson(res, 200, result);
1736
+ return writeJson(res, result.ok ? 200 : 409, result);
1729
1737
  }
1730
1738
 
1731
1739
  if (path === "/bridge/result" && req.method === "POST") {
@@ -1750,6 +1758,16 @@ export async function startHub({
1750
1758
  return writeJson(res, 200, result);
1751
1759
  }
1752
1760
 
1761
+ if (path === "/bridge/heartbeat" && req.method === "POST") {
1762
+ const { agent_id, ttl_ms } = body;
1763
+ if (!agent_id) return writeJson(res, 400, { ok: false, error: "agent_id 필수" });
1764
+ const result = await pipe.executeCommand("heartbeat", {
1765
+ agent_id,
1766
+ heartbeat_ttl_ms: clampDurationMs(ttl_ms, resolveWorkerLeaseTtlMs()),
1767
+ });
1768
+ return writeJson(res, result.ok ? 200 : 404, result);
1769
+ }
1770
+
1753
1771
  if (path === "/bridge/control" && req.method === "POST") {
1754
1772
  const {
1755
1773
  from_agent = "lead",
@@ -1881,8 +1899,8 @@ export async function startHub({
1881
1899
  topic = "assign.job",
1882
1900
  payload = {},
1883
1901
  priority = 5,
1884
- ttl_ms = 600000,
1885
- timeout_ms = 600000,
1902
+ ttl_ms = DEFAULT_ASSIGN_TTL_MS,
1903
+ timeout_ms = DEFAULT_ASSIGN_TIMEOUT_MS,
1886
1904
  max_retries = 0,
1887
1905
  trace_id,
1888
1906
  correlation_id,
@@ -2352,7 +2370,7 @@ export async function startHub({
2352
2370
  try {
2353
2371
  store.close();
2354
2372
  } catch {}
2355
- if (!preserveTokenFile) {
2373
+ if (!ephemeralPort && !preserveTokenFile) {
2356
2374
  cleanupOwnedTokenFile({ token: HUB_TOKEN, port });
2357
2375
  }
2358
2376
  releaseStartupLock();
@@ -2426,6 +2444,8 @@ export async function startHub({
2426
2444
  httpServer.listen(port, host, () => {
2427
2445
  httpServer.off("error", onError);
2428
2446
  try {
2447
+ const boundAddress = httpServer.address();
2448
+ if (boundAddress && typeof boundAddress === "object") port = boundAddress.port;
2429
2449
  let idleTimer = null;
2430
2450
  let stopPromise = null;
2431
2451
 
@@ -2446,7 +2466,7 @@ export async function startHub({
2446
2466
  idleTimeoutMs: hubIdleTimeoutMs,
2447
2467
  };
2448
2468
 
2449
- writeState({
2469
+ if (!ephemeralPort) writeState({
2450
2470
  pid: process.pid,
2451
2471
  port,
2452
2472
  host,
@@ -2464,7 +2484,7 @@ export async function startHub({
2464
2484
  session_id: sessionId,
2465
2485
  });
2466
2486
  releaseStartupLock();
2467
- void syncHubMcpSettingsIfAvailable({ hubUrl: info.url });
2487
+ if (!ephemeralPort) void syncHubMcpSettingsIfAvailable({ hubUrl: info.url });
2468
2488
 
2469
2489
  hubLog.info(
2470
2490
  {
@@ -2551,8 +2571,10 @@ export async function startHub({
2551
2571
  synapseRegistry.destroy();
2552
2572
  } catch {}
2553
2573
  store.close();
2554
- cleanupOwnedPidFile();
2555
- cleanupOwnedTokenFile({ token: HUB_TOKEN, port });
2574
+ if (!ephemeralPort) {
2575
+ cleanupOwnedPidFile();
2576
+ cleanupOwnedTokenFile({ token: HUB_TOKEN, port });
2577
+ }
2556
2578
  httpServer.closeAllConnections();
2557
2579
  await new Promise((resolveClose) =>
2558
2580
  httpServer.close(resolveClose),