@triflux/remote 10.1.0 → 10.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/hub/pipe.mjs +239 -121
  2. package/hub/public/dashboard.html +61 -0
  3. package/hub/server.mjs +2165 -708
  4. package/hub/store-adapter.mjs +59 -26
  5. package/hub/store.mjs +356 -153
  6. package/hub/team/agent-map.json +26 -7
  7. package/hub/team/ansi.mjs +245 -146
  8. package/hub/team/backend.mjs +84 -17
  9. package/hub/team/build-worker-prompt.mjs +42 -0
  10. package/hub/team/check-mcp-hub.mjs +49 -0
  11. package/hub/team/claude-daemon-control.mjs +219 -0
  12. package/hub/team/claude-native-bridge.mjs +784 -0
  13. package/hub/team/claude-session-projection.mjs +65 -0
  14. package/hub/team/cli/commands/attach.mjs +57 -37
  15. package/hub/team/cli/commands/control.mjs +29 -8
  16. package/hub/team/cli/commands/debug.mjs +95 -74
  17. package/hub/team/cli/commands/focus.mjs +80 -53
  18. package/hub/team/cli/commands/interrupt.mjs +18 -6
  19. package/hub/team/cli/commands/kill.mjs +16 -5
  20. package/hub/team/cli/commands/list.mjs +31 -24
  21. package/hub/team/cli/commands/send.mjs +19 -6
  22. package/hub/team/cli/commands/start/index.mjs +154 -33
  23. package/hub/team/cli/commands/start/parse-args.mjs +89 -12
  24. package/hub/team/cli/commands/start/start-headless.mjs +125 -37
  25. package/hub/team/cli/commands/start/start-in-process.mjs +50 -40
  26. package/hub/team/cli/commands/start/start-mux.mjs +122 -73
  27. package/hub/team/cli/commands/start/start-wt.mjs +86 -69
  28. package/hub/team/cli/commands/status.mjs +43 -14
  29. package/hub/team/cli/commands/stop.mjs +11 -4
  30. package/hub/team/cli/commands/task.mjs +8 -3
  31. package/hub/team/cli/commands/tasks.mjs +13 -13
  32. package/hub/team/cli/index.mjs +2 -2
  33. package/hub/team/cli/manifest.mjs +38 -8
  34. package/hub/team/cli/render.mjs +52 -30
  35. package/hub/team/cli/services/attach-fallback.mjs +74 -54
  36. package/hub/team/cli/services/hub-client.mjs +44 -16
  37. package/hub/team/cli/services/member-selector.mjs +37 -30
  38. package/hub/team/cli/services/native-control.mjs +144 -116
  39. package/hub/team/cli/services/runtime-mode.mjs +55 -1
  40. package/hub/team/cli/services/state-store.mjs +66 -9
  41. package/hub/team/cli/services/task-model.mjs +40 -30
  42. package/hub/team/codex-review.mjs +521 -0
  43. package/hub/team/conductor-mesh-bridge.mjs +25 -24
  44. package/hub/team/conductor-registry.mjs +121 -0
  45. package/hub/team/conductor.mjs +1379 -671
  46. package/hub/team/consensus-meta.mjs +176 -0
  47. package/hub/team/dashboard-anchor.mjs +4 -5
  48. package/hub/team/dashboard-layout.mjs +3 -1
  49. package/hub/team/dashboard-open.mjs +20 -118
  50. package/hub/team/dashboard.mjs +76 -28
  51. package/hub/team/event-log.mjs +18 -10
  52. package/hub/team/execution-mode.mjs +241 -0
  53. package/hub/team/extract-completion-payload.mjs +110 -0
  54. package/hub/team/git-preflight.mjs +286 -0
  55. package/hub/team/handoff.mjs +49 -18
  56. package/hub/team/headless-bridge-session.mjs +17 -0
  57. package/hub/team/headless.mjs +2065 -1156
  58. package/hub/team/health-probe.mjs +232 -69
  59. package/hub/team/launcher-template.mjs +23 -13
  60. package/hub/team/lead-control.mjs +91 -4
  61. package/hub/team/mcp-selector.mjs +145 -0
  62. package/hub/team/native-supervisor.mjs +90 -26
  63. package/hub/team/native.mjs +74 -35
  64. package/hub/team/nativeProxy.mjs +184 -113
  65. package/hub/team/notify.mjs +147 -78
  66. package/hub/team/orchestrator.mjs +173 -161
  67. package/hub/team/pane.mjs +43 -15
  68. package/hub/team/process-cleanup.mjs +69 -25
  69. package/hub/team/psmux.mjs +1020 -279
  70. package/hub/team/recovery-store.mjs +149 -0
  71. package/hub/team/remote-probe.mjs +68 -52
  72. package/hub/team/remote-session.mjs +131 -59
  73. package/hub/team/remote-watcher.mjs +61 -33
  74. package/hub/team/retry-state-machine.mjs +267 -0
  75. package/hub/team/routing.mjs +51 -25
  76. package/hub/team/runtime-strategy.mjs +200 -0
  77. package/hub/team/schemas/worker-completion.json +39 -0
  78. package/hub/team/sentinel-capture.mjs +127 -0
  79. package/hub/team/session-sync.mjs +153 -6
  80. package/hub/team/session.mjs +605 -611
  81. package/hub/team/shared.mjs +13 -13
  82. package/hub/team/staleState.mjs +72 -30
  83. package/hub/team/swarm-cli.mjs +390 -0
  84. package/hub/team/swarm-hypervisor.mjs +1522 -193
  85. package/hub/team/swarm-intent.mjs +216 -0
  86. package/hub/team/swarm-locks.mjs +140 -25
  87. package/hub/team/swarm-planner.mjs +121 -21
  88. package/hub/team/swarm-preflight.mjs +317 -0
  89. package/hub/team/swarm-reconciler.mjs +128 -23
  90. package/hub/team/synapse-cli.mjs +458 -0
  91. package/hub/team/synapse-http.mjs +60 -0
  92. package/hub/team/synapse-registry.mjs +329 -0
  93. package/hub/team/terminal-opener.mjs +216 -0
  94. package/hub/team/tui-core.mjs +288 -0
  95. package/hub/team/tui-lite.mjs +234 -146
  96. package/hub/team/tui-remote-adapter.mjs +18 -34
  97. package/hub/team/tui-synapse.mjs +223 -0
  98. package/hub/team/tui-viewer.mjs +104 -44
  99. package/hub/team/tui-widgets.mjs +262 -0
  100. package/hub/team/tui.mjs +927 -437
  101. package/hub/team/worker-completion-validator.mjs +84 -0
  102. package/hub/team/worker-sandbox.mjs +93 -0
  103. package/hub/team/worker-signal.mjs +263 -0
  104. package/hub/team/worker-signal.types.d.ts +52 -0
  105. package/hub/team/worktree-lifecycle.mjs +517 -63
  106. package/hub/team/wt-manager.mjs +297 -26
  107. package/hub/tools.mjs +474 -252
  108. package/hub/tray.mjs +411 -368
  109. package/hub/workers/claude-worker.mjs +162 -118
  110. package/hub/workers/codex-app-server-worker.mjs +1015 -0
  111. package/hub/workers/codex-mcp.mjs +724 -507
  112. package/hub/workers/delegator-mcp.mjs +662 -335
  113. package/hub/workers/factory.mjs +100 -21
  114. package/hub/workers/gemini-worker.mjs +179 -86
  115. package/hub/workers/interface.mjs +23 -2
  116. package/hub/workers/lib/jsonrpc-stdio.mjs +463 -0
  117. package/hub/workers/worker-utils.mjs +22 -16
  118. package/package.json +4 -3
  119. package/scripts/lib/agent-json.mjs +27 -0
  120. package/scripts/lib/async.mjs +174 -0
  121. package/scripts/lib/claudemd-scanner.mjs +223 -0
  122. package/scripts/lib/cli-agy.mjs +62 -0
  123. package/scripts/lib/cli-claude.mjs +78 -0
  124. package/scripts/lib/cli-codex.mjs +199 -0
  125. package/scripts/lib/cli-gemini.mjs +67 -0
  126. package/scripts/lib/codex-recovery.sh +50 -0
  127. package/scripts/lib/context.mjs +67 -0
  128. package/scripts/lib/cross-review-utils.mjs +59 -0
  129. package/scripts/lib/doctor-env-checks.mjs +121 -0
  130. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  131. package/scripts/lib/env-probe.mjs +269 -0
  132. package/scripts/lib/env.mjs +14 -0
  133. package/scripts/lib/gemini-profiles.mjs +119 -0
  134. package/scripts/lib/handoff.mjs +187 -0
  135. package/scripts/lib/hook-utils.mjs +16 -0
  136. package/scripts/lib/hub.mjs +48 -0
  137. package/scripts/lib/keyword-rules.mjs +195 -0
  138. package/scripts/lib/logger.mjs +105 -0
  139. package/scripts/lib/mcp-filter.mjs +883 -0
  140. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  141. package/scripts/lib/mcp-guard-engine.mjs +2120 -0
  142. package/scripts/lib/mcp-health.mjs +773 -0
  143. package/scripts/lib/mcp-manifest.mjs +89 -0
  144. package/scripts/lib/mcp-server-catalog.mjs +355 -0
  145. package/scripts/lib/pid.mjs +63 -0
  146. package/scripts/lib/process-utils.mjs +26 -0
  147. package/scripts/lib/psmux-info.mjs +179 -0
  148. package/scripts/lib/quota.mjs +47 -0
  149. package/scripts/lib/remote-spawn-transfer.mjs +226 -0
  150. package/scripts/lib/skill-state.mjs +220 -0
  151. package/scripts/lib/skill-template.mjs +375 -0
  152. package/scripts/lib/team.mjs +75 -0
  153. package/scripts/lib/timeout.mjs +66 -0
  154. package/scripts/lib/tmp.mjs +26 -0
  155. package/scripts/lib/toml.mjs +113 -0
