triflux 3.2.0-dev.8 → 3.2.0-dev.9

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 (42) hide show
  1. package/bin/triflux.mjs +581 -340
  2. package/hooks/keyword-rules.json +16 -0
  3. package/hub/bridge.mjs +410 -318
  4. package/hub/hitl.mjs +45 -31
  5. package/hub/pipe.mjs +457 -0
  6. package/hub/router.mjs +422 -161
  7. package/hub/server.mjs +429 -424
  8. package/hub/store.mjs +388 -314
  9. package/hub/team/cli-team-common.mjs +348 -0
  10. package/hub/team/cli-team-control.mjs +393 -0
  11. package/hub/team/cli-team-start.mjs +512 -0
  12. package/hub/team/cli-team-status.mjs +269 -0
  13. package/hub/team/cli.mjs +59 -1459
  14. package/hub/team/dashboard.mjs +1 -9
  15. package/hub/team/native.mjs +12 -80
  16. package/hub/team/nativeProxy.mjs +121 -47
  17. package/hub/team/pane.mjs +66 -43
  18. package/hub/team/psmux.mjs +297 -0
  19. package/hub/team/session.mjs +354 -291
  20. package/hub/team/shared.mjs +13 -0
  21. package/hub/team/staleState.mjs +299 -0
  22. package/hub/tools.mjs +41 -52
  23. package/hub/workers/claude-worker.mjs +446 -0
  24. package/hub/workers/codex-mcp.mjs +414 -0
  25. package/hub/workers/factory.mjs +18 -0
  26. package/hub/workers/gemini-worker.mjs +349 -0
  27. package/hub/workers/interface.mjs +41 -0
  28. package/hud/hud-qos-status.mjs +4 -2
  29. package/package.json +4 -1
  30. package/scripts/keyword-detector.mjs +15 -0
  31. package/scripts/lib/keyword-rules.mjs +4 -1
  32. package/scripts/psmux-steering-prototype.sh +368 -0
  33. package/scripts/setup.mjs +128 -70
  34. package/scripts/tfx-route-worker.mjs +161 -0
  35. package/scripts/tfx-route.sh +415 -80
  36. package/skills/tfx-auto/SKILL.md +90 -564
  37. package/skills/tfx-auto-codex/SKILL.md +1 -3
  38. package/skills/tfx-codex/SKILL.md +1 -4
  39. package/skills/tfx-doctor/SKILL.md +1 -0
  40. package/skills/tfx-gemini/SKILL.md +1 -4
  41. package/skills/tfx-setup/SKILL.md +1 -4
  42. package/skills/tfx-team/SKILL.md +53 -62
