@triflux/remote 10.0.0-alpha.1 → 10.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.
@@ -0,0 +1,393 @@
1
+ // hub/team/tui-remote-adapter.mjs — 원격 세션을 TUI 워커 형식으로 변환하는 어댑터
2
+ //
3
+ // conductor.mjs의 stateChange 이벤트(primary) + remote-watcher.mjs(supplemental)를
4
+ // tui.mjs updateWorker() 호환 형식으로 변환한다.
5
+ // 완료/실패 시 notify.mjs 자동 호출.
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { EventEmitter } from "node:events";
11
+
12
+ import { STATES } from "./conductor.mjs";
13
+
14
+ // ── 상수 ─────────────────────────────────────────────────────────────────────
15
+
16
+ const HOSTS_JSON_REL = "../../references/hosts.json";
17
+
18
+ const CONDUCTOR_STATE_TO_TUI_STATUS = Object.freeze({
19
+ [STATES.INIT]: "pending",
20
+ [STATES.STARTING]: "pending",
21
+ [STATES.HEALTHY]: "running",
22
+ [STATES.STALLED]: "running",
23
+ [STATES.INPUT_WAIT]: "running",
24
+ [STATES.FAILED]: "running",
25
+ [STATES.RESTARTING]: "running",
26
+ [STATES.COMPLETED]: "completed",
27
+ [STATES.DEAD]: "failed",
28
+ });
29
+
30
+ const SESSION_PREFIX = "tfx-spawn-";
31
+
32
+ // ── 유틸 ─────────────────────────────────────────────────────────────────────
33
+
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
+ function resolveSshUser(hostsData, host) {
50
+ if (!host || !hostsData?.hosts) return null;
51
+ return hostsData.hosts[host]?.ssh_user || null;
52
+ }
53
+
54
+ /**
55
+ * tfx-spawn-{host}-{id} 형식에서 host를 추출한다.
56
+ * @param {string} sessionName
57
+ * @returns {string|null}
58
+ */
59
+ function resolveHostFromSessionName(sessionName) {
60
+ if (!sessionName || !sessionName.startsWith(SESSION_PREFIX)) return null;
61
+ const rest = sessionName.slice(SESSION_PREFIX.length);
62
+ const dashIdx = rest.indexOf("-");
63
+ if (dashIdx === -1) return rest || null;
64
+ return rest.slice(0, dashIdx) || null;
65
+ }
66
+
67
+ /**
68
+ * sessionName에서 role을 파생한다.
69
+ * conductor config.id가 있으면 우선 사용, 없으면 sessionName 자체.
70
+ */
71
+ function resolveRole(configId, sessionName) {
72
+ return configId || sessionName || "unknown";
73
+ }
74
+
75
+ function buildPaneName(sessionName) {
76
+ return `remote:${sessionName}`;
77
+ }
78
+
79
+ function mapConductorStateToStatus(conductorState) {
80
+ return CONDUCTOR_STATE_TO_TUI_STATUS[conductorState] || "pending";
81
+ }
82
+
83
+ /**
84
+ * conductor snapshot 엔트리 → TUI 워커 데이터.
85
+ * @param {object} entry — conductor getSnapshot() 엔트리
86
+ * @param {object} watcherSession — remote-watcher의 세션 레코드 (nullable)
87
+ * @param {object} hostsData — hosts.json 데이터
88
+ * @returns {object}
89
+ */
90
+ function buildWorkerData(entry, watcherSession, hostsData) {
91
+ const host = entry.host || resolveHostFromSessionName(entry.id);
92
+ const snapshot = watcherSession?.lastOutput || "";
93
+ const probeLevel = entry.health?.level
94
+ || watcherSession?.lastProbeLevel
95
+ || null;
96
+
97
+ return Object.freeze({
98
+ cli: entry.agent || "claude",
99
+ role: resolveRole(entry.id, entry.id),
100
+ status: mapConductorStateToStatus(entry.state),
101
+ host: host || "unknown",
102
+ remote: true,
103
+ sshUser: resolveSshUser(hostsData, host),
104
+ sessionName: entry.id,
105
+ snapshot,
106
+ conductor: Object.freeze({
107
+ state: entry.state,
108
+ restarts: entry.restarts || 0,
109
+ probeLevel,
110
+ }),
111
+ });
112
+ }
113
+
114
+ /**
115
+ * remote-watcher 전용 엔트리 → TUI 워커 데이터 (conductor 미등록 세션).
116
+ */
117
+ function buildWatcherOnlyWorkerData(watcherRecord, hostsData) {
118
+ const host = watcherRecord.host
119
+ || resolveHostFromSessionName(watcherRecord.sessionName);
120
+
121
+ return Object.freeze({
122
+ cli: "claude",
123
+ role: resolveRole(null, watcherRecord.sessionName),
124
+ status: watcherRecord.state === "completed" ? "completed"
125
+ : watcherRecord.state === "failed" ? "failed"
126
+ : "running",
127
+ host: host || "unknown",
128
+ remote: true,
129
+ sshUser: resolveSshUser(hostsData, host),
130
+ sessionName: watcherRecord.sessionName,
131
+ snapshot: watcherRecord.lastOutput || "",
132
+ conductor: null,
133
+ });
134
+ }
135
+
136
+ // ── 팩토리 ───────────────────────────────────────────────────────────────────
137
+
138
+ /**
139
+ * 원격 세션 어댑터 팩토리.
140
+ *
141
+ * @param {object} opts
142
+ * @param {object} opts.conductor — createConductor() 인스턴스
143
+ * @param {object} [opts.watcher] — createRemoteWatcher() 인스턴스 (nullable)
144
+ * @param {object} [opts.notifier] — createNotifier() 인스턴스 (nullable)
145
+ * @param {string} [opts.hostsJsonPath] — hosts.json 경로 override
146
+ * @param {number} [opts.pollMs=10000] — conductor snapshot 폴링 간격
147
+ * @param {object} [opts.deps] — 테스트용 의존성 주입
148
+ * @returns {{ start, stop, getWorkers, on, off }}
149
+ */
150
+ export function createRemoteAdapter(opts = {}) {
151
+ const {
152
+ conductor,
153
+ watcher = null,
154
+ notifier = null,
155
+ pollMs = 10_000,
156
+ deps = {},
157
+ } = opts;
158
+
159
+ if (!conductor) throw new Error("conductor is required");
160
+
161
+ const hostsJsonPath = resolveHostsJsonPath(opts.hostsJsonPath);
162
+ const loadHosts = deps.loadHostsJson || loadHostsJson;
163
+ const setIntervalFn = deps.setInterval || setInterval;
164
+ const clearIntervalFn = deps.clearInterval || clearInterval;
165
+ const nowFn = deps.now || Date.now;
166
+
167
+ const emitter = new EventEmitter();
168
+ let hostsData = loadHosts(hostsJsonPath);
169
+ let workers = new Map();
170
+ let pollHandle = null;
171
+ let running = false;
172
+
173
+ // ── conductor stateChange 핸들러 (primary) ──
174
+
175
+ function handleStateChange({ sessionId, from, to, reason }) {
176
+ const snapshots = conductor.getSnapshot();
177
+ const entry = snapshots.find((s) => s.id === sessionId);
178
+ if (!entry || !entry.remote) return;
179
+
180
+ const watcherStatus = getWatcherSession(sessionId);
181
+ const workerData = buildWorkerData(entry, watcherStatus, hostsData);
182
+ const paneName = buildPaneName(sessionId);
183
+
184
+ workers = new Map(workers);
185
+ workers.set(paneName, workerData);
186
+
187
+ emitter.emit("workerUpdate", { ...workerData, paneName });
188
+
189
+ if (to === STATES.COMPLETED) {
190
+ emitter.emit("workerCompleted", {
191
+ name: paneName,
192
+ host: workerData.host,
193
+ exitCode: 0,
194
+ });
195
+ notifyIfAvailable({
196
+ type: "completed",
197
+ sessionId,
198
+ host: workerData.host,
199
+ summary: `completed (${reason})`,
200
+ });
201
+ } else if (to === STATES.DEAD) {
202
+ emitter.emit("workerFailed", {
203
+ name: paneName,
204
+ host: workerData.host,
205
+ reason,
206
+ });
207
+ notifyIfAvailable({
208
+ type: "failed",
209
+ sessionId,
210
+ host: workerData.host,
211
+ summary: `dead: ${reason}`,
212
+ });
213
+ } else if (to === STATES.INPUT_WAIT) {
214
+ emitter.emit("workerInputWait", {
215
+ name: paneName,
216
+ host: workerData.host,
217
+ pattern: reason,
218
+ });
219
+ notifyIfAvailable({
220
+ type: "inputWait",
221
+ sessionId,
222
+ host: workerData.host,
223
+ summary: `input_wait: ${reason}`,
224
+ });
225
+ }
226
+ }
227
+
228
+ // ── remote-watcher 이벤트 핸들러 (supplemental) ──
229
+
230
+ function handleWatcherCompleted({ sessionName, exitCode, host }) {
231
+ if (isConductorTracked(sessionName)) return;
232
+
233
+ const paneName = buildPaneName(sessionName);
234
+ notifyIfAvailable({
235
+ type: "completed",
236
+ sessionId: sessionName,
237
+ host: host || resolveHostFromSessionName(sessionName),
238
+ summary: `exit ${exitCode ?? 0}`,
239
+ });
240
+ emitter.emit("workerCompleted", {
241
+ name: paneName,
242
+ host: host || resolveHostFromSessionName(sessionName),
243
+ exitCode: exitCode ?? 0,
244
+ });
245
+ }
246
+
247
+ function handleWatcherFailed({ sessionName, reason, host }) {
248
+ if (isConductorTracked(sessionName)) return;
249
+
250
+ const paneName = buildPaneName(sessionName);
251
+ notifyIfAvailable({
252
+ type: "failed",
253
+ sessionId: sessionName,
254
+ host: host || resolveHostFromSessionName(sessionName),
255
+ summary: reason || "session failed",
256
+ });
257
+ emitter.emit("workerFailed", {
258
+ name: paneName,
259
+ host: host || resolveHostFromSessionName(sessionName),
260
+ reason: reason || "session failed",
261
+ });
262
+ }
263
+
264
+ function handleWatcherInputWait({ sessionName, inputWaitPattern, host }) {
265
+ if (isConductorTracked(sessionName)) return;
266
+
267
+ const paneName = buildPaneName(sessionName);
268
+ notifyIfAvailable({
269
+ type: "inputWait",
270
+ sessionId: sessionName,
271
+ host: host || resolveHostFromSessionName(sessionName),
272
+ summary: `input_wait: ${inputWaitPattern || "unknown"}`,
273
+ });
274
+ emitter.emit("workerInputWait", {
275
+ name: paneName,
276
+ host: host || resolveHostFromSessionName(sessionName),
277
+ pattern: inputWaitPattern || "unknown",
278
+ });
279
+ }
280
+
281
+ // ── 내부 헬퍼 ──
282
+
283
+ function isConductorTracked(sessionName) {
284
+ const snapshots = conductor.getSnapshot();
285
+ return snapshots.some((s) => s.id === sessionName && s.remote);
286
+ }
287
+
288
+ function getWatcherSession(sessionName) {
289
+ if (!watcher) return null;
290
+ const status = watcher.getStatus();
291
+ return status.sessions?.[sessionName] || null;
292
+ }
293
+
294
+ function notifyIfAvailable(event) {
295
+ if (!notifier) return;
296
+ try {
297
+ notifier.notify(event);
298
+ } catch {
299
+ // notify 실패는 adapter를 중단시키지 않는다
300
+ }
301
+ }
302
+
303
+ /**
304
+ * conductor snapshot을 폴링하여 모든 원격 워커를 갱신.
305
+ */
306
+ function pollConductorSnapshot() {
307
+ const snapshots = conductor.getSnapshot();
308
+ const nextWorkers = new Map(workers);
309
+ const conductorSessionIds = new Set();
310
+
311
+ for (const entry of snapshots) {
312
+ if (!entry.remote) continue;
313
+ conductorSessionIds.add(entry.id);
314
+
315
+ const watcherSession = getWatcherSession(entry.id);
316
+ const workerData = buildWorkerData(entry, watcherSession, hostsData);
317
+ const paneName = buildPaneName(entry.id);
318
+ nextWorkers.set(paneName, workerData);
319
+ }
320
+
321
+ // watcher-only 세션 (conductor 미등록)
322
+ if (watcher) {
323
+ const watcherStatus = watcher.getStatus();
324
+ for (const [sessionName, record] of Object.entries(watcherStatus.sessions || {})) {
325
+ if (conductorSessionIds.has(sessionName)) continue;
326
+ const paneName = buildPaneName(sessionName);
327
+ const workerData = buildWatcherOnlyWorkerData(record, hostsData);
328
+ nextWorkers.set(paneName, workerData);
329
+ }
330
+ }
331
+
332
+ workers = nextWorkers;
333
+ }
334
+
335
+ // ── 공개 API ───────────────────────────────────────────────────────────────
336
+
337
+ function start() {
338
+ if (running) return;
339
+ running = true;
340
+
341
+ // hosts.json 리로드
342
+ hostsData = loadHosts(hostsJsonPath);
343
+
344
+ // conductor stateChange 구독
345
+ conductor.on("stateChange", handleStateChange);
346
+
347
+ // watcher 이벤트 구독
348
+ if (watcher) {
349
+ watcher.on("sessionCompleted", handleWatcherCompleted);
350
+ watcher.on("sessionFailed", handleWatcherFailed);
351
+ watcher.on("sessionInputWait", handleWatcherInputWait);
352
+ }
353
+
354
+ // 초기 snapshot 로드
355
+ pollConductorSnapshot();
356
+
357
+ // 주기적 폴링 (snapshot 갱신용 — stateChange가 primary)
358
+ pollHandle = setIntervalFn(() => {
359
+ pollConductorSnapshot();
360
+ }, pollMs);
361
+ pollHandle?.unref?.();
362
+ }
363
+
364
+ function stop() {
365
+ if (!running) return;
366
+ running = false;
367
+
368
+ conductor.off("stateChange", handleStateChange);
369
+
370
+ if (watcher) {
371
+ watcher.off("sessionCompleted", handleWatcherCompleted);
372
+ watcher.off("sessionFailed", handleWatcherFailed);
373
+ watcher.off("sessionInputWait", handleWatcherInputWait);
374
+ }
375
+
376
+ if (pollHandle) {
377
+ clearIntervalFn(pollHandle);
378
+ pollHandle = null;
379
+ }
380
+ }
381
+
382
+ function getWorkers() {
383
+ return new Map(workers);
384
+ }
385
+
386
+ return Object.freeze({
387
+ start,
388
+ stop,
389
+ getWorkers,
390
+ on: emitter.on.bind(emitter),
391
+ off: emitter.off.bind(emitter),
392
+ });
393
+ }
package/hub/team/tui.mjs CHANGED
@@ -30,6 +30,8 @@ import {
30
30
  clearToEnd,
31
31
  } from "./ansi.mjs";