@@ -1,114 +1,56 @@
1
- import { altScreenOff, altScreenOn, BG, bold, box, clearScreen, clearToEnd, color, cursorHide, cursorHome, cursorShow, dim, eraseBelow, FG, MOCHA, padRight, progressBar, statusBadge, stripAnsi, truncate, wcswidth } from "./ansi.mjs";
1
+ import {
2
+ altScreenOff,
3
+ altScreenOn,
4
+ BG,
5
+ bold,
6
+ box,
7
+ clearScreen,
8
+ clearToEnd,
9
+ color,
10
+ cursorHide,
11
+ cursorHome,
12
+ cursorShow,
13
+ dim,
14
+ eraseBelow,
15
+ FG,
16
+ MOCHA,
17
+ moveTo,
18
+ padRight,
19
+ progressBar,
20
+ statusBadge,
21
+ stripAnsi,
22
+ truncate,
23
+ } from "./ansi.mjs";
24
+ import {
25
+ normalizeWorkerState as coreNormalizeWorkerState,
26
+ formatTokens,
27
+ loadVersion,
28
+ resolveViewportColumns,
29
+ resolveViewportRows,
30
+ runtimeStatus,
31
+ sanitizeFiles,
32
+ VALID_TABS as VALID_TABS_ARRAY,
33
+ wrapText as wrapTextFull,
34
+ } from "./tui-core.mjs";
2
35
 
