@wooojin/forgen 0.4.5 → 0.4.7
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/CHANGELOG.md +270 -0
- package/dist/cli.js +1 -1
- package/dist/core/auto-compound-runner.d.ts +13 -1
- package/dist/core/auto-compound-runner.js +93 -1
- package/dist/core/doctor.js +9 -0
- package/dist/core/harness.js +60 -1
- package/dist/core/notify.d.ts +18 -0
- package/dist/core/notify.js +93 -0
- package/dist/core/spawn.d.ts +13 -2
- package/dist/core/spawn.js +154 -32
- package/dist/core/state-gc.d.ts +10 -0
- package/dist/core/state-gc.js +71 -0
- package/dist/core/statusline-cli.js +55 -0
- package/dist/core/usage-telemetry.d.ts +34 -0
- package/dist/core/usage-telemetry.js +101 -0
- package/dist/fgx.js +6 -5
- package/dist/hooks/context-guard.d.ts +13 -0
- package/dist/hooks/context-guard.js +155 -4
- package/dist/hooks/post-tool-use.js +3 -0
- package/dist/hooks/pre-tool-use.js +31 -0
- package/dist/hooks/session-recovery.js +11 -0
- package/dist/hooks/shared/read-stdin.js +44 -29
- package/dist/host/codex-adapter.js +5 -0
- package/dist/host/host-runtime.d.ts +6 -0
- package/dist/host/host-runtime.js +2 -0
- package/dist/host/install-codex.js +7 -1
- package/dist/host/install-orchestrator.js +2 -1
- package/dist/services/session.js +13 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen — Desktop / webhook notification helper (ADR-008 §"테마 A 알림").
|
|
3
|
+
*
|
|
4
|
+
* 0.4.6 신설 — rate-limit auto-resume 이 sleep 끝나고 재기동될 때 사용자에게
|
|
5
|
+
* 알림을 보냄 (노트북 닫고 잘 때 끝났는지 알 수 있어야 함).
|
|
6
|
+
*
|
|
7
|
+
* 정책:
|
|
8
|
+
* - macOS: osascript 'display notification'
|
|
9
|
+
* - Linux: notify-send (있으면)
|
|
10
|
+
* - Windows: 생략 (PowerShell BurntToast 의존성 회피)
|
|
11
|
+
* - webhook: ~/.forgen/config.json 의 notifyWebhookUrl 설정 시 POST
|
|
12
|
+
* - fail-open: 모든 실패는 silent log
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import * as os from 'node:os';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import { FORGEN_HOME } from './paths.js';
|
|
19
|
+
import { createLogger } from './logger.js';
|
|
20
|
+
const log = createLogger('notify');
|
|
21
|
+
function loadConfig() {
|
|
22
|
+
try {
|
|
23
|
+
const cfgPath = path.join(FORGEN_HOME, 'config.json');
|
|
24
|
+
if (!fs.existsSync(cfgPath))
|
|
25
|
+
return {};
|
|
26
|
+
return JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function escapeForOsascript(s) {
|
|
33
|
+
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
34
|
+
}
|
|
35
|
+
/** macOS native notification via osascript. silent fail. */
|
|
36
|
+
function notifyDarwin(title, body) {
|
|
37
|
+
try {
|
|
38
|
+
const script = `display notification "${escapeForOsascript(body)}" with title "${escapeForOsascript(title)}"`;
|
|
39
|
+
const child = spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' });
|
|
40
|
+
child.unref();
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
log.debug('osascript notification 실패', e);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Linux notify-send. silent fail (notify-send 미설치 OK). */
|
|
47
|
+
function notifyLinux(title, body) {
|
|
48
|
+
try {
|
|
49
|
+
const child = spawn('notify-send', [title, body], { detached: true, stdio: 'ignore' });
|
|
50
|
+
child.unref();
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
log.debug('notify-send 실패', e);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** webhook POST (slack/discord 호환). silent fail. */
|
|
57
|
+
async function notifyWebhook(url, title, body) {
|
|
58
|
+
try {
|
|
59
|
+
// Slack / Discord 호환 — text 필드 우선, fallback 으로 content
|
|
60
|
+
const payload = JSON.stringify({
|
|
61
|
+
text: `*${title}*\n${body}`,
|
|
62
|
+
content: `**${title}**\n${body}`, // Discord
|
|
63
|
+
});
|
|
64
|
+
await fetch(url, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'Content-Type': 'application/json' },
|
|
67
|
+
body: payload,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
log.debug('webhook notify 실패', e);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* 통합 알림 진입점. desktop + webhook 둘 다 시도.
|
|
76
|
+
* 모든 실패는 silent — 호출 측 차단 안 함.
|
|
77
|
+
*/
|
|
78
|
+
export function sendNotification(title, body) {
|
|
79
|
+
const cfg = loadConfig();
|
|
80
|
+
const desktopEnabled = cfg.notifyDesktop !== false; // default true
|
|
81
|
+
if (desktopEnabled) {
|
|
82
|
+
const platform = os.platform();
|
|
83
|
+
if (platform === 'darwin')
|
|
84
|
+
notifyDarwin(title, body);
|
|
85
|
+
else if (platform === 'linux')
|
|
86
|
+
notifyLinux(title, body);
|
|
87
|
+
// win32: 생략
|
|
88
|
+
}
|
|
89
|
+
if (cfg.notifyWebhookUrl) {
|
|
90
|
+
// fire-and-forget — await 하지 않음 (caller 차단 방지)
|
|
91
|
+
void notifyWebhook(cfg.notifyWebhookUrl, title, body);
|
|
92
|
+
}
|
|
93
|
+
}
|
package/dist/core/spawn.d.ts
CHANGED
|
@@ -2,8 +2,19 @@ import type { V1HarnessContext } from './harness.js';
|
|
|
2
2
|
import type { RuntimeHost } from './types.js';
|
|
3
3
|
/** Claude Code를 하네스 환경으로 실행. exit code를 반환. */
|
|
4
4
|
export declare function spawnClaude(args: string[], context: V1HarnessContext, runtime?: RuntimeHost): Promise<number>;
|
|
5
|
+
declare const MAX_RESUMES = 3;
|
|
5
6
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
7
|
+
* Exponential backoff for rate-limit when resetAt 파싱 실패.
|
|
8
|
+
* 1m → 5m → 15m → 30m → 1h → 2h cap. 합 ≤ 6h.
|
|
9
|
+
*/
|
|
10
|
+
export declare function rateLimitBackoffMs(attempt: number): number;
|
|
11
|
+
/**
|
|
12
|
+
* 토큰 한도 / API rate-limit 도달 시 자동 재시작을 지원하는 claude 실행 래퍼.
|
|
13
|
+
* context-guard가 pending-resume.json 마커를 생성하면 reason 별 정책으로 처리.
|
|
14
|
+
*
|
|
15
|
+
* - reason='token-limit': 30s 쿨다운, MAX_RESUMES_TOKEN=3
|
|
16
|
+
* - reason='rate-limit': resetAt 정밀 sleep (+60s 버퍼) 또는 exponential backoff,
|
|
17
|
+
* MAX_RESUMES_RATE=10, hard cap 6h
|
|
8
18
|
*/
|
|
9
19
|
export declare function spawnClaudeWithResume(args: string[], context: V1HarnessContext, contextFactory: () => Promise<V1HarnessContext>, runtime?: RuntimeHost): Promise<void>;
|
|
20
|
+
export { MAX_RESUMES };
|
package/dist/core/spawn.js
CHANGED
|
@@ -8,19 +8,39 @@ import { loadGlobalConfig } from './global-config.js';
|
|
|
8
8
|
import { createLogger } from './logger.js';
|
|
9
9
|
import { STATE_DIR } from './paths.js';
|
|
10
10
|
import { getHostRuntime } from '../host/host-runtime.js';
|
|
11
|
+
import { sendNotification } from './notify.js';
|
|
11
12
|
const log = createLogger('spawn');
|
|
12
13
|
/** Phase 2: host-runtime 어댑터 위임. */
|
|
13
14
|
function findRuntimeLauncher(runtime) {
|
|
14
15
|
return getHostRuntime(runtime).launcher;
|
|
15
16
|
}
|
|
16
|
-
|
|
17
|
+
/**
|
|
18
|
+
* 0.4.6 — runtime 별 transcript 디렉토리.
|
|
19
|
+
*
|
|
20
|
+
* - claude: ~/.claude/projects/<sanitized-cwd>/<session>.jsonl
|
|
21
|
+
* - codex: ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<sid>.jsonl
|
|
22
|
+
*
|
|
23
|
+
* Codex 는 cwd 별 격리 없이 날짜별 격리 — 같은 사용자의 모든 codex 세션이
|
|
24
|
+
* 동일 날짜 dir 에 들어감. session attribution 은 파일 basename 의 session-id 로.
|
|
25
|
+
*
|
|
26
|
+
* Note: codex 는 일별 dir 라 자정 가로지르는 세션은 두 dir 에 걸칠 수 있음 —
|
|
27
|
+
* 본 함수는 시작 시점 dir 만 반환. 호출 측이 needed 시 보강.
|
|
28
|
+
*/
|
|
29
|
+
function transcriptProjectDir(cwd, runtime = 'claude') {
|
|
30
|
+
if (runtime === 'codex') {
|
|
31
|
+
const d = new Date();
|
|
32
|
+
const y = d.getUTCFullYear();
|
|
33
|
+
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
34
|
+
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
35
|
+
return path.join(os.homedir(), '.codex', 'sessions', String(y), m, day);
|
|
36
|
+
}
|
|
17
37
|
// Claude Code는 cwd의 /를 -로 치환하고 선행 -를 유지
|
|
18
38
|
const sanitized = cwd.replace(/\//g, '-');
|
|
19
39
|
return path.join(os.homedir(), '.claude', 'projects', sanitized);
|
|
20
40
|
}
|
|
21
41
|
/** 스냅샷용 — 세션 시작 전 존재하는 transcript basename 집합. */
|
|
22
|
-
function snapshotExistingTranscripts(cwd) {
|
|
23
|
-
const dir = transcriptProjectDir(cwd);
|
|
42
|
+
function snapshotExistingTranscripts(cwd, runtime = 'claude') {
|
|
43
|
+
const dir = transcriptProjectDir(cwd, runtime);
|
|
24
44
|
if (!fs.existsSync(dir))
|
|
25
45
|
return new Set();
|
|
26
46
|
try {
|
|
@@ -43,8 +63,8 @@ function snapshotExistingTranscripts(cwd) {
|
|
|
43
63
|
* 여전히 후보가 여러 개이면 (rare: 훅이 추가 파일을 쓴 경우) 가장 최근 수정본
|
|
44
64
|
* 을 고르되 debug 로그를 남긴다.
|
|
45
65
|
*/
|
|
46
|
-
function findSessionTranscript(cwd, sessionStartMs, preSnapshot) {
|
|
47
|
-
const dir = transcriptProjectDir(cwd);
|
|
66
|
+
function findSessionTranscript(cwd, sessionStartMs, preSnapshot, runtime = 'claude') {
|
|
67
|
+
const dir = transcriptProjectDir(cwd, runtime);
|
|
48
68
|
if (!fs.existsSync(dir))
|
|
49
69
|
return null;
|
|
50
70
|
let candidates;
|
|
@@ -81,6 +101,14 @@ function findSessionTranscript(cwd, sessionStartMs, preSnapshot) {
|
|
|
81
101
|
* 파일 전체를 메모리에 올렸다. 수백 MB 규모 transcript에서는 heap spike가
|
|
82
102
|
* 발생했고, 카운트 외엔 내용이 필요 없으니 streaming line-by-line로 충분하다.
|
|
83
103
|
*/
|
|
104
|
+
/**
|
|
105
|
+
* 0.4.6 — claude/codex 양 schema 호환.
|
|
106
|
+
*
|
|
107
|
+
* Claude JSONL: {type: 'user' | 'queue-operation', ...}
|
|
108
|
+
* Codex JSONL: {type: 'response_item', payload: {role: 'user' | 'developer' | 'assistant', ...}}
|
|
109
|
+
*
|
|
110
|
+
* 단일 함수에서 둘 다 처리 — schema 자동 감지.
|
|
111
|
+
*/
|
|
84
112
|
async function countUserMessages(transcriptPath) {
|
|
85
113
|
const { createInterface } = await import('node:readline');
|
|
86
114
|
const stream = fs.createReadStream(transcriptPath, { encoding: 'utf-8' });
|
|
@@ -91,8 +119,15 @@ async function countUserMessages(transcriptPath) {
|
|
|
91
119
|
if (!line)
|
|
92
120
|
continue;
|
|
93
121
|
try {
|
|
94
|
-
const
|
|
95
|
-
|
|
122
|
+
const obj = JSON.parse(line);
|
|
123
|
+
const t = obj.type;
|
|
124
|
+
// Claude schema
|
|
125
|
+
if (t === 'user' || t === 'queue-operation') {
|
|
126
|
+
count++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
// Codex schema (response_item with role=user)
|
|
130
|
+
if (t === 'response_item' && obj.payload?.role === 'user')
|
|
96
131
|
count++;
|
|
97
132
|
}
|
|
98
133
|
catch { /* skip malformed */ }
|
|
@@ -150,7 +185,7 @@ export async function spawnClaude(args, context, runtime = 'claude') {
|
|
|
150
185
|
// 세션 시작 전 timestamp + 기존 transcript 스냅샷 기록 (종료 후 finder 용).
|
|
151
186
|
// Audit fix #8 (2026-04-21): 스냅샷으로 동시 세션 transcript 오선택을 차단.
|
|
152
187
|
const sessionStartTime = Date.now();
|
|
153
|
-
const preSnapshot = snapshotExistingTranscripts(context.cwd);
|
|
188
|
+
const preSnapshot = snapshotExistingTranscripts(context.cwd, runtime);
|
|
154
189
|
return new Promise((resolve, reject) => {
|
|
155
190
|
const child = spawn(launcher, cleanArgs, {
|
|
156
191
|
stdio: 'inherit',
|
|
@@ -166,21 +201,35 @@ export async function spawnClaude(args, context, runtime = 'claude') {
|
|
|
166
201
|
}
|
|
167
202
|
});
|
|
168
203
|
child.on('exit', async (code) => {
|
|
169
|
-
if (runtime !== 'claude') {
|
|
204
|
+
if (runtime !== 'claude' && runtime !== 'codex') {
|
|
170
205
|
resolve(code ?? 0);
|
|
171
206
|
return;
|
|
172
207
|
}
|
|
173
|
-
// 세션 종료 후 하네스
|
|
208
|
+
// 세션 종료 후 하네스 작업.
|
|
209
|
+
// 0.4.6 — codex 도 transcript 인식 (~/.codex/sessions/YYYY/MM/DD/rollout-<sid>.jsonl).
|
|
210
|
+
// Codex transcript 의 schema 는 claude 와 다르므로 auto-compound 의 input parsing 은
|
|
211
|
+
// 별개 작업 (0.4.7). 현재 단계는 *transcript 위치 인식 + count* 만 codex 호환.
|
|
174
212
|
try {
|
|
175
|
-
const transcript = findSessionTranscript(context.cwd, sessionStartTime, preSnapshot);
|
|
213
|
+
const transcript = findSessionTranscript(context.cwd, sessionStartTime, preSnapshot, runtime);
|
|
176
214
|
if (!transcript) {
|
|
177
215
|
log.debug('이 세션에서 생성된 transcript를 찾을 수 없음 (snapshot diff)');
|
|
178
216
|
}
|
|
179
217
|
else {
|
|
180
|
-
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
218
|
+
// 0.4.6 — claude/codex 양 schema 호환 (countUserMessages + extractSummary).
|
|
219
|
+
// codex transcript 의 sessionId 는 파일명 패턴 'rollout-<ts>-<sid>.jsonl' 의 끝부분.
|
|
220
|
+
let sessionId;
|
|
221
|
+
if (runtime === 'codex') {
|
|
222
|
+
const m = path.basename(transcript, '.jsonl').match(/rollout-[\dT-]+-(.+)$/);
|
|
223
|
+
sessionId = m ? m[1] : path.basename(transcript, '.jsonl');
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
sessionId = path.basename(transcript, '.jsonl');
|
|
227
|
+
}
|
|
228
|
+
// 1. FTS5 인덱싱 (claude only — codex schema FTS 호환은 미검증, 별도 작업)
|
|
229
|
+
if (runtime === 'claude') {
|
|
230
|
+
await indexTranscriptToFTS(context.cwd, transcript, sessionId);
|
|
231
|
+
}
|
|
232
|
+
// 2. 자동 compound (10+ user 메시지인 경우만) — 양 runtime 호환
|
|
184
233
|
const userMsgCount = await countUserMessages(transcript);
|
|
185
234
|
if (userMsgCount >= 10) {
|
|
186
235
|
await runAutoCompound(context.cwd, transcript, sessionId);
|
|
@@ -198,17 +247,52 @@ export async function spawnClaude(args, context, runtime = 'claude') {
|
|
|
198
247
|
});
|
|
199
248
|
}
|
|
200
249
|
const RESUME_COOLDOWN_MS = 30_000;
|
|
201
|
-
const
|
|
250
|
+
const MAX_RESUMES_TOKEN = 3;
|
|
251
|
+
const MAX_RESUMES_RATE = 10; // ADR-008: rate-limit wait 은 5h 단위라 더 관대
|
|
252
|
+
const MAX_RESUMES = MAX_RESUMES_TOKEN; // backward compat — 기존 token-limit 호출자
|
|
253
|
+
const RATE_LIMIT_HARD_CAP_MS = 6 * 60 * 60 * 1000; // 6h max single wait
|
|
254
|
+
const COUNTDOWN_INTERVAL_MS = 30_000;
|
|
255
|
+
/**
|
|
256
|
+
* Exponential backoff for rate-limit when resetAt 파싱 실패.
|
|
257
|
+
* 1m → 5m → 15m → 30m → 1h → 2h cap. 합 ≤ 6h.
|
|
258
|
+
*/
|
|
259
|
+
export function rateLimitBackoffMs(attempt) {
|
|
260
|
+
const schedule = [60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000, 2 * 60 * 60_000];
|
|
261
|
+
return schedule[Math.min(attempt, schedule.length - 1)];
|
|
262
|
+
}
|
|
202
263
|
/**
|
|
203
|
-
*
|
|
204
|
-
|
|
264
|
+
* Foreground countdown — 30s 마다 남은 시간 단일 라인 갱신, Ctrl+C 시 abort.
|
|
265
|
+
*/
|
|
266
|
+
async function countdownSleep(totalMs, label) {
|
|
267
|
+
const deadline = Date.now() + totalMs;
|
|
268
|
+
while (Date.now() < deadline) {
|
|
269
|
+
const remainMs = deadline - Date.now();
|
|
270
|
+
const remainMin = Math.floor(remainMs / 60_000);
|
|
271
|
+
const remainSec = Math.floor((remainMs % 60_000) / 1000);
|
|
272
|
+
const eta = remainMin >= 60
|
|
273
|
+
? `${Math.floor(remainMin / 60)}h ${remainMin % 60}m`
|
|
274
|
+
: `${remainMin}m ${remainSec}s`;
|
|
275
|
+
process.stdout.write(`\r[forgen] ${label} — ${eta} remaining (Ctrl+C to abort) `);
|
|
276
|
+
const tick = Math.min(COUNTDOWN_INTERVAL_MS, remainMs);
|
|
277
|
+
await new Promise(resolve => setTimeout(resolve, tick));
|
|
278
|
+
}
|
|
279
|
+
process.stdout.write('\n');
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* 토큰 한도 / API rate-limit 도달 시 자동 재시작을 지원하는 claude 실행 래퍼.
|
|
283
|
+
* context-guard가 pending-resume.json 마커를 생성하면 reason 별 정책으로 처리.
|
|
284
|
+
*
|
|
285
|
+
* - reason='token-limit': 30s 쿨다운, MAX_RESUMES_TOKEN=3
|
|
286
|
+
* - reason='rate-limit': resetAt 정밀 sleep (+60s 버퍼) 또는 exponential backoff,
|
|
287
|
+
* MAX_RESUMES_RATE=10, hard cap 6h
|
|
205
288
|
*/
|
|
206
289
|
export async function spawnClaudeWithResume(args, context, contextFactory, runtime = 'claude') {
|
|
207
|
-
let
|
|
290
|
+
let tokenResumeCount = 0;
|
|
291
|
+
let rateResumeCount = 0;
|
|
208
292
|
let currentContext = context;
|
|
209
293
|
while (true) {
|
|
210
294
|
const exitCode = await spawnClaude(args, currentContext, runtime);
|
|
211
|
-
if (runtime !== 'claude') {
|
|
295
|
+
if (runtime !== 'claude' && runtime !== 'codex') {
|
|
212
296
|
if (exitCode !== 0)
|
|
213
297
|
process.exit(exitCode);
|
|
214
298
|
break;
|
|
@@ -222,20 +306,56 @@ export async function spawnClaudeWithResume(args, context, contextFactory, runti
|
|
|
222
306
|
try {
|
|
223
307
|
const marker = JSON.parse(fs.readFileSync(resumePath, 'utf-8'));
|
|
224
308
|
fs.unlinkSync(resumePath);
|
|
225
|
-
if (marker.reason
|
|
226
|
-
if (
|
|
227
|
-
|
|
228
|
-
|
|
309
|
+
if (marker.reason === 'token-limit') {
|
|
310
|
+
if (tokenResumeCount >= MAX_RESUMES_TOKEN) {
|
|
311
|
+
console.log(`[forgen] 최대 자동 재시작 횟수(${MAX_RESUMES_TOKEN}) 도달. 수동으로 다시 시작하세요.`);
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
tokenResumeCount++;
|
|
315
|
+
console.log(`[forgen] 토큰 한도 도달. ${RESUME_COOLDOWN_MS / 1000}초 후 자동 재시작합니다... (${tokenResumeCount}/${MAX_RESUMES_TOKEN})`);
|
|
316
|
+
await new Promise(resolve => setTimeout(resolve, RESUME_COOLDOWN_MS));
|
|
317
|
+
console.log('[forgen] 세션 재시작 중...');
|
|
318
|
+
currentContext = await contextFactory();
|
|
319
|
+
continue;
|
|
229
320
|
}
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
|
|
321
|
+
if (marker.reason === 'rate-limit') {
|
|
322
|
+
if (rateResumeCount >= MAX_RESUMES_RATE) {
|
|
323
|
+
console.log(`[forgen] rate-limit 자동 재시작 한도(${MAX_RESUMES_RATE}) 도달. 수동 재시작 필요.`);
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
rateResumeCount++;
|
|
327
|
+
let sleepMs;
|
|
328
|
+
let label;
|
|
329
|
+
if (marker.resetAt) {
|
|
330
|
+
const resetMs = Date.parse(marker.resetAt);
|
|
331
|
+
if (Number.isFinite(resetMs)) {
|
|
332
|
+
const target = resetMs - Date.now() + 60_000; // 60s 버퍼
|
|
333
|
+
sleepMs = Math.min(Math.max(target, 0), RATE_LIMIT_HARD_CAP_MS);
|
|
334
|
+
label = `Rate limit. Resuming after reset (${marker.resetAt})`;
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
sleepMs = rateLimitBackoffMs(rateResumeCount - 1);
|
|
338
|
+
label = `Rate limit. Backoff #${rateResumeCount}`;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
sleepMs = rateLimitBackoffMs(rateResumeCount - 1);
|
|
343
|
+
label = `Rate limit. Backoff #${rateResumeCount}`;
|
|
344
|
+
}
|
|
345
|
+
if (sleepMs >= RATE_LIMIT_HARD_CAP_MS) {
|
|
346
|
+
console.log(`[forgen] rate-limit reset 시각이 hard cap(6h) 초과 — 수동 재시작 필요. handoff 저장됨.`);
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
await countdownSleep(sleepMs, label);
|
|
350
|
+
console.log('[forgen] 세션 재시작 중...');
|
|
351
|
+
sendNotification('forgen — Rate limit 회복', `${runtime} 세션 재기동 중 (${rateResumeCount}/${MAX_RESUMES_RATE} resume)`);
|
|
352
|
+
currentContext = await contextFactory();
|
|
353
|
+
continue;
|
|
233
354
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
currentContext = await contextFactory();
|
|
355
|
+
// 알 수 없는 reason → exit (fail-open, 수동 처리)
|
|
356
|
+
if (exitCode !== 0)
|
|
357
|
+
process.exit(exitCode);
|
|
358
|
+
break;
|
|
239
359
|
}
|
|
240
360
|
catch {
|
|
241
361
|
if (exitCode !== 0)
|
|
@@ -244,3 +364,5 @@ export async function spawnClaudeWithResume(args, context, contextFactory, runti
|
|
|
244
364
|
}
|
|
245
365
|
}
|
|
246
366
|
}
|
|
367
|
+
// MAX_RESUMES re-export for backward-compat tests
|
|
368
|
+
export { MAX_RESUMES };
|
package/dist/core/state-gc.d.ts
CHANGED
|
@@ -17,6 +17,16 @@ export interface PruneOptions {
|
|
|
17
17
|
/** Current time for deterministic tests. Defaults to Date.now(). */
|
|
18
18
|
now?: number;
|
|
19
19
|
}
|
|
20
|
+
export interface RotateReport {
|
|
21
|
+
scanned: number;
|
|
22
|
+
rotated: number;
|
|
23
|
+
bytesFreed: number;
|
|
24
|
+
sample: string[];
|
|
25
|
+
}
|
|
26
|
+
export declare function rotateAppendOnlyLogs(opts?: {
|
|
27
|
+
stateDir?: string;
|
|
28
|
+
maxBytes?: number;
|
|
29
|
+
}): RotateReport;
|
|
20
30
|
/**
|
|
21
31
|
* Prune session-scoped files older than `retentionMs` from the state and
|
|
22
32
|
* outcomes directories. Defaults to a dry-run so callers must opt-in to
|
package/dist/core/state-gc.js
CHANGED
|
@@ -78,6 +78,77 @@ function pruneDir(dir, cutoff, dryRun, filter) {
|
|
|
78
78
|
}
|
|
79
79
|
return out;
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* 0.4.6 #14 — append-only jsonl 로그 회전.
|
|
83
|
+
*
|
|
84
|
+
* state-gc 의 SESSION_SCOPED_PREFIXES 는 session 별 파일을 prefix-base 로 잡지만,
|
|
85
|
+
* 단일 aggregate jsonl (hook-timing, prompt-history, usage-telemetry 등) 은 매번
|
|
86
|
+
* append 되어 무한 grow. 본 함수가 size cap (default 10MB) 초과 시 `<name>.1` 로
|
|
87
|
+
* rotate 하고 `<name>.2` 는 삭제 (한 단계만 보존).
|
|
88
|
+
*
|
|
89
|
+
* 회전 정책:
|
|
90
|
+
* - cap 미만: no-op
|
|
91
|
+
* - cap 초과: <name>.2 삭제 → <name>.1 → <name>.2, <name> → <name>.1, 새 빈 <name>
|
|
92
|
+
* - 0.4.6 신설 jsonl 들 (prompt-history, usage-telemetry, rate-limit-misses) 포함
|
|
93
|
+
*
|
|
94
|
+
* fail-open: 모든 I/O 실패는 silent — 호출 측 차단 안 함.
|
|
95
|
+
*/
|
|
96
|
+
const ROTATABLE_LOGS = [
|
|
97
|
+
'hook-errors.jsonl',
|
|
98
|
+
'hook-timing.jsonl',
|
|
99
|
+
'implicit-feedback.jsonl',
|
|
100
|
+
'match-eval-log.jsonl',
|
|
101
|
+
'solution-quarantine.jsonl',
|
|
102
|
+
'prompt-history.jsonl',
|
|
103
|
+
'usage-telemetry.jsonl',
|
|
104
|
+
'rate-limit-misses.jsonl',
|
|
105
|
+
];
|
|
106
|
+
const DEFAULT_MAX_LOG_BYTES = 10 * 1024 * 1024; // 10MB
|
|
107
|
+
export function rotateAppendOnlyLogs(opts = {}) {
|
|
108
|
+
const stateDir = opts.stateDir ?? STATE_DIR;
|
|
109
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_LOG_BYTES;
|
|
110
|
+
const out = { scanned: 0, rotated: 0, bytesFreed: 0, sample: [] };
|
|
111
|
+
if (!fs.existsSync(stateDir))
|
|
112
|
+
return out;
|
|
113
|
+
for (const name of ROTATABLE_LOGS) {
|
|
114
|
+
const full = path.join(stateDir, name);
|
|
115
|
+
let stat;
|
|
116
|
+
try {
|
|
117
|
+
stat = fs.statSync(full);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
out.scanned++;
|
|
123
|
+
if (stat.size <= maxBytes)
|
|
124
|
+
continue;
|
|
125
|
+
try {
|
|
126
|
+
const r1 = `${full}.1`;
|
|
127
|
+
const r2 = `${full}.2`;
|
|
128
|
+
// .2 삭제
|
|
129
|
+
try {
|
|
130
|
+
fs.unlinkSync(r2);
|
|
131
|
+
out.bytesFreed += fs.existsSync(r2) ? 0 : (fs.statSync(r2).size ?? 0);
|
|
132
|
+
}
|
|
133
|
+
catch { /* no .2 */ }
|
|
134
|
+
// .1 → .2
|
|
135
|
+
try {
|
|
136
|
+
if (fs.existsSync(r1))
|
|
137
|
+
fs.renameSync(r1, r2);
|
|
138
|
+
}
|
|
139
|
+
catch { /* skip */ }
|
|
140
|
+
// active → .1
|
|
141
|
+
fs.renameSync(full, r1);
|
|
142
|
+
// 새 빈 파일
|
|
143
|
+
fs.writeFileSync(full, '');
|
|
144
|
+
out.rotated++;
|
|
145
|
+
if (out.sample.length < 20)
|
|
146
|
+
out.sample.push(name);
|
|
147
|
+
}
|
|
148
|
+
catch { /* fail-open per file */ }
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
81
152
|
/**
|
|
82
153
|
* Prune session-scoped files older than `retentionMs` from the state and
|
|
83
154
|
* outcomes directories. Defaults to a dry-run so callers must opt-in to
|
|
@@ -15,6 +15,13 @@ import * as path from 'node:path';
|
|
|
15
15
|
import * as os from 'node:os';
|
|
16
16
|
import { execSync } from 'node:child_process';
|
|
17
17
|
import { loadActiveRules } from '../store/rule-store.js';
|
|
18
|
+
import { getUsageStats } from './usage-telemetry.js';
|
|
19
|
+
import { STATE_DIR } from './paths.js';
|
|
20
|
+
// 0.4.6 perf #13 — statusline 출력을 5초 캐싱.
|
|
21
|
+
// claude statusLine 은 짧은 간격으로 재호출되는데 매번 git/find/rule-store 를
|
|
22
|
+
// 실행하면 ~100ms 누적. CACHE_TTL_MS 동안 동일 출력 재사용.
|
|
23
|
+
const STATUSLINE_CACHE_PATH = path.join(STATE_DIR, 'statusline-cache.txt');
|
|
24
|
+
const CACHE_TTL_MS = 5_000;
|
|
18
25
|
// ANSI codes
|
|
19
26
|
const DIM = '\x1b[2m';
|
|
20
27
|
const CYAN = '\x1b[36m';
|
|
@@ -116,6 +123,23 @@ function buildLine1(payload, cwd) {
|
|
|
116
123
|
parts.push(`${GREEN}${gitBranch}${RESET}`);
|
|
117
124
|
return parts.join(` ${DIM}|${RESET} `);
|
|
118
125
|
}
|
|
126
|
+
/** Build usage line: "📊 87/5h · 412/wk (claude)" — 0.4.6 신설 */
|
|
127
|
+
function buildUsageLine() {
|
|
128
|
+
try {
|
|
129
|
+
const stats = getUsageStats();
|
|
130
|
+
if (stats.week.total === 0)
|
|
131
|
+
return null;
|
|
132
|
+
const dominant = stats.week.codex > stats.week.claude ? 'codex' : 'claude';
|
|
133
|
+
return [
|
|
134
|
+
`${YELLOW}📊 ${stats.hour5.total}/5h${RESET}`,
|
|
135
|
+
`${YELLOW}${stats.week.total}/wk${RESET}`,
|
|
136
|
+
`${DIM}(${dominant})${RESET}`,
|
|
137
|
+
].join(` ${DIM}·${RESET} `);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
119
143
|
function buildLine3(claudeDir, cwd) {
|
|
120
144
|
const settings = getSettingsJson(claudeDir);
|
|
121
145
|
const claudeMdCount = countClaudeMd(cwd);
|
|
@@ -136,15 +160,46 @@ function buildLine3(claudeDir, cwd) {
|
|
|
136
160
|
`${YELLOW}${hookCount} hooks${RESET}`,
|
|
137
161
|
].join(` ${DIM}|${RESET} `);
|
|
138
162
|
}
|
|
163
|
+
/** 0.4.6 perf #13: cached output if fresh. */
|
|
164
|
+
function readCacheIfFresh() {
|
|
165
|
+
try {
|
|
166
|
+
const stat = fs.statSync(STATUSLINE_CACHE_PATH);
|
|
167
|
+
if (Date.now() - stat.mtimeMs < CACHE_TTL_MS) {
|
|
168
|
+
return fs.readFileSync(STATUSLINE_CACHE_PATH, 'utf-8');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch { /* no cache or stale */ }
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
function writeCache(content) {
|
|
175
|
+
try {
|
|
176
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
177
|
+
fs.writeFileSync(STATUSLINE_CACHE_PATH, content);
|
|
178
|
+
}
|
|
179
|
+
catch { /* fail-open */ }
|
|
180
|
+
}
|
|
139
181
|
export async function handleStatusline() {
|
|
182
|
+
// 캐시 hit 시 stdin payload 무시하고 바로 출력 (5초 윈도우 내 동일 출력 가정).
|
|
183
|
+
// 라인 단위 cache → console.log 라인별 (테스트 호환).
|
|
184
|
+
const cached = readCacheIfFresh();
|
|
185
|
+
if (cached !== null) {
|
|
186
|
+
for (const line of cached.split('\n').filter(Boolean))
|
|
187
|
+
console.log(line);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
140
190
|
const payload = readStdinJson();
|
|
141
191
|
const cwd = payload.workspace?.current_dir ?? process.cwd();
|
|
142
192
|
const claudeDir = path.join(os.homedir(), '.claude');
|
|
143
193
|
const line1 = buildLine1(payload, cwd);
|
|
144
194
|
const line3 = buildLine3(claudeDir, cwd);
|
|
195
|
+
const usageLine = buildUsageLine();
|
|
145
196
|
// Line 2 (context/usage): stdin JSON spec 미확인으로 생략 — TODO
|
|
146
197
|
// Line 4 (tool counts): 추적 인프라 없음 — TODO
|
|
147
198
|
// Line 5 (active task): 추적 인프라 없음 — TODO
|
|
148
199
|
console.log(line1);
|
|
149
200
|
console.log(line3);
|
|
201
|
+
if (usageLine)
|
|
202
|
+
console.log(usageLine);
|
|
203
|
+
const cacheBody = usageLine ? `${line1}\n${line3}\n${usageLine}\n` : `${line1}\n${line3}\n`;
|
|
204
|
+
writeCache(cacheBody);
|
|
150
205
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen Usage Telemetry — ADR-008 §"테마 A unattended resilience"
|
|
3
|
+
*
|
|
4
|
+
* 5h / weekly window 의 tool call 수를 sliding window 로 추적하여 statusline
|
|
5
|
+
* (`forgen me`) 에 노출. rate-limit hit 전에 사용자가 사용량을 가시화할 수 있도록.
|
|
6
|
+
*
|
|
7
|
+
* 정책:
|
|
8
|
+
* - 각 PostToolUse 마다 append-only JSONL 에 timestamp 한 줄 기록
|
|
9
|
+
* - read 시 sliding window 로 필터 + 카운트 (스트리밍 — 메모리 절약)
|
|
10
|
+
* - 10K 엔트리 누적 시 weekly cap 밖 엔트리 prune (rewrite once)
|
|
11
|
+
* - fail-open: 모든 I/O 실패는 로그 후 무시, 호출 측 차단 안 함
|
|
12
|
+
*
|
|
13
|
+
* 0.4.6 신설. limit prediction 은 의도적으로 제외 — Anthropic 의 실제 limit 가
|
|
14
|
+
* 계정/플랜별 가변이라 hard-code 부정확. raw count 만 노출하고 사용자가 판단.
|
|
15
|
+
*/
|
|
16
|
+
export interface UsageStats {
|
|
17
|
+
hour5: {
|
|
18
|
+
claude: number;
|
|
19
|
+
codex: number;
|
|
20
|
+
total: number;
|
|
21
|
+
};
|
|
22
|
+
week: {
|
|
23
|
+
claude: number;
|
|
24
|
+
codex: number;
|
|
25
|
+
total: number;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export declare function recordToolCall(runtime?: 'claude' | 'codex'): void;
|
|
29
|
+
/**
|
|
30
|
+
* sliding window count. fail-open: 파일 미존재/parse 실패는 0 반환.
|
|
31
|
+
*
|
|
32
|
+
* @param now epoch ms (테스트 결정성)
|
|
33
|
+
*/
|
|
34
|
+
export declare function getUsageStats(now?: number): UsageStats;
|