triflux 10.3.3 → 10.3.4
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/.claude-plugin/plugin.json +1 -1
- package/hub/lib/path-utils.mjs +167 -0
- package/hub/team/runtime-strategy.mjs +74 -0
- package/hub/team/worktree-lifecycle.mjs +61 -2
- package/hud/hud-qos-status.mjs +17 -3
- package/hud/mission-board.mjs +53 -0
- package/hud/providers/claude.mjs +95 -22
- package/hud/renderers.mjs +39 -5
- package/package.json +1 -1
- package/scripts/demo.mjs +169 -0
- package/scripts/headless-guard.mjs +16 -4
- package/scripts/lib/skill-state.mjs +220 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// hub/lib/path-utils.mjs — Windows/POSIX 경로 변환 유틸리티
|
|
2
|
+
|
|
3
|
+
import { platform } from 'node:os';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Windows 경로를 Git Bash 스타일 POSIX 경로로 변환한다.
|
|
7
|
+
* C:\foo\bar → /c/foo/bar
|
|
8
|
+
* 이미 POSIX 경로면 그대로 반환.
|
|
9
|
+
* null/undefined → 빈 문자열
|
|
10
|
+
* @param {string|null|undefined} windowsPath
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
export function toPosixPath(windowsPath) {
|
|
14
|
+
if (windowsPath == null) return '';
|
|
15
|
+
const p = String(windowsPath);
|
|
16
|
+
if (!p) return '';
|
|
17
|
+
|
|
18
|
+
// 이미 POSIX 경로 (/ 로 시작하거나 드라이브 레터 없음)
|
|
19
|
+
const winDriveMatch = p.match(/^([A-Za-z]):[/\\](.*)/);
|
|
20
|
+
if (!winDriveMatch) {
|
|
21
|
+
// 백슬래시만 있는 상대 경로도 forward slash로 변환
|
|
22
|
+
return p.replace(/\\/g, '/');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const drive = winDriveMatch[1].toLowerCase();
|
|
26
|
+
const rest = winDriveMatch[2].replace(/\\/g, '/');
|
|
27
|
+
return `/${drive}/${rest}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* POSIX 경로(Git Bash 스타일)를 Windows 경로로 변환한다.
|
|
32
|
+
* /c/foo/bar → C:\foo\bar
|
|
33
|
+
* 이미 Windows 경로면 그대로 반환.
|
|
34
|
+
* null/undefined → 빈 문자열
|
|
35
|
+
* @param {string|null|undefined} posixPath
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
export function toWindowsPath(posixPath) {
|
|
39
|
+
if (posixPath == null) return '';
|
|
40
|
+
const p = String(posixPath);
|
|
41
|
+
if (!p) return '';
|
|
42
|
+
|
|
43
|
+
// 이미 Windows 경로
|
|
44
|
+
if (/^[A-Za-z]:/.test(p)) {
|
|
45
|
+
return p.replace(/\//g, '\\');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Git Bash 스타일: /c/foo/bar
|
|
49
|
+
const gitBashMatch = p.match(/^\/([a-zA-Z])(\/.*)?$/);
|
|
50
|
+
if (gitBashMatch) {
|
|
51
|
+
const drive = gitBashMatch[1].toUpperCase();
|
|
52
|
+
const rest = gitBashMatch[2] ? gitBashMatch[2].replace(/\//g, '\\') : '\\';
|
|
53
|
+
return `${drive}:${rest}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return p;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 현재 OS에 맞게 경로를 정규화한다.
|
|
61
|
+
* win32: backslash, 그 외: forward slash
|
|
62
|
+
* @param {string|null|undefined} p
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
export function normalizePath(p) {
|
|
66
|
+
if (p == null) return '';
|
|
67
|
+
const str = String(p);
|
|
68
|
+
if (process.platform === 'win32') {
|
|
69
|
+
return str.replace(/\//g, '\\');
|
|
70
|
+
}
|
|
71
|
+
return str.replace(/\\/g, '/');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 쉘 타입에 맞는 경로로 변환한다.
|
|
76
|
+
* @param {string|null|undefined} path
|
|
77
|
+
* @param {'git-bash'|'wsl'|'cmd'|'powershell'} shellType
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
export function resolveShellPath(path, shellType) {
|
|
81
|
+
if (path == null) return '';
|
|
82
|
+
const p = String(path);
|
|
83
|
+
|
|
84
|
+
switch (shellType) {
|
|
85
|
+
case 'git-bash':
|
|
86
|
+
return toPosixPath(p);
|
|
87
|
+
|
|
88
|
+
case 'wsl': {
|
|
89
|
+
// Git Bash 경로를 먼저 Windows로 변환 후 WSL 형식으로
|
|
90
|
+
let winPath = /^[A-Za-z]:/.test(p) ? p : toWindowsPath(p);
|
|
91
|
+
const wslDriveMatch = winPath.match(/^([A-Za-z]):[/\\](.*)/);
|
|
92
|
+
if (wslDriveMatch) {
|
|
93
|
+
const drive = wslDriveMatch[1].toLowerCase();
|
|
94
|
+
const rest = wslDriveMatch[2].replace(/\\/g, '/');
|
|
95
|
+
return rest ? `/mnt/${drive}/${rest}` : `/mnt/${drive}`;
|
|
96
|
+
}
|
|
97
|
+
// 이미 /mnt/ 형식이면 그대로
|
|
98
|
+
if (p.startsWith('/mnt/')) return p;
|
|
99
|
+
return p.replace(/\\/g, '/');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
case 'cmd':
|
|
103
|
+
case 'powershell':
|
|
104
|
+
return toWindowsPath(p);
|
|
105
|
+
|
|
106
|
+
default:
|
|
107
|
+
return p;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 환경 변수와 플랫폼 정보로 현재 쉘 타입을 추론한다.
|
|
113
|
+
* @returns {'git-bash'|'wsl'|'cmd'|'powershell'|'unix'}
|
|
114
|
+
*/
|
|
115
|
+
export function detectShellType() {
|
|
116
|
+
// WSL 감지: WSL_DISTRO_NAME 또는 WSLENV
|
|
117
|
+
if (process.env.WSL_DISTRO_NAME || process.env.WSLENV) {
|
|
118
|
+
return 'wsl';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Windows 플랫폼
|
|
122
|
+
if (process.platform === 'win32') {
|
|
123
|
+
const shell = process.env.SHELL || '';
|
|
124
|
+
const term = process.env.TERM || '';
|
|
125
|
+
const msystem = process.env.MSYSTEM || '';
|
|
126
|
+
|
|
127
|
+
// Git Bash 감지: SHELL=/usr/bin/bash + MSYSTEM=MINGW64 등
|
|
128
|
+
if (
|
|
129
|
+
shell.includes('bash') ||
|
|
130
|
+
msystem.startsWith('MINGW') ||
|
|
131
|
+
msystem.startsWith('MSYS') ||
|
|
132
|
+
term === 'xterm'
|
|
133
|
+
) {
|
|
134
|
+
return 'git-bash';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// PowerShell 감지
|
|
138
|
+
if (process.env.PSModulePath || process.env.PSHOME) {
|
|
139
|
+
return 'powershell';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return 'cmd';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 비-Windows
|
|
146
|
+
return 'unix';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* WSL 경로 여부를 확인한다 (/mnt/ 시작).
|
|
151
|
+
* @param {string|null|undefined} p
|
|
152
|
+
* @returns {boolean}
|
|
153
|
+
*/
|
|
154
|
+
export function isWslPath(p) {
|
|
155
|
+
if (p == null) return false;
|
|
156
|
+
return String(p).startsWith('/mnt/');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Git Bash 경로 여부를 확인한다 (/c/ 또는 /d/ 등 단일 소문자 드라이브 레터).
|
|
161
|
+
* @param {string|null|undefined} p
|
|
162
|
+
* @returns {boolean}
|
|
163
|
+
*/
|
|
164
|
+
export function isGitBashPath(p) {
|
|
165
|
+
if (p == null) return false;
|
|
166
|
+
return /^\/[a-zA-Z](\/|$)/.test(String(p));
|
|
167
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createPsmuxSession,
|
|
3
|
+
killPsmuxSession,
|
|
4
|
+
psmuxSessionExists,
|
|
5
|
+
} from "./psmux.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {object} RuntimeStatus
|
|
9
|
+
* @property {string} name
|
|
10
|
+
* @property {string} sessionName
|
|
11
|
+
* @property {boolean} alive
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} TeamRuntime
|
|
16
|
+
* @property {(sessionName: string, opts?: object) => unknown} start
|
|
17
|
+
* @property {(sessionName: string) => void} stop
|
|
18
|
+
* @property {(sessionName: string) => boolean} isAlive
|
|
19
|
+
* @property {(sessionName: string) => RuntimeStatus} getStatus
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const defaultPsmuxAdapter = {
|
|
23
|
+
createSession: createPsmuxSession,
|
|
24
|
+
killSession: killPsmuxSession,
|
|
25
|
+
hasSession: psmuxSessionExists,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {{
|
|
30
|
+
* createSession: typeof createPsmuxSession,
|
|
31
|
+
* killSession: typeof killPsmuxSession,
|
|
32
|
+
* hasSession: typeof psmuxSessionExists,
|
|
33
|
+
* }} [adapter]
|
|
34
|
+
* @returns {TeamRuntime & { name: "psmux" }}
|
|
35
|
+
*/
|
|
36
|
+
export function createPsmuxRuntime(adapter = defaultPsmuxAdapter) {
|
|
37
|
+
return {
|
|
38
|
+
name: "psmux",
|
|
39
|
+
start(sessionName, opts = {}) {
|
|
40
|
+
return adapter.createSession(sessionName, opts);
|
|
41
|
+
},
|
|
42
|
+
stop(sessionName) {
|
|
43
|
+
adapter.killSession(sessionName);
|
|
44
|
+
},
|
|
45
|
+
isAlive(sessionName) {
|
|
46
|
+
return adapter.hasSession(sessionName);
|
|
47
|
+
},
|
|
48
|
+
getStatus(sessionName) {
|
|
49
|
+
return {
|
|
50
|
+
name: "psmux",
|
|
51
|
+
sessionName,
|
|
52
|
+
alive: adapter.hasSession(sessionName),
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {string} mode
|
|
60
|
+
* @returns {TeamRuntime & { name: string }}
|
|
61
|
+
*/
|
|
62
|
+
export function createRuntime(mode) {
|
|
63
|
+
const normalizedMode = String(mode || "").trim().toLowerCase();
|
|
64
|
+
|
|
65
|
+
if (normalizedMode === "psmux") {
|
|
66
|
+
return createPsmuxRuntime();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (normalizedMode === "native" || normalizedMode === "wt") {
|
|
70
|
+
throw new Error(`Runtime mode "${normalizedMode}" is not implemented yet.`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
throw new Error(`Unsupported runtime mode: ${mode}`);
|
|
74
|
+
}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// Remote support: host option → SSH-based git operations via remote-session.mjs.
|
|
5
5
|
|
|
6
6
|
import { execFile } from 'node:child_process';
|
|
7
|
-
import { resolve, normalize } from 'node:path';
|
|
8
|
-
import { mkdir, rm, access } from 'node:fs/promises';
|
|
7
|
+
import { resolve, normalize, join } from 'node:path';
|
|
8
|
+
import { mkdir, rm, access, readdir } from 'node:fs/promises';
|
|
9
9
|
import { remoteGit, validateHost } from './remote-session.mjs';
|
|
10
10
|
|
|
11
11
|
const SWARM_ROOT = '.codex-swarm';
|
|
@@ -92,6 +92,10 @@ export async function ensureWorktree({ slug, runId, rootDir = process.cwd(), bas
|
|
|
92
92
|
await git(['worktree', 'add', wtDir, branchName], rootDir);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// #34 L2: worktree에 복사된 .claude-plugin 제거 (하네스가 PLUGIN_ROOT를 오인하는 것 방지)
|
|
96
|
+
const pluginDir = join(wtDir, '.claude-plugin');
|
|
97
|
+
try { await rm(pluginDir, { recursive: true, force: true }); } catch { /* absent → ok */ }
|
|
98
|
+
|
|
95
99
|
return { worktreePath: normPath(wtDir), branchName, remote: false };
|
|
96
100
|
}
|
|
97
101
|
|
|
@@ -192,6 +196,61 @@ export async function pruneWorktree({ worktreePath, branchName, rootDir = proces
|
|
|
192
196
|
}
|
|
193
197
|
}
|
|
194
198
|
|
|
199
|
+
/**
|
|
200
|
+
* #34 L3: Detect and remove orphan worktree directories.
|
|
201
|
+
* Compares .codex-swarm/wt-* directories against `git worktree list`.
|
|
202
|
+
* Directories not registered as worktrees are removed.
|
|
203
|
+
*
|
|
204
|
+
* @param {object} [opts]
|
|
205
|
+
* @param {string} [opts.rootDir=process.cwd()]
|
|
206
|
+
* @returns {Promise<string[]>} removed directory names
|
|
207
|
+
*/
|
|
208
|
+
export async function pruneOrphanWorktrees({ rootDir = process.cwd() } = {}) {
|
|
209
|
+
const swarmDir = resolve(rootDir, SWARM_ROOT);
|
|
210
|
+
const removed = [];
|
|
211
|
+
|
|
212
|
+
let entries;
|
|
213
|
+
try {
|
|
214
|
+
entries = await readdir(swarmDir);
|
|
215
|
+
} catch {
|
|
216
|
+
return removed; // .codex-swarm/ doesn't exist → nothing to clean
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const wtDirs = entries.filter(e => e.startsWith('wt-'));
|
|
220
|
+
if (wtDirs.length === 0) return removed;
|
|
221
|
+
|
|
222
|
+
// Get registered worktree paths from git
|
|
223
|
+
let registeredPaths;
|
|
224
|
+
try {
|
|
225
|
+
const raw = await git(['worktree', 'list', '--porcelain'], rootDir);
|
|
226
|
+
registeredPaths = new Set(
|
|
227
|
+
raw.split('\n')
|
|
228
|
+
.filter(l => l.startsWith('worktree '))
|
|
229
|
+
.map(l => normPath(l.slice('worktree '.length))),
|
|
230
|
+
);
|
|
231
|
+
} catch {
|
|
232
|
+
return removed; // git worktree list failed → don't remove anything
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for (const dir of wtDirs) {
|
|
236
|
+
const fullPath = resolve(swarmDir, dir);
|
|
237
|
+
const normalized = normPath(fullPath);
|
|
238
|
+
if (!registeredPaths.has(normalized)) {
|
|
239
|
+
try {
|
|
240
|
+
await rm(fullPath, { recursive: true, force: true });
|
|
241
|
+
removed.push(dir);
|
|
242
|
+
} catch { /* best-effort */ }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Prune stale git references
|
|
247
|
+
if (removed.length > 0) {
|
|
248
|
+
try { await git(['worktree', 'prune'], rootDir); } catch { /* best-effort */ }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return removed;
|
|
252
|
+
}
|
|
253
|
+
|
|
195
254
|
/**
|
|
196
255
|
* Fetch a remote shard's branch to the local repo via SSH.
|
|
197
256
|
* Workaround for hosts that cannot push to GitHub (e.g. Ultra4).
|
package/hud/hud-qos-status.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
readJson, readStdinJson, getProviderAccountId, getCliArgValue,
|
|
17
17
|
} from "./utils.mjs";
|
|
18
18
|
import { selectTier } from "./terminal.mjs";
|
|
19
|
+
import { getMissionBoardState } from "./mission-board.mjs";
|
|
19
20
|
|
|
20
21
|
// Claude provider
|
|
21
22
|
import {
|
|
@@ -41,7 +42,7 @@ import {
|
|
|
41
42
|
// Renderers
|
|
42
43
|
import {
|
|
43
44
|
getClaudeRows, getProviderRow, getTeamRow,
|
|
44
|
-
renderAlignedRows, getMicroLine,
|
|
45
|
+
renderAlignedRows, getMicroLine, renderMissionBoard,
|
|
45
46
|
readLatestBenchmarkDiff, formatTokenSummary,
|
|
46
47
|
readTokenSavings, readSvAccumulator,
|
|
47
48
|
} from "./renderers.mjs";
|
|
@@ -103,11 +104,15 @@ async function main() {
|
|
|
103
104
|
// 실측 데이터 추출
|
|
104
105
|
const stdin = await stdinPromise;
|
|
105
106
|
const contextView = buildContextUsageView(stdin, contextSnapshot);
|
|
107
|
+
const claudeUsage = claudeUsageSnapshot.isStale
|
|
108
|
+
? { ...(claudeUsageSnapshot.data || {}), stale: true }
|
|
109
|
+
: claudeUsageSnapshot.data;
|
|
106
110
|
const codexEmail = getCodexEmail();
|
|
107
111
|
const geminiEmail = getGeminiEmail();
|
|
108
112
|
const codexBuckets = codexSnapshot.buckets;
|
|
109
113
|
const geminiSession = geminiSessionSnapshot.session;
|
|
110
114
|
const geminiQuota = geminiQuotaSnapshot.quota;
|
|
115
|
+
const missionBoardState = await getMissionBoardState();
|
|
111
116
|
|
|
112
117
|
// 누적 절약 데이터 읽기
|
|
113
118
|
const svSavings = readTokenSavings();
|
|
@@ -149,7 +154,7 @@ async function main() {
|
|
|
149
154
|
|
|
150
155
|
// nano tier: 1줄 모드 (극소 폭 또는 알림 배너 대응)
|
|
151
156
|
if (CURRENT_TIER === "nano") {
|
|
152
|
-
const microLine = getMicroLine(contextView,
|
|
157
|
+
const microLine = getMicroLine(contextView, claudeUsage, codexBuckets,
|
|
153
158
|
geminiSession, geminiBucket, combinedSvPct);
|
|
154
159
|
process.stdout.write(`\x1b[0m${microLine}\n`);
|
|
155
160
|
return;
|
|
@@ -164,7 +169,7 @@ async function main() {
|
|
|
164
169
|
};
|
|
165
170
|
|
|
166
171
|
const rows = [
|
|
167
|
-
...getClaudeRows(CURRENT_TIER, contextView,
|
|
172
|
+
...getClaudeRows(CURRENT_TIER, contextView, claudeUsage, combinedSvPct),
|
|
168
173
|
getProviderRow(CURRENT_TIER, "codex", "x", codexWhite, qosProfile, accountsConfig, accountsState,
|
|
169
174
|
codexQuotaData, codexEmail, codexSv, null),
|
|
170
175
|
getProviderRow(CURRENT_TIER, "gemini", "g", geminiBlue, qosProfile, accountsConfig, accountsState,
|
|
@@ -175,6 +180,15 @@ async function main() {
|
|
|
175
180
|
const teamRow = getTeamRow(CURRENT_TIER);
|
|
176
181
|
if (teamRow) rows.push(teamRow);
|
|
177
182
|
|
|
183
|
+
const missionBoard = renderMissionBoard(missionBoardState);
|
|
184
|
+
if (missionBoard) {
|
|
185
|
+
rows.push({
|
|
186
|
+
prefix: bold(claudeOrange("\u25B2")),
|
|
187
|
+
left: missionBoard,
|
|
188
|
+
right: "",
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
178
192
|
// 최근 벤치마크 diff → 토큰 요약 행 추가
|
|
179
193
|
const latestDiff = readLatestBenchmarkDiff();
|
|
180
194
|
if (latestDiff) {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// HUD Mission Board — .omc/state/sessions/ 기반 에이전트 진행률 집계
|
|
3
|
+
// ============================================================================
|
|
4
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
const SESSIONS_DIR = join(homedir(), ".omc", "state", "sessions");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* .omc/state/sessions/ 디렉토리를 읽어 팀 상태를 반환한다.
|
|
12
|
+
* @returns {{ agents: Array<{name: string, status: string, progress: number}>, dagLevel: number, totalProgress: number } | null}
|
|
13
|
+
*/
|
|
14
|
+
export async function getMissionBoardState(sessionsDir = SESSIONS_DIR) {
|
|
15
|
+
let entries;
|
|
16
|
+
try {
|
|
17
|
+
entries = await readdir(sessionsDir);
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const jsonFiles = entries.filter((f) => f.endsWith(".json"));
|
|
23
|
+
if (jsonFiles.length === 0) return null;
|
|
24
|
+
|
|
25
|
+
const agents = [];
|
|
26
|
+
for (const file of jsonFiles) {
|
|
27
|
+
let data;
|
|
28
|
+
try {
|
|
29
|
+
const raw = await readFile(join(sessionsDir, file), "utf8");
|
|
30
|
+
data = JSON.parse(raw);
|
|
31
|
+
} catch {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const name = data.name ?? file.replace(/\.json$/, "");
|
|
36
|
+
const status = data.status ?? "idle";
|
|
37
|
+
const progress = typeof data.progress === "number" ? data.progress : 0;
|
|
38
|
+
agents.push({ name, status, progress });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (agents.length === 0) return null;
|
|
42
|
+
|
|
43
|
+
const totalProgress = agents.length > 0
|
|
44
|
+
? Math.round(agents.reduce((sum, a) => sum + a.progress, 0) / agents.length)
|
|
45
|
+
: 0;
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
agents,
|
|
49
|
+
// TODO: derive dagLevel from real mission dependency metadata instead of hardcoding 0.
|
|
50
|
+
dagLevel: 0,
|
|
51
|
+
totalProgress,
|
|
52
|
+
};
|
|
53
|
+
}
|
package/hud/providers/claude.mjs
CHANGED
|
@@ -16,6 +16,16 @@ import {
|
|
|
16
16
|
import { readJson, writeJsonSafe, clampPercent, advanceToNextCycle } from "../utils.mjs";
|
|
17
17
|
import { readContextMonitorSnapshot } from "../context-monitor.mjs";
|
|
18
18
|
|
|
19
|
+
export const CLAUDE_USAGE_POLL_BASE_MS = 5_000;
|
|
20
|
+
export const CLAUDE_USAGE_POLL_JITTER_RATIO = 0.2;
|
|
21
|
+
export const CLAUDE_USAGE_RATE_LIMIT_BACKOFF_MS = [
|
|
22
|
+
CLAUDE_USAGE_POLL_BASE_MS,
|
|
23
|
+
10_000,
|
|
24
|
+
30_000,
|
|
25
|
+
60_000,
|
|
26
|
+
120_000,
|
|
27
|
+
];
|
|
28
|
+
|
|
19
29
|
// OMC 활성 여부에 따라 캐시 TTL 동적 결정
|
|
20
30
|
function getClaudeUsageStaleMs() {
|
|
21
31
|
return existsSync(OMC_PLUGIN_USAGE_CACHE_PATH)
|
|
@@ -23,6 +33,49 @@ function getClaudeUsageStaleMs() {
|
|
|
23
33
|
: CLAUDE_USAGE_STALE_MS_SOLO;
|
|
24
34
|
}
|
|
25
35
|
|
|
36
|
+
export function computeClaudeUsagePollState({
|
|
37
|
+
consecutive429s = 0,
|
|
38
|
+
outcome = "success",
|
|
39
|
+
random = Math.random,
|
|
40
|
+
jitterRatio = CLAUDE_USAGE_POLL_JITTER_RATIO,
|
|
41
|
+
} = {}) {
|
|
42
|
+
const current429s = Number.isFinite(consecutive429s) ? Math.max(0, consecutive429s) : 0;
|
|
43
|
+
const next429s = outcome === "rate_limit" ? current429s + 1 : 0;
|
|
44
|
+
const stepIndex = outcome === "rate_limit"
|
|
45
|
+
? Math.min(next429s, CLAUDE_USAGE_RATE_LIMIT_BACKOFF_MS.length - 1)
|
|
46
|
+
: 0;
|
|
47
|
+
const baseDelayMs = CLAUDE_USAGE_RATE_LIMIT_BACKOFF_MS[stepIndex];
|
|
48
|
+
const sample = Number(random?.());
|
|
49
|
+
const normalized = Number.isFinite(sample) ? Math.min(1, Math.max(0, sample)) : 0.5;
|
|
50
|
+
const jitterFactor = 1 + ((normalized * 2) - 1) * jitterRatio;
|
|
51
|
+
return {
|
|
52
|
+
consecutive429s: next429s,
|
|
53
|
+
baseDelayMs,
|
|
54
|
+
delayMs: Math.max(1, Math.round(baseDelayMs * jitterFactor)),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getSnapshotSchedule(cache) {
|
|
59
|
+
const timestamp = Number(cache?.timestamp);
|
|
60
|
+
const nextRefreshAt = Number(cache?.nextRefreshAt);
|
|
61
|
+
if (Number.isFinite(nextRefreshAt)) {
|
|
62
|
+
return {
|
|
63
|
+
nextRefreshAt,
|
|
64
|
+
shouldRefresh: Date.now() >= nextRefreshAt,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ageMs = Number.isFinite(timestamp) ? Date.now() - timestamp : Number.MAX_SAFE_INTEGER;
|
|
69
|
+
const fallbackMs = cache?.error
|
|
70
|
+
? (cache.errorType === "rate_limit" ? CLAUDE_USAGE_429_BACKOFF_MS : CLAUDE_USAGE_ERROR_BACKOFF_MS)
|
|
71
|
+
: getClaudeUsageStaleMs();
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
nextRefreshAt: Number.isFinite(timestamp) ? timestamp + fallbackMs : null,
|
|
75
|
+
shouldRefresh: ageMs >= fallbackMs,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
26
79
|
export function readClaudeCredentials() {
|
|
27
80
|
const data = readJson(CLAUDE_CREDENTIALS_PATH, null);
|
|
28
81
|
if (!data) return null;
|
|
@@ -154,18 +207,21 @@ export function stripStaleResets(data) {
|
|
|
154
207
|
export function readClaudeUsageSnapshot() {
|
|
155
208
|
const cache = readJson(CLAUDE_USAGE_CACHE_PATH, null);
|
|
156
209
|
const ts = Number(cache?.timestamp);
|
|
157
|
-
const
|
|
210
|
+
const schedule = getSnapshotSchedule(cache);
|
|
211
|
+
const staleBackoffActive = cache?.errorType === "rate_limit"
|
|
212
|
+
&& Number.isFinite(schedule.nextRefreshAt)
|
|
213
|
+
&& Date.now() < schedule.nextRefreshAt;
|
|
158
214
|
|
|
159
215
|
// 1차: 자체 캐시에 유효 데이터가 있는 경우
|
|
160
216
|
if (cache?.data) {
|
|
161
217
|
// 에러 상태에서 보존된 stale 데이터 → backoff 존중하되 표시용 데이터 반환
|
|
162
218
|
if (cache.error) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
:
|
|
166
|
-
|
|
219
|
+
return {
|
|
220
|
+
data: stripStaleResets(cache.data),
|
|
221
|
+
shouldRefresh: schedule.shouldRefresh,
|
|
222
|
+
isStale: staleBackoffActive,
|
|
223
|
+
};
|
|
167
224
|
}
|
|
168
|
-
const isFresh = ageMs < getClaudeUsageStaleMs();
|
|
169
225
|
// resets_at이 지난 윈도우의 percent를 0으로 보정 (stale 캐시 방지)
|
|
170
226
|
const data = { ...cache.data };
|
|
171
227
|
const now = Date.now();
|
|
@@ -175,24 +231,21 @@ export function readClaudeUsageSnapshot() {
|
|
|
175
231
|
if (data.weeklyResetsAt && new Date(data.weeklyResetsAt).getTime() <= now) {
|
|
176
232
|
data.weeklyPercent = 0;
|
|
177
233
|
}
|
|
178
|
-
return { data, shouldRefresh:
|
|
234
|
+
return { data, shouldRefresh: schedule.shouldRefresh, isStale: false };
|
|
179
235
|
}
|
|
180
236
|
|
|
181
237
|
// 2차: 에러 backoff — 최근 에러 시 재시도 억제 (무한 spawn 방지)
|
|
182
238
|
if (cache?.error && Number.isFinite(ts)) {
|
|
183
|
-
|
|
184
|
-
? CLAUDE_USAGE_429_BACKOFF_MS
|
|
185
|
-
: CLAUDE_USAGE_ERROR_BACKOFF_MS;
|
|
186
|
-
if (ageMs < backoffMs) {
|
|
239
|
+
if (!schedule.shouldRefresh) {
|
|
187
240
|
const omcCache = readJson(OMC_PLUGIN_USAGE_CACHE_PATH, null);
|
|
188
241
|
// OMC 캐시가 에러 이후 갱신되었으면 → 에러 캐시 덮어쓰고 그 데이터 사용
|
|
189
242
|
if (omcCache?.data?.fiveHourPercent != null && omcCache.timestamp > ts) {
|
|
190
243
|
writeClaudeUsageCache(omcCache.data);
|
|
191
|
-
return { data: omcCache.data, shouldRefresh: false };
|
|
244
|
+
return { data: omcCache.data, shouldRefresh: false, isStale: false };
|
|
192
245
|
}
|
|
193
246
|
// stale OMC fallback 또는 null (--% 플레이스홀더 표시, 가짜 0% 방지)
|
|
194
247
|
const staleData = omcCache?.data?.fiveHourPercent != null ? stripStaleResets(omcCache.data) : null;
|
|
195
|
-
return { data: staleData, shouldRefresh: false };
|
|
248
|
+
return { data: staleData, shouldRefresh: false, isStale: staleBackoffActive };
|
|
196
249
|
}
|
|
197
250
|
}
|
|
198
251
|
|
|
@@ -203,23 +256,37 @@ export function readClaudeUsageSnapshot() {
|
|
|
203
256
|
const omcAge = Number.isFinite(omcCache.timestamp) ? Date.now() - omcCache.timestamp : Number.MAX_SAFE_INTEGER;
|
|
204
257
|
if (omcAge < OMC_CACHE_MAX_AGE_MS) {
|
|
205
258
|
writeClaudeUsageCache(omcCache.data);
|
|
206
|
-
return { data: omcCache.data, shouldRefresh: omcAge > getClaudeUsageStaleMs() };
|
|
259
|
+
return { data: omcCache.data, shouldRefresh: omcAge > getClaudeUsageStaleMs(), isStale: false };
|
|
207
260
|
}
|
|
208
261
|
// stale이어도 data: null보다는 오래된 데이터를 fallback으로 표시
|
|
209
|
-
return { data: stripStaleResets(omcCache.data), shouldRefresh: true };
|
|
262
|
+
return { data: stripStaleResets(omcCache.data), shouldRefresh: true, isStale: false };
|
|
210
263
|
}
|
|
211
264
|
|
|
212
265
|
// 캐시/fallback 모두 없음: null 반환 → --% 플레이스홀더 + 리프레시 시도
|
|
213
|
-
return { data: null, shouldRefresh: true };
|
|
266
|
+
return { data: null, shouldRefresh: true, isStale: false };
|
|
214
267
|
}
|
|
215
268
|
|
|
216
|
-
export function writeClaudeUsageCache(data, errorInfo = null) {
|
|
269
|
+
export function writeClaudeUsageCache(data, errorInfo = null, pollState = null) {
|
|
270
|
+
const state = pollState || (errorInfo
|
|
271
|
+
? {
|
|
272
|
+
consecutive429s: errorInfo.type === "rate_limit" ? 1 : 0,
|
|
273
|
+
baseDelayMs: errorInfo.type === "rate_limit"
|
|
274
|
+
? CLAUDE_USAGE_429_BACKOFF_MS
|
|
275
|
+
: CLAUDE_USAGE_ERROR_BACKOFF_MS,
|
|
276
|
+
delayMs: errorInfo.type === "rate_limit"
|
|
277
|
+
? CLAUDE_USAGE_429_BACKOFF_MS
|
|
278
|
+
: CLAUDE_USAGE_ERROR_BACKOFF_MS,
|
|
279
|
+
}
|
|
280
|
+
: computeClaudeUsagePollState({ outcome: "success" }));
|
|
217
281
|
const entry = {
|
|
218
282
|
timestamp: Date.now(),
|
|
219
283
|
data,
|
|
220
284
|
error: !!errorInfo,
|
|
221
285
|
errorType: errorInfo?.type || null, // "rate_limit" | "auth" | "network" | "unknown"
|
|
222
286
|
errorStatus: errorInfo?.status || null, // HTTP 상태 코드
|
|
287
|
+
consecutive429s: state.consecutive429s,
|
|
288
|
+
nextRefreshBaseMs: state.baseDelayMs,
|
|
289
|
+
nextRefreshAt: Date.now() + state.delayMs,
|
|
223
290
|
};
|
|
224
291
|
// 에러 시 기존 유효 데이터 보존 (--% n/a 방지)
|
|
225
292
|
if (errorInfo && data == null) {
|
|
@@ -234,9 +301,11 @@ export function writeClaudeUsageCache(data, errorInfo = null) {
|
|
|
234
301
|
|
|
235
302
|
export async function fetchClaudeUsage(forceRefresh = false) {
|
|
236
303
|
const existingSnapshot = readClaudeUsageSnapshot();
|
|
237
|
-
if (!forceRefresh && !existingSnapshot.shouldRefresh
|
|
238
|
-
return existingSnapshot.data;
|
|
304
|
+
if (!forceRefresh && !existingSnapshot.shouldRefresh) {
|
|
305
|
+
return existingSnapshot.data || null;
|
|
239
306
|
}
|
|
307
|
+
const cache = readJson(CLAUDE_USAGE_CACHE_PATH, null);
|
|
308
|
+
const consecutive429s = Number.isFinite(cache?.consecutive429s) ? cache.consecutive429s : 0;
|
|
240
309
|
let creds = readClaudeCredentials();
|
|
241
310
|
if (!creds) {
|
|
242
311
|
writeClaudeUsageCache(null, { type: "auth", status: 0 });
|
|
@@ -262,11 +331,15 @@ export async function fetchClaudeUsage(forceRefresh = false) {
|
|
|
262
331
|
: result.status === 401 || result.status === 403 ? "auth"
|
|
263
332
|
: result.error === "timeout" || result.error === "network" ? "network"
|
|
264
333
|
: "unknown";
|
|
265
|
-
|
|
334
|
+
const pollState = errorType === "rate_limit"
|
|
335
|
+
? computeClaudeUsagePollState({ consecutive429s, outcome: "rate_limit" })
|
|
336
|
+
: null;
|
|
337
|
+
writeClaudeUsageCache(existingSnapshot.data, { type: errorType, status: result.status }, pollState);
|
|
266
338
|
return existingSnapshot.data || null;
|
|
267
339
|
}
|
|
268
340
|
const usage = parseClaudeUsageResponse(result.data);
|
|
269
|
-
|
|
341
|
+
const pollState = usage ? computeClaudeUsagePollState({ outcome: "success" }) : null;
|
|
342
|
+
writeClaudeUsageCache(usage, usage ? null : { type: "unknown", status: 0 }, pollState);
|
|
270
343
|
return usage;
|
|
271
344
|
}
|
|
272
345
|
|
|
@@ -291,7 +364,7 @@ export function scheduleClaudeUsageRefresh() {
|
|
|
291
364
|
try {
|
|
292
365
|
if (existsSync(lockPath)) {
|
|
293
366
|
const lockAge = Date.now() - readJson(lockPath, {}).t;
|
|
294
|
-
if (lockAge <
|
|
367
|
+
if (lockAge < 1000) return; // 짧은 중복 스폰만 억제, 실제 폴링 주기는 nextRefreshAt가 제어
|
|
295
368
|
}
|
|
296
369
|
writeJsonSafe(lockPath, { t: Date.now() });
|
|
297
370
|
} catch { /* 락 실패 무시 — 스폰 진행 */ }
|
package/hud/renderers.mjs
CHANGED
|
@@ -122,6 +122,38 @@ export function getTeamRow(currentTier) {
|
|
|
122
122
|
};
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
// ============================================================================
|
|
126
|
+
// Mission Board 렌더러
|
|
127
|
+
// ============================================================================
|
|
128
|
+
|
|
129
|
+
const STATUS_ICON = {
|
|
130
|
+
active: "*",
|
|
131
|
+
idle: ".",
|
|
132
|
+
done: "+",
|
|
133
|
+
failed: "!",
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Mission Board 상태를 1줄 compact 포맷으로 렌더링한다.
|
|
138
|
+
* 예: "MB: exec:+ ui:* perf:. [2/3 67%]"
|
|
139
|
+
* @param {{ agents: Array<{name: string, status: string, progress: number}>, dagLevel: number, totalProgress: number } | null} state
|
|
140
|
+
* @returns {string}
|
|
141
|
+
*/
|
|
142
|
+
export function renderMissionBoard(state) {
|
|
143
|
+
if (!state) return "";
|
|
144
|
+
|
|
145
|
+
const { agents, totalProgress } = state;
|
|
146
|
+
const done = agents.filter((a) => a.status === "done").length;
|
|
147
|
+
const total = agents.length;
|
|
148
|
+
|
|
149
|
+
const parts = agents.map((a) => {
|
|
150
|
+
const icon = STATUS_ICON[a.status] ?? "?";
|
|
151
|
+
return `${a.name}:${icon}`;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return `MB: ${parts.join(" ")} [${done}/${total} ${totalProgress}%]`;
|
|
155
|
+
}
|
|
156
|
+
|
|
125
157
|
// ============================================================================
|
|
126
158
|
// 행 정렬 렌더링
|
|
127
159
|
// ============================================================================
|
|
@@ -159,6 +191,7 @@ export function renderAlignedRows(rows) {
|
|
|
159
191
|
// ============================================================================
|
|
160
192
|
export function getMicroLine(contextView, claudeUsage, codexBuckets, geminiSession, geminiBucket, combinedSvPct) {
|
|
161
193
|
const ctxView = contextView || buildContextUsageView({}, null);
|
|
194
|
+
const staleMarker = claudeUsage?.stale ? ` ${dim("[stale]")}` : "";
|
|
162
195
|
|
|
163
196
|
// Claude 5h/1w
|
|
164
197
|
const cF = claudeUsage?.fiveHourPercent != null ? clampPercent(claudeUsage.fiveHourPercent) : null;
|
|
@@ -198,7 +231,7 @@ export function getMicroLine(contextView, claudeUsage, codexBuckets, geminiSessi
|
|
|
198
231
|
`${bold(codexWhite("x"))}${dim(":")}${xVal} ` +
|
|
199
232
|
`${bold(geminiBlue("g"))}${dim(":")}${gVal} ` +
|
|
200
233
|
`${dim("sv:")}${sv} ` +
|
|
201
|
-
`${dim("CTX:")}${colorByPercent(ctxView.percent, ctxView.display)}`;
|
|
234
|
+
`${dim("CTX:")}${colorByPercent(ctxView.percent, ctxView.display)}${staleMarker}`;
|
|
202
235
|
return truncateAnsi(line, cols);
|
|
203
236
|
}
|
|
204
237
|
|
|
@@ -208,6 +241,7 @@ export function getMicroLine(contextView, claudeUsage, codexBuckets, geminiSessi
|
|
|
208
241
|
export function getClaudeRows(currentTier, contextView, claudeUsage, combinedSvPct) {
|
|
209
242
|
const ctxView = contextView || buildContextUsageView({}, null);
|
|
210
243
|
const prefix = `${bold(claudeOrange("c"))}:`;
|
|
244
|
+
const staleMarker = claudeUsage?.stale ? ` ${dim("[stale]")}` : "";
|
|
211
245
|
|
|
212
246
|
// 절약 퍼센트
|
|
213
247
|
const svStr = formatSvPct(combinedSvPct || 0);
|
|
@@ -235,25 +269,25 @@ export function getClaudeRows(currentTier, contextView, claudeUsage, combinedSvP
|
|
|
235
269
|
if (currentTier === "nano" || currentTier === "micro") {
|
|
236
270
|
const fShort = hasData && fiveHourPercent != null ? colorByProvider(fiveHourPercent, `${fiveHourPercent}%`, claudeOrange) : dim("--");
|
|
237
271
|
const wShort = hasData && weeklyPercent != null ? colorByProvider(weeklyPercent, `${weeklyPercent}%`, claudeOrange) : dim("--");
|
|
238
|
-
const quotaSection = `${fShort}${dim("/")}${wShort}`;
|
|
272
|
+
const quotaSection = `${fShort}${dim("/")}${wShort}${staleMarker}`;
|
|
239
273
|
return [{ prefix, left: quotaSection, right: "" }];
|
|
240
274
|
}
|
|
241
275
|
|
|
242
276
|
if (currentTier === "minimal") {
|
|
243
|
-
const quotaSection = `${dim("5h:")}${fStr} ${dim("1w:")}${wStr}`;
|
|
277
|
+
const quotaSection = `${dim("5h:")}${fStr} ${dim("1w:")}${wStr}${staleMarker}`;
|
|
244
278
|
const right = `${dim("CTX:")}${colorByPercent(ctxView.percent, ctxView.display)}`;
|
|
245
279
|
return [{ prefix, left: quotaSection, right }];
|
|
246
280
|
}
|
|
247
281
|
|
|
248
282
|
if (currentTier === "compact") {
|
|
249
|
-
const quotaSection = `${dim("5h:")}${fStr} ${dim(fTime)} ${dim("1w:")}${wStr} ${dim(wTime)}`;
|
|
283
|
+
const quotaSection = `${dim("5h:")}${fStr} ${dim(fTime)} ${dim("1w:")}${wStr} ${dim(wTime)}${staleMarker}`;
|
|
250
284
|
const warning = ctxView.warningTag ? ` ${dim("|")} ${yellow(ctxView.warningTag)}` : "";
|
|
251
285
|
const contextSection = `${svSuffix} ${dim("|")} ${dim("CTX:")}${colorByPercent(ctxView.percent, ctxView.display)}${warning}`;
|
|
252
286
|
return [{ prefix, left: quotaSection, right: contextSection }];
|
|
253
287
|
}
|
|
254
288
|
|
|
255
289
|
// full tier (>= 120 cols)
|
|
256
|
-
const quotaSection = `${dim("5h:")}${fBar}${fStr} ${dim(fTime)} ${dim("1w:")}${wBar}${wStr} ${dim(wTime)}`;
|
|
290
|
+
const quotaSection = `${dim("5h:")}${fBar}${fStr} ${dim(fTime)} ${dim("1w:")}${wBar}${wStr} ${dim(wTime)}${staleMarker}`;
|
|
257
291
|
const warning = ctxView.warningTag ? ` ${dim("|")} ${yellow(ctxView.warningTag)}` : "";
|
|
258
292
|
const contextSection = `${svSuffix} ${dim("|")} ${dim("CTX:")}${colorByPercent(ctxView.percent, ctxView.display)}${warning}`;
|
|
259
293
|
return [{ prefix, left: quotaSection, right: contextSection }];
|
package/package.json
CHANGED
package/scripts/demo.mjs
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import childProcess from "node:child_process";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
|
|
6
|
+
const { values: flags } = parseArgs({
|
|
7
|
+
options: {
|
|
8
|
+
"dry-run": { type: "boolean", default: false },
|
|
9
|
+
keep: { type: "boolean", default: false },
|
|
10
|
+
},
|
|
11
|
+
strict: false,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const SESSION_NAME = "triflux-demo";
|
|
15
|
+
|
|
16
|
+
const WORKERS = [
|
|
17
|
+
{
|
|
18
|
+
pane: 0,
|
|
19
|
+
agent: "codex",
|
|
20
|
+
messages: [
|
|
21
|
+
"[codex] Analyzing auth module...",
|
|
22
|
+
"[codex] Refactoring JWT validation...",
|
|
23
|
+
"[codex] Done ✓",
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
pane: 1,
|
|
28
|
+
agent: "gemini",
|
|
29
|
+
messages: [
|
|
30
|
+
"[gemini] Reviewing UI components...",
|
|
31
|
+
"[gemini] Optimizing render cycle...",
|
|
32
|
+
"[gemini] Done ✓",
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
pane: 2,
|
|
37
|
+
agent: "claude",
|
|
38
|
+
messages: [
|
|
39
|
+
"[claude] Security audit in progress...",
|
|
40
|
+
"[claude] Found 0 vulnerabilities",
|
|
41
|
+
"[claude] Done ✓",
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export function checkPsmux(opts = {}) {
|
|
47
|
+
if (opts.dryRun) return false;
|
|
48
|
+
try {
|
|
49
|
+
childProcess.execFileSync("psmux", ["-V"], { encoding: "utf8", stdio: "pipe" });
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createDemoSession(sessionName, opts = {}) {
|
|
57
|
+
if (opts.dryRun) {
|
|
58
|
+
console.log(`[dry-run] psmux new-session -d -s ${sessionName}`);
|
|
59
|
+
console.log(`[dry-run] psmux split-window -h -t ${sessionName}`);
|
|
60
|
+
console.log(`[dry-run] psmux split-window -h -t ${sessionName}`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
childProcess.execFileSync("psmux", ["new-session", "-d", "-s", sessionName], { stdio: "pipe" });
|
|
64
|
+
childProcess.execFileSync("psmux", ["split-window", "-h", "-t", sessionName], { stdio: "pipe" });
|
|
65
|
+
childProcess.execFileSync("psmux", ["split-window", "-h", "-t", sessionName], { stdio: "pipe" });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function simulateWorker(pane, agentName, messages, opts = {}) {
|
|
69
|
+
const sessionName = opts.sessionName || SESSION_NAME;
|
|
70
|
+
for (const msg of messages) {
|
|
71
|
+
const escapedMsg = msg.replace(/'/g, "'\\''");
|
|
72
|
+
if (opts.dryRun) {
|
|
73
|
+
console.log(`[dry-run] psmux send-keys -t ${sessionName}:0.${pane} "echo '${escapedMsg}'" Enter`);
|
|
74
|
+
} else {
|
|
75
|
+
childProcess.execFileSync(
|
|
76
|
+
"psmux",
|
|
77
|
+
["send-keys", "-t", `${sessionName}:0.${pane}`, `echo '${escapedMsg}'`, "Enter"],
|
|
78
|
+
{ stdio: "pipe" },
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function showSummary() {
|
|
85
|
+
const lines = [
|
|
86
|
+
"",
|
|
87
|
+
"=== triflux demo summary ===",
|
|
88
|
+
" codex → JWT auth refactor [done]",
|
|
89
|
+
" gemini → UI render optimize [done]",
|
|
90
|
+
" claude → Security audit [done]",
|
|
91
|
+
"============================",
|
|
92
|
+
"",
|
|
93
|
+
];
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
console.log(line);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function cleanup(sessionName, opts = {}) {
|
|
100
|
+
if (opts.dryRun) {
|
|
101
|
+
console.log(`[dry-run] psmux kill-session -t ${sessionName}`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
childProcess.execFileSync("psmux", ["kill-session", "-t", sessionName], { stdio: "pipe" });
|
|
106
|
+
} catch {
|
|
107
|
+
// session may already be gone
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function wait(ms) {
|
|
112
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function main() {
|
|
116
|
+
const { values: flags } = parseArgs({
|
|
117
|
+
options: {
|
|
118
|
+
"dry-run": { type: "boolean", default: false },
|
|
119
|
+
keep: { type: "boolean", default: false },
|
|
120
|
+
},
|
|
121
|
+
strict: false,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const psmuxAvailable = checkPsmux({ dryRun: flags["dry-run"] });
|
|
125
|
+
const dryRun = flags["dry-run"] || !psmuxAvailable;
|
|
126
|
+
|
|
127
|
+
if (!psmuxAvailable && !flags["dry-run"]) {
|
|
128
|
+
console.log("[demo] psmux not found — switching to dry-run mode");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const opts = {
|
|
132
|
+
dryRun,
|
|
133
|
+
keep: flags.keep,
|
|
134
|
+
sessionName: SESSION_NAME,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
createDemoSession(SESSION_NAME, opts);
|
|
138
|
+
|
|
139
|
+
for (const { pane, agent, messages } of WORKERS) {
|
|
140
|
+
simulateWorker(pane, agent, messages, opts);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!opts.dryRun) {
|
|
144
|
+
await wait(2000);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
showSummary();
|
|
148
|
+
|
|
149
|
+
if (!opts.keep) {
|
|
150
|
+
cleanup(SESSION_NAME, opts);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Only run main when executed directly (not imported as a module)
|
|
155
|
+
// Normalize both paths to forward-slash for cross-platform comparison
|
|
156
|
+
function isDirectExec() {
|
|
157
|
+
if (!process.argv[1]) return false;
|
|
158
|
+
const scriptPath = new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1");
|
|
159
|
+
const argv1 = process.argv[1].replace(/\\/g, "/");
|
|
160
|
+
const norm = scriptPath.replace(/\\/g, "/");
|
|
161
|
+
return argv1 === norm || argv1.endsWith(norm);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (isDirectExec()) {
|
|
165
|
+
main().catch((err) => {
|
|
166
|
+
console.error("demo error:", err.message);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -202,8 +202,12 @@ async function main() {
|
|
|
202
202
|
// codex/gemini 직접 CLI 호출 → deny (인라인 TFX_ALLOW_DIRECT_CLI=1 우회 허용)
|
|
203
203
|
// 복합 명령(&&, ||, ;, |) 분리 후 각 세그먼트의 커맨드 위치만 검사 (args/quotes 안의 codex는 무시)
|
|
204
204
|
// NOTE: || 는 | 보다 먼저 매칭되므로 logical OR이 단일 pipe로 잘못 분리되지 않음
|
|
205
|
+
// #37 Bug4: gh/git 명령은 본문에 codex/gemini 문자열이 있어도 차단하지 않음
|
|
206
|
+
const SAFE_CMD_RE = /^\s*(?:[\w_]+=\S+\s+)*\s*(gh|git)\b/;
|
|
205
207
|
const cmdParts = cmd.split(/\s*(?:&&|\|\||\||;)\s*/);
|
|
206
208
|
let hasDirectCli = cmdParts.some(part => {
|
|
209
|
+
// gh/git 세그먼트는 건너뜀 (이슈 본문/커밋 메시지 내 codex/gemini 언급은 정상)
|
|
210
|
+
if (SAFE_CMD_RE.test(part)) return false;
|
|
207
211
|
// 1단계: env var prefix 제거 (FOO=bar ...)
|
|
208
212
|
// 2단계: wrapper prefix 제거 (env, command, nohup, timeout N, 절대경로, bash -c/-lc "...")
|
|
209
213
|
const stripped = part
|
|
@@ -216,11 +220,19 @@ async function main() {
|
|
|
216
220
|
});
|
|
217
221
|
// 2차 휴리스틱: 1차 세그먼트 검사를 통과한 간접 실행 패턴 탐지
|
|
218
222
|
// full AST 파서 대신 현실적 위협 벡터만 커버 — eval, subshell, variable 확장
|
|
223
|
+
// 2차 휴리스틱: 간접 실행 패턴 탐지 (eval, subshell, variable 확장)
|
|
219
224
|
if (!hasDirectCli) {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
225
|
+
const isAllSafeCmd = cmdParts.every(p => SAFE_CMD_RE.test(p));
|
|
226
|
+
if (isAllSafeCmd) {
|
|
227
|
+
// gh/git 전용: $(codex exec ...) 직접 명령 치환만 차단
|
|
228
|
+
// $(cat <<'EOF'\n...codex exec text...\nEOF) 같은 heredoc 텍스트는 허용
|
|
229
|
+
hasDirectCli = /\$\(\s*(codex\s+exec|gemini\s+(-p|--prompt))\b/i.test(cmd);
|
|
230
|
+
} else {
|
|
231
|
+
hasDirectCli = (
|
|
232
|
+
/\beval\b.*\b(codex\s+exec|gemini\s+(-p|--prompt))\b/i.test(cmd) ||
|
|
233
|
+
/\$[({].*\b(codex\s+exec|gemini\s+(-p|--prompt))\b/i.test(cmd)
|
|
234
|
+
);
|
|
235
|
+
}
|
|
224
236
|
}
|
|
225
237
|
|
|
226
238
|
if (hasDirectCli) {
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import {
|
|
2
|
+
access,
|
|
3
|
+
mkdir,
|
|
4
|
+
open,
|
|
5
|
+
readdir,
|
|
6
|
+
readFile,
|
|
7
|
+
rename,
|
|
8
|
+
rm,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import { basename, join } from "node:path";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_STATE_DIR = join(process.cwd(), ".tfx", "state");
|
|
14
|
+
const STOP_HOOKS = new Map();
|
|
15
|
+
|
|
16
|
+
function stateFilePath(stateDir, skillName) {
|
|
17
|
+
return join(stateDir, `${skillName}-active.json`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function assertValidSkillName(skillName) {
|
|
21
|
+
if (basename(skillName) !== skillName) {
|
|
22
|
+
throw new Error(`Invalid skill name: ${skillName}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function assertValidOnStop(onStop) {
|
|
27
|
+
if (onStop !== undefined && typeof onStop !== "function") {
|
|
28
|
+
throw new TypeError("onStop must be a function");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function readStateFile(filePath) {
|
|
33
|
+
try {
|
|
34
|
+
const raw = await readFile(filePath, "utf8");
|
|
35
|
+
return JSON.parse(raw);
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function rememberStopHook(filePath, onStop) {
|
|
42
|
+
if (typeof onStop === "function") {
|
|
43
|
+
STOP_HOOKS.set(filePath, onStop);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
STOP_HOOKS.delete(filePath);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function logStopHookWarning(message, error) {
|
|
50
|
+
if (error) {
|
|
51
|
+
console.warn(message, error);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
console.warn(message);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function runStopHook(filePath, state, onStop) {
|
|
58
|
+
if (!state?.hasStopHook) {
|
|
59
|
+
STOP_HOOKS.delete(filePath);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const hook = onStop ?? STOP_HOOKS.get(filePath);
|
|
64
|
+
STOP_HOOKS.delete(filePath);
|
|
65
|
+
|
|
66
|
+
if (typeof hook !== "function") {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
await hook({
|
|
72
|
+
skillName: state.skillName,
|
|
73
|
+
filePath,
|
|
74
|
+
state,
|
|
75
|
+
});
|
|
76
|
+
} catch (error) {
|
|
77
|
+
logStopHookWarning(
|
|
78
|
+
`Failed to run stop-hook for skill: ${state.skillName}`,
|
|
79
|
+
error,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Activate a skill by writing its state file.
|
|
86
|
+
* Throws if the skill is already active.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} skillName
|
|
89
|
+
* @param {{ stateDir?: string, onStop?: (() => Promise<void> | void) }} options
|
|
90
|
+
*/
|
|
91
|
+
export async function activateSkill(
|
|
92
|
+
skillName,
|
|
93
|
+
{ stateDir = DEFAULT_STATE_DIR, onStop } = {},
|
|
94
|
+
) {
|
|
95
|
+
assertValidSkillName(skillName);
|
|
96
|
+
assertValidOnStop(onStop);
|
|
97
|
+
|
|
98
|
+
await mkdir(stateDir, { recursive: true });
|
|
99
|
+
|
|
100
|
+
const filePath = stateFilePath(stateDir, skillName);
|
|
101
|
+
const lockPath = `${filePath}.lock`;
|
|
102
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
103
|
+
let lockHandle;
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
lockHandle = await open(lockPath, "wx");
|
|
107
|
+
try {
|
|
108
|
+
await access(filePath);
|
|
109
|
+
throw new Error(`Skill already active: ${skillName}`);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error?.message === `Skill already active: ${skillName}`) {
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
if (error?.code !== "ENOENT") {
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const state = {
|
|
120
|
+
skillName,
|
|
121
|
+
pid: process.pid,
|
|
122
|
+
activatedAt: Date.now(),
|
|
123
|
+
hasStopHook: typeof onStop === "function",
|
|
124
|
+
};
|
|
125
|
+
await writeFile(tmpPath, JSON.stringify(state), "utf8");
|
|
126
|
+
await rename(tmpPath, filePath);
|
|
127
|
+
rememberStopHook(filePath, onStop);
|
|
128
|
+
} finally {
|
|
129
|
+
await rm(tmpPath, { force: true }).catch(() => {});
|
|
130
|
+
if (lockHandle) {
|
|
131
|
+
await lockHandle.close().catch(() => {});
|
|
132
|
+
}
|
|
133
|
+
await rm(lockPath, { force: true }).catch(() => {});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Deactivate a skill by removing its state file.
|
|
139
|
+
* Does not throw if the file does not exist.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} skillName
|
|
142
|
+
* @param {{ stateDir?: string, onStop?: (() => Promise<void> | void) }} options
|
|
143
|
+
*/
|
|
144
|
+
export async function deactivateSkill(
|
|
145
|
+
skillName,
|
|
146
|
+
{ stateDir = DEFAULT_STATE_DIR, onStop } = {},
|
|
147
|
+
) {
|
|
148
|
+
assertValidOnStop(onStop);
|
|
149
|
+
|
|
150
|
+
const filePath = stateFilePath(stateDir, skillName);
|
|
151
|
+
const state = await readStateFile(filePath);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
await runStopHook(filePath, state, onStop);
|
|
155
|
+
} finally {
|
|
156
|
+
STOP_HOOKS.delete(filePath);
|
|
157
|
+
await rm(filePath, { force: true }).catch(() => {});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Return all currently active skills by scanning *-active.json files.
|
|
163
|
+
*
|
|
164
|
+
* @param {{ stateDir?: string }} options
|
|
165
|
+
* @returns {Promise<Array<{ skillName: string, pid: number, activatedAt: number, hasStopHook?: boolean }>>}
|
|
166
|
+
*/
|
|
167
|
+
export async function getActiveSkills({ stateDir = DEFAULT_STATE_DIR } = {}) {
|
|
168
|
+
let entries;
|
|
169
|
+
try {
|
|
170
|
+
entries = await readdir(stateDir);
|
|
171
|
+
} catch {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const results = [];
|
|
176
|
+
for (const entry of entries) {
|
|
177
|
+
if (!entry.endsWith("-active.json")) continue;
|
|
178
|
+
const state = await readStateFile(join(stateDir, entry));
|
|
179
|
+
if (state) {
|
|
180
|
+
results.push(state);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return results;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Remove state files for skills whose processes are no longer alive.
|
|
188
|
+
*
|
|
189
|
+
* @param {{ stateDir?: string }} options
|
|
190
|
+
* @returns {Promise<string[]>} list of pruned skill names
|
|
191
|
+
*/
|
|
192
|
+
export async function pruneOrphanSkillStates({
|
|
193
|
+
stateDir = DEFAULT_STATE_DIR,
|
|
194
|
+
} = {}) {
|
|
195
|
+
const active = await getActiveSkills({ stateDir });
|
|
196
|
+
const pruned = [];
|
|
197
|
+
|
|
198
|
+
for (const state of active) {
|
|
199
|
+
let alive = true;
|
|
200
|
+
try {
|
|
201
|
+
process.kill(state.pid, 0);
|
|
202
|
+
} catch {
|
|
203
|
+
alive = false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!alive) {
|
|
207
|
+
const filePath = stateFilePath(stateDir, state.skillName);
|
|
208
|
+
STOP_HOOKS.delete(filePath);
|
|
209
|
+
if (state.hasStopHook) {
|
|
210
|
+
logStopHookWarning(
|
|
211
|
+
`Skipping stop-hook for orphaned skill state: ${state.skillName}`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
await rm(filePath, { force: true }).catch(() => {});
|
|
215
|
+
pruned.push(state.skillName);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return pruned;
|
|
220
|
+
}
|