3
- const FALLBACK_COLUMNS = 100, FALLBACK_ROWS = 24;
4
- const VALID_TABS = new Set(["log", "detail", "files"]);
5
-
6
- let VERSION = "lite";
7
- try { const { createRequire } = await import("node:module"); VERSION = createRequire(import.meta.url)("../../package.json").version; } catch {}
8
-
9
- const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
10
-
11
- function sanitizeBlock(text, rawMode = false) {
12
- const value = String(text || "").replace(/\r/g, "");
13
- const cleaned = rawMode
14
- ? value
15
- : value
16
- .replace(/```[\s\S]*?(?:```|$)/g, "\n")
17
- .replace(/^\s*```.*$/gm, "")
18
- .replace(/^(?:PS\s+\S[^\n]*?>|>\s+|\$\s+)[^\n]*/gm, "");
19
- return cleaned
20
- .split("\n")
21
- .map((line) => line.trim())
22
- .filter(Boolean)
23
- .filter((line) => line !== "--- HANDOFF ---")
24
- .join("\n")
25
- .trim();
26
- }
27
-
28
- function sanitizeOneLine(text, fallback = "") {
29
- return sanitizeBlock(text).replace(/\s+/g, " ").trim() || fallback;
30
- }
31
-
32
- function sanitizeFiles(files) {
33
- const list = Array.isArray(files) ? files : String(files || "").split(",");
34
- return list.map((entry) => sanitizeOneLine(entry)).filter(Boolean);
35
- }
36
-
37
- function normalizeTokens(tokens) {
38
- if (tokens === null || tokens === undefined || tokens === "") return "";
39
- if (typeof tokens === "number" && Number.isFinite(tokens)) return tokens;
40
- const raw = sanitizeOneLine(tokens);
41
- const match = raw.match(/(\d+(?:[.,]\d+)?\s*[kKmM]?)/);
42
- return match ? match[1].replace(/\s+/g, "").toLowerCase() : raw;
43
- }
44
-
45
- function formatTokens(tokens) {
46
- if (!tokens && tokens !== 0) return "n/a";
47
- if (typeof tokens === "number") {
48
- if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}m`;
49
- if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
50
- }
51
- return String(tokens);
52
- }
36
+ const VERSION = await loadVersion("lite");
37
+ const VALID_TABS = new Set(VALID_TABS_ARRAY);
53
38
 
54
39
  function wrap(text, width) {
55
- const limit = Math.max(8, width);
56
- const lines = [];
57
- for (const rawLine of sanitizeBlock(text).split("\n")) {
58
- const words = rawLine.split(/\s+/).filter(Boolean);
59
- if (words.length === 0) continue;
60
- let current = "";
61
- for (const word of words) {
62
- const next = current ? `${current} ${word}` : word;
63
- if (wcswidth(next) <= limit) {
64
- current = next;
65
- continue;
66
- }
67
- if (current) lines.push(current);
68
- current = word;
69
- while (wcswidth(current) > limit) {
70
- lines.push(current.slice(0, limit));
71
- current = current.slice(limit);
72
- }
73
- }
74
- if (current) lines.push(current);
75
- }
76
- return lines;
40
+ return wrapTextFull(text, width);
77
41
  }
78
42
 
