SLOPSHOPPER

state-compact

압축 전에 handoff 문서를 반영하고, 프롬프트 캐시 만료 직전 압축으로 재캐시 비용을 줄인다

newrowstoastpromptmodelprocess
A shopper browsing a rack in a slop shop
README

claude-code-mods

한국어 | English

Claude Code 함수 훅 플러그인(모드) 모음.

모드하는 일
state-compact긴 세션에서 자리를 비워도 캐시 재생성 비용을 크게 물지 않게 한다
token-speedometer답이 출력되는 속도를 입력창 위에 레이싱 게임 계기판처럼 보여 준다

state-compact

긴 세션을 열어 둔 채 자리를 비우면 프롬프트 캐시가 만료된다(1시간 또는 5분). 돌아와서 메시지를 하나 보내면 그동안 쌓인 컨텍스트 전체를 다시 캐시한다. 86만 토큰 세션이면 메시지 하나에 약 $6.9가 든다.

이 모드가 하는 일은 두 가지다.

1. 캐시가 만료되기 전에 정리하고 압축한다

Claude가 질문을 남기고 기다리거나 백그라운드 작업(서브에이전트 등)이 끝나기를 기다리는 동안 자리를 비우면, 캐시가 만료되기 직전에 모드가 대신 움직인다.

  1. Claude에게 handoff 문서(예: STATE.md)에 진행 상황과 다음 할 일을 적게 한다
  2. 그 턴이 끝나면 대화를 압축한다

돌아왔을 때는 압축된 요약만 다시 캐시하면 되고, 세부 내용은 handoff 문서에 남아 있다.

2. 만료된 긴 세션에 보내는 첫 메시지를 막는다

캐시가 이미 만료된 세션에 메시지를 보내면, 컨텍스트가 10만 토큰 이상일 때 한 번 막고 비용을 알린다.

state-compact: 캐시가 만료됐습니다. 보내면 컨텍스트 약 861k 토큰을 다시 캐시합니다(약 $6.88).
그대로 보내려면 다시 보내고, 아니면 /compact나 새 세션을 쓰세요.

막힌 메시지는 입력창에 돌아온다. 같은 메시지를 다시 보내면 그대로 진행되고, /compact 같은 슬래시 명령은 막지 않는다.

그 밖의 기능

  • 수동 압축 때도 문서 반영. /compact를 치거나 컨텍스트가 85%를 넘으면, 압축하기 전에 handoff 문서를 먼저 고치게 한다.
  • 답 끝 표시. 마지막 답 끝에 작은 상자로 압축·캐시 상태를 보여 준다. 압축 예정 줄의 취소를 누르면 예약이 풀린다.
  ◇ 압축 예정                     11:27  취소
  ◆ 압축됨 · 자리 비움             11:27
  ○ 캐시 만료 (컨텍스트 861k)       18:16
  ✕ 압축 실패 · 이유               11:27

설치

함수 훅 플러그인을 지원하는 Claude Code가 필요하다. 저장소를 받은 뒤 모드 폴더를 플러그인 폴더로 지정한다.

git clone https://github.com/iceberggymnast/claude-code-mods.git

터미널에서는 실행할 때 폴더를 넘긴다.

claude --plugin-dir <repo>/plugins/state-compact

데스크톱 앱처럼 실행 인자를 줄 수 없는 곳에서는 ~/.claude/settings.json의 env에 적는다. 여러 폴더는 경로 구분자(Windows ;, 그 밖 :)로 잇는다.

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "<repo>/plugins/state-compact"
  }
}

설치를 확인하려면 claude plugin validate <repo>/plugins/state-compact를 실행한다.

설정

항목기본값설명
handoff_file(비어 있음)압축 전에 반영할 파일. 저장소 루트 기준 이름. 비우면 반영 없이 압축만 한다
languageko답 끝 표시, 경고 문구, Claude에게 보내는 지시의 언어. ko 또는 en

/config의 플러그인 항목에서 바꾸거나, settings.json의 pluginConfigs에 적는다. --plugin-dir·CLAUDE_CODE_PLUGIN_DIRS로 불러온 경우 키는 state-compact 또는 state-compact@inline이다.

{
  "pluginConfigs": {
    "state-compact@inline": {
      "options": {
        "handoff_file": "STATE.md",
        "language": "ko"
      }
    }
  }
}

자세한 동작

만료 전 압축의 조건

턴이 끝났을 때 아래를 모두 만족하면 예약한다.

캐시 TTL컨텍스트압축 시점
1시간20만 토큰 이상마지막 요청 후 55분
5분30만 토큰 이상마지막 요청 후 4분
  • 마지막 답이 사용자의 대답을 기다리는 경우(질문, 확인 요청 등)나, 턴이 끝날 때 백그라운드 작업(서브에이전트, 백그라운드 셸 등)이 돌고 있는 경우만 예약한다. 작업이 끝나면 세션이 다시 깨어나기 때문이다. 끝난 보고면 돌아올 가능성이 낮아 압축 비용만 남는다. 대답을 기다리는지는 Haiku로 판정한다(백그라운드 작업이 없고 컨텍스트가 기준을 넘을 때만 턴마다 한 번)
  • 입력창에 쓰던 글이 있으면 자리에 있다고 보고 압축하지 않는다
  • 절전에서 깨어나 시점을 넘겼으면 캐시가 이미 만료됐다고 보고 건너뛴다
  • TTL은 세션 기록 파일의 마지막 응답에서 읽는다. 읽지 못하면 예약하지 않는다

handoff 문서 반영

  • 수동 /compact에 준 요약 지시는 압축할 때 그대로 넘긴다
  • 85% 선제 압축은 자동 압축보다 먼저 한다
  • 문서가 저장소 루트에 있으면 내용과 상관없이 매번 반영한다. 진행 중인 작업이 없는 세션에서도 반영 턴이 한 번 돈다
  • 문서가 git이 추적하는 파일이면 그 파일만 커밋하게 한다

