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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.3.2",
3
+ "version": "10.3.4",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "author": {
6
6
  "name": "tellang"
@@ -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).
@@ -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, claudeUsageSnapshot.data, codexBuckets,
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, claudeUsageSnapshot.data, combinedSvPct),
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
+ }
@@ -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 ageMs = Number.isFinite(ts) ? Date.now() - ts : Number.MAX_SAFE_INTEGER;
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
- const backoffMs = cache.errorType === "rate_limit"
164
- ? CLAUDE_USAGE_429_BACKOFF_MS
165
- : CLAUDE_USAGE_ERROR_BACKOFF_MS;
166
- return { data: stripStaleResets(cache.data), shouldRefresh: ageMs >= backoffMs };
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: !isFresh };
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
- const backoffMs = cache.errorType === "rate_limit"
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 && existingSnapshot.data) {
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
- writeClaudeUsageCache(existingSnapshot.data, { type: errorType, status: result.status });
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
- writeClaudeUsageCache(usage, usage ? null : { type: "unknown", status: 0 });
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 < 30000) return; // 30초 이내 스폰 이력 건너뜀
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.3.3",
3
+ "version": "10.3.4",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- hasDirectCli = (
221
- /\beval\b.*\b(codex\s+exec|gemini\s+(-p|--prompt))\b/i.test(cmd) ||
222
- /\$[({].*\b(codex\s+exec|gemini\s+(-p|--prompt))\b/i.test(cmd)
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
+ }