79
- const runtimeStatus = (worker) => worker?.handoff?.status || worker?.status || "pending";
80
-
81
43
  function normalizeWorkerState(existing = {}, state = {}) {
82
- const handoff = state.handoff === undefined
83
- ? existing.handoff
84
- : {
85
- ...(existing.handoff || {}),
86
- ...(state.handoff || {}),
87
- verdict: state.handoff?.verdict !== undefined ? sanitizeOneLine(state.handoff.verdict) : existing.handoff?.verdict,
88
- confidence: state.handoff?.confidence !== undefined ? sanitizeOneLine(state.handoff.confidence) : existing.handoff?.confidence,
89
- status: state.handoff?.status !== undefined ? sanitizeOneLine(state.handoff.status) : existing.handoff?.status,
90
- files_changed: state.handoff?.files_changed !== undefined ? sanitizeFiles(state.handoff.files_changed) : existing.handoff?.files_changed,
91
- };
92
- return {
93
- ...existing,
94
- ...state,
95
- cli: state.cli !== undefined ? sanitizeOneLine(state.cli, existing.cli || "codex") : (existing.cli || "codex"),
96
- status: state.status !== undefined ? sanitizeOneLine(state.status, existing.status || "pending") : (existing.status || "pending"),
97
- snapshot: state.snapshot !== undefined ? sanitizeBlock(state.snapshot) : existing.snapshot,
98
- summary: state.summary !== undefined ? sanitizeBlock(state.summary) : existing.summary,
99
- detail: state.detail !== undefined ? sanitizeBlock(state.detail) : existing.detail,
100
- findings: state.findings !== undefined ? sanitizeFiles(state.findings) : existing.findings,
101
- files_changed: state.files_changed !== undefined ? sanitizeFiles(state.files_changed) : existing.files_changed,
102
- confidence: state.confidence !== undefined ? sanitizeOneLine(state.confidence) : existing.confidence,
103
- tokens: state.tokens !== undefined ? normalizeTokens(state.tokens) : existing.tokens,
104
- progress: state.progress !== undefined ? clamp(Number(state.progress) || 0, 0, 1) : existing.progress,
105
- handoff,
106
- };
44
+ return coreNormalizeWorkerState(existing, state);
107
45
  }
108
46
 
109
47
  function frame(lines, width, border = MOCHA.border) {
110
48
  const body = lines.length ? lines : [dim("내용 없음")];
111
- const rendered = box(body.map((line) => padRight(truncate(line, width - 4), width - 4)), width, border);
49
+ const rendered = box(
50
+ body.map((line) => padRight(truncate(line, width - 4), width - 4)),
51
+ width,
52
+ border,
53
+ );
112
54
  return [rendered.top, ...rendered.body, rendered.bot];
113
55
  }
114
56
 
@@ -128,9 +70,10 @@ function buildHeader(width, names, workers, pipeline, startedAt) {
128
70
  else if (status === "running" || status === "in_progress") counts.running++;
129
71
  }
130
72
  const elapsed = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
131
- const line1 = color(` triflux ${VERSION} `, FG.black, BG.header)
132
- + ` ${bold(`phase ${pipeline.phase || "exec"}`)}`
133
- + ` ${dim(`+${elapsed}s`)} ${names.length} workers`;
73
+ const line1 =
74
+ color(` triflux ${VERSION} `, FG.white, BG.header) +
75
+ ` ${bold(`phase ${pipeline.phase || "exec"}`)}` +
76
+ ` ${dim(`+${elapsed}s`)} ${names.length} workers`;
134
77
  const line2 = `${color(`ok ${counts.ok}`, MOCHA.ok)} ${color(`partial ${counts.partial}`, MOCHA.partial)} ${color(`failed ${counts.failed}`, MOCHA.fail)} ${color(`running ${counts.running}`, MOCHA.executing)}`;
135
78
  return [padRight(line1, width), padRight(line2, width)];
136
79
  }
@@ -140,9 +83,14 @@ function buildWorkerRail(names, workers, selectedWorker, width) {
140
83
  ? names.map((name, index) => {
141
84
  const worker = workers.get(name);
142
85
  const status = runtimeStatus(worker);
143
- const pct = Math.round(((worker?.progress ?? (status === "completed" ? 1 : 0)) || 0) * 100);
144
- const token = worker?.tokens ? ` tok ${formatTokens(worker.tokens)}` : "";
145
- const prefix = name === selectedWorker ? color("▶", MOCHA.blue) : dim("·");
86
+ const pct = Math.round(
87
+ ((worker?.progress ?? (status === "completed" ? 1 : 0)) || 0) * 100,
88
+ );
89
+ const token = worker?.tokens
90
+ ? ` tok ${formatTokens(worker.tokens)}`
91
+ : "";
92
+ const prefix =
93
+ name === selectedWorker ? color("▶", MOCHA.blue) : dim("·");
146
94
  return `${prefix} ${index + 1}.${name} ${stripAnsi(statusBadge(status))} ${pct}%${token}`;
147
95
  })
148
96
  : [dim("workers 없음")];
@@ -175,12 +123,27 @@ function buildDetail(workerName, worker, width, tab, helpVisible) {
175
123
  `verdict ${worker.handoff?.verdict || worker.summary || worker.snapshot || "-"}`,
176
124
  ];
177
125
  if (tab === "files") {
178
- const files = [...sanitizeFiles(worker.handoff?.files_changed), ...sanitizeFiles(worker.files_changed)];
179
- detailLines.push(...(files.length ? files.map((file) => `files ${file}`) : ["files 없음"]));
126
+ const files = [
127
+ ...sanitizeFiles(worker.handoff?.files_changed),
128
+ ...sanitizeFiles(worker.files_changed),
129
+ ];
130
+ detailLines.push(
131
+ ...(files.length ? files.map((file) => `files ${file}`) : ["files 없음"]),
132
+ );
180
133
  } else if (tab === "detail") {
181
- detailLines.push(...wrap(worker.detail || worker.summary || worker.snapshot || "", width - 4));
134
+ detailLines.push(
135
+ ...wrap(
136
+ worker.detail || worker.summary || worker.snapshot || "",
137
+ width - 4,
138
+ ),
139
+ );
182
140
  } else {
183
- detailLines.push(...wrap(worker.summary || worker.snapshot || worker.detail || "", width - 4));
141
+ detailLines.push(
142
+ ...wrap(
143
+ worker.summary || worker.snapshot || worker.detail || "",
144
+ width - 4,
145
+ ),
146
+ );
184
147
  }
185
148
  return frame(detailLines, width, MOCHA.thinking);
186
149
  }
