triflux 3.2.0-dev.8 → 3.3.0-dev.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 (52) hide show
  1. package/bin/triflux.mjs +1296 -1055
  2. package/hooks/hooks.json +17 -0
  3. package/hooks/keyword-rules.json +20 -4
  4. package/hooks/pipeline-stop.mjs +54 -0
  5. package/hub/bridge.mjs +517 -318
  6. package/hub/hitl.mjs +45 -31
  7. package/hub/pipe.mjs +457 -0
  8. package/hub/pipeline/index.mjs +121 -0
  9. package/hub/pipeline/state.mjs +164 -0
  10. package/hub/pipeline/transitions.mjs +114 -0
  11. package/hub/router.mjs +422 -161
  12. package/hub/schema.sql +14 -0
  13. package/hub/server.mjs +499 -424
  14. package/hub/store.mjs +388 -314
  15. package/hub/team/cli-team-common.mjs +348 -0
  16. package/hub/team/cli-team-control.mjs +393 -0
  17. package/hub/team/cli-team-start.mjs +516 -0
  18. package/hub/team/cli-team-status.mjs +269 -0
  19. package/hub/team/cli.mjs +75 -1475
  20. package/hub/team/dashboard.mjs +1 -9
  21. package/hub/team/native.mjs +190 -130
  22. package/hub/team/nativeProxy.mjs +165 -78
  23. package/hub/team/orchestrator.mjs +15 -20
  24. package/hub/team/pane.mjs +137 -103
  25. package/hub/team/psmux.mjs +506 -0
  26. package/hub/team/session.mjs +393 -330
  27. package/hub/team/shared.mjs +13 -0
  28. package/hub/team/staleState.mjs +299 -0
  29. package/hub/tools.mjs +105 -31
  30. package/hub/workers/claude-worker.mjs +446 -0
  31. package/hub/workers/codex-mcp.mjs +414 -0
  32. package/hub/workers/factory.mjs +18 -0
  33. package/hub/workers/gemini-worker.mjs +349 -0
  34. package/hub/workers/interface.mjs +41 -0
  35. package/hud/hud-qos-status.mjs +1790 -1788
  36. package/package.json +4 -1
  37. package/scripts/__tests__/keyword-detector.test.mjs +8 -8
  38. package/scripts/keyword-detector.mjs +15 -0
  39. package/scripts/lib/keyword-rules.mjs +4 -1
  40. package/scripts/preflight-cache.mjs +72 -0
  41. package/scripts/psmux-steering-prototype.sh +368 -0
  42. package/scripts/setup.mjs +136 -71
  43. package/scripts/tfx-route-worker.mjs +161 -0
  44. package/scripts/tfx-route.sh +485 -91
  45. package/skills/tfx-auto/SKILL.md +90 -564
  46. package/skills/tfx-auto-codex/SKILL.md +1 -3
  47. package/skills/tfx-codex/SKILL.md +1 -4
  48. package/skills/tfx-doctor/SKILL.md +1 -0
  49. package/skills/tfx-gemini/SKILL.md +1 -4
  50. package/skills/tfx-multi/SKILL.md +378 -0
  51. package/skills/tfx-setup/SKILL.md +1 -4
  52. package/skills/tfx-team/SKILL.md +0 -304