@@ -0,0 +1,297 @@
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
+
7
+ function quoteArg(value) {
8
+ const str = String(value);
9
+ if (!/[\s"]/u.test(str)) return str;
10
+ return `"${str.replace(/"/g, '\\"')}"`;
11
+ }
12
+
13
+ function toPaneTitle(index) {
14
+ return index === 0 ? "lead" : `worker-${index}`;
15
+ }
16
+
17
+ function parsePaneList(output) {
18
+ return output
19
+ .split("\n")
20
+ .map((line) => line.trim())
21
+ .filter(Boolean)
22
+ .map((line) => {
23
+ const [indexText, target] = line.split("\t");
24
+ return {
25
+ index: parseInt(indexText, 10),
26
+ target: target?.trim() || "",
27
+ };
28
+ })
29
+ .filter((entry) => Number.isFinite(entry.index) && entry.target)
30
+ .sort((a, b) => a.index - b.index)
31
+ .map((entry) => entry.target);
32
+ }
33
+
34
+ function parseSessionSummaries(output) {
35
+ return output
36
+ .split("\n")
37
+ .map((line) => line.trim())
38
+ .filter(Boolean)
39
+ .map((line) => {
40
+ const colonIndex = line.indexOf(":");
41
+ if (colonIndex === -1) {
42
+ return null;
43
+ }
44
+
45
+ const sessionName = line.slice(0, colonIndex).trim();
46
+ const flags = [...line.matchAll(/\(([^)]*)\)/g)].map((match) => match[1]).join(", ");
47
+ const attachedMatch = flags.match(/(\d+)\s+attached/);
48
+ const attachedCount = attachedMatch
49
+ ? parseInt(attachedMatch[1], 10)
50
+ : /\battached\b/.test(flags)
51
+ ? 1
52
+ : 0;
53
+
54
+ return sessionName
55
+ ? { sessionName, attachedCount }
56
+ : null;
57
+ })
58
+ .filter(Boolean);
59
+ }
60
+
61
+ function collectSessionPanes(sessionName) {
62
+ const output = psmuxExec(
63
+ `list-panes -t ${quoteArg(`${sessionName}:0`)} -F "#{pane_index}\t#{session_name}:#{window_index}.#{pane_index}"`
64
+ );
65
+ return parsePaneList(output);
66
+ }
67
+
68
+ function psmux(args, opts = {}) {
69
+ if (Array.isArray(args)) {
70
+ const result = spawnSync(PSMUX_BIN, args.map((arg) => String(arg)), {
71
+ encoding: "utf8",
72
+ timeout: 10000,
73
+ stdio: ["pipe", "pipe", "pipe"],
74
+ windowsHide: true,
75
+ ...opts,
76
+ });
77
+ if ((result.status ?? 1) !== 0) {
78
+ const error = new Error((result.stderr || result.stdout || "psmux command failed").trim());
79
+ error.status = result.status;
80
+ throw error;
81
+ }
82
+ return (result.stdout || "").trim();
83
+ }
84
+
85
+ const result = execSync(`${quoteArg(PSMUX_BIN)} ${args}`, {
86
+ encoding: "utf8",
87
+ timeout: 10000,
88
+ stdio: ["pipe", "pipe", "pipe"],
89
+ windowsHide: true,
90
+ ...opts,
91
+ });
92
+ return result != null ? result.trim() : "";
93
+ }
94
+
95
+ /** psmux 실행 가능 여부 확인 */
96
+ export function hasPsmux() {
97
+ try {
98
+ execSync(`${quoteArg(PSMUX_BIN)} -V`, {
99
+ stdio: "ignore",
100
+ timeout: 3000,
101
+ windowsHide: true,
102
+ });
103
+ return true;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * psmux 커맨드 실행 래퍼
111
+ * @param {string|string[]} args
112
+ * @param {object} opts
113
+ * @returns {string}
114
+ */
115
+ export function psmuxExec(args, opts = {}) {
116
+ return psmux(args, opts);
117
+ }
118
+
119
+ /**
120
+ * psmux 세션 생성 + 레이아웃 분할
121
+ * @param {string} sessionName
122
+ * @param {object} opts
123
+ * @param {'2x2'|'1xN'|'Nx1'} opts.layout
124
+ * @param {number} opts.paneCount
125
+ * @returns {{ sessionName: string, panes: string[] }}
126
+ */
127
+ export function createPsmuxSession(sessionName, opts = {}) {
128
+ const layout = opts.layout === "1xN" || opts.layout === "Nx1" ? opts.layout : "2x2";
129
+ const paneCount = Math.max(
130
+ 1,
131
+ Number.isFinite(opts.paneCount) ? Math.trunc(opts.paneCount) : 4
132
+ );
133
+ const limitedPaneCount = layout === "2x2" ? Math.min(paneCount, 4) : paneCount;
134
+ const sessionTarget = `${sessionName}:0`;
135
+
136
+ const leadPane = psmuxExec(
137
+ `new-session -d -P -F "#{session_name}:#{window_index}.#{pane_index}" -s ${quoteArg(sessionName)} -x 220 -y 55`
138
+ );
139
+
140
+ if (layout === "2x2" && limitedPaneCount >= 3) {
141
+ const rightPane = psmuxExec(
142
+ `split-window -h -P -F "#{session_name}:#{window_index}.#{pane_index}" -t ${quoteArg(leadPane)}`
143
+ );
144
+ psmuxExec(
145
+ `split-window -v -P -F "#{session_name}:#{window_index}.#{pane_index}" -t ${quoteArg(rightPane)}`
146
+ );
147
+ if (limitedPaneCount >= 4) {
148
+ psmuxExec(
149
+ `split-window -v -P -F "#{session_name}:#{window_index}.#{pane_index}" -t ${quoteArg(leadPane)}`
150
+ );
151
+ }
152
+ psmuxExec(`select-layout -t ${quoteArg(sessionTarget)} tiled`);
153
+ } else if (layout === "1xN") {
154
+ for (let i = 1; i < limitedPaneCount; i++) {
155
+ psmuxExec(`split-window -h -t ${quoteArg(sessionTarget)}`);
156
+ }
157
+ psmuxExec(`select-layout -t ${quoteArg(sessionTarget)} even-horizontal`);
158
+ } else {
159
+ for (let i = 1; i < limitedPaneCount; i++) {
160
+ psmuxExec(`split-window -v -t ${quoteArg(sessionTarget)}`);
161
+ }
162
+ psmuxExec(`select-layout -t ${quoteArg(sessionTarget)} even-vertical`);
163
+ }
164
+
165
+ psmuxExec(`select-pane -t ${quoteArg(leadPane)}`);
166
+
167
+ const panes = collectSessionPanes(sessionName).slice(0, limitedPaneCount);
168
+ panes.forEach((pane, index) => {
169
+ psmuxExec(`select-pane -t ${quoteArg(pane)} -T ${quoteArg(toPaneTitle(index))}`);
170
+ });
171
+
172
+ return { sessionName, panes };
173
+ }
174
+
175
+ /**
176
+ * psmux 세션 종료
177
+ * @param {string} sessionName
178
+ */
179
+ export function killPsmuxSession(sessionName) {
180
+ try {
181
+ psmuxExec(`kill-session -t ${quoteArg(sessionName)}`, { stdio: "ignore" });
182
+ } catch {
183
+ // 이미 종료된 세션 — 무시
184
+ }
185
+ }
186
+
187
+ /**
188
+ * psmux 세션 존재 확인
189
+ * @param {string} sessionName
190
+ * @returns {boolean}
191
+ */
192
+ export function psmuxSessionExists(sessionName) {
193
+ try {
194
+ psmuxExec(`has-session -t ${quoteArg(sessionName)}`, { stdio: "ignore" });
195
+ return true;
196
+ } catch {
197
+ return false;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * tfx-team- 접두사 psmux 세션 목록
203
+ * @returns {string[]}
204
+ */
205
+ export function listPsmuxSessions() {
206
+ try {
207
+ return parseSessionSummaries(psmuxExec("list-sessions"))
208
+ .map((session) => session.sessionName)
209
+ .filter((sessionName) => sessionName.startsWith("tfx-team-"));
210
+ } catch {
211
+ return [];
212
+ }
213
+ }
214
+
215
+ /**
216
+ * pane 마지막 N줄 캡처
217
+ * @param {string} target
218
+ * @param {number} lines
219
+ * @returns {string}
220
+ */
221
+ export function capturePsmuxPane(target, lines = 5) {
222
+ try {
223
+ const full = psmuxExec(`capture-pane -t ${quoteArg(target)} -p`);
224
+ const nonEmpty = full.split("\n").filter((line) => line.trim() !== "");
225
+ return nonEmpty.slice(-lines).join("\n");
226
+ } catch {
227
+ return "";
228
+ }
229
+ }
230
+
231
+ /**
232
+ * psmux 세션 연결
233
+ * @param {string} sessionName
234
+ */
235
+ export function attachPsmuxSession(sessionName) {
236
+ const result = spawnSync(PSMUX_BIN, ["attach-session", "-t", sessionName], {
237
+ stdio: "inherit",
238
+ timeout: 0,
239
+ windowsHide: false,
240
+ });
241
+ if ((result.status ?? 1) !== 0) {
242
+ throw new Error(`psmux attach 실패 (exit=${result.status})`);
243
+ }
244
+ }
245
+
246
+ /**
247
+ * 세션 attach client 수 조회
248
+ * @param {string} sessionName
249
+ * @returns {number|null}
250
+ */
251
+ export function getPsmuxSessionAttachedCount(sessionName) {
252
+ try {
253
+ const session = parseSessionSummaries(psmuxExec("list-sessions"))
254
+ .find((entry) => entry.sessionName === sessionName);
255
+ return session ? session.attachedCount : null;
256
+ } catch {
257
+ return null;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * 팀메이트 조작 키 바인딩 설정
263
+ * @param {string} sessionName
264
+ * @param {object} opts
265
+ * @param {boolean} opts.inProcess
266
+ * @param {string} opts.taskListCommand
267
+ */
268
+ export function configurePsmuxKeybindings(sessionName, opts = {}) {
269
+ const { inProcess = false, taskListCommand = "" } = opts;
270
+ const cond = `#{==:#{session_name},${sessionName}}`;
271
+ const bindNext = inProcess
272
+ ? `'select-pane -t :.+ \\; resize-pane -Z'`
273
+ : `'select-pane -t :.+'`;
274
+ const bindPrev = inProcess
275
+ ? `'select-pane -t :.- \\; resize-pane -Z'`
276
+ : `'select-pane -t :.-'`;
277
+
278
+ psmuxExec(`bind-key -T root -n S-Down if-shell -F '${cond}' ${bindNext} 'send-keys S-Down'`);
279
+ psmuxExec(`bind-key -T root -n S-Up if-shell -F '${cond}' ${bindPrev} 'send-keys S-Up'`);
280
+ psmuxExec(`bind-key -T root -n S-Right if-shell -F '${cond}' ${bindNext} 'send-keys S-Right'`);
281
+ psmuxExec(`bind-key -T root -n S-Left if-shell -F '${cond}' ${bindPrev} 'send-keys S-Left'`);
282
+ psmuxExec(`bind-key -T root -n BTab if-shell -F '${cond}' ${bindPrev} 'send-keys BTab'`);
283
+ psmuxExec(`bind-key -T root -n Escape if-shell -F '${cond}' 'send-keys C-c' 'send-keys Escape'`);
284
+
285
+ if (taskListCommand) {
286
+ const escaped = taskListCommand.replace(/'/g, "'\\''");
287
+ try {
288
+ psmuxExec(
289
+ `bind-key -T root -n C-t if-shell -F '${cond}' "display-popup -E '${escaped}'" "send-keys C-t"`
290
+ );
291
+ } catch {
292
+ psmuxExec(
293
+ `bind-key -T root -n C-t if-shell -F '${cond}' 'display-message "tfx team tasks 명령으로 태스크 확인"' 'send-keys C-t'`
294
+ );
295
+ }
296
+ }
297
+ }