@triflux/core 10.53.0 → 10.55.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/hooks/codex-session-hook.mjs +1 -17
- package/hub/cli-adapter-base.mjs +3 -424
- package/hub/index.mjs +0 -5
- package/hub/lib/hosts-compat.mjs +18 -64
- package/hub/lib/private-tmp.mjs +35 -0
- package/hub/lib/process-utils.mjs +19 -220
- package/hub/lib/prompt-tmp.mjs +6 -24
- package/hub/lib/spawn-trace.mjs +112 -5
- package/hub/lib/timeout-defaults.mjs +34 -6
- package/hub/lib/worker-lifecycle.mjs +3 -0
- package/hub/platform.mjs +3 -1
- package/hub/team/agent-map.json +29 -0
- package/hub/team/intervention.mjs +6 -4
- package/hub/team/nativeProxy.mjs +2 -48
- package/hub/team/pane.mjs +329 -0
- package/hub/team/psmux.mjs +1765 -0
- package/hub/team/retry-state-machine.mjs +1 -1
- package/hub/team/session.mjs +541 -0
- package/hud/constants.mjs +0 -3
- package/hud/providers/claude.mjs +35 -15
- package/hud/providers/codex.mjs +169 -42
- package/hud/utils.mjs +0 -23
- package/package.json +2 -2
- package/scripts/lib/backup-rotation.mjs +60 -0
- package/scripts/lib/codex-profile-config.mjs +20 -7
- package/scripts/lib/codex-recovery.sh +2 -3
- package/scripts/lib/install-blob-history.json +3196 -0
- package/scripts/lib/install-retire.mjs +203 -0
- package/scripts/lib/legacy-hook-cleanup.mjs +148 -13
- package/scripts/lib/legacy-mcp-cleanup.mjs +405 -104
- package/scripts/lib/machine-profile.mjs +2 -2
- package/scripts/lib/mcp-filter.mjs +1 -42
- package/scripts/lib/mcp-guard-engine.mjs +198 -19
- package/scripts/lib/mcp-manifest.mjs +0 -40
- package/scripts/lib/process-utils.mjs +19 -0
- package/scripts/lib/stable-node.mjs +35 -0
- package/scripts/lib/test-env.mjs +20 -0
- package/scripts/lib/tmr-install.mjs +183 -0
- package/hooks/agy-session-hook.mjs +0 -155
- package/hooks/claude-cwd-projection-refresh.mjs +0 -11
- package/hooks/hook-orchestrator.mjs +0 -11
- package/hooks/lib/resolve-root.mjs +0 -62
- package/hub/codex-adapter.mjs +0 -193
- package/hub/codex-preflight.mjs +0 -219
- package/hub/lib/cto-env.mjs +0 -70
- package/scripts/lib/remote-spawn-transfer.mjs +0 -226
- package/scripts/lib/session-presence.mjs +0 -151
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// hub/team/retry-state-machine.mjs
|
|
2
2
|
// Phase 3 Step A — true ralph / auto-escalate retry state machine.
|
|
3
|
-
//
|
|
3
|
+
// 설계: 구현 커밋 565a2bad 메시지, 규칙은 .claude/rules/tfx-escalation-chain.md
|
|
4
4
|
//
|
|
5
5
|
// Modes:
|
|
6
6
|
// bounded — --retry 1 (기본): maxIterations 도달 시 BUDGET_EXCEEDED.
|
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
// tmux/psmux 세션 생명주기 관리
|
|
2
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
3
|
+
import { resolveGitBashExecutable } from "../lib/bash-path.mjs";
|
|
4
|
+
import { getEnvironment } from "../lib/env-detect.mjs";
|
|
5
|
+
import {
|
|
6
|
+
attachPsmuxSession,
|
|
7
|
+
capturePsmuxPane,
|
|
8
|
+
configurePsmuxKeybindings,
|
|
9
|
+
createPsmuxSession,
|
|
10
|
+
getMultiplexerType,
|
|
11
|
+
getPsmuxSessionAttachedCount,
|
|
12
|
+
hasMultiplexer,
|
|
13
|
+
killPsmuxSession,
|
|
14
|
+
listPsmuxSessions,
|
|
15
|
+
psmuxExec,
|
|
16
|
+
psmuxSessionExists,
|
|
17
|
+
tokenizeCommand,
|
|
18
|
+
} from "./psmux.mjs";
|
|
19
|
+
|
|
20
|
+
/** Windows Terminal 실행 파일 존재 여부 */
|
|
21
|
+
export function hasWindowsTerminal() {
|
|
22
|
+
return !!getEnvironment().terminal.hasWt;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 현재 프로세스가 Windows Terminal 내에서 실행 중인지 여부 */
|
|
26
|
+
export function hasWindowsTerminalSession() {
|
|
27
|
+
return process.platform === "win32" && !!process.env.WT_SESSION;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** tmux 실행 가능 여부 확인 */
|
|
31
|
+
function hasTmux() {
|
|
32
|
+
try {
|
|
33
|
+
execFileSync("tmux", ["-V"], {
|
|
34
|
+
stdio: "ignore",
|
|
35
|
+
timeout: 3000,
|
|
36
|
+
windowsHide: true,
|
|
37
|
+
});
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Git Bash 내 tmux 사용 가능 여부 (Windows 전용) */
|
|
45
|
+
function hasGitBashTmux() {
|
|
46
|
+
const bash = resolveGitBashExecutable();
|
|
47
|
+
if (!bash) return false;
|
|
48
|
+
try {
|
|
49
|
+
const r = spawnSync(bash, ["-lc", "tmux -V"], {
|
|
50
|
+
encoding: "utf8",
|
|
51
|
+
timeout: 5000,
|
|
52
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
53
|
+
windowsHide: true,
|
|
54
|
+
});
|
|
55
|
+
return (r.status ?? 1) === 0;
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isPsmuxCommandName(command) {
|
|
62
|
+
const name = String(command || "")
|
|
63
|
+
.replace(/\\/g, "/")
|
|
64
|
+
.split("/")
|
|
65
|
+
.pop()
|
|
66
|
+
.toLowerCase();
|
|
67
|
+
return name === "psmux" || name === "psmux.exe" || name === "psmux.cmd";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 터미널 멀티플렉서 감지 (결과 캐싱 — 프로세스 수명 동안 불변)
|
|
72
|
+
* @returns {'tmux'|'git-bash-tmux'|'psmux'|null}
|
|
73
|
+
*/
|
|
74
|
+
let _cachedMux;
|
|
75
|
+
export function detectMultiplexer() {
|
|
76
|
+
if (_cachedMux !== undefined) return _cachedMux;
|
|
77
|
+
|
|
78
|
+
if (process.platform !== "win32") {
|
|
79
|
+
const primaryMux = getMultiplexerType();
|
|
80
|
+
if (primaryMux === "tmux" && hasMultiplexer()) {
|
|
81
|
+
_cachedMux = "tmux";
|
|
82
|
+
return _cachedMux;
|
|
83
|
+
}
|
|
84
|
+
if (hasTmux()) {
|
|
85
|
+
_cachedMux = "tmux";
|
|
86
|
+
return _cachedMux;
|
|
87
|
+
}
|
|
88
|
+
_cachedMux = null;
|
|
89
|
+
return _cachedMux;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (hasMultiplexer() && isPsmuxCommandName(getMultiplexerType())) {
|
|
93
|
+
_cachedMux = "psmux";
|
|
94
|
+
return _cachedMux;
|
|
95
|
+
}
|
|
96
|
+
if (hasTmux()) {
|
|
97
|
+
_cachedMux = "tmux";
|
|
98
|
+
return _cachedMux;
|
|
99
|
+
}
|
|
100
|
+
if (hasGitBashTmux()) {
|
|
101
|
+
_cachedMux = "git-bash-tmux";
|
|
102
|
+
return _cachedMux;
|
|
103
|
+
}
|
|
104
|
+
_cachedMux = null;
|
|
105
|
+
return _cachedMux;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function bashArg(value) {
|
|
109
|
+
return /^[\w@%+=:,./-]+$/u.test(value)
|
|
110
|
+
? value
|
|
111
|
+
: `'${value.replace(/'/g, "'\\''")}'`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* tmux/psmux 커맨드 실행
|
|
116
|
+
* @param {string[]|string} args: tmux 서브커맨드 + 인자. 문자열은 psmux 와 같은 규칙으로 쪼갠다.
|
|
117
|
+
* @param {object} opts: execFileSync 옵션
|
|
118
|
+
* @returns {string} stdout
|
|
119
|
+
*/
|
|
120
|
+
function tmux(args, opts = {}) {
|
|
121
|
+
const mux = detectMultiplexer();
|
|
122
|
+
if (!mux) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
"tmux/psmux 미발견.\n\n" +
|
|
125
|
+
"tfx multi은 tmux 계열 멀티플렉서가 필요합니다:\n" +
|
|
126
|
+
" Windows: psmux 설치\n" +
|
|
127
|
+
" macOS: brew install tmux\n" +
|
|
128
|
+
" Linux: apt install tmux",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (mux === "psmux") {
|
|
132
|
+
return psmuxExec(args, opts);
|
|
133
|
+
}
|
|
134
|
+
// 셸을 거치지 않게 인자 배열로 넘긴다. 세션 이름이나 pane 대상이 셸 문법으로 읽히지 않는다.
|
|
135
|
+
const argv = Array.isArray(args) ? args.map(String) : tokenizeCommand(args);
|
|
136
|
+
if (mux === "git-bash-tmux") {
|
|
137
|
+
const bash = resolveGitBashExecutable();
|
|
138
|
+
if (!bash) throw new Error("git-bash-tmux 감지 실패");
|
|
139
|
+
const r = spawnSync(
|
|
140
|
+
bash,
|
|
141
|
+
["-lc", ["tmux", ...argv.map(bashArg)].join(" ")],
|
|
142
|
+
{
|
|
143
|
+
encoding: "utf8",
|
|
144
|
+
timeout: 10000,
|
|
145
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
146
|
+
windowsHide: true,
|
|
147
|
+
...opts,
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
if ((r.status ?? 1) !== 0) {
|
|
151
|
+
const e = new Error(r.stderr || "tmux command failed");
|
|
152
|
+
e.status = r.status;
|
|
153
|
+
throw e;
|
|
154
|
+
}
|
|
155
|
+
return (r.stdout || "").trim();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const result = execFileSync("tmux", argv, {
|
|
159
|
+
encoding: "utf8",
|
|
160
|
+
timeout: 10000,
|
|
161
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
162
|
+
windowsHide: true,
|
|
163
|
+
...opts,
|
|
164
|
+
});
|
|
165
|
+
return result != null ? result.trim() : "";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* tmux 명령 직접 실행 (고수준 모듈에서 재사용)
|
|
170
|
+
* @param {string[]|string} args
|
|
171
|
+
* @param {object} opts
|
|
172
|
+
* @returns {string}
|
|
173
|
+
*/
|
|
174
|
+
export function tmuxExec(args, opts = {}) {
|
|
175
|
+
return tmux(args, opts);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 현재 멀티플렉서 환경에 맞는 attach 실행 스펙 반환
|
|
180
|
+
* @param {string} sessionName
|
|
181
|
+
* @returns {{ command: string, args: string[] }}
|
|
182
|
+
*/
|
|
183
|
+
export function resolveAttachCommand(sessionName, opts = {}) {
|
|
184
|
+
const mux = opts.mux || detectMultiplexer();
|
|
185
|
+
if (!mux) {
|
|
186
|
+
throw new Error("tmux/psmux 미발견");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (mux === "psmux") {
|
|
190
|
+
return {
|
|
191
|
+
command: process.env.PSMUX_BIN || "psmux",
|
|
192
|
+
args: ["attach-session", "-t", sessionName],
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (mux === "git-bash-tmux") {
|
|
197
|
+
const bash = opts.bashCommand || resolveGitBashExecutable();
|
|
198
|
+
if (!bash) throw new Error("git-bash-tmux 감지 실패");
|
|
199
|
+
return {
|
|
200
|
+
command: bash,
|
|
201
|
+
args: ["-lc", `tmux attach-session -t ${bashArg(sessionName)}`],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
command: "tmux",
|
|
207
|
+
args: ["attach-session", "-t", sessionName],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* tmux 세션 생성 + 레이아웃 분할
|
|
213
|
+
* @param {string} sessionName — 세션 이름
|
|
214
|
+
* @param {object} opts
|
|
215
|
+
* @param {'2x2'|'1xN'|'Nx1'} opts.layout — 레이아웃 (기본 2x2)
|
|
216
|
+
* @param {number} opts.paneCount — pane 수 (기본 4)
|
|
217
|
+
* @returns {{ sessionName: string, panes: string[] }}
|
|
218
|
+
*/
|
|
219
|
+
export function createSession(sessionName, opts = {}) {
|
|
220
|
+
const { layout = "2x2", paneCount = 4 } = opts;
|
|
221
|
+
const mux = detectMultiplexer();
|
|
222
|
+
|
|
223
|
+
// 기존 세션 정리
|
|
224
|
+
if (sessionExists(sessionName)) {
|
|
225
|
+
killSession(sessionName);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (mux === "psmux") {
|
|
229
|
+
return createPsmuxSession(sessionName, { layout, paneCount });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 새 세션 생성 (detached)
|
|
233
|
+
tmux(["new-session", "-d", "-s", sessionName, "-x", "220", "-y", "55"]);
|
|
234
|
+
|
|
235
|
+
const panes = [`${sessionName}:0.0`];
|
|
236
|
+
|
|
237
|
+
if (layout === "2x2" && paneCount >= 3) {
|
|
238
|
+
// 3-pane 기본: lead 왼쪽, workers 오른쪽 상/하
|
|
239
|
+
// 4-pane: 좌/우 각각 상/하(균등 2x2)
|
|
240
|
+
tmux(["split-window", "-h", "-t", `${sessionName}:0.0`]);
|
|
241
|
+
tmux(["split-window", "-v", "-t", `${sessionName}:0.1`]);
|
|
242
|
+
if (paneCount >= 4) {
|
|
243
|
+
tmux(["split-window", "-v", "-t", `${sessionName}:0.0`]);
|
|
244
|
+
}
|
|
245
|
+
// pane ID 재수집
|
|
246
|
+
panes.length = 0;
|
|
247
|
+
for (let i = 0; i < Math.min(paneCount, 4); i++) {
|
|
248
|
+
panes.push(`${sessionName}:0.${i}`);
|
|
249
|
+
}
|
|
250
|
+
} else if (layout === "1xN") {
|
|
251
|
+
// 세로 분할(좌/우 컬럼 확장)
|
|
252
|
+
for (let i = 1; i < paneCount; i++) {
|
|
253
|
+
tmux(["split-window", "-h", "-t", `${sessionName}:0`]);
|
|
254
|
+
}
|
|
255
|
+
tmux(["select-layout", "-t", `${sessionName}:0`, "even-horizontal"]);
|
|
256
|
+
panes.length = 0;
|
|
257
|
+
for (let i = 0; i < paneCount; i++) {
|
|
258
|
+
panes.push(`${sessionName}:0.${i}`);
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
// Nx1 가로 분할(상/하 스택)
|
|
262
|
+
for (let i = 1; i < paneCount; i++) {
|
|
263
|
+
tmux(["split-window", "-v", "-t", `${sessionName}:0`]);
|
|
264
|
+
}
|
|
265
|
+
tmux(["select-layout", "-t", `${sessionName}:0`, "even-vertical"]);
|
|
266
|
+
panes.length = 0;
|
|
267
|
+
for (let i = 0; i < paneCount; i++) {
|
|
268
|
+
panes.push(`${sessionName}:0.${i}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { sessionName, panes };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* pane 포커스 이동
|
|
277
|
+
* @param {string} target
|
|
278
|
+
* @param {object} opts
|
|
279
|
+
* @param {boolean} opts.zoom
|
|
280
|
+
*/
|
|
281
|
+
export function focusPane(target, opts = {}) {
|
|
282
|
+
const { zoom = false } = opts;
|
|
283
|
+
tmux(["select-pane", "-t", target]);
|
|
284
|
+
if (zoom) {
|
|
285
|
+
try {
|
|
286
|
+
tmux(["resize-pane", "-t", target, "-Z"]);
|
|
287
|
+
} catch {}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* 팀메이트 조작 키 바인딩 설정
|
|
293
|
+
* - Shift+Down: 다음 팀메이트
|
|
294
|
+
* - Shift+Up: 이전 팀메이트
|
|
295
|
+
* - Shift+Left / Shift+Tab: 이전 팀메이트 대체 키
|
|
296
|
+
* - Shift+Right: 다음 팀메이트 대체 키
|
|
297
|
+
* - Escape: 현재 팀메이트 인터럽트(C-c)
|
|
298
|
+
* - Ctrl+T: 태스크 목록 표시
|
|
299
|
+
* @param {string} sessionName
|
|
300
|
+
* @param {object} opts
|
|
301
|
+
* @param {boolean} opts.inProcess
|
|
302
|
+
* @param {string} opts.taskListCommand
|
|
303
|
+
*/
|
|
304
|
+
export function configureTeammateKeybindings(sessionName, opts = {}) {
|
|
305
|
+
if (detectMultiplexer() === "psmux") {
|
|
306
|
+
configurePsmuxKeybindings(sessionName, opts);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const { inProcess = false, taskListCommand = "" } = opts;
|
|
311
|
+
const cond = `#{==:#{session_name},${sessionName}}`;
|
|
312
|
+
|
|
313
|
+
// Shift+Up이 터미널/호스트 조합에 따라 전달되지 않는 경우가 있어
|
|
314
|
+
// 좌/우/Shift+Tab 대체 키를 함께 바인딩한다.
|
|
315
|
+
const bindNext = inProcess
|
|
316
|
+
? "select-pane -t :.+ \\; resize-pane -Z"
|
|
317
|
+
: "select-pane -t :.+";
|
|
318
|
+
const bindPrev = inProcess
|
|
319
|
+
? "select-pane -t :.- \\; resize-pane -Z"
|
|
320
|
+
: "select-pane -t :.-";
|
|
321
|
+
const bindIf = (key, onMatch, otherwise) =>
|
|
322
|
+
tmux([
|
|
323
|
+
"bind-key",
|
|
324
|
+
"-T",
|
|
325
|
+
"root",
|
|
326
|
+
"-n",
|
|
327
|
+
key,
|
|
328
|
+
"if-shell",
|
|
329
|
+
"-F",
|
|
330
|
+
cond,
|
|
331
|
+
onMatch,
|
|
332
|
+
otherwise,
|
|
333
|
+
]);
|
|
334
|
+
|
|
335
|
+
bindIf("S-Down", bindNext, "send-keys S-Down");
|
|
336
|
+
bindIf("S-Up", bindPrev, "send-keys S-Up");
|
|
337
|
+
|
|
338
|
+
// 대체 키: 일부 환경에서 S-Up이 누락될 때 사용
|
|
339
|
+
bindIf("S-Right", bindNext, "send-keys S-Right");
|
|
340
|
+
bindIf("S-Left", bindPrev, "send-keys S-Left");
|
|
341
|
+
bindIf("BTab", bindPrev, "send-keys BTab");
|
|
342
|
+
|
|
343
|
+
// 현재 활성 pane 인터럽트
|
|
344
|
+
bindIf("Escape", "send-keys C-c", "send-keys Escape");
|
|
345
|
+
|
|
346
|
+
// 태스크 목록 토글 (tmux 3.2+ popup 우선, 실패 시 안내 메시지)
|
|
347
|
+
if (taskListCommand) {
|
|
348
|
+
const escaped = taskListCommand.replace(/'/g, "'\\''");
|
|
349
|
+
try {
|
|
350
|
+
bindIf("C-t", `display-popup -E '${escaped}'`, "send-keys C-t");
|
|
351
|
+
} catch {
|
|
352
|
+
bindIf(
|
|
353
|
+
"C-t",
|
|
354
|
+
'display-message "tfx multi tasks 명령으로 태스크 확인"',
|
|
355
|
+
"send-keys C-t",
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* tmux 세션 연결 (포그라운드 전환)
|
|
363
|
+
* @param {string} sessionName
|
|
364
|
+
*/
|
|
365
|
+
export function attachSession(sessionName) {
|
|
366
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
367
|
+
throw new Error("현재 터미널은 tmux attach를 지원하지 않음 (non-TTY)");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (detectMultiplexer() === "psmux") {
|
|
371
|
+
attachPsmuxSession(sessionName);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const { command, args } = resolveAttachCommand(sessionName);
|
|
376
|
+
const r = spawnSync(command, args, {
|
|
377
|
+
stdio: "inherit",
|
|
378
|
+
timeout: 0, // 타임아웃 없음 (사용자가 detach할 때까지)
|
|
379
|
+
});
|
|
380
|
+
if ((r.status ?? 1) !== 0) {
|
|
381
|
+
throw new Error(`tmux attach 실패 (exit=${r.status})`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* non-TTY 환경용 attach fallback: 요청 프로세스가 명시적으로 넘긴 lead pane을
|
|
387
|
+
* 분할해 그 pane 안에서 대상 세션에 attach한다.
|
|
388
|
+
*
|
|
389
|
+
* 호출 프로세스가 pipe(non-TTY)로 실행돼 attachSession()이 불가능해도,
|
|
390
|
+
* tmux 서버에 보내는 split-window는 호출자 TTY가 필요 없다. 새 pane에는
|
|
391
|
+
* tmux가 실제 pty를 붙여주므로 그 안의 attach는 정상 동작한다.
|
|
392
|
+
* env -u TMUX는 nested-attach 보호 해제용이다.
|
|
393
|
+
*
|
|
394
|
+
* @param {string} sessionName attach할 대상 세션
|
|
395
|
+
* @param {string} targetPane 요청 lead의 `$TMUX_PANE` (`%N`)
|
|
396
|
+
* @param {string} tmuxEnv 요청 lead의 `$TMUX` (`socket,pid,index`)
|
|
397
|
+
* @returns {boolean} 분할 attach 성공 여부 (false면 호출자가 기존 생략 경로 유지)
|
|
398
|
+
*/
|
|
399
|
+
export function buildSplitAttachArgs(sessionName, targetPane, tmuxEnv) {
|
|
400
|
+
const normalizedSessionName = String(sessionName || "");
|
|
401
|
+
if (!normalizedSessionName.trim()) {
|
|
402
|
+
throw new Error("session name is required");
|
|
403
|
+
}
|
|
404
|
+
if (!/^%\d+$/u.test(String(targetPane || ""))) {
|
|
405
|
+
throw new Error("requesting lead TMUX_PANE is required");
|
|
406
|
+
}
|
|
407
|
+
const socketPath = String(tmuxEnv || "").split(",", 1)[0];
|
|
408
|
+
if (!socketPath.trim()) {
|
|
409
|
+
throw new Error("requesting lead TMUX socket is required");
|
|
410
|
+
}
|
|
411
|
+
const quotedSocket = `'${socketPath.replace(/'/gu, "'\\''")}'`;
|
|
412
|
+
const quotedSessionName = `'${normalizedSessionName.replace(/'/gu, "'\\''")}'`;
|
|
413
|
+
return [
|
|
414
|
+
"split-window",
|
|
415
|
+
"-t",
|
|
416
|
+
targetPane,
|
|
417
|
+
"-v",
|
|
418
|
+
"-l",
|
|
419
|
+
"50%",
|
|
420
|
+
`env -u TMUX tmux -S ${quotedSocket} attach -t ${quotedSessionName}`,
|
|
421
|
+
];
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function splitAttachToClient(sessionName, opts = {}) {
|
|
425
|
+
const deps = opts._deps || {};
|
|
426
|
+
const detectMux = deps.detectMultiplexer || detectMultiplexer;
|
|
427
|
+
if (detectMux() !== "tmux") return false;
|
|
428
|
+
|
|
429
|
+
let args;
|
|
430
|
+
try {
|
|
431
|
+
args = buildSplitAttachArgs(sessionName, opts.targetPane, opts.tmuxEnv);
|
|
432
|
+
} catch {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// 상하 분할: 좌우 분할은 절반 폭이 워커 pane 가독 하한(100cols) 미만인 경우가 흔함
|
|
437
|
+
const spawn = deps.spawnSync || spawnSync;
|
|
438
|
+
const split = spawn("tmux", args);
|
|
439
|
+
return (split.status ?? 1) === 0;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* tmux 세션 존재 확인
|
|
444
|
+
* @param {string} sessionName
|
|
445
|
+
* @returns {boolean}
|
|
446
|
+
*/
|
|
447
|
+
export function sessionExists(sessionName) {
|
|
448
|
+
if (detectMultiplexer() === "psmux") {
|
|
449
|
+
return psmuxSessionExists(sessionName);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
// '=' 가 없으면 tmux 가 접두사로 다른 세션을 잡는다(#548).
|
|
454
|
+
tmux(["has-session", "-t", `=${sessionName}`], { stdio: "ignore" });
|
|
455
|
+
return true;
|
|
456
|
+
} catch {
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* tmux 세션 종료
|
|
463
|
+
* @param {string} sessionName
|
|
464
|
+
*/
|
|
465
|
+
export function killSession(sessionName) {
|
|
466
|
+
if (detectMultiplexer() === "psmux") {
|
|
467
|
+
killPsmuxSession(sessionName);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
tmux(["kill-session", "-t", `=${sessionName}`], { stdio: "ignore" });
|
|
473
|
+
} catch {
|
|
474
|
+
// 이미 종료된 세션 — 무시
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* tfx-multi- 접두사 세션 목록
|
|
480
|
+
* @returns {string[]}
|
|
481
|
+
*/
|
|
482
|
+
export function listSessions() {
|
|
483
|
+
if (detectMultiplexer() === "psmux") {
|
|
484
|
+
return listPsmuxSessions();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
try {
|
|
488
|
+
const output = tmux(["list-sessions", "-F", "#{session_name}"]);
|
|
489
|
+
return output.split("\n").filter((s) => s.startsWith("tfx-multi-"));
|
|
490
|
+
} catch {
|
|
491
|
+
return [];
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* 세션 attach client 수 조회
|
|
497
|
+
* @param {string} sessionName
|
|
498
|
+
* @returns {number|null}
|
|
499
|
+
*/
|
|
500
|
+
export function getSessionAttachedCount(sessionName) {
|
|
501
|
+
if (detectMultiplexer() === "psmux") {
|
|
502
|
+
return getPsmuxSessionAttachedCount(sessionName);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
try {
|
|
506
|
+
const output = tmux([
|
|
507
|
+
"list-sessions",
|
|
508
|
+
"-F",
|
|
509
|
+
"#{session_name} #{session_attached}",
|
|
510
|
+
]);
|
|
511
|
+
const line = output
|
|
512
|
+
.split("\n")
|
|
513
|
+
.find((l) => l.startsWith(`${sessionName} `));
|
|
514
|
+
if (!line) return null;
|
|
515
|
+
const n = parseInt(line.split(" ")[1], 10);
|
|
516
|
+
return Number.isFinite(n) ? n : null;
|
|
517
|
+
} catch {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* pane 마지막 N줄 캡처
|
|
524
|
+
* @param {string} target — 예: tfx-multi-abc:0.1
|
|
525
|
+
* @param {number} lines — 캡처할 줄 수 (기본 5)
|
|
526
|
+
* @returns {string}
|
|
527
|
+
*/
|
|
528
|
+
export function capturePaneOutput(target, lines = 5) {
|
|
529
|
+
if (detectMultiplexer() === "psmux") {
|
|
530
|
+
return capturePsmuxPane(target, lines);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
try {
|
|
534
|
+
// -l 플래그는 일부 tmux 빌드(MSYS2)에서 미지원 → 전체 캡처 후 JS에서 절삭
|
|
535
|
+
const full = tmux(["capture-pane", "-t", target, "-p"]);
|
|
536
|
+
const nonEmpty = full.split("\n").filter((l) => l.trim() !== "");
|
|
537
|
+
return nonEmpty.slice(-lines).join("\n");
|
|
538
|
+
} catch {
|
|
539
|
+
return "";
|
|
540
|
+
}
|
|
541
|
+
}
|
package/hud/constants.mjs
CHANGED
|
@@ -82,9 +82,6 @@ export const OMC_PLUGIN_USAGE_CACHE_PATH = join(
|
|
|
82
82
|
export const CLAUDE_USAGE_STALE_MS_SOLO = 5 * 60 * 1000; // OMC 없을 때: 5분 캐시
|
|
83
83
|
export const CLAUDE_USAGE_STALE_MS_WITH_OMC = 15 * 60 * 1000; // OMC 있을 때: 15분
|
|
84
84
|
export const CLAUDE_USAGE_429_BACKOFF_MS = 10 * 60 * 1000; // 429 에러 시 10분 backoff
|
|
85
|
-
export const GEMINI_429_BASE_DELAY_MS = 2000;
|
|
86
|
-
export const GEMINI_429_MAX_RETRIES = 3;
|
|
87
|
-
export const GEMINI_429_COOLDOWN_MS = 30000;
|
|
88
85
|
export const CLAUDE_USAGE_ERROR_BACKOFF_MS = 3 * 60 * 1000; // 기타 에러 시 3분 backoff
|
|
89
86
|
export const CLAUDE_API_TIMEOUT_MS = 10_000;
|
|
90
87
|
export const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
|
package/hud/providers/claude.mjs
CHANGED
|
@@ -182,8 +182,19 @@ function readClaudeKeychainCredentials(
|
|
|
182
182
|
const entry = readClaudeKeychainEntry({ execFileSyncFn, env });
|
|
183
183
|
if (!entry) return null;
|
|
184
184
|
const creds = normalizeClaudeCredentials(entry.raw, "keychain");
|
|
185
|
+
if (!creds) return null;
|
|
185
186
|
// 만료된 항목도 그대로 돌려준다. 호출자가 refreshToken 으로 갱신한 뒤 같은 항목에 되쓴다.
|
|
186
|
-
|
|
187
|
+
const lineLength = keychainWriteLine(
|
|
188
|
+
entry.account,
|
|
189
|
+
entry.serviceName,
|
|
190
|
+
entry.raw,
|
|
191
|
+
).length;
|
|
192
|
+
return {
|
|
193
|
+
...creds,
|
|
194
|
+
keychainAccount: entry.account,
|
|
195
|
+
keychainWritable:
|
|
196
|
+
lineLength + KEYCHAIN_TOKEN_GROWTH_MARGIN <= KEYCHAIN_STDIN_LINE_LIMIT,
|
|
197
|
+
};
|
|
187
198
|
}
|
|
188
199
|
|
|
189
200
|
// 읽기 순서는 Claude Code 와 같다. macOS 에서는 Keychain 이 정본이고 평문 파일은 Keychain 을 못 쓸 때의
|
|
@@ -316,8 +327,16 @@ function mergeClaudeCredentials(creds, existingRaw) {
|
|
|
316
327
|
}
|
|
317
328
|
|
|
318
329
|
// Claude Code 와 같은 쓰기 경로: 비밀값을 argv 에 두지 않도록 `security -i` 표준입력으로 명령 한 줄을
|
|
319
|
-
// 넘기고 payload 는 `-X` 16진수로 싣는다. 한 줄 상한(4032B)을 넘는 큰
|
|
330
|
+
// 넘기고 payload 는 `-X` 16진수로 싣는다. 한 줄 상한(4032B)을 넘는 큰 항목은 argv 로 넘기면 토큰이
|
|
331
|
+
// ps 에 보여서 되쓰지 않는다.
|
|
320
332
|
const KEYCHAIN_STDIN_LINE_LIMIT = 4032;
|
|
333
|
+
// 갱신 뒤 토큰 길이가 조금 달라져도 상한을 넘지 않도록 둔 여유(글자 수).
|
|
334
|
+
const KEYCHAIN_TOKEN_GROWTH_MARGIN = 512;
|
|
335
|
+
|
|
336
|
+
function keychainWriteLine(account, serviceName, payload) {
|
|
337
|
+
const hex = Buffer.from(JSON.stringify(payload), "utf8").toString("hex");
|
|
338
|
+
return `add-generic-password -U -a "${account}" -s "${serviceName}" -X "${hex}"\n`;
|
|
339
|
+
}
|
|
321
340
|
|
|
322
341
|
function writeClaudeKeychainCredentials(
|
|
323
342
|
creds,
|
|
@@ -333,20 +352,17 @@ function writeClaudeKeychainCredentials(
|
|
|
333
352
|
execFileSyncFn,
|
|
334
353
|
);
|
|
335
354
|
const payload = mergeClaudeCredentials(creds, existingRaw);
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
stdio: ["pipe", "ignore", "ignore"],
|
|
342
|
-
});
|
|
355
|
+
const line = keychainWriteLine(account, serviceName, payload);
|
|
356
|
+
if (line.length > KEYCHAIN_STDIN_LINE_LIMIT) {
|
|
357
|
+
process.stderr.write(
|
|
358
|
+
"[hud] Keychain 항목이 커서 갱신한 토큰을 되쓰지 않습니다\n",
|
|
359
|
+
);
|
|
343
360
|
return;
|
|
344
361
|
}
|
|
345
|
-
execFileSyncFn(
|
|
346
|
-
|
|
347
|
-
["
|
|
348
|
-
|
|
349
|
-
);
|
|
362
|
+
execFileSyncFn("security", ["-i"], {
|
|
363
|
+
input: line,
|
|
364
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
365
|
+
});
|
|
350
366
|
}
|
|
351
367
|
|
|
352
368
|
function writeClaudeFileCredentials(
|
|
@@ -631,7 +647,11 @@ export async function fetchClaudeUsage(forceRefresh = false) {
|
|
|
631
647
|
|
|
632
648
|
// 토큰 만료 시 리프레시
|
|
633
649
|
if (creds.expiresAt && creds.expiresAt <= Date.now() && creds.refreshToken) {
|
|
634
|
-
|
|
650
|
+
// 되쓰지 못할 갱신은 회전된 refreshToken 을 잃어 Claude Code 로그인을 풀 수 있다. 갱신은 Claude Code 에 맡긴다.
|
|
651
|
+
const refreshed =
|
|
652
|
+
creds.keychainWritable === false
|
|
653
|
+
? null
|
|
654
|
+
: await refreshClaudeAccessToken(creds.refreshToken);
|
|
635
655
|
if (refreshed) {
|
|
636
656
|
creds = { ...creds, ...refreshed };
|
|
637
657
|
writeBackClaudeCredentials(creds);
|