@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.
Files changed (47) hide show
  1. package/hooks/codex-session-hook.mjs +1 -17
  2. package/hub/cli-adapter-base.mjs +3 -424
  3. package/hub/index.mjs +0 -5
  4. package/hub/lib/hosts-compat.mjs +18 -64
  5. package/hub/lib/private-tmp.mjs +35 -0
  6. package/hub/lib/process-utils.mjs +19 -220
  7. package/hub/lib/prompt-tmp.mjs +6 -24
  8. package/hub/lib/spawn-trace.mjs +112 -5
  9. package/hub/lib/timeout-defaults.mjs +34 -6
  10. package/hub/lib/worker-lifecycle.mjs +3 -0
  11. package/hub/platform.mjs +3 -1
  12. package/hub/team/agent-map.json +29 -0
  13. package/hub/team/intervention.mjs +6 -4
  14. package/hub/team/nativeProxy.mjs +2 -48
  15. package/hub/team/pane.mjs +329 -0
  16. package/hub/team/psmux.mjs +1765 -0
  17. package/hub/team/retry-state-machine.mjs +1 -1
  18. package/hub/team/session.mjs +541 -0
  19. package/hud/constants.mjs +0 -3
  20. package/hud/providers/claude.mjs +35 -15
  21. package/hud/providers/codex.mjs +169 -42
  22. package/hud/utils.mjs +0 -23
  23. package/package.json +2 -2
  24. package/scripts/lib/backup-rotation.mjs +60 -0
  25. package/scripts/lib/codex-profile-config.mjs +20 -7
  26. package/scripts/lib/codex-recovery.sh +2 -3
  27. package/scripts/lib/install-blob-history.json +3196 -0
  28. package/scripts/lib/install-retire.mjs +203 -0
  29. package/scripts/lib/legacy-hook-cleanup.mjs +148 -13
  30. package/scripts/lib/legacy-mcp-cleanup.mjs +405 -104
  31. package/scripts/lib/machine-profile.mjs +2 -2
  32. package/scripts/lib/mcp-filter.mjs +1 -42
  33. package/scripts/lib/mcp-guard-engine.mjs +198 -19
  34. package/scripts/lib/mcp-manifest.mjs +0 -40
  35. package/scripts/lib/process-utils.mjs +19 -0
  36. package/scripts/lib/stable-node.mjs +35 -0
  37. package/scripts/lib/test-env.mjs +20 -0
  38. package/scripts/lib/tmr-install.mjs +183 -0
  39. package/hooks/agy-session-hook.mjs +0 -155
  40. package/hooks/claude-cwd-projection-refresh.mjs +0 -11
  41. package/hooks/hook-orchestrator.mjs +0 -11
  42. package/hooks/lib/resolve-root.mjs +0 -62
  43. package/hub/codex-adapter.mjs +0 -193
  44. package/hub/codex-preflight.mjs +0 -219
  45. package/hub/lib/cto-env.mjs +0 -70
  46. package/scripts/lib/remote-spawn-transfer.mjs +0 -226
  47. 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
- // 설계 문서: .triflux/plans/phase3-lead-codex-ralph-escalate.md
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;
@@ -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
- return creds ? { ...creds, keychainAccount: entry.account } : null;
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)을 넘는 큰 항목만 argv 로 내려간다.
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 hex = Buffer.from(JSON.stringify(payload), "utf8").toString("hex");
337
- const line = `add-generic-password -U -a "${account}" -s "${serviceName}" -X "${hex}"\n`;
338
- if (line.length <= KEYCHAIN_STDIN_LINE_LIMIT) {
339
- execFileSyncFn("security", ["-i"], {
340
- input: line,
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
- "security",
347
- ["add-generic-password", "-U", "-a", account, "-s", serviceName, "-X", hex],
348
- { stdio: "ignore" },
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
- const refreshed = await refreshClaudeAccessToken(creds.refreshToken);
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);