답 끝 표시

  • 압축 예정과 압축 중(◆ 압축 중…)은 마지막 답에만 뜬다
  • 압축됨, 캐시 만료, 압축 실패는 그때의 마지막 답 끝에 남고 지워지지 않는다
  • 캐시 만료는 마지막 요청 뒤 TTL이 지나면 토큰 양과 상관없이 표시하고, 그 전에 압축했으면 표시하지 않는다. 괄호 안은 만료 때의 컨텍스트 크기다

판단 기록

만료 전 압축을 예약하지 않았거나 건너뛴 이유는 세션 기록에 남지 않는다. 그래서 턴이 끝날 때와 예약한 시각에 내린 판단을 세션 기록 파일(~/.claude/projects/<프로젝트>/<세션 id>.jsonl) 옆의 <세션 id>.state-compact.log에 한 줄씩 남긴다. TTL, 컨텍스트 토큰 수, 백그라운드 작업 수, Haiku 판정 결과와 걸린 시간, 건너뛴 이유가 들어가고 답 본문은 넣지 않는다. 25만 자를 넘으면 앞부분부터 지운다.

한계

  • resume하거나 앱을 재시작한 직후에는 마지막 요청 시각을 모르므로, 그 세션에서 요청을 한 번 보내기 전까지 만료 전 압축과 수동 /compact 앞의 반영을 하지 않는다. 캐시 만료 표시는 마지막 응답 시각을 기준으로 붙인다. 꺼져 있던 동안 만료됐으면 그 세션에 다시 메시지를 보낼 때 그 전의 마지막 답에 붙인다(데스크톱 앱은 목록에서 세션을 열기만 해서는 모드를 실행하지 않는다). 이때 표시 시각은 실제 만료보다 응답에 걸린 시간만큼 늦다
  • 데스크톱 앱은 플러그인 토스트를 표시하지 않는다. 실패 이유는 답 끝 표시에서 확인한다
  • 수동 /compact를 하면 대화에 /compact 말풍선이 두 개 남는다. 첫 번째는 사용자가 입력한 것(문서 반영을 위해 멈춤)이고, 두 번째는 반영이 끝난 뒤 모드가 다시 실행한 것이다

확인 범위

Windows 데스크톱 앱에서 수동 /compact → 문서 반영 → 압축 순서(요약 지시 인자 포함), 1시간 TTL 세션의 만료 전 압축(마지막 요청 55분 뒤 실행), 수동 /compact 때 반영한 답 끝의 ◆ 압축 중…→◆ 15:35 압축됨 · 수동 표시, 다른 세션을 보는 동안 TTL이 지난 세션의 ○ 18:16 캐시 만료 표시, 캐시가 만료된 86만 토큰 세션에서 첫 메시지를 막고 경고한 뒤 메시지를 입력창에 되돌리는 것을 확인했다. 아래는 아직 확인하지 못했다.

  • 만료 전 압축 앞의 문서 반영 (확인한 압축은 문서 반영 없이 바로 압축했다)
  • 답 끝 표시 중 압축 예정과 그 취소 버튼, 압축 실패, 그리고 앱을 재시작한 뒤에도 표시가 남는지
  • 막힌 첫 메시지를 다시 보냈을 때 통과하는지, 꺼져 있던 동안 지난 캐시 만료가 다시 연 세션의 이전 답에 붙는지
  • 85% 선제 압축
  • language를 en으로 바꿨을 때의 표시와 지시
  • 5분 TTL 세션
  • 터미널 CLI (함수 훅이 꺼진 빌드라 로드되지 않았다)
  • macOS·Linux
  • 마켓플레이스(claude plugin marketplace add)로 설치했을 때 모드가 로드되는지

token-speedometer

답이 출력되는 속도(초당 토큰)를 입력창 위에 레이싱 게임 계기판처럼 보여 준다.

  • 큰 숫자: 지금 속도. 앞자리 0은 어둡게 그린다
  • 막대: 0~200 tok/s, 160부터 빨간 구간. 속도가 0이어도 3칸은 켜 둔다. 세모는 이번 턴의 최고 속도다
  • 기어: 이번 턴의 몇 번째 모델 요청인지(초록). 생각하거나 도구를 실행하는 동안은 N, 턴이 끝나면 P(빨강)
  • 표시등: REQ 응답 대기 · THK 생각 중 · OUT 출력 중 · TOOL 도구 실행
  • AVG · TOP · LAUNCH: 끝난 응답들의 평균, 이번 턴의 최고, 첫 조각이 오기까지 걸린 시간

턴이 끝나면 숫자와 막대가 0으로 내려가고 P로 남는다. 설치는 설치와 같고, 폴더만 <repo>/plugins/token-speedometer로 준다. 설정 항목은 없다.

속도를 재는 방법

모델 비교 차트의 숫자와 견줄 수 있게 Artificial Analysis의 출력 속도와 같은 기준으로 잰다.

  • 토큰은 Claude 토크나이저가 아니라 OpenAI o200k_base 기준으로 센다. 화면에 나온 답(본문과 도구 입력)을 문자 종류별 비용으로 추정한다: 한글 0.90, 영문·숫자 0.25, 공백 0.10, 기호 0.61, 그 밖 1.0토큰/글자. Claude Code 세션 기록의 답 6,000개로 tiktoken과 맞춘 값이고, 따로 둔 6,000개에서 전체 오차는 -0.6%, 한글 비중별로 나눠도 ±4% 안이다
  • 생각 구간은 빼고 답이 나오는 동안만 잰다. 생각하는 동안과 도구를 실행하는 동안 숫자는 0을 향해 내려간다
  • 지금 속도는 최근 1초 동안의 값이다. 초당 10번 갱신하면서 매번 30%씩 따라가게 해서 숫자가 차례로 오르내린다
  • 서브에이전트의 응답은 세지 않는다

Artificial Analysis는 답 조각의 처음 20%를 빼고 재지만 이 모드는 처음부터 잰다. 서빙 환경과 effort도 다르므로 차트의 숫자와 똑같이 나오지는 않는다.