32
32
 
33
+ import { execFile as _execFile } from "node:child_process";
34
+
33
35
  // package.json에서 동적 로드 (실패 시 fallback)
34
36
  let VERSION = "7.x";
35
37
  try {
@@ -481,9 +483,13 @@ function buildWorkerRail(name, st, opts = {}) {
481
483
  ? dim("~")
482
484
  : " ";
483
485
  const hb = heartbeat(status, status === "running" ? currentShimmer(time) : 0, st._statusChangedAt, time);
486
+ // host 배지 (원격 워커용)
487
+ const hostBadge = st.host && st.host !== "local"
488
+ ? color(`[${st.host}]`, MOCHA.mauve) + " "
489
+ : "";
484
490
  const displayRole = dedupeRole(role, name, cli);
485
491
  const title = truncate(
486
- `${selMark} ${hb} ${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${displayRole ? ` ${color(`(${displayRole})`, MOCHA.overlay)}` : ""}`,
492
+ `${selMark} ${hb} ${hostBadge}${color(name, FG.triflux)} ${color("•", MOCHA.overlay)} ${color(cli, cliColor(cli))}${displayRole ? ` ${color(`(${displayRole})`, MOCHA.overlay)}` : ""}`,
487
493
  innerWidth,
488
494
  );
489
495
 
@@ -509,7 +515,7 @@ function buildWorkerRail(name, st, opts = {}) {
509
515
  const progress = Number.isFinite(st.progress) ? clamp(st.progress, 0, 1) : (status === "running" ? 0.3 : 1);
510
516
  const percent = Math.round(progress * 100);
511
517
  const compactLine1 = truncate(
512
- `${selMark} ${hb} ${color(name, FG.triflux)} ${dim("•")} ${color(cli, cliColor(cli))} ${statusBadge(status)} ${String(percent).padStart(3)}%`,
518
+ `${selMark} ${hb} ${hostBadge}${color(name, FG.triflux)} ${dim("•")} ${color(cli, cliColor(cli))} ${statusBadge(status)} ${String(percent).padStart(3)}%`,
513
519
  innerWidth,
514
520
  );
515
521
  const verdict = sanitizeOneLine(st.handoff?.verdict || st.summary || st.snapshot, status);
@@ -901,6 +907,17 @@ export function createLogDashboard(opts = {}) {
901
907
  return;
902
908
  }
903
909
 
910
+ // Enter: 선택된 워커 세션에 attach (k9s 패턴)
911
+ if (key === "\r" || key === "\n") {
912
+ if (!selectedWorker) return;
913
+ const w = workers.get(selectedWorker);
914
+ if (!w) return;
915
+ const sessionTarget = w.sessionName || w.paneName;
916
+ if (!sessionTarget) return;
917
+ attachToSession(w);
918
+ return;
919
+ }
920
+
904
921
  // Tab: rail ↔ detail 포커스 전환
905
922
  if (key === "\t") {
906
923
  focus = focus === "rail" ? "detail" : "rail";
@@ -959,6 +976,51 @@ export function createLogDashboard(opts = {}) {
959
976
  }
960
977
  }
961
978
 
979
+ // ── Enter→attach (k9s 패턴) ───────────────────────────────────────────
980
+ function attachToSession(worker) {
981
+ const execFileFn = opts.deps?.execFile || _execFile;
982
+ // 1. rawMode 해제 + input 일시정지 (키 이벤트 차단)
983
+ if (rawModeEnabled && typeof input?.setRawMode === "function") input.setRawMode(false);
984
+ if (typeof input?.pause === "function") input.pause();
985
+ // 2. altScreen 퇴장
986
+ exitAltScreen();
987
+
988
+ const sessionName = worker.sessionName || worker.paneName;
989
+ if (worker.remote && worker.sshUser) {
990
+ // 원격: SSH + psmux attach in new WT tab
991
+ const host = worker.host || "unknown";
992
+ const ip = worker._sshIp || host;
993
+ const title = `${host}:${worker.role || sessionName}`;
994
+ execFileFn("wt.exe", ["-w", "0", "nt", "--title", title, "--",
995
+ "ssh", `${worker.sshUser}@${ip}`, "-t", `psmux attach -t ${sessionName}`],
996
+ { detached: true, stdio: "ignore", windowsHide: false }, () => {});
997
+ } else {
998
+ // 로컬: psmux attach in new WT tab
999
+ const title = worker.role || sessionName;
1000
+ execFileFn("wt.exe", ["-w", "0", "nt", "--title", title, "--",
1001
+ "psmux", "attach", "-t", sessionName],
1002
+ { detached: true, stdio: "ignore", windowsHide: false }, () => {});
1003
+ }
1004
+
1005
+ // 3. 200ms 후 altScreen 복귀 + rawMode 재활성화
1006
+ setTimeout(() => {
1007
+ enterAltScreen();
1008
+ if (typeof input?.setRawMode === "function") { input.setRawMode(true); rawModeEnabled = true; }
1009
+ if (typeof input?.resume === "function") input.resume();
1010
+ render();
1011
+ }, 200);
1012
+ }
1013
+
1014
+ // ── flash 메시지 (완료/실패 알림용) ────────────────────────────────────
1015
+ let flashMessage = "";
1016
+ let flashTimer = null;
1017
+ function showFlash(msg, durationMs = 5000) {
1018
+ flashMessage = msg;
1019
+ if (flashTimer) clearTimeout(flashTimer);
1020
+ flashTimer = setTimeout(() => { flashMessage = ""; render(); }, durationMs);
1021
+ render();
1022
+ }
1023
+
962
1024
  function attachInput() {
963
1025
  if (inputAttached) return;
964
1026
  if (!isTTY || (!forceTTY && !input?.isTTY) || typeof input?.on !== "function") return;
@@ -1000,6 +1062,10 @@ export function createLogDashboard(opts = {}) {
1000
1062
 
1001
1063
  // Tier1: 상단 고정 2행
1002
1064
  const tier1 = buildTier1(names, workers, pipeline, elapsed, totalCols, VERSION, renderTime);
1065
+ // flash 메시지 (완료/실패 알림)
1066
+ if (flashMessage) {
1067
+ tier1.push(truncate(` ${color("▸", MOCHA.green)} ${flashMessage}`, totalCols));
1068
+ }
1003
1069
 
1004
1070
  // 레이아웃 결정
1005
1071
  let effectiveLayout = layoutHint;
@@ -1235,11 +1301,149 @@ export function createLogDashboard(opts = {}) {
1235
1301
  return helpOverlay;
1236
1302
  },
1237
1303
 
1304
+ showFlash,
1305
+
1306
+ attachWorker(name) {
1307
+ const w = workers.get(name);
1308
+ if (w) attachToSession(w);
1309
+ },
1310
+
1238
1311
  close() {
1239
1312
  doClose();
1240
1313
  },
1241
1314
  };
1242
1315
  }
1243
1316
 
1317
+ // ── Conductor Tier: 세션 테이블 렌더러 ─────────────────────────────────
1318
+ //
1319
+ // renderConductorTier(snapshot, cols)
1320
+ // snapshot: conductor.getSnapshot() 반환 배열
1321
+ // cols: 터미널 폭 (기본 100)
1322
+ //
1323
+ // 레이아웃:
1324
+ // ┌─ CONDUCTOR ──────────────────────────────────────────┐
1325
+ // │ ID Agent Host Health Last Out Restarts Why │
1326
+ // │ abc123 codex local ■ OK 2s ago 0 │
1327
+ // └──────────────────────────────────────────────────────┘
1328
+ //
1329
+ // Health 색상: healthy=green, stalled=yellow, input_wait=cyan,
1330
+ // failed=red, dead/init/starting=dim
1331
+
1332
+ const CONDUCTOR_STATE_LABEL = {
1333
+ init: { label: 'INIT', seq: MOCHA.subtext },
1334
+ starting: { label: 'START', seq: MOCHA.executing },
1335
+ healthy: { label: 'OK', seq: MOCHA.ok },
1336
+ stalled: { label: 'STALL', seq: MOCHA.yellow },
1337
+ input_wait: { label: 'INPUT_WAIT', seq: FG.cyan },
1338
+ failed: { label: 'FAIL', seq: MOCHA.fail },
1339
+ restarting: { label: 'RESTART', seq: MOCHA.partial },
1340
+ dead: { label: 'DEAD', seq: FG.gray },
1341
+ completed: { label: 'DONE', seq: MOCHA.ok },
1342
+ };
1343
+
1344
+ function conductorHealthCell(state) {
1345
+ const entry = CONDUCTOR_STATE_LABEL[state] || { label: state.toUpperCase(), seq: FG.gray };
1346
+ return `${entry.seq}■ ${entry.label}${RESET}`;
1347
+ }
1348
+
1349
+ function conductorRelTime(ms) {
1350
+ if (!ms) return '—';
1351
+ const sec = Math.round((Date.now() - ms) / 1000);
1352
+ if (sec < 0) return '—';
1353
+ if (sec < 60) return `${sec}s ago`;
1354
+ if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
1355
+ return `${Math.floor(sec / 3600)}h ago`;
1356
+ }
1357
+
1358
+ /**
1359
+ * Conductor 세션 테이블을 문자열 배열(행 목록)로 렌더링.
1360
+ *
1361
+ * @param {object[]} snapshot — conductor.getSnapshot() 결과
1362
+ * @param {number} [cols=100] — 터미널 폭
1363
+ * @returns {string[]} 렌더링된 행 목록 (altScreen rowBuf.set()에 바로 삽입 가능)
1364
+ */
1365
+ export function renderConductorTier(snapshot, cols = 100) {
1366
+ const width = Math.max(48, cols);
1367
+ const inner = width - 4; // border: '│ ' + content + ' │'
1368
+
1369
+ // ── 열 너비 계산 ────────────────────────────────────────
1370
+ // ID(8) Agent(7) Host(6) Health(dyn) LastOut(dyn) Restarts(8) Why(rest)
1371
+ const COL_ID = 8;
1372
+ const COL_AGENT = 7;
1373
+ const COL_HOST = 6;
1374
+ const COL_RESTARTS = 4;
1375
+ const COL_HEALTH = 12; // '■ INPUT_WAIT' = 12 chars
1376
+ const COL_LASTOUT = 9; // '999m ago' = 8 + space
1377
+ // Why gets the remainder
1378
+ const fixedCols = COL_ID + COL_AGENT + COL_HOST + COL_HEALTH + COL_LASTOUT + COL_RESTARTS + 6; // 6 spaces between cols
1379
+ const COL_WHY = Math.max(4, inner - fixedCols);
1380
+
1381
+ function cell(text, width_) {
1382
+ return clip(String(text ?? ''), width_);
1383
+ }
1384
+
1385
+ function buildRow(id, agent, host, healthCell, lastOut, restarts, why) {
1386
+ const idC = cell(id, COL_ID);
1387
+ const agentC = cell(agent, COL_AGENT);
1388
+ const hostC = cell(host, COL_HOST);
1389
+ const restartsC = cell(String(restarts ?? 0), COL_RESTARTS);
1390
+ const lastOutC = clip(lastOut, COL_LASTOUT);
1391
+ const whyC = cell(why, COL_WHY);
1392
+ // healthCell already has ANSI codes; pad its visible width manually
1393
+ const healthVis = wcswidth(stripAnsi(healthCell));
1394
+ const healthPad = Math.max(0, COL_HEALTH - healthVis);
1395
+ const healthC = healthCell + ' '.repeat(healthPad);
1396
+
1397
+ return `${idC} ${agentC} ${hostC} ${healthC} ${lastOutC} ${restartsC} ${whyC}`;
1398
+ }
1399
+
1400
+ const boxWidth = inner;
1401
+
1402
+ // ── 타이틀 행 ───────────────────────────────────────────
1403
+ const titleText = ` CONDUCTOR `;
1404
+ const titleColored = bold(color(titleText, FG.accent));
1405
+ // Border top with title embedded: ┌─ CONDUCTOR ──...─┐
1406
+ const dashLen = Math.max(0, boxWidth - titleText.length);
1407
+ const dashLeft = 1;
1408
+ const dashRight = Math.max(0, dashLen - dashLeft);
1409
+ const borderSeq = MOCHA.border;
1410
+ const topBorder =
1411
+ `${borderSeq}┌${'─'.repeat(dashLeft)}${RESET}${titleColored}${borderSeq}${'─'.repeat(dashRight)}┐${RESET}`;
1412
+
1413
+ // ── ヘッダー行 ───────────────────────────────────────────
1414
+ const headerRow = buildRow('ID', 'Agent', 'Host', clip('Health', COL_HEALTH), 'Last Out', 'Rst', 'Why');
1415
+ const headerLine = `${borderSeq}│${RESET} ${dim(headerRow)} ${borderSeq}│${RESET}`;
1416
+
1417
+ // ── データ行 ────────────────────────────────────────────
1418
+ const dataLines = [];
1419
+ if (!snapshot || snapshot.length === 0) {
1420
+ const emptyMsg = color('(no sessions)', FG.muted);
1421
+ const emptyPad = clip(stripAnsi(emptyMsg) === '(no sessions)' ? emptyMsg : emptyMsg, inner);
1422
+ dataLines.push(`${borderSeq}│${RESET} ${padRight(emptyMsg, inner - 2)} ${borderSeq}│${RESET}`);
1423
+ } else {
1424
+ for (const s of snapshot) {
1425
+ const id = String(s.id ?? '').slice(0, COL_ID);
1426
+ const agent = String(s.agent ?? 'unknown').slice(0, COL_AGENT);
1427
+ const host = 'local';
1428
+ const state = s.state ?? 'init';
1429
+ const healthCell = conductorHealthCell(state);
1430
+ const lastOut = conductorRelTime(s.health?.lastProbeAt ?? null);
1431
+ const restarts = s.restarts ?? 0;
1432
+ // derive "why" from last state transition context
1433
+ const why = s.health?.inputWaitPattern
1434
+ ? String(s.health.inputWaitPattern).slice(0, COL_WHY)
1435
+ : '';
1436
+
1437
+ const rowText = buildRow(id, agent, host, healthCell, lastOut, restarts, why);
1438
+ dataLines.push(`${borderSeq}│${RESET} ${rowText} ${borderSeq}│${RESET}`);
1439
+ }
1440
+ }
1441
+
1442
+ // ── Bottom border ─────────────────────────────────────
1443
+ const botBorder = `${borderSeq}└${'─'.repeat(boxWidth)}┘${RESET}`;
1444
+
1445
+ return [topBorder, headerLine, ...dataLines, botBorder];
1446
+ }
1447
+
1244
1448
  // 하위 호환
1245
1449
  export { createLogDashboard as createTui };