@@ -209,14 +172,21 @@ export function createLiteDashboard(opts = {}) {
209
172
  let detailExpanded = true;
210
173
  let focusTab = "log";
211
174
  let helpVisible = false;
175
+ let prevFrame = [];
176
+ let prevWidth = 0;
212
177
  let inputAttached = false;
213
178
  let rawModeEnabled = false;
214
179
 
215
- const write = (text) => { if (!closed) stream.write(text); };
180
+ const write = (text) => {
181
+ if (!closed) stream.write(text);
182
+ };
216
183
  const workerNames = () => [...workers.keys()].sort();
217
- const viewportColumns = () => Math.max(48, columns || stream?.columns || process.stdout?.columns || FALLBACK_COLUMNS);
218
- const viewportRows = () => Math.max(10, rows || stream?.rows || process.stdout?.rows || FALLBACK_ROWS);
219
- const ensureSelection = (names) => { if (names.length && (!selectedWorker || !workers.has(selectedWorker))) selectedWorker = names[0]; };
184
+ const viewportColumns = () => resolveViewportColumns({ columns, stream });
185
+ const viewportRows = () => resolveViewportRows({ rows, stream });
186
+ const ensureSelection = (names) => {
187
+ if (names.length && (!selectedWorker || !workers.has(selectedWorker)))
188
+ selectedWorker = names[0];
189
+ };
220
190
 
221
191
  function selectRelative(offset) {
222
192
  const names = workerNames();
@@ -227,9 +197,18 @@ export function createLiteDashboard(opts = {}) {
227
197
  }
228
198
 
229
199
  function triggerOpenSelected() {
230
- if (typeof onOpenSelectedWorker !== "function" || !selectedWorker || !workers.has(selectedWorker)) return;
200
+ if (
201
+ typeof onOpenSelectedWorker !== "function" ||
202
+ !selectedWorker ||
203
+ !workers.has(selectedWorker)
204
+ )
205
+ return;
231
206
  try {
232
- const result = onOpenSelectedWorker(selectedWorker, workers.get(selectedWorker), new Map(workers));
207
+ const result = onOpenSelectedWorker(
208
+ selectedWorker,
209
+ workers.get(selectedWorker),
210
+ new Map(workers),
211
+ );
233
212
  if (result && typeof result.catch === "function") result.catch(() => {});
234
213
  } catch {}
235
214
  }
@@ -237,7 +216,11 @@ export function createLiteDashboard(opts = {}) {
237
216
  function triggerOpenAll() {
238
217
  if (typeof onOpenAllWorkers !== "function") return;
239
218
  try {
240
- const result = onOpenAllWorkers(selectedWorker, workers.get(selectedWorker), new Map(workers));
219
+ const result = onOpenAllWorkers(
220
+ selectedWorker,
221
+ workers.get(selectedWorker),
222
+ new Map(workers),
223
+ );
241
224
  if (result && typeof result.catch === "function") result.catch(() => {});
242
225
  } catch {}
243
226
  }
@@ -263,10 +246,26 @@ export function createLiteDashboard(opts = {}) {
263
246
  return;
264
247
  }
265
248
  if (key === "\r" || key === "\n") {
266
- triggerOpenSelected();
249
+ if (
250
+ typeof onOpenSelectedWorker === "function" &&
251
+ selectedWorker &&
252
+ workers.has(selectedWorker)
253
+ ) {
254
+ triggerOpenSelected();
255
+ } else {
256
+ // 콜백 없거나 선택 워커 없으면 탭 순환
257
+ const tabs = ["log", "detail", "files"];
258
+ focusTab = tabs[(tabs.indexOf(focusTab) + 1) % tabs.length];
259
+ }
260
+ render();
267
261
  return;
268
262
  }
269
- if (key === "\x1b[13;2u" || key === "\x1b[27;13;2~" || key === "\x1b\r" || key === "\x1b\n") {
263
+ if (
264
+ key === "\x1b[13;2u" ||
265
+ key === "\x1b[27;13;2~" ||
266
+ key === "\x1b\r" ||
267
+ key === "\x1b\n"
268
+ ) {
270
269
  triggerOpenAll();
271
270
  return;
272
271
  }
@@ -297,7 +296,7 @@ export function createLiteDashboard(opts = {}) {
297
296
 
298
297
  function attachInput() {
299
298
  if (inputAttached) return;
300
- if (!isTTY || !input?.isTTY || typeof input?.on !== "function") return;
299
+ if (!input?.isTTY || typeof input?.on !== "function") return;
301
300
  inputAttached = true;
302
301
  if (typeof input.setRawMode === "function") {
303
302
  input.setRawMode(true);
@@ -313,20 +312,53 @@ export function createLiteDashboard(opts = {}) {
313
312
  const width = viewportColumns();
314
313
  const height = viewportRows();
315
314
  const header = buildHeader(width, names, workers, pipeline, startedAt);
316
- const railOnly = !detailExpanded || names.length <= 1 || width < 100 || layout === "single";
315
+ const railOnly =
316
+ !detailExpanded ||
317
+ names.length <= 1 ||
318
+ width < 100 ||
319
+ layout === "single";
317
320
  if (railOnly) {
318
- const sections = [header, ...buildWorkerRail(names, workers, selectedWorker, width)];
319
- if (detailExpanded) sections.push(...buildDetail(selectedWorker, workers.get(selectedWorker), width, focusTab, helpVisible));
321
+ const sections = [
322
+ header,
323
+ ...buildWorkerRail(names, workers, selectedWorker, width),
324
+ ];
325
+ if (detailExpanded)
326
+ sections.push(
327
+ ...buildDetail(
328
+ selectedWorker,
329
+ workers.get(selectedWorker),
330
+ width,
331
+ focusTab,
332
+ helpVisible,
333
+ ),
334
+ );
320
335
  return fitHeight(sections, width, height);
321
336
  }
322
337
  const railWidth = Math.max(28, Math.floor(width * 0.32));
323
338
  const detailWidth = width - railWidth - 1;
324
339
  const bodyHeight = Math.max(6, height - header.length);
325
- const rail = fitHeight(buildWorkerRail(names, workers, selectedWorker, railWidth), railWidth, bodyHeight);
326
- const detail = fitHeight(buildDetail(selectedWorker, workers.get(selectedWorker), detailWidth, focusTab, helpVisible), detailWidth, bodyHeight);
340
+ const rail = fitHeight(
341
+ buildWorkerRail(names, workers, selectedWorker, railWidth),
342
+ railWidth,
343
+ bodyHeight,
344
+ );
345
+ const detail = fitHeight(
346
+ buildDetail(
347
+ selectedWorker,
348
+ workers.get(selectedWorker),
349
+ detailWidth,
350
+ focusTab,
351
+ helpVisible,
352
+ ),
353
+ detailWidth,
354
+ bodyHeight,
355
+ );
327
356
  return [
328
357
  ...header,
329
- ...Array.from({ length: bodyHeight }, (_, index) => `${rail[index]}${dim("│")}${detail[index]}`),
358
+ ...Array.from(
359
+ { length: bodyHeight },
360
+ (_, index) => `${rail[index]}${dim("│")}${detail[index]}`,
361
+ ),
330
362
  ];
331
363
  }
332
364
 
@@ -337,18 +369,43 @@ export function createLiteDashboard(opts = {}) {
337
369
  const rowsOut = buildRows();
338
370
  if (isTTY) {
339
371
  const width = viewportColumns();
340
- const padded = rowsOut.map((line) => padRight(String(line ?? ""), width) + clearToEnd);
341
- write(cursorHome + padded.join("\n") + eraseBelow);
372
+ const padded = rowsOut.map((line) => padRight(String(line ?? ""), width));
373
+ // Full redraw on first frame or terminal resize to avoid artifacts
374
+ if (prevFrame.length === 0 || width !== prevWidth) {
375
+ prevWidth = width;
376
+ write(
377
+ cursorHome +
378
+ padded.map((l) => l + clearToEnd).join("\n") +
379
+ eraseBelow,
380
+ );
381
+ prevFrame = padded;
382
+ return;
383
+ }
384
+ // Diff-based rendering: only rewrite lines that actually changed
385
+ let buf = "";
386
+ for (let i = 0; i < padded.length; i++) {
387
+ if (padded[i] !== prevFrame[i]) {
388
+ buf += moveTo(i + 1, 1) + padded[i] + clearToEnd;
389
+ }
390
+ }
391
+ if (prevFrame.length > padded.length) {
392
+ buf += moveTo(padded.length + 1, 1) + eraseBelow;
393
+ }
394
+ if (buf) write(buf);
395
+ prevFrame = padded;
342
396
  } else write(`${rowsOut.join("\n")}\n`);
343
397
  }
344
398
 
345
399
  function close() {
346
400
  if (closed) return;
347
401
  if (timer) clearInterval(timer);
348
- if (inputAttached && typeof input?.off === "function") input.off("data", handleInput);
349
- if (rawModeEnabled && typeof input?.setRawMode === "function") input.setRawMode(false);
402
+ if (inputAttached && typeof input?.off === "function")
403
+ input.off("data", handleInput);
404
+ if (rawModeEnabled && typeof input?.setRawMode === "function")
405
+ input.setRawMode(false);
350
406
  if (inputAttached && typeof input?.pause === "function") input.pause();
351
407
  if (isTTY) write(cursorShow + altScreenOff);
408
+ prevFrame = [];
352
409
  closed = true;
353
410
  }
354
411
 
@@ -359,22 +416,53 @@ export function createLiteDashboard(opts = {}) {
359
416
  }
360
417
 
361
418
  return {
362
- updateWorker(name, state) { workers.set(name, normalizeWorkerState(workers.get(name), state)); ensureSelection(workerNames()); },
363
- updatePipeline(state) { pipeline = { ...pipeline, ...state }; },
364
- setStartTime(ms) { startedAt = ms; },
365
- selectWorker(name) { if (workers.has(name)) selectedWorker = name; },
366
- toggleDetail(force) { detailExpanded = typeof force === "boolean" ? force : !detailExpanded; },
419
+ updateWorker(name, state) {
420
+ workers.set(name, normalizeWorkerState(workers.get(name), state));
421
+ ensureSelection(workerNames());
422
+ },
423
+ updatePipeline(state) {
424
+ pipeline = { ...pipeline, ...state };
425
+ },
426
+ setStartTime(ms) {
427
+ startedAt = ms;
428
+ },
429
+ selectWorker(name) {
430
+ if (workers.has(name)) selectedWorker = name;
431
+ },
432
+ toggleDetail(force) {
433
+ detailExpanded = typeof force === "boolean" ? force : !detailExpanded;
434
+ },
367
435
  render,
368
- getWorkers() { return new Map(workers); },
369
- getFrameCount() { return frameCount; },
370
- getPipelineState() { return { ...pipeline }; },
371
- getSelectedWorker() { return selectedWorker; },
372
- isDetailExpanded() { return detailExpanded; },
373
- getFocusTab() { return focusTab; },
374
- setFocusTab(tab) { if (VALID_TABS.has(tab)) focusTab = tab; },
375
- getLayout() { return layout; },
376
- toggleHelp(force) { helpVisible = typeof force === "boolean" ? force : !helpVisible; },
377
- isHelpVisible() { return helpVisible; },
436
+ getWorkers() {
437
+ return new Map(workers);
438
+ },
439
+ getFrameCount() {
440
+ return frameCount;
441
+ },
442
+ getPipelineState() {
443
+ return { ...pipeline };
444
+ },
445
+ getSelectedWorker() {
446
+ return selectedWorker;
447
+ },
448
+ isDetailExpanded() {
449
+ return detailExpanded;
450
+ },
451
+ getFocusTab() {
452
+ return focusTab;
453
+ },
454
+ setFocusTab(tab) {
455
+ if (VALID_TABS.has(tab)) focusTab = tab;
456
+ },
457
+ getLayout() {
458
+ return layout;
459
+ },
460
+ toggleHelp(force) {
461
+ helpVisible = typeof force === "boolean" ? force : !helpVisible;
462
+ },
463
+ isHelpVisible() {
464
+ return helpVisible;
465
+ },
378
466
  close,
379
467
  };
380
468
  }
@@ -4,17 +4,13 @@
4
4
  // tui.mjs updateWorker() 호환 형식으로 변환한다.
5
5
  // 완료/실패 시 notify.mjs 자동 호출.
6
6
 
7
- import { readFileSync } from "node:fs";
8
- import { join } from "node:path";
9
- import { fileURLToPath } from "node:url";
10
7
  import { EventEmitter } from "node:events";
11
8
 
9
+ import { readHosts } from "@triflux/core/hub/lib/hosts-compat.mjs";
12
10
  import { STATES } from "./conductor.mjs";
13
11
 
14
12
  // ── 상수 ─────────────────────────────────────────────────────────────────────
15
13
 
16
- const HOSTS_JSON_REL = "../../references/hosts.json";
17
-
18
14
  const CONDUCTOR_STATE_TO_TUI_STATUS = Object.freeze({
19
15
  [STATES.INIT]: "pending",
20
16
  [STATES.STARTING]: "pending",
@@ -31,21 +27,6 @@ const SESSION_PREFIX = "tfx-spawn-";
31
27
 
32
28
  // ── 유틸 ─────────────────────────────────────────────────────────────────────
33
29
 
34
- function loadHostsJson(hostsJsonPath) {
35
- try {
36
- const raw = readFileSync(hostsJsonPath, "utf8");
37
- return JSON.parse(raw);
38
- } catch {
39
- return { hosts: {} };
40
- }
41
- }
42
-
43
- function resolveHostsJsonPath(overridePath) {
44
- if (overridePath) return overridePath;
45
- const thisDir = fileURLToPath(new URL(".", import.meta.url));
46
- return join(thisDir, HOSTS_JSON_REL);
47
- }
48
-
49
30
  function resolveSshUser(hostsData, host) {
50
31
  if (!host || !hostsData?.hosts) return null;
51
32
  return hostsData.hosts[host]?.ssh_user || null;
@@ -90,9 +71,8 @@ function mapConductorStateToStatus(conductorState) {
90
71
  function buildWorkerData(entry, watcherSession, hostsData) {
91
72
  const host = entry.host || resolveHostFromSessionName(entry.id);
92
73
  const snapshot = watcherSession?.lastOutput || "";
93
- const probeLevel = entry.health?.level
94
- || watcherSession?.lastProbeLevel
95
- || null;
74
+ const probeLevel =
75
+ entry.health?.level || watcherSession?.lastProbeLevel || null;
96
76
 
97
77
  return Object.freeze({
98
78
  cli: entry.agent || "claude",
@@ -115,15 +95,18 @@ function buildWorkerData(entry, watcherSession, hostsData) {
115
95
  * remote-watcher 전용 엔트리 → TUI 워커 데이터 (conductor 미등록 세션).
116
96
  */
117
97
  function buildWatcherOnlyWorkerData(watcherRecord, hostsData) {
118
- const host = watcherRecord.host
119
- || resolveHostFromSessionName(watcherRecord.sessionName);
98
+ const host =
99
+ watcherRecord.host || resolveHostFromSessionName(watcherRecord.sessionName);
120
100
 
121
101
  return Object.freeze({
122
102
  cli: "claude",
123
103
  role: resolveRole(null, watcherRecord.sessionName),
124
- status: watcherRecord.state === "completed" ? "completed"
125
- : watcherRecord.state === "failed" ? "failed"
126
- : "running",
104
+ status:
105
+ watcherRecord.state === "completed"
106
+ ? "completed"
107
+ : watcherRecord.state === "failed"
108
+ ? "failed"
109
+ : "running",
127
110
  host: host || "unknown",
128
111
  remote: true,
129
112
  sshUser: resolveSshUser(hostsData, host),
@@ -142,7 +125,7 @@ function buildWatcherOnlyWorkerData(watcherRecord, hostsData) {
142
125
  * @param {object} opts.conductor — createConductor() 인스턴스
143
126
  * @param {object} [opts.watcher] — createRemoteWatcher() 인스턴스 (nullable)
144
127
  * @param {object} [opts.notifier] — createNotifier() 인스턴스 (nullable)
145
- * @param {string} [opts.hostsJsonPath] — hosts.json 경로 override
128
+ * @param {string} [opts.repoRoot] — hosts fallback 탐색용 repo root override
146
129
  * @param {number} [opts.pollMs=10000] — conductor snapshot 폴링 간격
147
130
  * @param {object} [opts.deps] — 테스트용 의존성 주입
148
131
  * @returns {{ start, stop, getWorkers, on, off }}
@@ -158,14 +141,13 @@ export function createRemoteAdapter(opts = {}) {
158
141
 
159
142
  if (!conductor) throw new Error("conductor is required");
160
143
 
161
- const hostsJsonPath = resolveHostsJsonPath(opts.hostsJsonPath);
162
- const loadHosts = deps.loadHostsJson || loadHostsJson;
144
+ const loadHosts = deps.readHosts || readHosts;
163
145
  const setIntervalFn = deps.setInterval || setInterval;
164
146
  const clearIntervalFn = deps.clearInterval || clearInterval;
165
147
  const _nowFn = deps.now || Date.now;
166
148
 
167
149
  const emitter = new EventEmitter();
168
- let hostsData = loadHosts(hostsJsonPath);
150
+ let hostsData = loadHosts(opts.repoRoot);
169
151
  let workers = new Map();
170
152
  let pollHandle = null;
171
153
  let running = false;
@@ -321,7 +303,9 @@ export function createRemoteAdapter(opts = {}) {
321
303
  // watcher-only 세션 (conductor 미등록)
322
304
  if (watcher) {
323
305
  const watcherStatus = watcher.getStatus();
324
- for (const [sessionName, record] of Object.entries(watcherStatus.sessions || {})) {
306
+ for (const [sessionName, record] of Object.entries(
307
+ watcherStatus.sessions || {},
308
+ )) {
325
309
  if (conductorSessionIds.has(sessionName)) continue;
326
310
  const paneName = buildPaneName(sessionName);
327
311
  const workerData = buildWatcherOnlyWorkerData(record, hostsData);
@@ -339,7 +323,7 @@ export function createRemoteAdapter(opts = {}) {
339
323
  running = true;
340
324
 
341
325
  // hosts.json 리로드
342
- hostsData = loadHosts(hostsJsonPath);
326
+ hostsData = loadHosts(opts.repoRoot);
343
327
 
344
328
  // conductor stateChange 구독
345
329
  conductor.on("stateChange", handleStateChange);