@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,101 @@
|
|
|
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
|
+
import * as fs from 'node:fs';
|
|
17
|
+
import * as path from 'node:path';
|
|
18
|
+
import { STATE_DIR } from './paths.js';
|
|
19
|
+
import { createLogger } from './logger.js';
|
|
20
|
+
const log = createLogger('usage-telemetry');
|
|
21
|
+
const TELEMETRY_PATH = path.join(STATE_DIR, 'usage-telemetry.jsonl');
|
|
22
|
+
const HOUR5_MS = 5 * 60 * 60 * 1000;
|
|
23
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
24
|
+
const PRUNE_THRESHOLD = 10_000; // 10K lines 누적 시 prune
|
|
25
|
+
export function recordToolCall(runtime = 'claude') {
|
|
26
|
+
try {
|
|
27
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
28
|
+
const entry = { ts: Date.now(), rt: runtime };
|
|
29
|
+
fs.appendFileSync(TELEMETRY_PATH, `${JSON.stringify(entry)}\n`);
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
log.debug('telemetry append 실패', e);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* sliding window count. fail-open: 파일 미존재/parse 실패는 0 반환.
|
|
37
|
+
*
|
|
38
|
+
* @param now epoch ms (테스트 결정성)
|
|
39
|
+
*/
|
|
40
|
+
export function getUsageStats(now = Date.now()) {
|
|
41
|
+
const stats = {
|
|
42
|
+
hour5: { claude: 0, codex: 0, total: 0 },
|
|
43
|
+
week: { claude: 0, codex: 0, total: 0 },
|
|
44
|
+
};
|
|
45
|
+
try {
|
|
46
|
+
if (!fs.existsSync(TELEMETRY_PATH))
|
|
47
|
+
return stats;
|
|
48
|
+
const cutoff5h = now - HOUR5_MS;
|
|
49
|
+
const cutoffWeek = now - WEEK_MS;
|
|
50
|
+
const raw = fs.readFileSync(TELEMETRY_PATH, 'utf-8');
|
|
51
|
+
const lines = raw.split('\n');
|
|
52
|
+
let total = 0;
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
if (!line)
|
|
55
|
+
continue;
|
|
56
|
+
total++;
|
|
57
|
+
try {
|
|
58
|
+
const e = JSON.parse(line);
|
|
59
|
+
if (typeof e.ts !== 'number' || e.ts < cutoffWeek)
|
|
60
|
+
continue;
|
|
61
|
+
const rt = e.rt === 'codex' ? 'codex' : 'claude';
|
|
62
|
+
stats.week[rt]++;
|
|
63
|
+
stats.week.total++;
|
|
64
|
+
if (e.ts >= cutoff5h) {
|
|
65
|
+
stats.hour5[rt]++;
|
|
66
|
+
stats.hour5.total++;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch { /* skip malformed line */ }
|
|
70
|
+
}
|
|
71
|
+
if (total > PRUNE_THRESHOLD)
|
|
72
|
+
pruneOldEntries(now);
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
log.debug('telemetry read 실패', e);
|
|
76
|
+
}
|
|
77
|
+
return stats;
|
|
78
|
+
}
|
|
79
|
+
/** 누적 엔트리가 PRUNE_THRESHOLD 초과 시 weekly cap 밖 entry 제거 (rewrite). */
|
|
80
|
+
function pruneOldEntries(now) {
|
|
81
|
+
try {
|
|
82
|
+
const cutoff = now - WEEK_MS;
|
|
83
|
+
const raw = fs.readFileSync(TELEMETRY_PATH, 'utf-8');
|
|
84
|
+
const kept = [];
|
|
85
|
+
for (const line of raw.split('\n')) {
|
|
86
|
+
if (!line)
|
|
87
|
+
continue;
|
|
88
|
+
try {
|
|
89
|
+
const e = JSON.parse(line);
|
|
90
|
+
if (typeof e.ts === 'number' && e.ts >= cutoff)
|
|
91
|
+
kept.push(line);
|
|
92
|
+
}
|
|
93
|
+
catch { /* drop malformed */ }
|
|
94
|
+
}
|
|
95
|
+
fs.writeFileSync(TELEMETRY_PATH, kept.join('\n') + (kept.length ? '\n' : ''));
|
|
96
|
+
log.debug(`telemetry pruned to ${kept.length} entries`);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
log.debug('telemetry prune 실패', e);
|
|
100
|
+
}
|
|
101
|
+
}
|
package/dist/fgx.js
CHANGED
|
@@ -11,9 +11,10 @@ const args = process.argv.slice(2);
|
|
|
11
11
|
// 이미 포함되어 있으면 중복 추가하지 않음
|
|
12
12
|
const launchContext = resolveLaunchContext(args);
|
|
13
13
|
const runtime = launchContext.runtime;
|
|
14
|
+
const skipFlag = getHostRuntime(runtime).dangerousSkipFlag;
|
|
14
15
|
const launchArgs = [...launchContext.args];
|
|
15
|
-
if (!launchArgs.includes(
|
|
16
|
-
launchArgs.unshift(
|
|
16
|
+
if (!launchArgs.includes(skipFlag)) {
|
|
17
|
+
launchArgs.unshift(skipFlag);
|
|
17
18
|
}
|
|
18
19
|
async function main() {
|
|
19
20
|
// Security warning — fgx bypasses all Claude Code permission checks.
|
|
@@ -23,8 +24,8 @@ async function main() {
|
|
|
23
24
|
// alias `fgx` unknowingly run with zero guardrails. Users who rely on
|
|
24
25
|
// the profile trust policy should NOT use `fgx`. Surface the mismatch
|
|
25
26
|
// loudly (harness.ts also prints the Trust 상승 warning downstream).
|
|
26
|
-
console.warn(
|
|
27
|
-
console.warn(
|
|
27
|
+
console.warn(`\n ⚠ fgx: ALL permission checks are disabled (${skipFlag})`);
|
|
28
|
+
console.warn(` ⚠ ${getHostRuntime(runtime).displayName} will execute tools without asking for confirmation.`);
|
|
28
29
|
console.warn(' ⚠ Use only in trusted environments. If your profile trust policy is');
|
|
29
30
|
console.warn(' ⚠ "가드레일 우선" or "승인 완화", consider `forgen` (no flag) instead.\n');
|
|
30
31
|
// fgx는 서브커맨드 없이 바로 Claude Code 실행 전용
|
|
@@ -43,7 +44,7 @@ async function main() {
|
|
|
43
44
|
if (v1.session) {
|
|
44
45
|
console.log(`[forgen] Trust: ${v1.session.effective_trust_policy}`);
|
|
45
46
|
}
|
|
46
|
-
console.log(
|
|
47
|
+
console.log(`[forgen] Mode: ${skipFlag.replace(/^--/, '')}`);
|
|
47
48
|
const runtimeLabel = getHostRuntime(runtime).displayName;
|
|
48
49
|
console.log(`[forgen] Starting ${runtimeLabel}...\n`);
|
|
49
50
|
await spawnClaude(launchArgs, context, runtime);
|
|
@@ -9,6 +9,19 @@
|
|
|
9
9
|
* 또한 UserPromptSubmit에서 현재 대화 길이를 추적하여
|
|
10
10
|
* context 한계에 접근 시 preemptive 경고를 제공합니다.
|
|
11
11
|
*/
|
|
12
|
+
export declare const TOKEN_LIMIT_REGEX: RegExp;
|
|
13
|
+
export declare const RATE_LIMIT_REGEX: RegExp;
|
|
14
|
+
/**
|
|
15
|
+
* Best-effort reset 시각 파서 (ADR-008 §2).
|
|
16
|
+
*
|
|
17
|
+
* 5 패턴 시도, 모두 실패 시 null. 실제 메시지 포맷은 Claude/Codex CLI 의
|
|
18
|
+
* 공식 contract 가 아니므로 hotfix 가능한 best-effort.
|
|
19
|
+
*
|
|
20
|
+
* @param msg stderr/error message text
|
|
21
|
+
* @param now epoch ms (테스트 결정성 위해 주입 가능)
|
|
22
|
+
* @returns ISO timestamp 또는 null
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseRateLimitResetAt(msg: string, now?: number): string | null;
|
|
12
25
|
/** 경고 표시 여부 판정 (순수 함수) */
|
|
13
26
|
export declare function shouldWarn(contextPercent: {
|
|
14
27
|
promptCount: number;
|
|
@@ -21,8 +21,111 @@ import { approve, approveWithContext, approveWithWarning, failOpenWithTracking }
|
|
|
21
21
|
import { HANDOFFS_DIR, STATE_DIR } from '../core/paths.js';
|
|
22
22
|
import { recordHookTiming } from './shared/hook-timing.js';
|
|
23
23
|
import { sanitizeId } from './shared/sanitize-id.js';
|
|
24
|
+
import { redactSecrets } from './secret-filter.js';
|
|
24
25
|
const log = createLogger('context-guard');
|
|
25
26
|
const CONTEXT_STATE_PATH = path.join(STATE_DIR, 'context-guard.json');
|
|
27
|
+
const PROMPT_HISTORY_PATH = path.join(STATE_DIR, 'prompt-history.jsonl');
|
|
28
|
+
const PROMPT_HISTORY_TRUNCATE = 1024; // ADR-008: 1KB cap per entry
|
|
29
|
+
const RATE_LIMIT_MISSES_PATH = path.join(STATE_DIR, 'rate-limit-misses.jsonl');
|
|
30
|
+
// ADR-008: detection regex 분리. token-limit 은 context window, rate-limit 은 API quota.
|
|
31
|
+
export const TOKEN_LIMIT_REGEX = /context.*limit|token.*limit|conversation.*too.*long/i;
|
|
32
|
+
export const RATE_LIMIT_REGEX = /rate.?limit|5.?hour.*limit|weekly.*limit|usage.*limit|quota.*exceeded/i;
|
|
33
|
+
/**
|
|
34
|
+
* Best-effort reset 시각 파서 (ADR-008 §2).
|
|
35
|
+
*
|
|
36
|
+
* 5 패턴 시도, 모두 실패 시 null. 실제 메시지 포맷은 Claude/Codex CLI 의
|
|
37
|
+
* 공식 contract 가 아니므로 hotfix 가능한 best-effort.
|
|
38
|
+
*
|
|
39
|
+
* @param msg stderr/error message text
|
|
40
|
+
* @param now epoch ms (테스트 결정성 위해 주입 가능)
|
|
41
|
+
* @returns ISO timestamp 또는 null
|
|
42
|
+
*/
|
|
43
|
+
export function parseRateLimitResetAt(msg, now = Date.now()) {
|
|
44
|
+
// Pattern 4: explicit ISO timestamp — "available again at <ISO>"
|
|
45
|
+
const isoMatch = msg.match(/(?:available|reset|retry)\s+(?:again\s+)?(?:at|on)\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?)/i);
|
|
46
|
+
if (isoMatch) {
|
|
47
|
+
const ts = Date.parse(isoMatch[1]);
|
|
48
|
+
if (Number.isFinite(ts))
|
|
49
|
+
return new Date(ts).toISOString();
|
|
50
|
+
}
|
|
51
|
+
// Pattern 2: "Resets in 4h 12m" / "in 4h" / "in 12m"
|
|
52
|
+
const hMin = msg.match(/(?:reset|retry|try\s+again)s?\s+in\s+(?:(\d+)\s*h)?\s*(?:(\d+)\s*m(?:in)?)?/i);
|
|
53
|
+
if (hMin && (hMin[1] || hMin[2])) {
|
|
54
|
+
const hours = parseInt(hMin[1] ?? '0', 10);
|
|
55
|
+
const mins = parseInt(hMin[2] ?? '0', 10);
|
|
56
|
+
const offset = (hours * 3600 + mins * 60) * 1000;
|
|
57
|
+
if (offset > 0)
|
|
58
|
+
return new Date(now + offset).toISOString();
|
|
59
|
+
}
|
|
60
|
+
// Pattern 3: "Resets in 18000 seconds"
|
|
61
|
+
const secMatch = msg.match(/(?:reset|retry|try\s+again)s?\s+in\s+(\d+)\s*sec(?:ond)?s?/i);
|
|
62
|
+
if (secMatch) {
|
|
63
|
+
const sec = parseInt(secMatch[1], 10);
|
|
64
|
+
if (sec > 0)
|
|
65
|
+
return new Date(now + sec * 1000).toISOString();
|
|
66
|
+
}
|
|
67
|
+
// Pattern 1: "Resets at HH:MM(:SS)? TZ" — TZ 미지원 (UTC 가정)
|
|
68
|
+
const hhmm = msg.match(/(?:reset|retry|available)s?\s+at\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(UTC|GMT|PST|PDT|EST|EDT|KST|JST)?/i);
|
|
69
|
+
if (hhmm) {
|
|
70
|
+
const h = parseInt(hhmm[1], 10);
|
|
71
|
+
const m = parseInt(hhmm[2], 10);
|
|
72
|
+
const s = parseInt(hhmm[3] ?? '0', 10);
|
|
73
|
+
if (h < 24 && m < 60 && s < 60) {
|
|
74
|
+
const d = new Date(now);
|
|
75
|
+
d.setUTCHours(h, m, s, 0);
|
|
76
|
+
// 이미 지난 시각이면 다음 날
|
|
77
|
+
if (d.getTime() <= now)
|
|
78
|
+
d.setUTCDate(d.getUTCDate() + 1);
|
|
79
|
+
return d.toISOString();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Detector 실패 (RATE_LIMIT_REGEX 매칭 실패) raw 메시지를 누적.
|
|
86
|
+
* ADR-008 §5: 5건 누적 시 사용자 경고 — patch release 로 hotfix 신호.
|
|
87
|
+
*/
|
|
88
|
+
function logRateLimitMiss(errorMsg) {
|
|
89
|
+
try {
|
|
90
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
91
|
+
fs.appendFileSync(RATE_LIMIT_MISSES_PATH, `${JSON.stringify({
|
|
92
|
+
timestamp: new Date().toISOString(),
|
|
93
|
+
message: errorMsg.slice(0, 500),
|
|
94
|
+
})}\n`);
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
log.debug('rate-limit-miss log 실패', e);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Append-only prompt history writer (docs/codex-integration.md §"prompt-history.jsonl").
|
|
102
|
+
*
|
|
103
|
+
* compound-extractor.ts:547 의 read 코드가 0.4.5 까지 dead code 였음. 0.4.6
|
|
104
|
+
* 부터 UserPromptSubmit hook 에서 truncated prompt 를 append 하여 활성화.
|
|
105
|
+
*
|
|
106
|
+
* fail-open: 실패는 hook 차단하지 않음.
|
|
107
|
+
*/
|
|
108
|
+
function appendPromptHistory(sessionId, prompt) {
|
|
109
|
+
try {
|
|
110
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
111
|
+
// ADR-008 critical: secret/PII redaction via secret-filter regex 재사용.
|
|
112
|
+
// password/api-key 가 평문 prompt 에 들어가면 그대로 disk 에 박제되는 위험 차단.
|
|
113
|
+
const safePrompt = redactSecrets(prompt).redacted;
|
|
114
|
+
const truncated = safePrompt.length > PROMPT_HISTORY_TRUNCATE
|
|
115
|
+
? safePrompt.slice(0, PROMPT_HISTORY_TRUNCATE)
|
|
116
|
+
: safePrompt;
|
|
117
|
+
const entry = JSON.stringify({
|
|
118
|
+
timestamp: new Date().toISOString(),
|
|
119
|
+
sessionId,
|
|
120
|
+
promptLength: prompt.length,
|
|
121
|
+
prompt: truncated,
|
|
122
|
+
});
|
|
123
|
+
fs.appendFileSync(PROMPT_HISTORY_PATH, `${entry}\n`);
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
log.debug('prompt-history append 실패', e);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
26
129
|
// 경고 임계값: 프롬프트 50회 또는 총 문자 수 200K 이상
|
|
27
130
|
const PROMPT_WARNING_THRESHOLD = 50;
|
|
28
131
|
const CHARS_WARNING_THRESHOLD = 200_000;
|
|
@@ -108,16 +211,20 @@ export async function main() {
|
|
|
108
211
|
console.log(forgeLoopBlock);
|
|
109
212
|
return;
|
|
110
213
|
}
|
|
111
|
-
// 에러가 포함된 경우: context limit 감지
|
|
214
|
+
// 에러가 포함된 경우: context-limit / rate-limit 감지 (ADR-008)
|
|
112
215
|
if (input.error) {
|
|
113
216
|
const errorMsg = input.error;
|
|
114
|
-
|
|
217
|
+
// FORGEN_RUNTIME 은 buildEnv (config-injector.ts:479) 에서 spawn 시 주입.
|
|
218
|
+
// hook 은 claude/codex CLI 를 통해 invoke 되어 부모 env 상속.
|
|
219
|
+
const runtime = process.env.FORGEN_RUNTIME ?? 'claude';
|
|
220
|
+
if (TOKEN_LIMIT_REGEX.test(errorMsg)) {
|
|
115
221
|
saveHandoff(sessionId, 'context-limit', errorMsg);
|
|
116
222
|
try {
|
|
117
223
|
const resumePath = path.join(STATE_DIR, 'pending-resume.json');
|
|
118
224
|
fs.writeFileSync(resumePath, JSON.stringify({
|
|
119
225
|
reason: 'token-limit',
|
|
120
226
|
sessionId,
|
|
227
|
+
runtime,
|
|
121
228
|
savedAt: new Date().toISOString(),
|
|
122
229
|
cwd: process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd(),
|
|
123
230
|
}, null, 2));
|
|
@@ -126,6 +233,30 @@ export async function main() {
|
|
|
126
233
|
console.log(approveWithWarning(`[Forgen] Context limit reached. Current state has been saved to ~/.forgen/handoffs/.\nThe previous work will be automatically recovered in the next session.`));
|
|
127
234
|
return;
|
|
128
235
|
}
|
|
236
|
+
if (RATE_LIMIT_REGEX.test(errorMsg)) {
|
|
237
|
+
const resetAt = parseRateLimitResetAt(errorMsg);
|
|
238
|
+
saveHandoff(sessionId, 'rate-limit', errorMsg);
|
|
239
|
+
try {
|
|
240
|
+
const resumePath = path.join(STATE_DIR, 'pending-resume.json');
|
|
241
|
+
fs.writeFileSync(resumePath, JSON.stringify({
|
|
242
|
+
reason: 'rate-limit',
|
|
243
|
+
sessionId,
|
|
244
|
+
runtime,
|
|
245
|
+
resetAt,
|
|
246
|
+
savedAt: new Date().toISOString(),
|
|
247
|
+
cwd: process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd(),
|
|
248
|
+
}, null, 2));
|
|
249
|
+
}
|
|
250
|
+
catch { /* fail-open */ }
|
|
251
|
+
const eta = resetAt ? `at ${resetAt}` : 'with backoff schedule';
|
|
252
|
+
console.log(approveWithWarning(`[Forgen] Rate limit reached. Auto-resume scheduled ${eta}. State saved to ~/.forgen/handoffs/.`));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// ADR-008 §5: detection 실패 raw 누적 → patch hotfix 신호
|
|
256
|
+
// (어느 limit regex 도 매칭 안 됨 = unknown error 또는 detector miss)
|
|
257
|
+
if (/limit|quota|throttle/i.test(errorMsg)) {
|
|
258
|
+
logRateLimitMiss(errorMsg);
|
|
259
|
+
}
|
|
129
260
|
}
|
|
130
261
|
// 정상 종료 시: 의미 있는 세션이었으면 compound 안내/자동 트리거
|
|
131
262
|
if (input.stop_hook_type === 'user' || input.stop_hook_type === 'end_turn') {
|
|
@@ -175,6 +306,8 @@ export async function main() {
|
|
|
175
306
|
const state = loadContextState(sessionId);
|
|
176
307
|
state.promptCount++;
|
|
177
308
|
state.totalChars += input.prompt.length;
|
|
309
|
+
// ADR-008 / docs/codex-integration.md — prompt-history writer 활성화
|
|
310
|
+
appendPromptHistory(sessionId, input.prompt);
|
|
178
311
|
// auto-compact: 추적 문자 120K 이상이면 compact 지시 주입
|
|
179
312
|
const autoCompactThreshold = typeof config?.autoCompactChars === 'number' ? config.autoCompactChars : undefined;
|
|
180
313
|
if (shouldAutoCompact(state, autoCompactThreshold !== undefined ? { charsThreshold: autoCompactThreshold } : {})) {
|
|
@@ -246,7 +379,24 @@ const FORGE_LOOP_STATE_PATH = path.join(STATE_DIR, 'forge-loop.json');
|
|
|
246
379
|
* dedup 파일은 session-recovery hook 과 공유되어 double-run 방지.
|
|
247
380
|
* fire-and-forget (detached) — hook timeout 과 무관.
|
|
248
381
|
*/
|
|
249
|
-
const AUTO_COMPOUND_COOLDOWN_MS = 5 * 60 * 1000; // 5 min
|
|
382
|
+
const AUTO_COMPOUND_COOLDOWN_MS = 5 * 60 * 1000; // 5 min (default)
|
|
383
|
+
const AUTO_COMPOUND_BARREN_COOLDOWN_MS = 30 * 60 * 1000; // 30 min if last run extracted nothing
|
|
384
|
+
/**
|
|
385
|
+
* 0.4.6 perf #12 — adaptive cooldown.
|
|
386
|
+
*
|
|
387
|
+
* 마지막 auto-compound run 이 0건 추출했으면 (barren), 다음 cooldown 을 5min →
|
|
388
|
+
* 30min 으로 확장. 같은 session 의 짧은 prompt 추가에서 동일 transcript 로 매번
|
|
389
|
+
* 3 LLM 호출하는 wasted run 차단. completed marker 의 extractedSolutions /
|
|
390
|
+
* promotedRules / userPatternFound 합산이 0 이면 barren 판정.
|
|
391
|
+
*
|
|
392
|
+
* 일반 case (추출 있음) 은 5분 cooldown 유지 — adaptive 가 sparsity 시그널을
|
|
393
|
+
* 강화할 뿐 정상 동작 차단 안 함.
|
|
394
|
+
*/
|
|
395
|
+
function effectiveCooldownMs(parsed) {
|
|
396
|
+
const total = (parsed.extractedSolutions ?? 0) + (parsed.promotedRules ?? 0)
|
|
397
|
+
+ (parsed.userPatternFound ? 1 : 0);
|
|
398
|
+
return total === 0 ? AUTO_COMPOUND_BARREN_COOLDOWN_MS : AUTO_COMPOUND_COOLDOWN_MS;
|
|
399
|
+
}
|
|
250
400
|
async function maybeSpawnAutoCompound(sessionId, transcriptPath, promptCount) {
|
|
251
401
|
if (!transcriptPath || promptCount < 10)
|
|
252
402
|
return;
|
|
@@ -256,7 +406,8 @@ async function maybeSpawnAutoCompound(sessionId, transcriptPath, promptCount) {
|
|
|
256
406
|
const parsed = JSON.parse(raw);
|
|
257
407
|
if (parsed.sessionId === sessionId) {
|
|
258
408
|
const last = parsed.completedAt ? Date.parse(parsed.completedAt) : 0;
|
|
259
|
-
|
|
409
|
+
const cooldown = effectiveCooldownMs(parsed);
|
|
410
|
+
if (Number.isFinite(last) && Date.now() - last < cooldown)
|
|
260
411
|
return;
|
|
261
412
|
}
|
|
262
413
|
}
|
|
@@ -21,6 +21,7 @@ import { STATE_DIR } from '../core/paths.js';
|
|
|
21
21
|
import { recordHookTiming } from './shared/hook-timing.js';
|
|
22
22
|
import { createDriftState, evaluateDrift } from '../core/drift-score.js';
|
|
23
23
|
import { appendImplicitFeedback } from '../store/implicit-feedback-store.js';
|
|
24
|
+
import { recordToolCall } from '../core/usage-telemetry.js';
|
|
24
25
|
const RECENT_TOOL_NAMES_WINDOW = 20;
|
|
25
26
|
/** Lightweight hash for content comparison (not cryptographic) */
|
|
26
27
|
function simpleHash(content) {
|
|
@@ -118,6 +119,8 @@ async function main() {
|
|
|
118
119
|
console.log(approve());
|
|
119
120
|
return;
|
|
120
121
|
}
|
|
122
|
+
// ADR-008 / 0.4.6 — usage telemetry. fail-open, hook 차단 안 함.
|
|
123
|
+
recordToolCall(process.env.FORGEN_RUNTIME ?? 'claude');
|
|
121
124
|
const toolName = data.tool_name ?? data.toolName ?? '';
|
|
122
125
|
const toolInput = data.tool_input ?? data.toolInput ?? {};
|
|
123
126
|
// tool_response 는 string / object / array 모두 가능 (sub-agent 결과는 object 가 흔함).
|
|
@@ -165,6 +165,32 @@ function getActiveReminders() {
|
|
|
165
165
|
}
|
|
166
166
|
return reminders;
|
|
167
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Tool-call supplement log (ADR-008 / docs/codex-integration.md).
|
|
170
|
+
*
|
|
171
|
+
* Codex `approval_policy=auto`/`workspace-write` 환경에서 PermissionRequest
|
|
172
|
+
* hook 이 dispatch 되지 않아 `permissions-<id>.jsonl` 가 생성되지 않는 갭을
|
|
173
|
+
* 보완. 모든 PreToolUse 호출에서 동일 파일에 append (source='pre-tool-use'
|
|
174
|
+
* 로 구분). permission-handler 의 entry 와 source 필드로 reader 측 dedup.
|
|
175
|
+
*
|
|
176
|
+
* fail-open: write 실패는 hook 차단하지 않음.
|
|
177
|
+
*/
|
|
178
|
+
function logToolCallSupplement(sessionId, toolName) {
|
|
179
|
+
try {
|
|
180
|
+
const logPath = path.join(STATE_DIR, `permissions-${sanitizeId(sessionId)}.jsonl`);
|
|
181
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
182
|
+
const entry = JSON.stringify({
|
|
183
|
+
timestamp: new Date().toISOString(),
|
|
184
|
+
tool: toolName,
|
|
185
|
+
decision: 'pre-approved',
|
|
186
|
+
source: 'pre-tool-use',
|
|
187
|
+
});
|
|
188
|
+
fs.appendFileSync(logPath, `${entry}\n`);
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
log.debug('tool-call supplement log 실패', e);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
168
194
|
/** 연속 파싱 실패 카운터 관리 */
|
|
169
195
|
function getAndIncrementFailCount() {
|
|
170
196
|
try {
|
|
@@ -322,6 +348,11 @@ async function main() {
|
|
|
322
348
|
const toolName = data.tool_name ?? data.toolName ?? '';
|
|
323
349
|
const toolInput = data.tool_input ?? data.toolInput ?? {};
|
|
324
350
|
const sessionId = data.session_id ?? 'default';
|
|
351
|
+
// ADR-008 / codex-integration.md — Codex `approval_policy=auto` 에서
|
|
352
|
+
// PermissionRequest 가 dispatch 안 되는 갭 보완. 모든 tool call 을
|
|
353
|
+
// permissions-<id>.jsonl 에 supplement 로 append (source='pre-tool-use').
|
|
354
|
+
if (toolName)
|
|
355
|
+
logToolCallSupplement(sessionId, toolName);
|
|
325
356
|
// ADR-001 Mech-A PreToolUse dispatcher — 사용자가 정의한 rule 이 빌트인 위험-명령 감지보다 먼저.
|
|
326
357
|
// 이렇게 해야 rule.block_message (맥락 있는 안내) 가 제네릭 "Dangerous command blocked" 대신 노출됨.
|
|
327
358
|
// fail-open: 예외는 hook 차단 안 함.
|
|
@@ -149,6 +149,17 @@ async function main() {
|
|
|
149
149
|
console.log(approve());
|
|
150
150
|
return;
|
|
151
151
|
}
|
|
152
|
+
// 0.4.6 — codex/claude 진입점 양쪽에서 v1-bootstrap 보장.
|
|
153
|
+
// 이전엔 prepareHarness (fgx/forgen wrapper) 만 호출 → 직접 claude/codex 호출 시
|
|
154
|
+
// ~/.forgen/state/sessions/<id>.json 미생성. SessionStart hook 에서도 호출하여
|
|
155
|
+
// 양쪽 진입 경로 모두에서 session state 박제.
|
|
156
|
+
try {
|
|
157
|
+
const { bootstrapV1Session } = await import('../core/v1-bootstrap.js');
|
|
158
|
+
bootstrapV1Session();
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
log.debug('v1-bootstrap SessionStart 호출 실패 (fail-open)', e);
|
|
162
|
+
}
|
|
152
163
|
if (!fs.existsSync(STATE_DIR)) {
|
|
153
164
|
console.log(approve());
|
|
154
165
|
return;
|
|
@@ -5,54 +5,69 @@
|
|
|
5
5
|
* (for await of process.stdin은 일부 환경에서 hang 발생)
|
|
6
6
|
*/
|
|
7
7
|
const MAX_STDIN_BYTES = 10 * 1024 * 1024; // 10MB — 메모리 고갈 방지
|
|
8
|
+
/**
|
|
9
|
+
* 0.4.6 perf #11 — idle-based early resolve + initial-wait fallback.
|
|
10
|
+
*
|
|
11
|
+
* Claude/Codex CLI 가 hook 호출 후 stdin 을 EOF 닫지 않고 hang on 하는 케이스가 있어
|
|
12
|
+
* 이전엔 timeoutMs (2000ms) 까지 무조건 대기 → p95 hook latency 가 2003ms 였음
|
|
13
|
+
* (hook-timing.jsonl 측정 결과).
|
|
14
|
+
*
|
|
15
|
+
* 두 단계 fix:
|
|
16
|
+
* 1. IDLE_RESOLVE_MS — 'data' 받은 후 추가 chunk 없으면 100ms 후 resolve
|
|
17
|
+
* 2. INITIAL_WAIT_MS — 'data' 자체가 안 오는 케이스 (codex 일부 hook event)
|
|
18
|
+
* 를 대비해 INITIAL_WAIT_MS 후 chunks 가 비어 있으면 빈 데이터로 resolve
|
|
19
|
+
*
|
|
20
|
+
* 합법적 데이터 손실 위험 없음 — Claude/Codex 의 stdin 페이로드는 호출 즉시
|
|
21
|
+
* (≤ INITIAL_WAIT_MS) 첫 chunk 도착. 안 오면 그 이벤트는 stdin 자체가 없는 것.
|
|
22
|
+
*/
|
|
23
|
+
const IDLE_RESOLVE_MS = 100;
|
|
24
|
+
const INITIAL_WAIT_MS = 300;
|
|
8
25
|
/** stdin에서 JSON 데이터를 읽어 파싱. 실패 시 null 반환. */
|
|
9
26
|
export async function readStdinJSON(timeoutMs = 2000) {
|
|
10
27
|
const chunks = [];
|
|
11
28
|
let totalSize = 0;
|
|
12
29
|
let settled = false;
|
|
30
|
+
let idleTimer = null;
|
|
13
31
|
const raw = await new Promise((resolve) => {
|
|
14
|
-
const
|
|
15
|
-
if (
|
|
16
|
-
|
|
17
|
-
|
|
32
|
+
const settle = (clearIdle = true) => {
|
|
33
|
+
if (settled)
|
|
34
|
+
return;
|
|
35
|
+
settled = true;
|
|
36
|
+
if (clearIdle && idleTimer)
|
|
37
|
+
clearTimeout(idleTimer);
|
|
38
|
+
clearTimeout(timeout);
|
|
39
|
+
process.stdin.removeAllListeners();
|
|
40
|
+
if (typeof process.stdin.pause === 'function')
|
|
18
41
|
process.stdin.pause();
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
42
|
+
resolve(Buffer.concat(chunks).toString('utf-8'));
|
|
43
|
+
};
|
|
44
|
+
const timeout = setTimeout(() => settle(), timeoutMs);
|
|
45
|
+
// perf: INITIAL_WAIT_MS 안에 'data' 가 한 번도 안 오면 stdin 부재로 간주, 조기 resolve.
|
|
46
|
+
// codex 일부 hook event (e.g. SessionStart, Stop without payload) 가 stdin 안 보내는 케이스 대응.
|
|
47
|
+
const initialWait = setTimeout(() => {
|
|
48
|
+
if (!settled && chunks.length === 0)
|
|
49
|
+
settle();
|
|
50
|
+
}, INITIAL_WAIT_MS);
|
|
22
51
|
// 일부 Node.js 환경에서 stdin이 paused 상태로 시작 — 명시적 resume 필요
|
|
23
52
|
if (typeof process.stdin.resume === 'function') {
|
|
24
53
|
process.stdin.resume();
|
|
25
54
|
}
|
|
26
55
|
process.stdin.on('data', (chunk) => {
|
|
56
|
+
clearTimeout(initialWait);
|
|
27
57
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
28
58
|
totalSize += buf.length;
|
|
29
59
|
if (totalSize > MAX_STDIN_BYTES) {
|
|
30
|
-
|
|
31
|
-
settled = true;
|
|
32
|
-
clearTimeout(timeout);
|
|
33
|
-
process.stdin.removeAllListeners();
|
|
34
|
-
if (typeof process.stdin.pause === 'function')
|
|
35
|
-
process.stdin.pause();
|
|
36
|
-
resolve('');
|
|
37
|
-
}
|
|
60
|
+
settle();
|
|
38
61
|
return;
|
|
39
62
|
}
|
|
40
63
|
chunks.push(buf);
|
|
64
|
+
// perf: 데이터 수신 후 IDLE_RESOLVE_MS 동안 추가 chunk 없으면 early resolve
|
|
65
|
+
if (idleTimer)
|
|
66
|
+
clearTimeout(idleTimer);
|
|
67
|
+
idleTimer = setTimeout(() => settle(false), IDLE_RESOLVE_MS);
|
|
41
68
|
});
|
|
42
|
-
process.stdin.on('end', () =>
|
|
43
|
-
|
|
44
|
-
settled = true;
|
|
45
|
-
clearTimeout(timeout);
|
|
46
|
-
resolve(Buffer.concat(chunks).toString('utf-8'));
|
|
47
|
-
}
|
|
48
|
-
});
|
|
49
|
-
process.stdin.on('error', () => {
|
|
50
|
-
if (!settled) {
|
|
51
|
-
settled = true;
|
|
52
|
-
clearTimeout(timeout);
|
|
53
|
-
resolve('');
|
|
54
|
-
}
|
|
55
|
-
});
|
|
69
|
+
process.stdin.on('end', () => settle());
|
|
70
|
+
process.stdin.on('error', () => settle());
|
|
56
71
|
});
|
|
57
72
|
try {
|
|
58
73
|
return JSON.parse(raw);
|
|
@@ -55,11 +55,16 @@ async function main() {
|
|
|
55
55
|
}
|
|
56
56
|
})();
|
|
57
57
|
try {
|
|
58
|
+
// 0.4.6 fix — delegate hook 이 자기가 codex runtime context 인지 알 수 있게
|
|
59
|
+
// FORGEN_RUNTIME=codex 명시 주입. 이전엔 buildEnv (forgen wrapper) 경로로만
|
|
60
|
+
// set 되어 codex 직접 호출 시 default 'claude' 로 fall-through → usage-telemetry
|
|
61
|
+
// 의 rt 필드가 잘못 박힘 (clean-container e2e 라운드 20 에서 발견).
|
|
58
62
|
const result = spawnSync(process.execPath, [delegatePath, ...restArgs], {
|
|
59
63
|
encoding: 'utf-8',
|
|
60
64
|
input: JSON.stringify(input),
|
|
61
65
|
cwd: process.cwd(),
|
|
62
66
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
67
|
+
env: { ...process.env, FORGEN_RUNTIME: 'codex' },
|
|
63
68
|
});
|
|
64
69
|
if (result.error) {
|
|
65
70
|
console.log(JSON.stringify({ continue: true }));
|
|
@@ -33,5 +33,11 @@ export interface HostRuntime {
|
|
|
33
33
|
* - 'pre-baked-file': pkgRoot/hooks/hooks.json 읽고 ${CLAUDE_PLUGIN_ROOT} 치환 (Claude — 빌드 산출물 재사용)
|
|
34
34
|
*/
|
|
35
35
|
readonly hookInjectionStrategy: 'generate' | 'pre-baked-file';
|
|
36
|
+
/**
|
|
37
|
+
* 권한 전수 우회용 CLI 플래그 (fgx 등 dangerously-skip 모드에서 사용).
|
|
38
|
+
* Claude: --dangerously-skip-permissions
|
|
39
|
+
* Codex: --dangerously-bypass-approvals-and-sandbox
|
|
40
|
+
*/
|
|
41
|
+
readonly dangerousSkipFlag: string;
|
|
36
42
|
}
|
|
37
43
|
export declare function getHostRuntime(runtime: RuntimeHost): HostRuntime;
|
|
@@ -25,6 +25,7 @@ const claudeRuntime = {
|
|
|
25
25
|
return args ? `node ${quoteArg(fullScript)} ${args}` : `node ${quoteArg(fullScript)}`;
|
|
26
26
|
},
|
|
27
27
|
hookInjectionStrategy: 'pre-baked-file',
|
|
28
|
+
dangerousSkipFlag: '--dangerously-skip-permissions',
|
|
28
29
|
};
|
|
29
30
|
const codexRuntime = {
|
|
30
31
|
id: 'codex',
|
|
@@ -38,6 +39,7 @@ const codexRuntime = {
|
|
|
38
39
|
return args ? `${base} ${args}` : base;
|
|
39
40
|
},
|
|
40
41
|
hookInjectionStrategy: 'generate',
|
|
42
|
+
dangerousSkipFlag: '--dangerously-bypass-approvals-and-sandbox',
|
|
41
43
|
};
|
|
42
44
|
const RUNTIMES = {
|
|
43
45
|
claude: claudeRuntime,
|
|
@@ -23,13 +23,19 @@ const AGENTS_MD_END = '<!-- <<< forgen-managed-rules -->';
|
|
|
23
23
|
function resolveCodexHome(opts) {
|
|
24
24
|
return opts.codexHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex');
|
|
25
25
|
}
|
|
26
|
+
// 0.4.6 fix — pkgRoot match 외에 script-marker fallback 추가.
|
|
27
|
+
// Stale install path (예: 다른 머신에서 install 한 hooks.json 마운트, 또는
|
|
28
|
+
// node_modules path 변경) 의 forgen entry 를 "user entry" 로 오분류 → 중복 누적
|
|
29
|
+
// 하던 버그. forgen hook 의 시그니처는 dist/host/codex-adapter.js 또는
|
|
30
|
+
// dist/hooks/<name>.js — 사용자 custom hook 과 충돌 가능성 거의 없음.
|
|
31
|
+
const FORGEN_HOOK_SCRIPT_MARKER = /\bdist\/(host\/codex-adapter|hooks\/[a-z][a-z0-9-]+)\.js\b/;
|
|
26
32
|
function isForgenManagedHook(entry, pkgRoot) {
|
|
27
33
|
if (!entry || typeof entry !== 'object')
|
|
28
34
|
return false;
|
|
29
35
|
const e = entry;
|
|
30
36
|
if (!Array.isArray(e.hooks))
|
|
31
37
|
return false;
|
|
32
|
-
return e.hooks.some((h) => typeof h.command === 'string' && h.command.includes(pkgRoot));
|
|
38
|
+
return e.hooks.some((h) => typeof h.command === 'string' && (h.command.includes(pkgRoot) || FORGEN_HOOK_SCRIPT_MARKER.test(h.command)));
|
|
33
39
|
}
|
|
34
40
|
function readJsonFile(p) {
|
|
35
41
|
try {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import * as path from 'node:path';
|
|
14
14
|
import * as readline from 'node:readline';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
15
16
|
import { detectAvailableHosts } from '../core/host-detect.js';
|
|
16
17
|
import { planClaudeInstall } from './install-claude.js';
|
|
17
18
|
import { planCodexInstall } from './install-codex.js';
|
|
@@ -121,6 +122,6 @@ export function renderResult(result, dryRun) {
|
|
|
121
122
|
}
|
|
122
123
|
/** pkgRoot resolve from binary location (dist/cli.js → pkgRoot). */
|
|
123
124
|
export function resolvePkgRootFromBinary(metaUrl) {
|
|
124
|
-
const here = path.dirname(
|
|
125
|
+
const here = path.dirname(fileURLToPath(metaUrl));
|
|
125
126
|
return path.resolve(here, '..');
|
|
126
127
|
}
|
package/dist/services/session.js
CHANGED
|
@@ -85,6 +85,19 @@ export function resolveLaunchContext(args) {
|
|
|
85
85
|
}
|
|
86
86
|
continue;
|
|
87
87
|
}
|
|
88
|
+
// 0.4.6 — shortcut flags `--codex` / `--claude` (= `--runtime codex|claude`).
|
|
89
|
+
// fgx --codex 같은 짧은 호출을 위해. claude/codex CLI 가 동일 이름 플래그를
|
|
90
|
+
// 자체적으로 정의하지 않는 한 forwarding 충돌 없음.
|
|
91
|
+
if (arg === '--codex') {
|
|
92
|
+
result.runtime = 'codex';
|
|
93
|
+
result.runtimeSource = 'flag';
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (arg === '--claude') {
|
|
97
|
+
result.runtime = 'claude';
|
|
98
|
+
result.runtimeSource = 'flag';
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
88
101
|
result.args.push(arg);
|
|
89
102
|
}
|
|
90
103
|
return result;
|