sprag-cli 3.59.0 → 3.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -109,6 +109,23 @@ LLMRouterBench(Findings of ACL 2026)에서 오프라인으로 측정했습니다
109
109
 
110
110
  위임된 실행은 한 건마다 단가 차액을 기록하므로, `sprag route-scan savings` 가 어떤 금액이든 그 원인이 된 규칙까지 역추적해 보여 줍니다. 집계에서 무엇을 빼는지는 [명령 문서](https://github.com/rootstudioyaml/sprag/blob/main/docs/COMMANDS.ko.md)에 적어 두었습니다.
111
111
 
112
+ ## 티어 기준의 근거가 된 연구
113
+
114
+ 티어 기준은 감으로 정한 값이 아닙니다. 기준마다 공개된 라우팅 연구를 근거로 삼았고, 룰 문구에 나오는 수치(지식 회상 28% 대 54%, 도구 체인 작업의 +63%p 격차)는 아래 데이터셋으로 sprag가 직접 오프라인 실측한 값입니다.
115
+
116
+ | 기준 | 논문이 밝힌 내용 | 논문 |
117
+ |---|---|---|
118
+ | 보수적으로 내려보낸다 | 학습형 라우터는 예산이 커질수록 더 싼 모델로 충분한 경우에도 가장 비싼 모델을 고르는 쪽으로 수렴합니다. | Lai & Ye, [When Routing Collapses: On the Degenerate Convergence of LLM Routers](https://arxiv.org/abs/2602.03478) (2026) |
119
+ | 난이도는 결과로 판정한다 | 기존 라우터는 모두 오라클에 못 미치며, 주된 원인은 작은 모델로 충분한 경우를 알아보지 못하는 데 있습니다. 문항 난이도는 42개 모델 중 실제로 맞힌 모델 수로 정의합니다. | Lu et al., [RouterArena: An Open Platform for Comprehensive Comparison of LLM Routers](https://arxiv.org/abs/2510.00202) (2025) |
120
+ | 임계값은 내 로그에서 정한다 | 예측한 승률을 비용 임계값과 비교해 라우팅하고, 결과는 강한 모델로 보낸 호출 비율로 읽습니다. sprag는 스캔할 때마다 최근 14일 분포에서 이 임계값을 다시 계산합니다. | Ong et al., [RouteLLM: Learning to Route LLMs with Preference Data](https://arxiv.org/abs/2406.18665) (2024) |
121
+ | 실패하면 위로 올린다 | 캐스케이드는 싼 모델부터 시도하고, 답을 검사한 결과로 상위 모델로 올릴지 정합니다. GPT-4와 같은 성능을 최대 98% 낮은 비용으로 냈습니다. | Chen, Zaharia & Zou, [FrugalGPT](https://arxiv.org/abs/2305.05176) (2023) |
122
+ | 답이 어디에 있나 | 짧은 사실 회상 능력만 따로 측정합니다. 지식 회상 28% 대 54% 격차는 이 계열의 데이터셋에서 나왔습니다. | Wei et al., [Measuring short-form factuality in large language models (SimpleQA)](https://arxiv.org/abs/2411.04368) (2024) |
123
+ | 도구 오케스트레이션은 내리지 않는다 | 에이전트가 공유 환경에서 사용자와 함께 도구 호출을 조율해야 하는 벤치마크입니다. +63%p 격차를 여기서 측정했습니다. | Barres et al., [τ²-Bench](https://arxiv.org/abs/2506.07982) (2025) |
124
+ | 공개 벤치마크 실측 | 40만 문항, 21개 데이터셋, 33개 모델 규모이며, 상용 제품을 포함한 여러 라우터가 단순한 기준선을 안정적으로 넘지 못했습니다. | Li et al., [LLMRouterBench](https://arxiv.org/abs/2601.07206) (Findings of ACL 2026) |
125
+ | 라우팅과 캐스케이드 | 라우팅 결정을 언제 내리는지, 무엇을 읽는지, 어떻게 계산하는지로 기존 방식을 분류합니다. | Moslem & Kelleher, [Dynamic Model Routing and Cascading for Efficient LLM Inference: A Survey](https://arxiv.org/abs/2603.04445) (2026) |
126
+
127
+ 논문 근거가 없는 기준도 두 개 있습니다. 같은 파일을 읽는 작업을 한 에이전트에 묶는 기준과, 설계와 코드 작성을 나누는 기준은 sprag의 비용 집계에서 나왔습니다. 서브에이전트에서는 생성보다 컨텍스트 적재 비용이 더 크기 때문입니다. 자세한 근거는 [티어 기준 문서](https://github.com/rootstudioyaml/sprag/blob/main/docs/TIER_CRITERIA.md)에 있습니다.
128
+
112
129
  ## 설치 한 번에 전부 들어 있고, 첫날부터 동작합니다
113
130
 
114
131
  | | 하는 일 | 더 보기 |
package/README.md CHANGED
@@ -140,6 +140,29 @@ ledger. Nothing here is a projection.
140
140
 
141
141
  Every delegated run records its own price difference, so `sprag route-scan savings` traces any amount back to the rule that caused it. What the ledger deliberately leaves out is written down in [the command reference](https://github.com/rootstudioyaml/sprag/blob/main/docs/COMMANDS.md).
142
142
 
143
+ ## Research behind the tier rules
144
+
145
+ The tier criteria are not a hunch. Each one traces back to published routing
146
+ research, and the percentages quoted in the rules (28% vs 54% recall, a +63pp
147
+ gap on chained tool work) are sprag's own offline measurement on the datasets
148
+ below.
149
+
150
+ | Criterion | What the paper found | Paper |
151
+ |---|---|---|
152
+ | Delegate down, conservatively | As budgets grow, learned routers default to the most expensive model even when a cheaper one already suffices. | Lai & Ye, [When Routing Collapses: On the Degenerate Convergence of LLM Routers](https://arxiv.org/abs/2602.03478) (2026) |
153
+ | Judge difficulty by outcome | Every router on the board falls short of the oracle, mainly because it cannot tell when a smaller model is enough. Query difficulty is defined by how many of 42 models actually answered it. | Lu et al., [RouterArena: An Open Platform for Comprehensive Comparison of LLM Routers](https://arxiv.org/abs/2510.00202) (2025) |
154
+ | Thresholds from your own logs | Routing compares a predicted win rate against a cost threshold, and results are read by the share of calls sent to the strong model. sprag sets that threshold from your last 14 days at every scan. | Ong et al., [RouteLLM: Learning to Route LLMs with Preference Data](https://arxiv.org/abs/2406.18665) (2024) |
155
+ | Escalate on failure | A cascade tries the cheap model first and lets a check on the answer decide whether to go up. It matched GPT-4 with up to 98% lower cost. | Chen, Zaharia & Zou, [FrugalGPT](https://arxiv.org/abs/2305.05176) (2023) |
156
+ | Where the answer lives | Short-form factual recall measured on its own. The 28% vs 54% recall gap comes from this class of set. | Wei et al., [Measuring short-form factuality in large language models (SimpleQA)](https://arxiv.org/abs/2411.04368) (2024) |
157
+ | Tool orchestration stays up | Agents must coordinate tool calls with a user in a shared environment. The +63pp gap was measured here. | Barres et al., [τ²-Bench](https://arxiv.org/abs/2506.07982) (2025) |
158
+ | The public benchmark run | 400K instances, 21 datasets, 33 models. Several routers, commercial ones included, fail to beat a simple baseline. | Li et al., [LLMRouterBench](https://arxiv.org/abs/2601.07206) (Findings of ACL 2026) |
159
+ | Routing vs cascading | A taxonomy of when a routing decision is made, what it reads, and how it is computed. | Moslem & Kelleher, [Dynamic Model Routing and Cascading for Efficient LLM Inference: A Survey](https://arxiv.org/abs/2603.04445) (2026) |
160
+
161
+ Two criteria have no paper behind them. Batching tasks that read the same files
162
+ and splitting design from code writing come from sprag's own cost accounting:
163
+ on a sub-agent, loading context costs more than generating. The full reasoning,
164
+ in Korean, is in [Tier criteria](https://github.com/rootstudioyaml/sprag/blob/main/docs/TIER_CRITERIA.md).
165
+
143
166
  ## Everything ships in one install, working from day one
144
167
 
145
168
  | | What it does | More |
package/bin/cli.js CHANGED
@@ -57,7 +57,7 @@ import { sessionCostAcross } from '../src/claude-price.js';
57
57
  import { chipForIssues } from '../src/advice.js';
58
58
  import { debug } from '../src/debug.js';
59
59
  import { createArgs } from '../src/cli-args.js';
60
- import { updateStatus, maybeSpawnUpdateCheck } from '../src/update-check.js';
60
+ import { updateStatus, maybeSpawnUpdateCheck, readUpdateState, updateCheckDisabled } from '../src/update-check.js';
61
61
  import { selectAgent } from '../src/agent.js';
62
62
 
63
63
  const selection = (() => {
@@ -86,7 +86,13 @@ const { getArg, hasFlag, numArg } = createArgs(args);
86
86
  function readUpdateChip() {
87
87
  try {
88
88
  maybeSpawnUpdateCheck(PKG_VERSION);
89
- return updateStatus(PKG_VERSION);
89
+ const status = updateStatus(PKG_VERSION);
90
+ // When the registry was last asked, for the verbose "✓6h" on an up-to-date
91
+ // version. updateStatus() does not carry it, and a second read of the
92
+ // 200-byte state file is cheaper than a second status computation. With
93
+ // checks switched off the file is a leftover, so it says nothing.
94
+ const checkedAt = updateCheckDisabled() ? null : (Number(readUpdateState().checkedAt) || null);
95
+ return { ...status, checkedAt };
90
96
  } catch (e) {
91
97
  debug('update-check:chip', e);
92
98
  return null;
@@ -823,9 +829,15 @@ async function main() {
823
829
  // the statusline shows the same blank for "no delegation happened" and for
824
830
  // "delegation happened and was silently discarded".
825
831
  let unresolvedRuns = 0;
832
+ // The same cached scan says how old the savings figures are: the ledger is
833
+ // filled by route-scan, so a stale scan means a stale total.
834
+ let routeScanAt = null;
826
835
  try {
827
836
  const { readRouteScan } = await import('../src/route-scan.js');
828
- unresolvedRuns = Number(readRouteScan()?.unresolvedRuns) || 0;
837
+ const scan = readRouteScan();
838
+ unresolvedRuns = Number(scan?.unresolvedRuns) || 0;
839
+ const scannedAt = Date.parse(scan?.scannedAt);
840
+ routeScanAt = Number.isFinite(scannedAt) ? scannedAt : null;
829
841
  } catch (e) {
830
842
  debug('route-scan:unresolved', e);
831
843
  }
@@ -840,6 +852,38 @@ async function main() {
840
852
  debug('config:ttlBucket', e);
841
853
  }
842
854
 
855
+ // Statusline-only additions. Each reuses data already in hand: the sessions
856
+ // parsed above (subagent runs ride on them) and the small model-rules.json
857
+ // the harness chip reads anyway.
858
+ //
859
+ // This session's subagent runs, matched to the session Claude Code is
860
+ // drawing for by the transcript path / session id in its payload.
861
+ let sessionDelegation;
862
+ // Delegation-rule counts, read only when `--segments` names `rules`: the
863
+ // chip is opt-in, so nothing else would look at them.
864
+ let ruleStats;
865
+ const rulesRequested = String(getArg('--segments') || '').split(',').some((s) => s.trim() === 'rules');
866
+ if (isStatusline) {
867
+ try {
868
+ const { sessionSubagentSummary } = await import('../src/statusline-data.js');
869
+ sessionDelegation = sessionSubagentSummary(sessions, {
870
+ transcriptPath: extractTranscriptPath(stdinJson),
871
+ sessionId: extractSessionId(stdinJson),
872
+ });
873
+ } catch (e) {
874
+ debug('statusline-data:session-delegation', e);
875
+ }
876
+ if (rulesRequested) {
877
+ try {
878
+ const { delegationRuleStats } = await import('../src/statusline-data.js');
879
+ const { findProjectRoot } = await import('../src/harness.js');
880
+ ruleStats = delegationRuleStats(findProjectRoot());
881
+ } catch (e) {
882
+ debug('statusline-data:rule-stats', e);
883
+ }
884
+ }
885
+ }
886
+
843
887
  const data = {
844
888
  summary: sum,
845
889
  trend,
@@ -862,6 +906,10 @@ async function main() {
862
906
  delegationTotals,
863
907
  doc2mdTotals,
864
908
  unresolvedRuns,
909
+ // Statusline-only; left undefined elsewhere so --format json keeps its shape.
910
+ routeScanAt: isStatusline ? routeScanAt : undefined,
911
+ sessionDelegation,
912
+ ruleStats,
865
913
  ttlBucket,
866
914
  };
867
915
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.59.0",
3
+ "version": "3.60.0",
4
4
  "description": "Lower your Claude Code bill without changing how you work: simple tasks go to cheaper models, documents go in as compact Markdown, and the statusline shows spend and savings live. Works with Codex too.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/caps-cache.js CHANGED
@@ -9,9 +9,10 @@
9
9
  * than `maxAgeMs` the loader returns null and the table view stays quiet.
10
10
  */
11
11
 
12
- import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
12
+ import { readFileSync, existsSync, mkdirSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import { userDataDir } from './paths.js';
15
+ import { writeViaTmp } from './state-file.js';
15
16
 
16
17
  const CACHE_PATH = join(userDataDir(), 'last-caps.json');
17
18
 
@@ -41,8 +42,7 @@ export function persistSnapshot(snapshot) {
41
42
  // tmp + rename: the statusline reads this every tick and must never see a
42
43
  // half-written file.
43
44
  const tmp = `${CACHE_PATH}.${process.pid}.tmp`;
44
- writeFileSync(tmp, JSON.stringify(payload) + '\n');
45
- renameSync(tmp, CACHE_PATH);
45
+ writeViaTmp(tmp, CACHE_PATH, JSON.stringify(payload) + '\n');
46
46
  } catch {
47
47
  // best-effort cache, never blocks the statusline
48
48
  }
@@ -1,8 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readFileSync, writeFileSync, mkdirSync, renameSync, readdirSync } from 'node:fs';
2
+ import { readFileSync, mkdirSync, readdirSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { codexUserDir } from './agent.js';
5
5
  import { userDataDir } from './paths.js';
6
+ import { writeViaTmp } from './state-file.js';
6
7
  import { readCodexSnapshot } from './codex-parser.js';
7
8
  import { loadCodexModelRules } from './codex-delegation.js';
8
9
  import { loadCodexLedger } from './codex-ledger.js';
@@ -105,8 +106,7 @@ export function runCodexBrief({ sessionId, transcriptPath, cwd, root: projectRoo
105
106
  const events = [...state.events, ...next.map((issue) => ({ ...issue, at, sessionId, project: cwd || snapshot.projectDir || '' }))].slice(-200);
106
107
  mkdirSync(directory, { recursive: true });
107
108
  const tmp = `${file}.${process.pid}.tmp`;
108
- writeFileSync(tmp, JSON.stringify({ active: nextActive, events }) + '\n', { mode: 0o600 });
109
- renameSync(tmp, file);
109
+ writeViaTmp(tmp, file, JSON.stringify({ active: nextActive, events }) + '\n', { mode: 0o600 });
110
110
  return next.length ? ['[Sprag Codex briefing]', ...next.map((issue) => `${issue.message} ${issue.advice}`)].join('\n') : null;
111
111
  }
112
112
 
@@ -125,8 +125,7 @@ export function recordCodexHistoryEvent({ sessionId, project = '', key, level =
125
125
  at: new Date(now).toISOString(), sessionId: owner, project }].slice(-200);
126
126
  mkdirSync(directory, { recursive: true });
127
127
  const tmp = `${file}.${process.pid}.tmp`;
128
- writeFileSync(tmp, JSON.stringify({ ...state, events }) + '\n', { mode: 0o600 });
129
- renameSync(tmp, file);
128
+ writeViaTmp(tmp, file, JSON.stringify({ ...state, events }) + '\n', { mode: 0o600 });
130
129
  return true;
131
130
  }
132
131
 
@@ -1,9 +1,10 @@
1
- import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
1
+ import { readFileSync, mkdirSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
4
  import { parse } from 'smol-toml';
5
5
  import { codexUserDir } from './agent.js';
6
6
  import { userDataDir } from './paths.js';
7
+ import { writeViaTmp } from './state-file.js';
7
8
  import { codexBudgetProvider, codexProviderKey } from './codex-budget.js';
8
9
  import { codexCachePolicy } from './codex-cache.js';
9
10
  import { PRICE_MAX_AGE_MS, gatewayPrices } from './gateway-prices.js';
@@ -153,8 +154,7 @@ async function writeGatewaySnapshot(provider, { fetchImpl = fetch, execute, ...o
153
154
  // Persist only policy and price fields, never raw model/info (which can contain secrets).
154
155
  mkdirSync(join(opts.dir || userDataDir(), 'codex-cache-policy'), { recursive: true, mode: 0o700 });
155
156
  const tmp = `${file}.${randomUUID()}.tmp`;
156
- writeFileSync(tmp, JSON.stringify(snapshot) + '\n', { mode: 0o600 });
157
- renameSync(tmp, file);
157
+ writeViaTmp(tmp, file, JSON.stringify(snapshot) + '\n', { mode: 0o600 });
158
158
  return snapshot;
159
159
  }
160
160
 
@@ -1,8 +1,9 @@
1
- import { readFileSync, readdirSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
1
+ import { readFileSync, readdirSync, mkdirSync } from 'node:fs';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { parse } from 'smol-toml';
5
5
  import { userDataDir } from './paths.js';
6
+ import { writeViaTmp } from './state-file.js';
6
7
  import { codexUserDir, codexProviderName } from './agent.js';
7
8
  import { categorize, ESCALATE_RE, EDIT_RE } from './route-scan.js';
8
9
  import { looksPasted } from './route-inject.js';
@@ -41,8 +42,7 @@ export function loadCodexModelRules({ dir = userDataDir() } = {}) {
41
42
  function saveRules(rules, dir) {
42
43
  mkdirSync(dir, { recursive: true });
43
44
  const file = rulesFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
44
- writeFileSync(tmp, JSON.stringify({ version: 1, rules }, null, 2) + '\n', { mode: 0o600 });
45
- renameSync(tmp, file);
45
+ writeViaTmp(tmp, file, JSON.stringify({ version: 1, rules }, null, 2) + '\n', { mode: 0o600 });
46
46
  }
47
47
 
48
48
  export function addCodexModelRule({ category, model, effort, from, scope, root, provider }, { dir = userDataDir() } = {}) {
@@ -1,8 +1,9 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, realpathSync, statSync } from 'node:fs';
2
+ import { mkdirSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
3
3
  import { extname, join, resolve } from 'node:path';
4
4
  import { codexUserDir } from './agent.js';
5
5
  import { userDataDir } from './paths.js';
6
+ import { writeViaTmp } from './state-file.js';
6
7
  import { readResetMarks, isBeforeReset } from './saved-reset.cjs';
7
8
 
8
9
  const hash = (value) => createHash('sha256').update(value).digest('hex');
@@ -22,8 +23,7 @@ export function recordCodexDocument(source, result, opts) {
22
23
  clipped: !!(meta.clipped || meta.truncated) };
23
24
  mkdirSync(dir, { recursive: true, mode: 0o700 });
24
25
  const tmp = `${file}.${randomUUID()}.tmp`;
25
- writeFileSync(tmp, JSON.stringify(event) + '\n', { mode: 0o600 });
26
- renameSync(tmp, file);
26
+ writeViaTmp(tmp, file, JSON.stringify(event) + '\n', { mode: 0o600 });
27
27
  } catch { /* Accounting must not prevent document conversion. */ }
28
28
  }
29
29
 
@@ -1,12 +1,13 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { createHash } from 'node:crypto';
4
- import { mkdirSync, openSync, readFileSync, readSync, closeSync, renameSync, statSync, writeFileSync } from 'node:fs';
4
+ import { mkdirSync, openSync, readFileSync, readSync, closeSync, statSync } from 'node:fs';
5
5
  import { join, resolve } from 'node:path';
6
6
  import { loadConfig, userLanguage } from './config.js';
7
7
  import { koreanStyleInjection, koreanStyleEnabled } from './korean-style.js';
8
8
  import { cohesionInjection } from './cohesion.js';
9
9
  import { codexHarnessPaths } from './codex-harness.js';
10
+ import { writeViaTmp } from './state-file.js';
10
11
  import { findProjectRoot } from './harness.js';
11
12
  import { runCodexBrief } from './codex-brief.js';
12
13
  import { codexDocumentTool, codexDocumentNote, convertCodexDocument } from './codex-doc2md.js';
@@ -69,8 +70,7 @@ export function fitCodexContext(parts, { budget = CODEX_CONTEXT_TOKEN_BUDGET, ov
69
70
  function writeOverflow(file, text) {
70
71
  mkdirSync(join(file, '..'), { recursive: true, mode: 0o700 });
71
72
  const tmp = `${file}.${process.pid}.tmp`;
72
- writeFileSync(tmp, text, { mode: 0o600 });
73
- renameSync(tmp, file);
73
+ writeViaTmp(tmp, file, text, { mode: 0o600 });
74
74
  }
75
75
 
76
76
  function overflowFileFor(root) {
@@ -1,6 +1,7 @@
1
- import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
3
  import { codexUserDir } from './agent.js';
4
+ import { writeViaTmp } from './state-file.js';
4
5
 
5
6
  export const CODEX_HANDLERS = {
6
7
  SessionStart: { matcher: 'startup|resume|clear|compact', command: 'sprag codex-hook --agent codex --event session-start', timeout: 15 },
@@ -72,8 +73,7 @@ export function configureCodexHooks({ remove = false, panelAuto } = {}) {
72
73
  mkdirSync(dirname(file), { recursive: true });
73
74
  if (existsSync(file)) writeFileSync(`${file}.bak-${Date.now()}`, readFileSync(file));
74
75
  const tmp = `${file}.${process.pid}.tmp`;
75
- writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
76
- renameSync(tmp, file);
76
+ writeViaTmp(tmp, file, JSON.stringify(data, null, 2) + '\n');
77
77
  }
78
78
  return { file, action: remove ? 'removed' : 'configured' };
79
79
  }
@@ -1,7 +1,8 @@
1
- import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
1
+ import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { randomBytes, randomUUID } from 'node:crypto';
4
4
  import { userDataDir } from './paths.js';
5
+ import { writeViaTmp } from './state-file.js';
5
6
  import { roundUsd } from './savings-ledger.js';
6
7
  import { readResetMarks, isBeforeReset } from './saved-reset.cjs';
7
8
  import { discoverCodexSessionFiles, parseCodexTurns } from './codex-parser.js';
@@ -52,8 +53,7 @@ export function pruneCodexDelegations({ dir = userDataDir(), now = Date.now() }
52
53
  try { all = readFileSync(pendingFile(dir), 'utf8').split('\n').filter(Boolean).length; } catch { return; }
53
54
  if (keep.length === all) return;
54
55
  const file = pendingFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
55
- writeFileSync(tmp, keep.map((r) => JSON.stringify(r) + '\n').join(''), { mode: 0o600 });
56
- renameSync(tmp, file);
56
+ writeViaTmp(tmp, file, keep.map((r) => JSON.stringify(r) + '\n').join(''), { mode: 0o600 });
57
57
  }
58
58
 
59
59
  export function readCodexDelegations({ dir = userDataDir() } = {}) {
@@ -173,8 +173,7 @@ export function loadCodexLedger({ dir = userDataDir() } = {}) {
173
173
  function saveLedger(data, dir) {
174
174
  mkdirSync(dir, { recursive: true });
175
175
  const file = ledgerFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
176
- writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
177
- renameSync(tmp, file);
176
+ writeViaTmp(tmp, file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
178
177
  }
179
178
 
180
179
  /**
@@ -1,5 +1,6 @@
1
- import { readFileSync, writeFileSync, renameSync } from 'node:fs';
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { isAbsolute } from 'node:path';
3
+ import { writeViaTmp } from './state-file.js';
3
4
 
4
5
  export function createPanelBinding(file) {
5
6
  writeFileSync(file, JSON.stringify({ version: 1, sessionId: null, transcriptPath: null }), { mode: 0o600 });
@@ -27,9 +28,8 @@ export function bindPanelSession(event, payload, { file = process.env.SPRAG_CODE
27
28
  if (current.version !== 1) return false;
28
29
  if (payload.source === 'compact' && current.sessionId && current.sessionId !== payload.session_id) return false;
29
30
  const tmp = `${file}.${process.pid}.tmp`;
30
- writeFileSync(tmp, JSON.stringify({ version: 1, sessionId: payload.session_id,
31
+ writeViaTmp(tmp, file, JSON.stringify({ version: 1, sessionId: payload.session_id,
31
32
  transcriptPath: typeof payload.transcript_path === 'string' && isAbsolute(payload.transcript_path) ? payload.transcript_path : null }), { mode: 0o600 });
32
- renameSync(tmp, file);
33
33
  return true;
34
34
  } catch { return false; }
35
35
  }
@@ -1,8 +1,15 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
2
+ import { readFileSync, mkdirSync, readdirSync, lstatSync, unlinkSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { userDataDir } from './paths.js';
5
5
  import { codexUserDir } from './agent.js';
6
+ import { writeViaTmp, runHousekeeping, sweepStaleTmp } from './state-file.js';
7
+
8
+ // Every panel session leaves a `-frame` and a `-hook` file behind, keyed by a
9
+ // hash that is not reused once the session is gone. They are diagnostics for a
10
+ // live session, so a file untouched for a week describes one that is over.
11
+ const PANEL_STATE_FILE = /^codex-panel-[0-9a-f]{64}-(?:frame|hook)\.json$/;
12
+ export const PANEL_STATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
6
13
 
7
14
  export function panelStatePath(root, kind, dir = userDataDir(), sessionId) {
8
15
  const key = createHash('sha256').update(codexUserDir() + '\0' + resolve(root) + (sessionId ? `\0${sessionId}` : '')).digest('hex');
@@ -15,17 +22,56 @@ export function recordPanelState(root, kind, value, dir = userDataDir()) {
15
22
  const file = panelStatePath(root, kind, dir);
16
23
  const tmp = `${file}.${process.pid}.tmp`;
17
24
  const text = JSON.stringify({ ...value, at: new Date().toISOString() });
18
- writeFileSync(tmp, text);
19
- renameSync(tmp, file);
25
+ writeViaTmp(tmp, file, text);
20
26
  if (value.sessionId) {
21
27
  const sessionFile = panelStatePath(root, kind, dir, value.sessionId);
22
28
  const sessionTmp = `${sessionFile}.${process.pid}.tmp`;
23
- writeFileSync(sessionTmp, text);
24
- renameSync(sessionTmp, sessionFile);
29
+ writeViaTmp(sessionTmp, sessionFile, text);
25
30
  }
31
+ // A live panel records a frame every couple of seconds, so the clean-up is
32
+ // rate-limited to once a day (see runHousekeeping) rather than listing the
33
+ // directory on every write. This session's own files, of both kinds, are
34
+ // never candidates: a session that has run for over a week keeps writing
35
+ // frames while its hook record is just as old.
36
+ runHousekeeping(dir, {
37
+ 'stale-tmp': () => sweepStaleTmp(dir),
38
+ 'codex-panel': () => sweepPanelState({
39
+ dir,
40
+ keep: ['frame', 'hook'].flatMap((k) => [
41
+ panelStatePath(root, k, dir),
42
+ ...(value.sessionId ? [panelStatePath(root, k, dir, value.sessionId)] : []),
43
+ ]),
44
+ }),
45
+ });
26
46
  } catch { /* Diagnostics must not stop a session or panel. */ }
27
47
  }
28
48
 
49
+ /**
50
+ * Remove panel state files untouched for `maxAgeMs`. Only the exact
51
+ * `codex-panel-<64 hex>-(frame|hook).json` shape is considered, and anything
52
+ * in `keep` (full paths) is left alone whatever its mtime.
53
+ *
54
+ * @returns {number} how many files were removed
55
+ */
56
+ export function sweepPanelState({ dir = userDataDir(), now = Date.now(), maxAgeMs = PANEL_STATE_MAX_AGE_MS, keep = [] } = {}) {
57
+ let names;
58
+ try { names = readdirSync(dir); } catch { return 0; }
59
+ const protectedPaths = new Set(keep);
60
+ let removed = 0;
61
+ for (const name of names) {
62
+ if (!PANEL_STATE_FILE.test(name)) continue;
63
+ const path = join(dir, name);
64
+ if (protectedPaths.has(path)) continue;
65
+ try {
66
+ const st = lstatSync(path);
67
+ if (!st.isFile() || now - st.mtimeMs <= maxAgeMs) continue;
68
+ unlinkSync(path);
69
+ removed += 1;
70
+ } catch { /* already gone, or not ours to remove */ }
71
+ }
72
+ return removed;
73
+ }
74
+
29
75
  export function readPanelState(root, kind, dir = userDataDir(), sessionId) {
30
76
  try { return JSON.parse(readFileSync(panelStatePath(root, kind, dir, sessionId), 'utf8')); }
31
77
  catch { return null; }
@@ -1,7 +1,8 @@
1
- import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
1
+ import { readFileSync, mkdirSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { userDataDir } from './paths.js';
5
+ import { writeViaTmp } from './state-file.js';
5
6
  import { loadConfig } from './config.js';
6
7
  import { findProjectRoot } from './harness.js';
7
8
  import { discoverCodexSessionFiles, parseCodexTurns } from './codex-parser.js';
@@ -32,8 +33,7 @@ export function readCodexRouteScan({ dir = userDataDir() } = {}) {
32
33
  function writeCache(data, dir) {
33
34
  mkdirSync(dir, { recursive: true });
34
35
  const file = cacheFile(dir), tmp = `${file}.${randomUUID()}.tmp`;
35
- writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
36
- renameSync(tmp, file);
36
+ writeViaTmp(tmp, file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
37
37
  }
38
38
 
39
39
  /** Candidates not yet approved or dismissed; with `root`, only that project's. */
@@ -58,9 +58,13 @@ export async function run({ args, hasFlag }) {
58
58
  }
59
59
 
60
60
  if (!sub || sub === 'status' || sub === 'check') {
61
- const s = cw.compactWindowStatus({ root });
61
+ // The statusline knows the real window; the settings model id often does
62
+ // not (a bare `opus` carries no `[1m]`), so the last reported size decides.
63
+ let liveWindow = null;
64
+ try { liveWindow = (await import('../ctx-window-cache.js')).latestLiveWindow(); } catch { /* id-only fallback */ }
65
+ const s = cw.compactWindowStatus({ root, liveWindow });
62
66
  console.log(`model: ${s.model || '(not set — Claude Code default)'}${s.modelSource ? ` [${s.modelSource}]` : ''}`);
63
- console.log(`window: ${s.is1m ? '1M context' : '200k context'}`);
67
+ console.log(`window: ${s.is1m ? '1M context' : '200k context'}${liveWindow ? ` [statusline: ${liveWindow >= 1e6 ? `${liveWindow / 1e6}M` : fmt(liveWindow)}]` : ''}`);
64
68
  console.log(`autoCompactWindow: ${s.window === null ? '(unset)' : `${s.window} (${fmt(s.window)})`}${s.windowSource ? ` [${s.windowSource}${s.windowPath ? ` → ${s.windowPath}` : ''}]` : ''}`);
65
69
  if (s.ok && s.reason === 'not-1m') {
66
70
  console.log(ko
@@ -251,8 +251,11 @@ export async function run({ hasFlag }) {
251
251
  // good, and upgrading would silently leave shell-written files
252
252
  // unchecked. installKoreanLintHook widens in place and is idempotent.
253
253
  if (ks.koreanStyleEnabled()) {
254
- const { installKoreanLintHook } = await import('../installer.js');
254
+ const { installKoreanLintHook, installReplyLanguageHook } = await import('../installer.js');
255
255
  const migrated = installKoreanLintHook();
256
+ // Older installs have the lint hook but no Stop hook; add it here so
257
+ // an upgrade closes the English-reply gap without a manual `korean on`.
258
+ installReplyLanguageHook();
256
259
  if (migrated.action === 'updated') {
257
260
  console.log(lang === 'ko'
258
261
  ? ' 검사 범위 갱신: Bash 로 쓴 파일도 이제 확인합니다'
@@ -20,6 +20,10 @@
20
20
  * wrote:
21
21
  * sprag korean lint block|warn|off # how findings are handled
22
22
  * sprag korean lint <file...> # check files on disk
23
+ *
24
+ * The third half is a Stop hook for the one slip a write-time check cannot
25
+ * see: an English reply to a Korean prompt, which writes no file at all.
26
+ * sprag korean reply on|off # toggle it (default on)
23
27
  */
24
28
 
25
29
  // Enforcement is only useful if it is on by default — a check the user has to
@@ -39,6 +43,12 @@ function koreanLintScope(cfg) {
39
43
  return cfg?.koreanStyle?.lintScope === 'prose' ? 'prose' : 'all';
40
44
  }
41
45
 
46
+ // Default on, like the lint: a check the user has to discover is the same
47
+ // hole in a different shape. Only an explicit false turns it off.
48
+ function koreanReplyCheckOn(cfg) {
49
+ return cfg?.koreanReplyCheck !== false;
50
+ }
51
+
42
52
  export async function run({ args, hasFlag }) {
43
53
  const sub = args[1] || 'status';
44
54
  const ks = await import('../korean-style.js');
@@ -72,6 +82,55 @@ export async function run({ args, hasFlag }) {
72
82
  return;
73
83
  }
74
84
 
85
+ // Stop hook. Fires when the model finishes a turn; if the reply is English
86
+ // to a Korean prompt, block the stop so the model answers again in Korean.
87
+ // Imports stay minimal on purpose: this runs after every turn.
88
+ if (hasFlag?.('--reply-hook') || sub === '--reply-hook') {
89
+ const { readStdinJson } = await import('../stdin-payload.js');
90
+ const payload = readStdinJson();
91
+ if (!payload) return;
92
+ // Claude Code sets stop_hook_active while the model is already continuing
93
+ // because of a Stop hook. Blocking again would loop forever.
94
+ if (payload.stop_hook_active) return;
95
+ if (!ks.koreanStyleEnabled()) return;
96
+ if (!koreanReplyCheckOn(loadConfig())) return;
97
+ const { createRequire } = await import('node:module');
98
+ const found = createRequire(import.meta.url)('../reply-language.cjs').checkReplyLanguage(payload.transcript_path);
99
+ if (!found) return;
100
+ console.log(JSON.stringify({
101
+ decision: 'block',
102
+ reason: '사용자는 한국어로 질문했는데 마지막 답변이 영어로 작성됐습니다. 같은 내용을 한국어로 다시 답하십시오. 코드, 명령어, 오류 원문, 커밋 메시지처럼 원문을 유지해야 하는 부분은 그대로 둡니다.',
103
+ }));
104
+ return;
105
+ }
106
+
107
+ // Toggle: `korean reply on|off`. Off keeps the hook installed and makes it
108
+ // a no-op, the same way lint mode `off` works; `korean off` removes it.
109
+ if (sub === 'reply') {
110
+ const cfg = loadConfig();
111
+ const next = args[2];
112
+ if (!next) {
113
+ console.log(lang === 'ko'
114
+ ? `답변 언어 검사: ${koreanReplyCheckOn(cfg) ? '켜짐' : '꺼짐'}`
115
+ : `Reply-language check: ${koreanReplyCheckOn(cfg) ? 'on' : 'off'}`);
116
+ return;
117
+ }
118
+ if (!['on', 'off'].includes(next)) {
119
+ console.error('Usage: sprag korean reply [on|off]');
120
+ process.exit(1);
121
+ }
122
+ cfg.koreanReplyCheck = next === 'on';
123
+ saveConfig(cfg);
124
+ console.log(next === 'on'
125
+ ? (lang === 'ko'
126
+ ? '답변 언어 검사를 켰습니다. 한국어 질문에 영어로 답하면 모델에게 되돌려 보내 다시 답하게 합니다.'
127
+ : 'Reply-language check is on. An English reply to a Korean prompt is sent back for a Korean answer.')
128
+ : (lang === 'ko'
129
+ ? '답변 언어 검사를 껐습니다. 훅은 남아 있지만 아무 일도 하지 않습니다.'
130
+ : 'Reply-language check is off. The hook stays installed and does nothing.'));
131
+ return;
132
+ }
133
+
75
134
  // Manual run over files already on disk: `korean lint docs/*.md`.
76
135
  if (sub === 'lint' && args.length > 2 && !['on', 'off', 'warn', 'block', 'scope'].includes(args[2])) {
77
136
  const { readFileSync } = await import('node:fs');
@@ -116,8 +175,8 @@ export async function run({ args, hasFlag }) {
116
175
  saveConfig(cfg);
117
176
  console.log(lang === 'ko'
118
177
  ? (next === 'all'
119
- ? '검사 범위: all — 문서와 코드 주석, UI 문자열까지 세션이 쓴 모든 텍스트 파일을 검사합니다.'
120
- : '검사 범위: prose — 마크다운과 텍스트 문서만 검사합니다.')
178
+ ? '검사 범위를 all 로 바꿨습니다. 문서와 코드 주석, UI 문자열까지 세션이 쓴 모든 텍스트 파일을 검사합니다.'
179
+ : '검사 범위를 prose 로 좁혔습니다. 마크다운과 텍스트 문서만 검사합니다.')
121
180
  : (next === 'all'
122
181
  ? 'Check scope: all — every text file the session writes, comments and UI strings included.'
123
182
  : 'Check scope: prose — documents only.'));
@@ -163,8 +222,13 @@ export async function run({ args, hasFlag }) {
163
222
  if (sub === 'on' || sub === 'off') {
164
223
  const enabled = sub === 'on';
165
224
  ks.setKoreanStyleEnabled(enabled);
166
- const { installKoreanLintHook, removeKoreanLintHook } = await import('../installer.js');
225
+ const {
226
+ installKoreanLintHook, removeKoreanLintHook,
227
+ installReplyLanguageHook, removeReplyLanguageHook,
228
+ } = await import('../installer.js');
167
229
  const hook = enabled ? installKoreanLintHook() : removeKoreanLintHook();
230
+ if (enabled) installReplyLanguageHook();
231
+ else removeReplyLanguageHook();
168
232
  if (enabled) {
169
233
  console.log(lang === 'ko'
170
234
  ? '한국어 문체 지침을 켰습니다. 다음 세션부터 모든 프로젝트에 적용됩니다.'
@@ -202,6 +266,7 @@ export async function run({ args, hasFlag }) {
202
266
  console.log(`한국어 문체 지침: ${on ? '켜짐' : '꺼짐'}`);
203
267
  console.log(` 비용: 세션당 약 ${tokens} 토큰 (세션 시작 1회 주입)`);
204
268
  console.log(` 쓰기 시점 검사: ${koreanLintMode(loadConfig())}, 범위 ${koreanLintScope(loadConfig())} (바꾸려면 sprag korean lint block|warn|off, korean lint scope all|prose)`);
269
+ console.log(` 답변 언어 검사: ${koreanReplyCheckOn(loadConfig()) ? '켜짐' : '꺼짐'} (바꾸려면 sprag korean reply on|off)`);
205
270
  console.log(` 출처: ${ks.KOREAN_STYLE_SOURCE}`);
206
271
  console.log(` 라이선스 전문: ${ks.KOREAN_STYLE_LICENSE_PATH}`);
207
272
  console.log(on
@@ -211,6 +276,7 @@ export async function run({ args, hasFlag }) {
211
276
  console.log(`Korean writing guidance: ${on ? 'on' : 'off'}`);
212
277
  console.log(` Cost: ~${tokens} tokens per session (injected once at session start)`);
213
278
  console.log(` Write-time check: ${koreanLintMode(loadConfig())}, scope ${koreanLintScope(loadConfig())} (change with: sprag korean lint block|warn|off, korean lint scope all|prose)`);
279
+ console.log(` Reply-language check: ${koreanReplyCheckOn(loadConfig()) ? 'on' : 'off'} (change with: sprag korean reply on|off)`);
214
280
  console.log(` Source: ${ks.KOREAN_STYLE_SOURCE}`);
215
281
  console.log(` License text: ${ks.KOREAN_STYLE_LICENSE_PATH}`);
216
282
  console.log(on