한계

  • 데스크톱 앱은 플러그인 그림을 초당 10번까지 다시 그린다. 그 사이의 움직임은 그릴 수 없다. SVG 애니메이션은 대화형 그림(isInteractive)에서만 도는데, 대화형 그림은 갱신할 때마다 깜박이고 크기가 줄어서 쓰지 않는다
  • 터미널에서는 그림 대신 글자 막대와 숫자 한 줄로 보여 준다
  • 밝은·어두운 모드는 그림 안의 prefers-color-scheme으로 가른다

확인 범위

Windows 데스크톱 앱(라이트 모드)에서 계기판 표시, 상태·기어 전환, 출력이 끝나면 바로 감속하는 것, 턴이 끝난 뒤 0으로 내려가 P로 남는 것, 창을 좁히면 비율대로 줄어드는 것을 확인했다. 아래는 아직 확인하지 못했다.

  • 다크 모드에서 색이 바뀌는지
  • 터미널 CLI 표시
  • macOS·Linux

라이선스

MIT

Source 1 files
hooks/register.tsx 616 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// 만료 전 압축의 TTL별 기준. 값은 입력 단가 단위로 "돌아왔을 때 아끼는 양 > 0"에서 정했다.
4//  - minTokens: 이보다 작으면 압축하지 않는다. 1시간은 다시 쓰기가 2배라 1.8 × 컨텍스트 - 19만,
5//    5분은 1.25배라 1.15 × 컨텍스트 - 14만이 남는다. 5분은 자리에 있는데 압축할 일도 잦아 기준을 더 높였다.
6//  - compactAfterMs: 마지막 요청 시작 후 이만큼 지나면 압축을 시작한다. 만료 전에 첫 요청이 나가야 한다.
7//  - lateLimitMs: 타이머가 이보다 늦게 돌면(절전에서 깨어남) 캐시가 이미 만료된 것으로 보고 건너뛴다.
8const IDLE_RULES = {
9  '1h': { minTokens: 200_000, compactAfterMs: 55 * 60_000, lateLimitMs: 58 * 60_000 },
10  '5m': { minTokens: 300_000, compactAfterMs: 4 * 60_000, lateLimitMs: 4.5 * 60_000 },
11} as const
12// 컨텍스트가 창의 이 비율을 넘기면 자동 압축이 오기 전에 handoff 문서 반영 후 먼저 압축한다.
13const PREEMPT_PERCENT = 85
14const ONE_HOUR_MS = 60 * 60_000
15const FIVE_MIN_MS = 5 * 60_000
16// 수동 /compact 때 캐시가 살아 있다고 보는 여유. 이보다 만료에 가까우면 반영 턴을 넣지 않는다.
17const CACHE_MARGIN_MS = 2 * 60_000
18// 응답 대기 판정에 넘기는 마지막 답변의 길이.
19const CLASSIFY_TAIL_CHARS = 4000
20// 압축 직전 턴이 끝난 뒤 압축을 부르기까지의 간격. 턴이 도는 동안 compact는 거절된다.
21const AFTER_TURN_MS = 1000
22// TTL을 찾으려고 세션 기록 파일 끝에서 읽는 바이트 수.
23const TRANSCRIPT_TAIL_BYTES = 1024 * 1024
24// 답 끝 표시를 남겨 두는 답의 수. 넘으면 오래된 것부터 지운다($.store는 4 MiB가 한도다).
25const MAX_MARKED_REPLIES = 200
26// 마지막 답을 찾을 때 비교하는 끝부분 길이. 화면에 그리는 텍스트는 앞부분이 원문과 다를 수 있다.
27const MATCH_TAIL_CHARS = 80
28// 캐시가 만료된 세션을 다시 열어 첫 메시지를 보낼 때, 컨텍스트가 이 이상이면 한 번 막고 경고한다.
29const EXPIRED_WARN_TOKENS = 100_000
30// 판단 기록 파일의 최대 길이. 넘으면 앞부분부터 지운다($.fs는 4 MiB가 한도다).
31const DIAG_MAX_CHARS = 256_000
32
33// 화면에 내는 글자와 Claude에게 보내는 지시. 설정 language로 고른다.
34const KO = {
35  compacting: '◆ 압축 중…',
36  now: '지금',
37  scheduled: '◇ 압축 예정',
38  cancel: '취소',
39  compacted: (detail?: string) => `◆ 압축됨 · ${detail}`,
40  expired: (tokens?: number) => `○ 캐시 만료${tokens === undefined ? '' : ` (컨텍스트 ${Math.round(tokens / 1000)}k)`}`,
41  failed: (detail?: string) => `✕ 압축 실패 · ${detail}`,
42  auto: '자동',
43  manual: '수동',
44  away: '자리 비움',
45  contextLabel: (percent?: number) => `컨텍스트 ${percent}%`,
46  contextReason: (percent?: number) => `컨텍스트가 ${percent}%까지 찼다.`,
47  manualReason: '/compact를 실행했다.',
48  awayReason: '자리를 비운 사이 프롬프트 캐시가 곧 만료된다.',
49  compactAsTurn: '/compact 명령이 압축 대신 턴으로 처리됐다',
50  handoffUnfinished: (file: string) => `${file} 반영이 끝나지 않아 압축하지 않았다`,
51  compactSkipped: (skip: string) => `압축이 취소됐다 (${skip})`,
52  commandNoCompact: (out?: string) => `/compact 명령이 압축하지 않았다 (${out ?? '출력 없음'})`,
53  commandFailed: (err: string) => `/compact 명령을 실행하지 못했다 (${err})`,
54  compactFailed: (err: string) => `압축하지 못했다 (${err})`,
55  handoffFirst: (file: string) => `state-compact: ${file}을 먼저 반영한 뒤 압축합니다`,
56  expiredWarn: (tokens: number, usd: number | undefined, isFilled: boolean) =>
57    `state-compact: 캐시가 만료됐습니다. 보내면 컨텍스트 약 ${Math.round(tokens / 1000)}k 토큰을 다시 캐시합니다` +
58    `${usd === undefined ? '' : `(약 $${usd.toFixed(2)})`}. ` +
59    `그대로 보내려면 다시 보내고, 아니면 /compact나 새 세션을 쓰세요.${isFilled ? '' : ' 보낸 메시지는 입력창에 되돌리지 못했습니다.'}`,
60  handoffPrompt: (path: string, file: string, isTracked: boolean, reason: string) =>
61    [
62      `[state-compact] ${reason} 곧 대화를 압축한다. 압축하면 지금 대화의 세부 내용은 요약으로 바뀐다.`,
63      `압축 전에 ${path}에 지금까지 진행한 내용과 다음에 할 일을 반영하라.`,
64      isTracked
65        ? `${file}은 git이 추적하는 파일이다. \`git commit -- ${file}\` 형식으로 이 파일만 커밋하라. 다른 변경은 커밋에 넣지 마라.`
66        : `${file}은 git이 추적하지 않는 파일이다. 커밋하지 마라.`,
67      '그 밖의 작업은 하지 말고, 끝나면 무엇을 고쳤는지 한 줄로만 답하라.',
68    ].join('\n'),
69}
70const EN: typeof KO = {
71  compacting: '◆ Compacting…',
72  now: 'now',
73  scheduled: '◇ Compaction scheduled',
74  cancel: 'Cancel',
75  compacted: detail => `◆ Compacted · ${detail}`,
76  expired: tokens => `○ Cache expired${tokens === undefined ? '' : ` (context ${Math.round(tokens / 1000)}k)`}`,
77  failed: detail => `✕ Compaction failed · ${detail}`,
78  auto: 'auto',
79  manual: 'manual',
80  away: 'away',
81  contextLabel: percent => `context ${percent}%`,
82  contextReason: percent => `Context has reached ${percent}%.`,
83  manualReason: '/compact was run.',
84  awayReason: 'The prompt cache is about to expire while the user is away.',
85  compactAsTurn: '/compact ran as a turn instead of compacting',
86  handoffUnfinished: file => `not compacted because the ${file} update didn't finish`,
87  compactSkipped: skip => `compaction was cancelled (${skip})`,
88  commandNoCompact: out => `/compact didn't compact (${out ?? 'no output'})`,
89  commandFailed: err => `couldn't run /compact (${err})`,
90  compactFailed: err => `couldn't compact (${err})`,
91  handoffFirst: file => `state-compact: updating ${file} before compacting`,
92  expiredWarn: (tokens, usd, isFilled) =>
93    `state-compact: The cache has expired. Sending will re-cache about ${Math.round(tokens / 1000)}k tokens of context` +
94    `${usd === undefined ? '' : ` (about $${usd.toFixed(2)})`}. ` +
95    `Send again to go ahead, or use /compact or a new session.${isFilled ? '' : " Couldn't put your message back in the input box."}`,
96  handoffPrompt: (path, file, isTracked, reason) =>
97    [
98      `[state-compact] ${reason} The conversation will be compacted soon. Compaction replaces the details of this conversation with a summary.`,
99      `Before that, update ${path} with the progress so far and what to do next.`,
100      isTracked
101        ? `${file} is tracked by git. Commit only this file, as \`git commit -- ${file}\`. Don't include other changes in the commit.`
102        : `${file} isn't tracked by git. Don't commit it.`,
103      'Do nothing else, and when done reply in one line saying what you changed.',
104    ].join('\n'),
105}
106
107const CLASSIFY_SYSTEM =
108  'You label the last message an AI coding assistant sent to its user. Reply with one word. ' +
109  "WAIT: the message asks the user a question, asks for a decision or confirmation, or otherwise needs the user's reply before the work can go on. " +
110  'DONE: the message reports finished work or answers a question and needs no reply.'
111
112// 세션 기록 파일의 끝부분을 출력한다. 기록 파일은 수 MB까지 커지므로 끝에서만 읽는다.
113const TAIL_POWERSHELL =
114  '$f=[IO.File]::Open($env:STATE_COMPACT_TRANSCRIPT,"Open","Read","ReadWrite");' +
115  `$n=[Math]::Min($f.Length,${TRANSCRIPT_TAIL_BYTES});$null=$f.Seek(-$n,"End");` +
116  '$b=New-Object byte[] $n;$null=$f.Read($b,0,$n);$f.Close();' +
117  '[Console]::OutputEncoding=[Text.Encoding]::UTF8;[Console]::Out.Write([Text.Encoding]::UTF8.GetString($b))'
118
119type Ttl = '1h' | '5m'
120type HandoffDoc = { path: string; isTracked: boolean }
121// 답 끝에 남기는 기록. 그 답에 붙은 채로 지우지 않는다.
122// tokens는 캐시 만료 때의 컨텍스트 크기다.
123type Mark = { kind: 'compacted' | 'expired' | 'failed'; at: number; detail?: string; tokens?: number }
124
125// 이 프로세스에서 마지막으로 보낸 메인 요청의 시작 시각. resume·재시작 직후에는 없으므로
126// 그때는 만료 전 압축도, 수동 압축 앞의 반영 턴도 하지 않는다.
127let lastRequestAt: number | undefined
128let transcriptPath: string | undefined
129let idleTimer: { cancel: () => void } | undefined
130let idleAt: number | undefined
131let idleLateAt: number | undefined
132let expiryTimer: { cancel: () => void } | undefined
133// handoff 문서 반영 → 압축 순서가 진행 중이다.
134let isBusy = false
135// 진행 중인 압축을 답 끝에 적을 때의 이유. 플러그인이 시작한 압축에만 있다.
136let compactLabel: string | undefined
137let isAwaitingHandoffTurn = false
138let handoffTurnId: string | undefined
139let pendingInstructions: string | undefined
140// SDK 세션에서 /compact 명령을 실행하고 그 압축이 지나가기를 기다리는 중이다.
141let isAwaitingCompactPrompt = false
142// 사용자 설정(userConfig). handoffFile이 비어 있으면 문서 반영 없이 압축만 한다.
143let handoffFile = ''
144let msg = KO
145// 답(메시지 id)별 기록. $.store에 같이 써서 앱을 다시 켜도 남긴다.
146let marks = new Map<string, Mark[]>()
147// 마지막 답의 텍스트와, 그 답을 그리는 블록의 id. 진행 상태(압축 예정·압축 중)는 이 블록에만 붙인다.
148let lastAnswer: string | undefined
149let lastId: string | undefined
150// 마지막 답 블록을 찾기 전에 생긴 기록. 찾으면 그 답에 붙인다.
151let pendingMarks: Mark[] = []
152// 다시 연 세션의 캐시가 만료돼 첫 메시지가 컨텍스트 전체를 다시 캐시한다. 경고하거나 턴이 시작되면 지운다.
153let expiredResume: { tokens: number; usd?: number } | undefined
154// 턴이 끝날 때 백그라운드 작업(서브에이전트, 백그라운드 셸 등)이 돌고 있었다. 끝나면 세션을 다시 깨운다.
155let hasBackgroundWork = false
156// 판단 기록 쓰기를 차례로 잇는다. 읽고 덧붙여 다시 쓰므로 겹치면 줄이 사라진다.
157let diagWrite = Promise.resolve()
158
159export const register: Register = (on, options) => {
160  handoffFile = String(options.handoff_file ?? '').trim()
161  msg = options.language === 'en' ? EN : KO
162
163  on('session.start', async ($, e, next) => {
164    const saved = await $.store.get('marks')
165    if (saved && typeof saved === 'object') marks = new Map(Object.entries(saved as Record<string, Mark[]>))
166    redraw($)
167    return next(e)
168  })
169
170  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
171    const drawn = await next(e)
172    // 마지막 답이 정해진 뒤 처음 그려지는 블록 중 끝부분이 같은 것을 그 답으로 본다.
173    // 그린 순서로 고르면 위로 스크롤해 처음 그려진 옛 블록이 잡힌다.
174    if (lastId === undefined && lastAnswer !== undefined && tail(e.props.text) !== '' && tail(e.props.text) === tail(lastAnswer)) {
175      lastId = e.requestId
176      for (const mark of pendingMarks.splice(0)) void addMark($, mark)
177    }
178    // 한 줄에 왼쪽은 항목, 오른쪽은 시각.
179    const rows = (marks.get(e.requestId) ?? []).map(markRow)
180    const isLast = e.requestId === lastId
181    if (isLast && isBusy) rows.push([msg.compacting, msg.now])
182    // 압축 예정 줄에는 예약을 푸는 버튼을 시각 오른쪽에 붙인다.
183    const scheduledAt = isLast && !isBusy ? idleAt : undefined
184    if (rows.length === 0 && scheduledAt === undefined) return drawn
185    const { Box, Text, Button } = $.ui.resolve(e)
186    return (
187      <Box flexDirection="column">
188        {drawn}
189        <Box flexDirection="column" width="100%" borderStyle="round" borderDimColor paddingX={1}>
190          {rows.map(([item, time]) => (
191            <Box flexDirection="row" justifyContent="space-between">
192              <Text dimColor>{item}</Text>
193              <Text dimColor>{time}</Text>
194            </Box>
195          ))}
196          {scheduledAt !== undefined && (
197            <Box flexDirection="row" justifyContent="space-between">
198              <Text dimColor>{msg.scheduled}</Text>
199              <Box flexDirection="row" gap={1}>
200                <Text dimColor>{hhmm(scheduledAt)}</Text>
201                <Button key="cancel-idle" plain dimColor onPress={() => { cancelIdle(); redraw($) }}>{msg.cancel}</Button>
202              </Box>
203            </Box>
204          )}
205        </Box>
206      </Box>
207    )
208  })
209
210  // 앱을 재시작하거나 세션을 다시 열었다. 꺼져 있던 동안 지난 캐시 만료를 남긴다.
211  on('classic.SessionStart', async ($, e, next) => {
212    transcriptPath = e.transcript_path
213    if (e.source === 'resume' && e.prompt_cache_likely_expired && (e.context_tokens ?? 0) >= EXPIRED_WARN_TOKENS) {
214      expiredResume = { tokens: e.context_tokens ?? 0, usd: e.estimated_cache_write_usd }
215    }
216    const result = await next(e)
217    if (e.source === 'resume' && e.seconds_since_last_response !== undefined) {
218      void restoreExpiry($, e.seconds_since_last_response, e.context_tokens)
219    }
220    return result
221  })
222
223  // 사용자가 쓴 첫 메시지만 막는다. 명령(/compact 등)은 그대로 보낸다. 같은 메시지를 다시 보내면 통과한다.
224  on('prompt.submit', async ($, e, next) => {
225    const warn = expiredResume
226    const isUser = e.origin.kind === 'composer' || e.origin.kind === 'bridge' || e.origin.kind === 'sdk'
227    if (!warn || !isUser || e.text.trimStart().startsWith('/')) return next(e)
228    expiredResume = undefined
229    const { isFilled } = await $.prompt.fill({ text: e.text })
230    return { drop: msg.expiredWarn(warn.tokens, warn.usd, isFilled) }
231  })
232
233  on('classic.UserPromptSubmit', ($, e, next) => {
234    transcriptPath = e.transcript_path
235    return next(e)
236  })
237
238  // Stop 훅은 턴을 이어 가게 할 수 있어 turn.complete보다 먼저 온다.
239  on('classic.Stop', ($, e, next) => {
240    transcriptPath = e.transcript_path
241    const tasks = e.background_tasks ?? []
242    hasBackgroundWork = tasks.length > 0
243    diag($, `stop bg=${tasks.length}${tasks.length > 0 ? ` (${tasks.map(t => t.type).join(',')})` : ''}`)
244    return next(e)
245  })
246
247  on('turn.step', async function* ($, e, next) {
248    if (e.agentId) return yield* next(e)
249    lastRequestAt = await $.clock.now()
250    return yield* next(e)
251  })
252
253  on('turn.start', ($, e, next) => {
254    expiredResume = undefined
255    hasBackgroundWork = false
256    if (idleAt !== undefined) diag($, `turn.start cancels idle at ${stamp(idleAt)}`)
257    cancelIdle()
258    cancelExpiry()
259    if (isAwaitingHandoffTurn) {
260      isAwaitingHandoffTurn = false
261      handoffTurnId = e.turnId
262    }
263    redraw($)
264    return next(e)
265  })
266
267  on('turn.complete', async ($, e, next) => {
268    const result = await next(e)
269    if (e.agentId) return result
270    diag($, `turn.complete reason=${e.reason} busy=${isBusy} bg=${hasBackgroundWork}`)
271    if (e.answer.trim()) {
272      lastAnswer = e.answer
273      lastId = undefined
274      redraw($)
275    }
276
277    // 실행한 /compact가 압축 없이 턴으로 끝났다.
278    if (isAwaitingCompactPrompt) {
279      finish($)
280      notifyFailure($, msg.compactAsTurn)
281      return result
282    }
283
284    if (handoffTurnId !== undefined && e.turnId === handoffTurnId) {
285      handoffTurnId = undefined
286      if (e.reason === 'answer') {
287        $.clock.after(AFTER_TURN_MS, () => void compactNow($))
288      } else {
289        finish($)
290        notifyFailure($, msg.handoffUnfinished(handoffFile))
291      }
292      return result
293    }
294
295    if (isBusy) return result
296    // 중단된 턴도 캐시는 남으므로 만료 표시만 예약한다(토큰 0이면 만료 전 압축은 건너뛴다).
297    if (e.reason !== 'answer') {
298      void scheduleTimers($, 0, '')
299      return result
300    }
301
302    const { context } = await $.session.usage()
303    if ((context.percent ?? 0) >= PREEMPT_PERCENT) {
304      diag($, `preempt percent=${context.percent}`)
305      $.clock.after(AFTER_TURN_MS, () => void begin($, msg.contextReason(context.percent), msg.contextLabel(context.percent)))
306      return result
307    }
308    // 기록 파일 읽기와 응답 대기 판정에 몇 초가 걸린다. 턴 종료를 붙잡지 않도록 기다리지 않는다.
309    void scheduleTimers($, context.tokens ?? 0, e.answer)
310    return result
311  })
312
313  // 메인 대화의 압축이 끝나면 그 시점의 마지막 답 끝에 남긴다. 어떤 경로로 압축됐든 같다.
314  on('session.compact', async ($, e, next) => {
315    if (e.agentId || e.trigger === 'precompute') return next(e)
316    const label = compactLabel ?? (e.trigger === 'auto' ? msg.auto : msg.manual)
317    const r = await next(e)
318    if (r.skip) return r
319    // 압축하면서 캐시를 새로 만들었으므로 이전 요청 기준의 만료 표시와 경고는 맞지 않다.
320    cancelExpiry()
321    expiredResume = undefined
322    await addMark($, { kind: 'compacted', at: await $.clock.now(), detail: label })
323    return r
324  })
325
326  on('session.compact', { trigger: 'manual' }, async ($, e, next) => {
327    if (!e.agentId && isAwaitingCompactPrompt) {
328      try {
329        return await next(e)
330      } finally {
331        finish($)
332      }
333    }
334    if (e.agentId || isBusy) return next(e)
335    // TTL을 확인하지 못하면 짧은 쪽으로 본다.
336    const ttlMs = (await readTtl($)) === '1h' ? ONE_HOUR_MS : FIVE_MIN_MS
337    // resume한 세션이나 오래 쉰 세션은 캐시가 이미 없다. 반영 턴을 넣으면 전체를 한 번 더
338    // 캐시하게 되므로 그대로 압축한다.
339    if (lastRequestAt === undefined || (await $.clock.now()) - lastRequestAt > ttlMs - CACHE_MARGIN_MS) {
340      return next(e)
341    }
342    if (!(await findHandoffDoc($))) return next(e)
343    $.clock.after(AFTER_TURN_MS, () => void begin($, msg.manualReason, msg.manual, e.instructions))
344    return { skip: msg.handoffFirst(handoffFile) }
345  })
346}
347
348// 캐시 만료 표시는 매 턴 예약한다. 만료 전 압축은 사용자의 답을 기다리는 턴이나 백그라운드 작업이
349// 돌고 있는 턴에서만 예약한다. 끝난 보고면 돌아올 가능성이 낮아 압축 비용만 남는다.
350async function scheduleTimers($: EngineInterface, tokens: number, answer: string) {
351  cancelIdle()
352  cancelExpiry()
353  redraw($)
354  const requestAt = lastRequestAt
355  if (requestAt === undefined) return diag($, 'schedule: no request in this process')
356  const at = `schedule req=${stamp(requestAt)}`
357  // 마지막 응답이 실제로 캐시된 TTL. 확인하지 못하면 예약하지 않는다.
358  const ttl = await readTtl($)
359  if (!ttl || lastRequestAt !== requestAt || isBusy) {
360    return diag($, `${at} stop ttl=${ttl} stale=${lastRequestAt !== requestAt} busy=${isBusy}`)
361  }
362  const expireAt = requestAt + (ttl === '1h' ? ONE_HOUR_MS : FIVE_MIN_MS)
363  expiryTimer = $.clock.after(Math.max(0, expireAt - (await $.clock.now())), () => void onExpire($, expireAt))
364  const rule = IDLE_RULES[ttl]
365  if (tokens < rule.minTokens) return diag($, `${at} ttl=${ttl} tokens=${tokens} below ${rule.minTokens}`)
366  if (!hasBackgroundWork && !(await isAwaitingReply($, answer))) return diag($, `${at} ttl=${ttl} tokens=${tokens} not waiting, no bg`)
367  // 판정하는 몇 초 사이에 새 턴이 시작됐거나 압축이 진행 중이면 이 예약은 낡았다.
368  if (lastRequestAt !== requestAt || isBusy) {
369    return diag($, `${at} stop after check stale=${lastRequestAt !== requestAt} busy=${isBusy}`)
370  }
371  const wait = rule.compactAfterMs - ((await $.clock.now()) - requestAt)
372  if (wait <= 0) return diag($, `${at} too late wait=${wait}ms`)
373  idleAt = requestAt + rule.compactAfterMs
374  idleLateAt = requestAt + rule.lateLimitMs
375  idleTimer = $.clock.after(wait, () => void onIdle($))
376  diag($, `${at} ttl=${ttl} tokens=${tokens} bg=${hasBackgroundWork} idle at ${stamp(idleAt)}`)
377  redraw($)
378}
379
380async function onIdle($: EngineInterface) {
381  const lateAt = idleLateAt
382  idleTimer = undefined
383  idleAt = undefined
384  idleLateAt = undefined
385  redraw($)
386  if (isBusy || lateAt === undefined) return diag($, `idle: skip busy=${isBusy} lateAt=${lateAt}`)
387  const now = await $.clock.now()
388  if (now > lateAt) return diag($, `idle: skip, fired ${Math.round((now - lateAt) / 1000)}s past late limit`)
389  // 입력창에 쓰던 글이 있으면 자리에 있는 것이다.
390  if ((await $.prompt.read()).text.trim() !== '') return diag($, 'idle: skip, prompt has text')
391  diag($, 'idle: begin')
392  await begin($, msg.awayReason, msg.away)
393}
394
395async function onExpire($: EngineInterface, expireAt: number) {
396  expiryTimer = undefined
397  if (isBusy) return
398  const { context } = await $.session.usage()
399  await addMark($, { kind: 'expired', at: expireAt, tokens: context.tokens })
400}
401
402// 다시 연 세션의 마지막 응답 기준으로 만료를 남긴다. 이미 지났으면 바로, 아니면 예약한다.
403// 데스크톱 앱은 세션을 열기만 해서는 프로세스를 띄우지 않아, 대개 새 메시지를 보낼 때 여기에 온다.
404// 마지막 요청 시각은 모르므로 응답 시각을 쓴다. 표시 시각은 실제 만료보다 응답 시간만큼 늦다.
405async function restoreExpiry($: EngineInterface, secondsSinceResponse: number, tokens: number | undefined) {
406  const respondedAt = (await $.clock.now()) - secondsSinceResponse * 1000
407  const ttl = await readTtl($)
408  if (!ttl) return
409  // 꺼지기 전에 만료나 압축을 이미 남겼으면 다시 남기지 않는다.
410  const saved = (await $.store.get('marks')) as Record<string, Mark[]> | undefined
411  if (Object.values(saved ?? {}).flat().some(m => m.kind !== 'failed' && m.at >= respondedAt)) return
412  const messages = await $.session.messages()
413  if (!Array.isArray(messages)) return
414  // 새 메시지의 답이 아직 안 왔으면 다시 열기 전의 마지막 답에 붙인다.
415  if (lastAnswer === undefined) {
416    const answers = messages.filter(m => m.role === 'assistant' && m.text.trim())
417    lastAnswer = answers[answers.length - 1]?.text
418    redraw($)
419  }
420  const expireAt = respondedAt + (ttl === '1h' ? ONE_HOUR_MS : FIVE_MIN_MS)
421  const wait = expireAt - (await $.clock.now())
422  if (wait <= 0) {
423    await addMark($, { kind: 'expired', at: expireAt, tokens })
424    return
425  }
426  // 그 사이 새 요청이 나갔으면 그 턴이 예약한다.
427  if (lastRequestAt !== undefined) return
428  cancelExpiry()
429  expiryTimer = $.clock.after(wait, () => void onExpire($, expireAt))
430}
431
432function cancelIdle() {
433  idleTimer?.cancel()
434  idleTimer = undefined
435  idleAt = undefined
436  idleLateAt = undefined
437}
438
439function cancelExpiry() {
440  expiryTimer?.cancel()
441  expiryTimer = undefined
442}
443
444async function begin($: EngineInterface, reason: string, label: string, instructions?: string) {
445  if (isBusy) return
446  isBusy = true
447  compactLabel = label
448  cancelIdle()
449  cancelExpiry()
450  redraw($)
451  pendingInstructions = instructions
452  const doc = await findHandoffDoc($)
453  if (!doc) {
454    await compactNow($)
455    return
456  }
457  isAwaitingHandoffTurn = true
458  await $.prompt.submit({ text: msg.handoffPrompt(doc.path, handoffFile, doc.isTracked, reason) })
459}
460
461async function compactNow($: EngineInterface) {
462  try {
463    const r = await $.session.compact(pendingInstructions ? { instructions: pendingInstructions } : undefined)
464    if (r.skip) notifyFailure($, msg.compactSkipped(r.skip))
465  } catch (err) {
466    // SDK 세션(데스크톱 앱 등)은 플러그인의 압축 호출을 거절한다. /compact를 프롬프트로 넣어도
467    // 큐에 들어가지 않았으므로 슬래시 명령으로 실행한다.
468    if (String(err).includes('headless')) {
469      isAwaitingCompactPrompt = true
470      try {
471        const r = await $.command.run({ command: 'compact', ...(pendingInstructions ? { args: pendingInstructions } : {}) })
472        // 명령이 끝났는데 압축 훅을 지나지 않았다.
473        if (isAwaitingCompactPrompt) notifyFailure($, msg.commandNoCompact(r.text))
474      } catch (runErr) {
475        notifyFailure($, msg.commandFailed(String(runErr)))
476      }
477      finish($)
478      return
479    }
480    notifyFailure($, msg.compactFailed(String(err)))
481  }
482  finish($)
483}
484
485function finish($: EngineInterface) {
486  isBusy = false
487  isAwaitingHandoffTurn = false
488  isAwaitingCompactPrompt = false
489  compactLabel = undefined
490  pendingInstructions = undefined
491  redraw($)
492}
493
494// 데스크톱 앱은 플러그인 토스트를 그리지 않으므로 답 끝에도 남긴다.
495function notifyFailure($: EngineInterface, text: string) {
496  $.ui.toast(`state-compact: ${text}`)
497  void $.clock.now().then(at => addMark($, { kind: 'failed', at, detail: text }))
498}
499
500// 지금 마지막 답에 기록을 붙인다. 답은 알지만 블록을 아직 못 찾았으면 찾을 때 붙이고,
501// 답도 모르면(재시작 직후 턴 전) 남기지 않는다.
502async function addMark($: EngineInterface, mark: Mark) {
503  diag($, `mark ${mark.kind} at=${stamp(mark.at)}${mark.detail ? ` ${mark.detail}` : ''}${mark.tokens === undefined ? '' : ` tokens=${mark.tokens}`}`)
504  if (lastId === undefined) {
505    if (lastAnswer !== undefined) pendingMarks.push(mark)
506    return
507  }
508  marks.set(lastId, [...(marks.get(lastId) ?? []), mark])
509  while (marks.size > MAX_MARKED_REPLIES) marks.delete(marks.keys().next().value!)
510  redraw($)
511  await $.store.set('marks', Object.fromEntries(marks))
512}
513
514function redraw($: EngineInterface) {
515  $.ui.invalidate('ui.render')
516}
517
518function markRow(mark: Mark): [string, string] {
519  if (mark.kind === 'compacted') return [msg.compacted(mark.detail), hhmm(mark.at)]
520  if (mark.kind === 'expired') return [msg.expired(mark.tokens), hhmm(mark.at)]
521  return [msg.failed(mark.detail), hhmm(mark.at)]
522}
523
524function hhmm(ms: number): string {
525  const d = new Date(ms)
526  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
527}
528
529function tail(text: string): string {
530  return text.trim().slice(-MATCH_TAIL_CHARS)
531}
532
533function stamp(ms: number): string {
534  const d = new Date(ms)
535  const p = (n: number) => String(n).padStart(2, '0')
536  return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
537}
538
539// 만료 전 압축을 예약하지 않거나 건너뛴 이유는 세션 기록에 남지 않는다. 판단마다 한 줄을
540// 세션 기록 파일 옆(<세션 id>.state-compact.log)에 덧붙인다. 답 본문은 넣지 않는다.
541function diag($: EngineInterface, text: string) {
542  // 이름이 다르면 세션 기록 파일 자체를 덮어쓰게 되므로 남기지 않는다.
543  if (!transcriptPath?.endsWith('.jsonl')) return
544  const path = `${transcriptPath.slice(0, -'.jsonl'.length)}.state-compact.log`
545  diagWrite = diagWrite
546    .then(async () => {
547      const line = `${stamp(await $.clock.now())} ${text}\n`
548      const all = (await $.fs.read(path).catch(() => '')) + line
549      const cut = all.length > DIAG_MAX_CHARS ? all.indexOf('\n', all.length - DIAG_MAX_CHARS) + 1 : 0
550      await $.fs.write(path, all.slice(cut))
551    })
552    .catch(() => {})
553}
554
555// 설정한 handoff 문서가 저장소 루트에 있으면 돌려준다. 설정이 비었거나 파일이 없으면 undefined.
556async function findHandoffDoc($: EngineInterface): Promise<HandoffDoc | undefined> {
557  if (!handoffFile) return undefined
558  const cwd = await $.session.cwd()
559  const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
560  if (top.exitCode !== 0) return undefined
561  const root = top.stdout.trim()
562  const path = `${root}/${handoffFile}`
563  if (!(await $.fs.exists(path))) return undefined
564  const tracked = await $.process.run(['git', 'ls-files', '--error-unmatch', handoffFile], { cwd: root })
565  return { path, isTracked: tracked.exitCode === 0 }
566}
567
568async function isAwaitingReply($: EngineInterface, answer: string): Promise<boolean> {
569  if (!answer.trim()) return false
570  const startAt = await $.clock.now()
571  const r = await $.model.complete({
572    model: 'haiku',
573    system: CLASSIFY_SYSTEM,
574    prompt: answer.slice(-CLASSIFY_TAIL_CHARS),
575    maxTokens: 5,
576    timeoutMs: 30_000,
577  })
578  const result = r.isAnswered ? JSON.stringify(r.text.trim()) : r.reason === 'api-error' ? `api-error ${r.status} ${r.error}` : r.reason
579  diag($, `haiku ${(await $.clock.now()) - startAt}ms ${result}`)
580  // 판정하지 못하면 압축하지 않는다. 요약 손실을 감수할 근거가 없다.
581  return r.isAnswered && r.text.trim().toUpperCase().startsWith('WAIT')
582}
583
584// 세션 기록 파일에서 마지막으로 캐시를 쓴 메인 응답의 TTL을 읽는다. 응답 usage에는 TTL별 값이
585// 실려 오지 않아 기록 파일이 유일한 실측이다. 읽지 못하면 undefined.
586async function readTtl($: EngineInterface): Promise<Ttl | undefined> {
587  if (!transcriptPath) return undefined
588  const isWindows = (await $.env.get('OS')) === 'Windows_NT'
589  const r = isWindows
590    ? await $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', TAIL_POWERSHELL], {
591        env: { STATE_COMPACT_TRANSCRIPT: transcriptPath },
592      })
593    : await $.process.run(['tail', '-c', String(TRANSCRIPT_TAIL_BYTES), transcriptPath])
594  if (r.exitCode !== 0) {
595    diag($, `ttl: tail exit=${r.exitCode} ${r.stderr.trim().slice(0, 200)}`)
596    return undefined
597  }
598  const lines = r.stdout.split('\n')
599  for (let i = lines.length - 1; i >= 0; i--) {
600    const line = lines[i]!
601    if (!line.includes('"cache_creation"')) continue
602    let entry: { type?: string; isSidechain?: boolean; message?: { usage?: { cache_creation?: Record<string, number> } } }
603    try {
604      entry = JSON.parse(line)
605    } catch {
606      continue
607    }
608    const cc = entry.message?.usage?.cache_creation
609    if (entry.type !== 'assistant' || entry.isSidechain || !cc) continue
610    if ((cc.ephemeral_1h_input_tokens ?? 0) > 0) return '1h'
611    if ((cc.ephemeral_5m_input_tokens ?? 0) > 0) return '5m'
612  }
613  diag($, `ttl: no main cache_creation in last ${lines.length} lines`)
614  return undefined
615}
616