@@ -0,0 +1,506 @@
1
+ // hub/team/psmux.mjs — Windows psmux 세션/키바인딩/캡처 관리
2
+ // 의존성: child_process (Node.js 내장)만 사용
3
+ import { execSync, spawnSync } from "node:child_process";
4
+
5
+ const PSMUX_BIN = process.env.PSMUX_BIN || "psmux";
6
+ const GIT_BASH = process.env.GIT_BASH_PATH || "C:\\Program Files\\Git\\bin\\bash.exe";
7
+ const IS_WINDOWS = process.platform === "win32";
8
+
9
+ function quoteArg(value) {
10
+ const str = String(value);
11
+ if (!/[\s"]/u.test(str)) return str;
12
+ return `"${str.replace(/"/g, '\\"')}"`;
13
+ }
14
+
15
+ function toPaneTitle(index) {
16
+ return index === 0 ? "lead" : `worker-${index}`;
17
+ }
18
+
19
+ function parsePaneList(output) {
20
+ return output
21
+ .split("\n")
22
+ .map((line) => line.trim())
23
+ .filter(Boolean)
24
+ .map((line) => {
25
+ const [indexText, target] = line.split("\t");
26
+ return {
27
+ index: parseInt(indexText, 10),
28
+ target: target?.trim() || "",
29
+ };
30
+ })
31
+ .filter((entry) => Number.isFinite(entry.index) && entry.target)
32
+ .sort((a, b) => a.index - b.index)
33
+ .map((entry) => entry.target);
34
+ }
35
+
36
+ function parseSessionSummaries(output) {
37
+ return output
38
+ .split("\n")
39
+ .map((line) => line.trim())
40
+ .filter(Boolean)
41
+ .map((line) => {
42
+ const colonIndex = line.indexOf(":");
43
+ if (colonIndex === -1) {
44
+ return null;
45
+ }
46
+
47
+ const sessionName = line.slice(0, colonIndex).trim();
48
+ const flags = [...line.matchAll(/\(([^)]*)\)/g)].map((match) => match[1]).join(", ");
49
+ const attachedMatch = flags.match(/(\d+)\s+attached/);
50
+ const attachedCount = attachedMatch
51
+ ? parseInt(attachedMatch[1], 10)
52
+ : /\battached\b/.test(flags)
53
+ ? 1
54
+ : 0;
55
+
56
+ return sessionName
57
+ ? { sessionName, attachedCount }
58
+ : null;
59
+ })
60
+ .filter(Boolean);
61
+ }
62
+
63
+ function collectSessionPanes(sessionName) {
64
+ const output = psmuxExec(
65
+ `list-panes -t ${quoteArg(`${sessionName}:0`)} -F "#{pane_index}\t#{session_name}:#{window_index}.#{pane_index}"`
66
+ );
67
+ return parsePaneList(output);
68
+ }
69
+
70
+ function psmux(args, opts = {}) {
71
+ if (Array.isArray(args)) {
72
+ const result = spawnSync(PSMUX_BIN, args.map((arg) => String(arg)), {
73
+ encoding: "utf8",
74
+ timeout: 10000,
75
+ stdio: ["pipe", "pipe", "pipe"],
76
+ windowsHide: true,
77
+ ...opts,
78
+ });
79
+ if ((result.status ?? 1) !== 0) {
80
+ const error = new Error((result.stderr || result.stdout || "psmux command failed").trim());
81
+ error.status = result.status;
82
+ throw error;
83
+ }
84
+ return (result.stdout || "").trim();
85
+ }
86
+
87
+ const result = execSync(`${quoteArg(PSMUX_BIN)} ${args}`, {
88
+ encoding: "utf8",
89
+ timeout: 10000,
90
+ stdio: ["pipe", "pipe", "pipe"],
91
+ windowsHide: true,
92
+ ...opts,
93
+ });
94
+ return result != null ? result.trim() : "";
95
+ }
96
+
97
+ /** psmux 실행 가능 여부 확인 */
98
+ export function hasPsmux() {
99
+ try {
100
+ execSync(`${quoteArg(PSMUX_BIN)} -V`, {
101
+ stdio: "ignore",
102
+ timeout: 3000,
103
+ windowsHide: true,
104
+ });
105
+ return true;
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * psmux 커맨드 실행 래퍼
113
+ * @param {string|string[]} args
114
+ * @param {object} opts
115
+ * @returns {string}
116
+ */
117
+ export function psmuxExec(args, opts = {}) {
118
+ return psmux(args, opts);
119
+ }
120
+
121
+ /**
122
+ * psmux 세션 생성 + 레이아웃 분할
123
+ * @param {string} sessionName
124
+ * @param {object} opts
125
+ * @param {'2x2'|'1xN'|'Nx1'} opts.layout
126
+ * @param {number} opts.paneCount
127
+ * @returns {{ sessionName: string, panes: string[] }}
128
+ */
129
+ export function createPsmuxSession(sessionName, opts = {}) {
130
+ const layout = opts.layout === "1xN" || opts.layout === "Nx1" ? opts.layout : "2x2";
131
+ const paneCount = Math.max(
132
+ 1,
133
+ Number.isFinite(opts.paneCount) ? Math.trunc(opts.paneCount) : 4
134
+ );
135
+ const limitedPaneCount = layout === "2x2" ? Math.min(paneCount, 4) : paneCount;
136
+ const sessionTarget = `${sessionName}:0`;
137
+
138
+ const leadPane = psmuxExec(
139
+ `new-session -d -P -F "#{session_name}:#{window_index}.#{pane_index}" -s ${quoteArg(sessionName)} -x 220 -y 55`
140
+ );
141
+
142
+ if (layout === "2x2" && limitedPaneCount >= 3) {
143
+ const rightPane = psmuxExec(
144
+ `split-window -h -P -F "#{session_name}:#{window_index}.#{pane_index}" -t ${quoteArg(leadPane)}`
145
+ );
146
+ psmuxExec(
147
+ `split-window -v -P -F "#{session_name}:#{window_index}.#{pane_index}" -t ${quoteArg(rightPane)}`
148
+ );
149
+ if (limitedPaneCount >= 4) {
150
+ psmuxExec(
151
+ `split-window -v -P -F "#{session_name}:#{window_index}.#{pane_index}" -t ${quoteArg(leadPane)}`
152
+ );
153
+ }
154
+ psmuxExec(`select-layout -t ${quoteArg(sessionTarget)} tiled`);
155
+ } else if (layout === "1xN") {
156
+ for (let i = 1; i < limitedPaneCount; i++) {
157
+ psmuxExec(`split-window -h -t ${quoteArg(sessionTarget)}`);
158
+ }
159
+ psmuxExec(`select-layout -t ${quoteArg(sessionTarget)} even-horizontal`);
160
+ } else {
161
+ for (let i = 1; i < limitedPaneCount; i++) {
162
+ psmuxExec(`split-window -v -t ${quoteArg(sessionTarget)}`);
163
+ }
164
+ psmuxExec(`select-layout -t ${quoteArg(sessionTarget)} even-vertical`);
165
+ }
166
+
167
+ psmuxExec(`select-pane -t ${quoteArg(leadPane)}`);
168
+
169
+ const panes = collectSessionPanes(sessionName).slice(0, limitedPaneCount);
170
+ panes.forEach((pane, index) => {
171
+ psmuxExec(`select-pane -t ${quoteArg(pane)} -T ${quoteArg(toPaneTitle(index))}`);
172
+ });
173
+
174
+ return { sessionName, panes };
175
+ }
176
+
177
+ /**
178
+ * psmux 세션 종료
179
+ * @param {string} sessionName
180
+ */
181
+ export function killPsmuxSession(sessionName) {
182
+ try {
183
+ psmuxExec(`kill-session -t ${quoteArg(sessionName)}`, { stdio: "ignore" });
184
+ } catch {
185
+ // 이미 종료된 세션 — 무시
186
+ }
187
+ }
188
+
189
+ /**
190
+ * psmux 세션 존재 확인
191
+ * @param {string} sessionName
192
+ * @returns {boolean}
193
+ */
194
+ export function psmuxSessionExists(sessionName) {
195
+ try {
196
+ psmuxExec(`has-session -t ${quoteArg(sessionName)}`, { stdio: "ignore" });
197
+ return true;
198
+ } catch {
199
+ return false;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * tfx-multi- 접두사 psmux 세션 목록
205
+ * @returns {string[]}
206
+ */
207
+ export function listPsmuxSessions() {
208
+ try {
209
+ return parseSessionSummaries(psmuxExec("list-sessions"))
210
+ .map((session) => session.sessionName)
211
+ .filter((sessionName) => sessionName.startsWith("tfx-multi-"));
212
+ } catch {
213
+ return [];
214
+ }
215
+ }
216
+
217
+ /**
218
+ * pane 마지막 N줄 캡처
219
+ * @param {string} target
220
+ * @param {number} lines
221
+ * @returns {string}
222
+ */
223
+ export function capturePsmuxPane(target, lines = 5) {
224
+ try {
225
+ const full = psmuxExec(`capture-pane -t ${quoteArg(target)} -p`);
226
+ const nonEmpty = full.split("\n").filter((line) => line.trim() !== "");
227
+ return nonEmpty.slice(-lines).join("\n");
228
+ } catch {
229
+ return "";
230
+ }
231
+ }
232
+
233
+ /**
234
+ * psmux 세션 연결
235
+ * @param {string} sessionName
236
+ */
237
+ export function attachPsmuxSession(sessionName) {
238
+ const result = spawnSync(PSMUX_BIN, ["attach-session", "-t", sessionName], {
239
+ stdio: "inherit",
240
+ timeout: 0,
241
+ windowsHide: false,
242
+ });
243
+ if ((result.status ?? 1) !== 0) {
244
+ throw new Error(`psmux attach 실패 (exit=${result.status})`);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * 세션 attach client 수 조회
250
+ * @param {string} sessionName
251
+ * @returns {number|null}
252
+ */
253
+ export function getPsmuxSessionAttachedCount(sessionName) {
254
+ try {
255
+ const session = parseSessionSummaries(psmuxExec("list-sessions"))
256
+ .find((entry) => entry.sessionName === sessionName);
257
+ return session ? session.attachedCount : null;
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * 팀메이트 조작 키 바인딩 설정
265
+ * @param {string} sessionName
266
+ * @param {object} opts
267
+ * @param {boolean} opts.inProcess
268
+ * @param {string} opts.taskListCommand
269
+ */
270
+ export function configurePsmuxKeybindings(sessionName, opts = {}) {
271
+ const { inProcess = false, taskListCommand = "" } = opts;
272
+ const cond = `#{==:#{session_name},${sessionName}}`;
273
+ const target = `${sessionName}:0`;
274
+ const bindNext = inProcess
275
+ ? `'select-pane -t :.+ \\; resize-pane -Z'`
276
+ : `'select-pane -t :.+'`;
277
+ const bindPrev = inProcess
278
+ ? `'select-pane -t :.- \\; resize-pane -Z'`
279
+ : `'select-pane -t :.-'`;
280
+
281
+ // psmux는 세션별 서버이므로 -t target으로 세션 컨텍스트를 전달해야 한다
282
+ const bindSafe = (cmd) => {
283
+ try { psmuxExec(`-t ${quoteArg(target)} ${cmd}`); } catch { /* 미지원 시 무시 */ }
284
+ };
285
+
286
+ bindSafe(`bind-key -T root -n S-Down if-shell -F '${cond}' ${bindNext} 'send-keys S-Down'`);
287
+ bindSafe(`bind-key -T root -n S-Up if-shell -F '${cond}' ${bindPrev} 'send-keys S-Up'`);
288
+ bindSafe(`bind-key -T root -n S-Right if-shell -F '${cond}' ${bindNext} 'send-keys S-Right'`);
289
+ bindSafe(`bind-key -T root -n S-Left if-shell -F '${cond}' ${bindPrev} 'send-keys S-Left'`);
290
+ bindSafe(`bind-key -T root -n BTab if-shell -F '${cond}' ${bindPrev} 'send-keys BTab'`);
291
+ bindSafe(`bind-key -T root -n Escape if-shell -F '${cond}' 'send-keys C-c' 'send-keys Escape'`);
292
+
293
+ if (taskListCommand) {
294
+ const escaped = taskListCommand.replace(/'/g, "'\\''");
295
+ bindSafe(
296
+ `bind-key -T root -n C-t if-shell -F '${cond}' "display-popup -E '${escaped}'" "send-keys C-t"`
297
+ );
298
+ }
299
+ }
300
+
301
+ // ─── 하이브리드 모드 워커 관리 함수 ───
302
+
303
+ /**
304
+ * psmux 세션의 새 pane에서 워커 실행
305
+ * @param {string} sessionName - 대상 psmux 세션 이름
306
+ * @param {string} workerName - 워커 식별용 pane 타이틀
307
+ * @param {string} cmd - 실행할 커맨드
308
+ * @returns {{ paneId: string, workerName: string }}
309
+ */
310
+ export function spawnWorker(sessionName, workerName, cmd) {
311
+ if (!hasPsmux()) {
312
+ throw new Error("psmux가 설치되어 있지 않습니다. psmux를 먼저 설치하세요.");
313
+ }
314
+
315
+ // remain-on-exit: 종료된 pane이 즉시 사라지는 것 방지
316
+ try {
317
+ psmuxExec(`set-option -t ${quoteArg(sessionName)} remain-on-exit on`);
318
+ } catch { /* 미지원 시 무시 */ }
319
+
320
+ // Windows: pane 기본셸이 PowerShell → Git Bash로 래핑
321
+ // psmux가 이스케이프 시퀀스를 처리하므로 포워드 슬래시로 변환
322
+ const shellCmd = IS_WINDOWS
323
+ ? `& '${GIT_BASH.replace(/\\/g, '/')}' -l -c '${cmd.replace(/'/g, "'\\''")}'`
324
+ : cmd;
325
+
326
+ try {
327
+ // 배열 형태 spawnSync → 쉘 해석 우회 (백슬래시 경로 보존)
328
+ const paneTarget = psmux([
329
+ "split-window", "-t", sessionName,
330
+ "-P", "-F", "#{session_name}:#{window_index}.#{pane_index}",
331
+ shellCmd,
332
+ ]);
333
+ psmux(["select-pane", "-t", paneTarget, "-T", workerName]);
334
+ return { paneId: paneTarget, workerName };
335
+ } catch (err) {
336
+ throw new Error(`워커 생성 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
337
+ }
338
+ }
339
+
340
+ /**
341
+ * 워커 pane 실행 상태 확인
342
+ * @param {string} sessionName - 대상 psmux 세션 이름
343
+ * @param {string} workerName - 워커 pane 타이틀
344
+ * @returns {{ status: "running"|"exited", exitCode: number|null, paneId: string }}
345
+ */
346
+ export function getWorkerStatus(sessionName, workerName) {
347
+ if (!hasPsmux()) {
348
+ throw new Error("psmux가 설치되어 있지 않습니다.");
349
+ }
350
+ try {
351
+ const output = psmuxExec(
352
+ `list-panes -t ${quoteArg(sessionName)} -F "#{pane_title}\t#{session_name}:#{window_index}.#{pane_index}\t#{pane_dead}\t#{pane_dead_status}"`
353
+ );
354
+ const lines = output.split("\n").filter(Boolean);
355
+ for (const line of lines) {
356
+ const [title, paneId, dead, deadStatus] = line.split("\t");
357
+ if (title === workerName) {
358
+ const isDead = dead === "1";
359
+ return {
360
+ status: isDead ? "exited" : "running",
361
+ exitCode: isDead ? parseInt(deadStatus, 10) || 0 : null,
362
+ paneId,
363
+ };
364
+ }
365
+ }
366
+ throw new Error(`워커를 찾을 수 없습니다: ${workerName}`);
367
+ } catch (err) {
368
+ if (err.message.includes("워커를 찾을 수 없습니다")) throw err;
369
+ throw new Error(`워커 상태 조회 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
370
+ }
371
+ }
372
+
373
+ /**
374
+ * 워커 pane 프로세스 강제 종료
375
+ * @param {string} sessionName - 대상 psmux 세션 이름
376
+ * @param {string} workerName - 워커 pane 타이틀
377
+ * @returns {{ killed: boolean }}
378
+ */
379
+ export function killWorker(sessionName, workerName) {
380
+ if (!hasPsmux()) {
381
+ throw new Error("psmux가 설치되어 있지 않습니다.");
382
+ }
383
+ try {
384
+ const { paneId, status } = getWorkerStatus(sessionName, workerName);
385
+
386
+ // 이미 종료된 워커 → pane 정리만 수행
387
+ if (status === "exited") {
388
+ try { psmuxExec(`kill-pane -t ${quoteArg(paneId)}`); } catch { /* 무시 */ }
389
+ return { killed: true };
390
+ }
391
+
392
+ // running → C-c 우아한 종료 시도
393
+ try {
394
+ psmuxExec(`send-keys -t ${quoteArg(paneId)} C-c`);
395
+ } catch {
396
+ // send-keys 실패 무시
397
+ }
398
+ // 1초 대기 후 pane 강제 종료
399
+ spawnSync("sleep", ["1"], { stdio: "ignore", windowsHide: true });
400
+ try {
401
+ psmuxExec(`kill-pane -t ${quoteArg(paneId)}`);
402
+ } catch {
403
+ // 이미 종료된 pane — 무시
404
+ }
405
+ return { killed: true };
406
+ } catch (err) {
407
+ // 워커를 찾을 수 없음 → 이미 종료된 것으로 간주
408
+ if (err.message.includes("워커를 찾을 수 없습니다")) {
409
+ return { killed: true };
410
+ }
411
+ throw new Error(`워커 종료 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
412
+ }
413
+ }
414
+
415
+ /**
416
+ * 워커 pane 출력 마지막 N줄 캡처
417
+ * @param {string} sessionName - 대상 psmux 세션 이름
418
+ * @param {string} workerName - 워커 pane 타이틀
419
+ * @param {number} lines - 캡처할 줄 수 (기본 50)
420
+ * @returns {string} 캡처된 출력
421
+ */
422
+ export function captureWorkerOutput(sessionName, workerName, lines = 50) {
423
+ if (!hasPsmux()) {
424
+ throw new Error("psmux가 설치되어 있지 않습니다.");
425
+ }
426
+ try {
427
+ const { paneId } = getWorkerStatus(sessionName, workerName);
428
+ return psmuxExec(`capture-pane -t ${quoteArg(paneId)} -p -S -${lines}`);
429
+ } catch (err) {
430
+ if (err.message.includes("워커를 찾을 수 없습니다")) throw err;
431
+ throw new Error(`출력 캡처 실패 (session=${sessionName}, worker=${workerName}): ${err.message}`);
432
+ }
433
+ }
434
+
435
+ // ─── CLI 진입점 ───
436
+
437
+ if (process.argv[1] && process.argv[1].endsWith("psmux.mjs")) {
438
+ const [,, cmd, ...args] = process.argv;
439
+
440
+ // CLI 인자 파싱 헬퍼
441
+ function getArg(name) {
442
+ const idx = args.indexOf(`--${name}`);
443
+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
444
+ }
445
+
446
+ try {
447
+ switch (cmd) {
448
+ case "spawn": {
449
+ const session = getArg("session");
450
+ const name = getArg("name");
451
+ const workerCmd = getArg("cmd");
452
+ if (!session || !name || !workerCmd) {
453
+ console.error("사용법: node psmux.mjs spawn --session <세션> --name <워커명> --cmd <커맨드>");
454
+ process.exit(1);
455
+ }
456
+ const result = spawnWorker(session, name, workerCmd);
457
+ console.log(JSON.stringify(result, null, 2));
458
+ break;
459
+ }
460
+ case "status": {
461
+ const session = getArg("session");
462
+ const name = getArg("name");
463
+ if (!session || !name) {
464
+ console.error("사용법: node psmux.mjs status --session <세션> --name <워커명>");
465
+ process.exit(1);
466
+ }
467
+ const result = getWorkerStatus(session, name);
468
+ console.log(JSON.stringify(result, null, 2));
469
+ break;
470
+ }
471
+ case "kill": {
472
+ const session = getArg("session");
473
+ const name = getArg("name");
474
+ if (!session || !name) {
475
+ console.error("사용법: node psmux.mjs kill --session <세션> --name <워커명>");
476
+ process.exit(1);
477
+ }
478
+ const result = killWorker(session, name);
479
+ console.log(JSON.stringify(result, null, 2));
480
+ break;
481
+ }
482
+ case "output": {
483
+ const session = getArg("session");
484
+ const name = getArg("name");
485
+ const lines = parseInt(getArg("lines") || "50", 10);
486
+ if (!session || !name) {
487
+ console.error("사용법: node psmux.mjs output --session <세션> --name <워커명> [--lines <줄수>]");
488
+ process.exit(1);
489
+ }
490
+ console.log(captureWorkerOutput(session, name, lines));
491
+ break;
492
+ }
493
+ default:
494
+ console.error("사용법: node psmux.mjs spawn|status|kill|output [args]");
495
+ console.error("");
496
+ console.error(" spawn --session <세션> --name <워커명> --cmd <커맨드>");
497
+ console.error(" status --session <세션> --name <워커명>");
498
+ console.error(" kill --session <세션> --name <워커명>");
499
+ console.error(" output --session <세션> --name <워커명> [--lines <줄수>]");
500
+ process.exit(1);
501
+ }
502
+ } catch (err) {
503
+ console.error(`오류: ${err.message}`);
504
+ process.exit(1);
505
+ }
506
+ }