@triflux/remote 10.0.0 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,407 @@
1
+ import childProcess from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
3
+ import { platform as osPlatform, tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { sendKeysToPane } from "./psmux.mjs";
7
+
8
+ const DEFAULT_WINDOW_NAME = "triflux";
9
+ const DEFAULT_MAX_TABS = 8;
10
+ const DEFAULT_TAB_CREATE_DELAY_MS = 500;
11
+ const DEFAULT_WAIT_TIMEOUT_MS = 5_000;
12
+ const DEFAULT_WAIT_POLL_MS = 300;
13
+
14
+ function sleep(ms) {
15
+ return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
16
+ }
17
+
18
+ function resolvePositiveInteger(...values) {
19
+ for (const value of values) {
20
+ const parsed = Number.parseInt(String(value ?? ""), 10);
21
+ if (Number.isInteger(parsed) && parsed > 0) {
22
+ return parsed;
23
+ }
24
+ }
25
+ return null;
26
+ }
27
+
28
+ function sanitizeTitleForPidFile(title) {
29
+ const sanitized = [...String(title).trim()]
30
+ .map((character) => {
31
+ const code = character.charCodeAt(0);
32
+ return code <= 31 || '<>:"/\\|?*'.includes(character) ? "_" : character;
33
+ })
34
+ .join("");
35
+
36
+ return (
37
+ sanitized
38
+ .replace(/\s+/gu, "-")
39
+ .replace(/_+/gu, "_")
40
+ .replace(/-+/gu, "-")
41
+ .replace(/^[-_]+|[-_]+$/gu, "") || "wt-tab"
42
+ );
43
+ }
44
+
45
+ function escapePowerShellSingleQuoted(value) {
46
+ return String(value).replace(/'/g, "''");
47
+ }
48
+
49
+ function buildPidFilePath(pidDir, title) {
50
+ return join(pidDir, `${sanitizeTitleForPidFile(title)}.pid`);
51
+ }
52
+
53
+ function buildWrappedCommand(pidFile, command) {
54
+ const escapedPidFile = escapePowerShellSingleQuoted(pidFile);
55
+ const pidWrite = `$PID | Set-Content '${escapedPidFile}'`;
56
+ return command ? `${pidWrite}; ${command}` : pidWrite;
57
+ }
58
+
59
+ /**
60
+ * PowerShell -EncodedCommand용 Base64 인코딩.
61
+ * CreateProcess 이중 쿼팅 문제를 완전히 회피한다.
62
+ */
63
+ function encodeForPowerShell(script) {
64
+ const buf = Buffer.from(script, 'utf16le');
65
+ return buf.toString('base64');
66
+ }
67
+
68
+ function matchesTitlePattern(title, pattern) {
69
+ if (!pattern) return true;
70
+ if (pattern instanceof RegExp) return pattern.test(title);
71
+ return String(title).includes(String(pattern));
72
+ }
73
+
74
+ function defaultIsPidAlive(pid, execFileSyncFn = childProcess.execFileSync) {
75
+ try {
76
+ process.kill(pid, 0);
77
+ return true;
78
+ } catch {
79
+ // tasklist fallback — process.kill(..., 0)이 권한/플랫폼 차이로 실패할 수 있음.
80
+ }
81
+
82
+ try {
83
+ const output = execFileSyncFn(
84
+ "tasklist",
85
+ ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"],
86
+ {
87
+ encoding: "utf8",
88
+ timeout: 3_000,
89
+ stdio: ["ignore", "pipe", "ignore"],
90
+ windowsHide: true,
91
+ },
92
+ );
93
+ return (
94
+ !/No tasks are running/u.test(String(output)) &&
95
+ String(output).includes(`"${pid}"`)
96
+ );
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Windows Terminal 탭/세션 라이프사이클 관리자.
104
+ *
105
+ * @param {object} [opts]
106
+ * @param {string} [opts.windowName='triflux']
107
+ * @param {number} [opts.maxTabs=8]
108
+ * @param {string} [opts.pidDir=os.tmpdir()/wt-manager-pids]
109
+ * @param {number} [opts.tabCreateDelayMs=500]
110
+ * @param {object} [opts.deps] — 테스트용 의존성 주입
111
+ */
112
+ export function createWtManager(opts = {}) {
113
+ const deps = opts.deps || {};
114
+ const platform = deps.platform || osPlatform;
115
+
116
+ if (platform() !== "win32") {
117
+ throw new Error("wt-manager.mjs is Windows-only");
118
+ }
119
+
120
+ const now = deps.now || Date.now;
121
+ const sleepFn = deps.sleep || sleep;
122
+ const spawnFn = deps.spawn || childProcess.spawn;
123
+ const execFileSyncFn = deps.execFileSync || childProcess.execFileSync;
124
+ const killFn = deps.kill || ((pid) => process.kill(pid));
125
+ const sendKeysFn = deps.sendKeysToPane || sendKeysToPane;
126
+ const isPidAlive =
127
+ deps.isPidAlive || ((pid) => defaultIsPidAlive(pid, execFileSyncFn));
128
+ const ensureDir =
129
+ deps.ensureDir || ((dir) => mkdirSync(dir, { recursive: true }));
130
+ const exists = deps.exists || existsSync;
131
+ const readText =
132
+ deps.readText || ((filePath) => readFileSync(filePath, "utf8"));
133
+ const removeFile =
134
+ deps.removeFile || ((filePath) => rmSync(filePath, { force: true }));
135
+
136
+ const windowName = String(opts.windowName || DEFAULT_WINDOW_NAME);
137
+ const maxTabs =
138
+ resolvePositiveInteger(
139
+ opts.maxTabs,
140
+ process.env.WTM_MAX_TABS,
141
+ DEFAULT_MAX_TABS,
142
+ ) || DEFAULT_MAX_TABS;
143
+ const pidDir = String(opts.pidDir || join(tmpdir(), "wt-manager-pids"));
144
+ const tabCreateDelayMs =
145
+ resolvePositiveInteger(
146
+ opts.tabCreateDelayMs,
147
+ DEFAULT_TAB_CREATE_DELAY_MS,
148
+ ) || DEFAULT_TAB_CREATE_DELAY_MS;
149
+
150
+ ensureDir(pidDir);
151
+
152
+ /** @type {Map<string, { pid: number, createdAt: number, pidFile: string }>} */
153
+ const tabs = new Map();
154
+ let lastTabCreateAt = null;
155
+
156
+ function forgetTab(title) {
157
+ const entry = tabs.get(title);
158
+ if (entry) {
159
+ tabs.delete(title);
160
+ try {
161
+ removeFile(entry.pidFile);
162
+ } catch {
163
+ /* ignore */
164
+ }
165
+ }
166
+ }
167
+
168
+ function pruneDeadTabs() {
169
+ for (const [title, entry] of tabs.entries()) {
170
+ if (!isPidAlive(entry.pid)) {
171
+ forgetTab(title);
172
+ }
173
+ }
174
+ }
175
+
176
+ async function throttleTabCreate() {
177
+ if (lastTabCreateAt == null) return;
178
+ const elapsed = now() - lastTabCreateAt;
179
+ if (elapsed < tabCreateDelayMs) {
180
+ await sleepFn(tabCreateDelayMs - elapsed);
181
+ }
182
+ }
183
+
184
+ async function waitTabReady(title, pidFile) {
185
+ const deadline = now() + DEFAULT_WAIT_TIMEOUT_MS;
186
+
187
+ while (now() <= deadline) {
188
+ if (exists(pidFile)) {
189
+ const pid = Number.parseInt(String(readText(pidFile)).trim(), 10);
190
+ if (Number.isInteger(pid) && pid > 0 && isPidAlive(pid)) {
191
+ return pid;
192
+ }
193
+ }
194
+
195
+ const remaining = deadline - now();
196
+ if (remaining <= 0) break;
197
+ await sleepFn(Math.min(DEFAULT_WAIT_POLL_MS, remaining));
198
+ }
199
+
200
+ throw new Error(`WT tab ready timeout: ${title}`);
201
+ }
202
+
203
+ async function createTab(tab = {}) {
204
+ const title = String(tab.title || "").trim();
205
+ if (!title) {
206
+ throw new Error("title is required");
207
+ }
208
+
209
+ pruneDeadTabs();
210
+
211
+ if (tabs.has(title)) {
212
+ throw new Error(`WT tab already exists: ${title}`);
213
+ }
214
+
215
+ if (tabs.size >= maxTabs) {
216
+ throw new Error(`WT max tabs exceeded (${maxTabs})`);
217
+ }
218
+
219
+ await throttleTabCreate();
220
+
221
+ const pidFile = buildPidFilePath(pidDir, title);
222
+ try {
223
+ removeFile(pidFile);
224
+ } catch {
225
+ /* ignore */
226
+ }
227
+
228
+ const args = ["-w", windowName, "nt", "--title", title];
229
+ if (tab.profile) {
230
+ args.push("--profile", String(tab.profile));
231
+ }
232
+ if (tab.cwd) {
233
+ args.push("-d", String(tab.cwd));
234
+ }
235
+ const script = buildWrappedCommand(pidFile, tab.command);
236
+ args.push(
237
+ "--",
238
+ "powershell.exe",
239
+ "-NoExit",
240
+ "-EncodedCommand",
241
+ encodeForPowerShell(script),
242
+ );
243
+
244
+ const child = spawnFn("wt.exe", args, {
245
+ detached: true,
246
+ stdio: "ignore",
247
+ });
248
+ child?.unref?.();
249
+
250
+ lastTabCreateAt = now();
251
+ const pid = await waitTabReady(title, pidFile);
252
+ const entry = Object.freeze({
253
+ pid,
254
+ createdAt: now(),
255
+ pidFile,
256
+ });
257
+ tabs.set(title, entry);
258
+ }
259
+
260
+ async function closeTab(title) {
261
+ const normalizedTitle = String(title || "").trim();
262
+ if (!normalizedTitle) return;
263
+
264
+ const entry = tabs.get(normalizedTitle);
265
+ if (!entry) return;
266
+
267
+ try {
268
+ killFn(entry.pid);
269
+ } catch {
270
+ // 이미 종료된 PID는 map 정리만 수행한다.
271
+ }
272
+
273
+ forgetTab(normalizedTitle);
274
+ }
275
+
276
+ function listTabs() {
277
+ pruneDeadTabs();
278
+ return [...tabs.entries()].map(([title, entry]) =>
279
+ Object.freeze({
280
+ title,
281
+ pid: entry.pid,
282
+ createdAt: entry.createdAt,
283
+ }),
284
+ );
285
+ }
286
+
287
+ async function closeStale(closeOpts = {}) {
288
+ pruneDeadTabs();
289
+
290
+ const olderThanMs = Number.isFinite(closeOpts.olderThanMs)
291
+ ? Math.max(0, Math.trunc(closeOpts.olderThanMs))
292
+ : 0;
293
+ const titlePattern = closeOpts.titlePattern;
294
+ const snapshot = [...tabs.entries()];
295
+ let closed = 0;
296
+
297
+ for (const [title, entry] of snapshot) {
298
+ const ageMs = now() - entry.createdAt;
299
+ if (ageMs < olderThanMs) continue;
300
+ if (!matchesTitlePattern(title, titlePattern)) continue;
301
+ await closeTab(title);
302
+ closed += 1;
303
+ }
304
+
305
+ return closed;
306
+ }
307
+
308
+ /**
309
+ * 현재 탭을 split-pane으로 분할.
310
+ * @param {object} opts
311
+ * @param {'H'|'V'} [opts.direction='V'] — H=좌우, V=상하
312
+ * @param {string} [opts.title]
313
+ * @param {string} [opts.profile]
314
+ * @param {string} [opts.cwd]
315
+ * @param {string} [opts.command] — pane에서 실행할 명령
316
+ * @param {number} [opts.size] — 퍼센트 (0-100)
317
+ */
318
+ async function splitPane(opts = {}) {
319
+ const direction = opts.direction === 'H' ? '-H' : '-V';
320
+ const args = ['-w', windowName, 'sp', direction];
321
+
322
+ if (opts.title) {
323
+ args.push('--title', String(opts.title));
324
+ }
325
+ if (opts.profile) {
326
+ args.push('--profile', String(opts.profile));
327
+ }
328
+ if (opts.size && Number.isFinite(opts.size)) {
329
+ args.push('-s', String(opts.size / 100));
330
+ }
331
+ if (opts.cwd) {
332
+ args.push('-d', String(opts.cwd));
333
+ }
334
+ if (opts.command) {
335
+ const script = opts.command;
336
+ args.push('--', 'powershell.exe', '-NoExit', '-EncodedCommand', encodeForPowerShell(script));
337
+ }
338
+
339
+ const child = spawnFn('wt.exe', args, {
340
+ detached: true,
341
+ stdio: 'ignore',
342
+ });
343
+ child?.unref?.();
344
+
345
+ // split-pane은 기존 탭 안에서 분할하므로 throttle 적용
346
+ await sleepFn(tabCreateDelayMs);
347
+ }
348
+
349
+ /**
350
+ * 여러 세션을 split-pane 레이아웃으로 한번에 배치.
351
+ * @param {Array<{title: string, command: string, direction?: 'H'|'V', size?: number}>} panes
352
+ */
353
+ async function applySplitLayout(panes) {
354
+ if (!panes || panes.length === 0) return;
355
+
356
+ // 첫 번째는 새 탭으로 생성
357
+ const first = panes[0];
358
+ await createTab({ title: first.title, command: first.command, profile: first.profile });
359
+
360
+ // 나머지는 split-pane으로 분할
361
+ for (let i = 1; i < panes.length; i++) {
362
+ await splitPane({
363
+ direction: panes[i].direction || 'V',
364
+ title: panes[i].title,
365
+ command: panes[i].command,
366
+ profile: panes[i].profile,
367
+ size: panes[i].size,
368
+ });
369
+ }
370
+ }
371
+
372
+ async function createSession(sessionOpts = {}) {
373
+ const tab =
374
+ typeof sessionOpts.tab === "string"
375
+ ? { title: sessionOpts.tab }
376
+ : sessionOpts.tab;
377
+
378
+ if (!tab || typeof tab !== "object") {
379
+ throw new Error("tab is required");
380
+ }
381
+ if (!sessionOpts.pane) {
382
+ throw new Error("pane is required");
383
+ }
384
+ if (!sessionOpts.command) {
385
+ throw new Error("command is required");
386
+ }
387
+
388
+ await createTab(tab);
389
+ sendKeysFn(String(sessionOpts.pane), String(sessionOpts.command), true);
390
+ }
391
+
392
+ function getTabCount() {
393
+ pruneDeadTabs();
394
+ return tabs.size;
395
+ }
396
+
397
+ return Object.freeze({
398
+ createTab,
399
+ closeTab,
400
+ listTabs,
401
+ closeStale,
402
+ createSession,
403
+ splitPane,
404
+ applySplitLayout,
405
+ getTabCount,
406
+ });
407
+ }
@@ -0,0 +1,43 @@
1
+ {
2
+ "version": 1,
3
+ "templates": {
4
+ "swarm-3": {
5
+ "description": "Lead tab plus two worker tabs in the shared triflux window.",
6
+ "windowName": "triflux",
7
+ "tabs": [
8
+ {
9
+ "title": "swarm lead",
10
+ "profile": "triflux"
11
+ },
12
+ {
13
+ "title": "swarm worker-1",
14
+ "profile": "triflux"
15
+ },
16
+ {
17
+ "title": "swarm worker-2",
18
+ "profile": "triflux"
19
+ }
20
+ ]
21
+ },
22
+ "monitor": {
23
+ "description": "Single monitoring tab for dashboard, log tailing, or observer flows.",
24
+ "windowName": "triflux",
25
+ "tabs": [
26
+ {
27
+ "title": "swarm monitor",
28
+ "profile": "triflux"
29
+ }
30
+ ]
31
+ },
32
+ "single": {
33
+ "description": "One focused tab for direct operator work.",
34
+ "windowName": "triflux",
35
+ "tabs": [
36
+ {
37
+ "title": "single worker",
38
+ "profile": "triflux"
39
+ }
40
+ ]
41
+ }
42
+ }
43
+ }