@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.
- package/hub/index.mjs +21 -0
- package/hub/pipe.mjs +98 -13
- package/hub/server.mjs +1245 -1124
- package/hub/store-adapter.mjs +14 -747
- package/hub/store.mjs +4 -44
- package/hub/team/backend.mjs +1 -1
- package/hub/team/cli/services/hub-client.mjs +38 -19
- package/hub/team/cli/services/native-control.mjs +1 -1
- package/hub/team/conductor.mjs +671 -0
- package/hub/team/event-log.mjs +76 -0
- package/hub/team/headless.mjs +8 -6
- package/hub/team/health-probe.mjs +272 -0
- package/hub/team/launcher-template.mjs +95 -0
- package/hub/team/lead-control.mjs +104 -0
- package/hub/team/nativeProxy.mjs +9 -2
- package/hub/team/notify.mjs +293 -0
- package/hub/team/pane.mjs +1 -1
- package/hub/team/process-cleanup.mjs +342 -0
- package/hub/team/psmux.mjs +1 -1
- package/hub/team/remote-probe.mjs +276 -0
- package/hub/team/remote-session.mjs +296 -0
- package/hub/team/remote-watcher.mjs +478 -0
- package/hub/team/session-sync.mjs +169 -0
- package/hub/team/staleState.mjs +1 -1
- package/hub/team/tui-remote-adapter.mjs +393 -0
- package/hub/team/tui.mjs +206 -2
- package/hub/tools.mjs +94 -12
- package/hub/tray.mjs +1 -1
- package/hub/workers/codex-mcp.mjs +8 -2
- package/hub/workers/gemini-worker.mjs +2 -1
- package/package.json +1 -1
|
@@ -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 };
|