claude-dashboard v1.14~v1.24: stdin 우선, OSC8 링크, 그리고 파서를 한 번 더 100배 빠르게
claude-dashboard v1.14~v1.24의 변화를 정리합니다. Claude Code stdin이 새 필드를 추가하면서 API 호출을 0으로 줄였고, 트랜스크립트 파서를 두 번 더 100배 빠르게 만들었으며, lastPrompt 위젯의 데이터 소스를 history.jsonl로 옮겼습니다.
1. 지난 이야기
claude-dashboard v1.10~v1.13 글에서는 5종 테마, Effort Level 표시, mtime 기반 캐싱, 그리고 트랜스크립트 증분 파싱(1막)까지 다뤘습니다. v1.13 시점에서 상태줄은 이미 충분히 가벼웠고, 별로 더 손댈 게 없을 거라고 생각했습니다.
그런데 v1.14부터 v1.24까지 약 7주간, 11번의 마이너 릴리스를 거치면서 상태줄은 또 한 번 크게 달라졌습니다.
가장 큰 변화는 세 가지였습니다.
- Claude Code 본체가 stdin에 새 필드를 추가해서, 굳이 API를 부르지 않아도 rate limit을 알 수 있게 됐습니다. 그래서 데이터 소스 자체를 갈아엎었습니다.
- 이미 빠르다고 믿었던 트랜스크립트 파서가 사실은 두 군데에서 더 느려질 여지를 가지고 있었고, 그걸 두 번 더 100배쯤 빨라지게 만들었습니다.
- lastPrompt 위젯이 잘못된 데이터 소스를 보고 있었습니다. 사용자가 입력한 진짜 텍스트가 아니라, 그 위에 슬래시 커맨드와 스킬이 잔뜩 덧붙은 확장 결과를 보여주고 있었습니다.
그 외에도 OSC8 클릭 가능한 git 브랜치 링크, sync 호출 추가 사냥, git diff HEAD의 untracked 블라인드 스팟, sibling 디렉토리 오탐 같은 자잘한 이야기가 섞여 있습니다. 한 편에 다 담아 정리해 두겠습니다.
2. stdin이 우선이 된 날 (v1.21.0)
2.1 매 렌더마다 API를 부르던 시절
claude-dashboard는 처음부터 OAuth API(api.anthropic.com/api/oauth/usage)를 호출해서 5시간/7일 rate limit을 가져왔습니다. 매 렌더마다 부르면 당연히 안 되니까 3-tier 캐싱으로 60초 정도 캐시하고 있었습니다.
그래도 마음에 걸리는 부분이 있었습니다.
- 캐시가 만료될 때마다 백엔드에 진짜 HTTP 요청이 한 번씩 나갑니다.
- 네트워크가 잠시 끊기면 폴백 처리를 해야 하고, 429를 만나면 retry 로직도 필요합니다.
- 사용자가 무거운 IDE 두세 개와 동시에 Claude Code를 띄워 두면, 같은 토큰으로 같은 API를 같은 시간에 두 번 부르게 됩니다.
2.2 Claude Code 2.1.80+의 새 stdin 필드
그러던 중 Claude Code 2.1.80에서 상태줄에 넘기는 stdin JSON에 새 필드가 생겼습니다. rate_limits 객체입니다. 이미 본체가 백엔드에 한 번 물어본 결과를 그대로 stdin에 실어서 내려주기 시작한 것입니다.
타입으로 정리하면 이렇게 생겼습니다.
// scripts/types.ts
interface StdinInput {
// ... 기존 필드들 ...
/**
* Rate limits from Claude Code stdin (Pro/Max subscribers, after first API response).
* Each window may be independently absent.
*/
rate_limits?: {
five_hour?: {
used_percentage: number;
/** Unix epoch seconds */
resets_at: number;
};
seven_day?: {
used_percentage: number;
/** Unix epoch seconds */
resets_at: number;
};
};
}
이게 의미하는 바는 단순하지만 강력합니다. 상태줄이 더 이상 직접 API를 부를 필요가 없다는 뜻입니다.
2.3 변환 한 단계만 끼우면 끝
stdin은 Unix epoch seconds, 내부 UsageLimits 타입은 ISO 문자열을 쓰고 있어서 변환만 한 단계 거치면 됩니다.
// scripts/statusline.ts
function convertStdinLimit(window: { used_percentage: number; resets_at: number }) {
return {
utilization: window.used_percentage,
resets_at: new Date(window.resets_at * 1000).toISOString(),
};
}
function parseStdinRateLimits(stdin: StdinInput): UsageLimits | null {
const rl = stdin.rate_limits;
if (!rl) return null;
return {
five_hour: rl.five_hour ? convertStdinLimit(rl.five_hour) : null,
seven_day: rl.seven_day ? convertStdinLimit(rl.seven_day) : null,
seven_day_sonnet: null, // ← stdin에는 아직 없음
};
}
2.4 Pro 플랜은 0회, Max 플랜은 하이브리드
이걸 메인 파이프라인에 끼우면 데이터 소스 우선순위가 자연스럽게 정리됩니다.
// scripts/statusline.ts
const stdinLimits = parseStdinRateLimits(stdin);
let rateLimits: UsageLimits | null;
if (!stdinLimits) {
// 1) stdin에 rate_limits가 아예 없는 경우 — 구 버전 fallback
rateLimits = await fetchUsageLimits(config.cache.ttlSeconds);
} else if (config.plan === 'max') {
// 2) Max 플랜: 5h/7d는 stdin, seven_day_sonnet만 API에서
const apiLimits = await fetchUsageLimits(config.cache.ttlSeconds);
rateLimits = { ...stdinLimits, seven_day_sonnet: apiLimits?.seven_day_sonnet ?? null };
} else {
// 3) Pro 플랜: 완전히 stdin만 사용 → API 호출 0회
rateLimits = stdinLimits;
}
세 가지 분기의 결과를 정리하면 이렇습니다.
| 플랜 | 렌더당 API 호출 | 비고 |
|---|---|---|
| Pro | 0회 | 완전히 stdin 기반 |
| Max | 1회 (캐시 적중 시 0회) | seven_day_sonnet만 API 폴백 |
| 구 버전 (stdin 미제공) | 기존과 동일 | 하위호환 유지 |
2.5 교훈
상류 플랫폼이 새 필드를 추가하면, 데이터 소스 자체를 다시 그리는 게 캐시 튜닝보다 빠르게 효과를 봅니다. 60초 캐싱을 더 영리하게 만드는 것보다, 그냥 API를 안 부르는 쪽이 본질적으로 더 빠르고 더 안정적입니다.
3. 파서를 다시, 또 다시 100배 빠르게
3.1 1막 회상 — 증분 파싱 (v1.12.0)
이전 글에서 설명한 1막은 짧게 회상하고 가겠습니다. 매 렌더마다 트랜스크립트 JSONL 전체를 읽던 코드를, 마지막으로 읽은 바이트 오프셋부터 새로 추가된 부분만 읽도록 바꿨습니다. 8시간 이상 되는 긴 세션에서 ~500ms → ~5ms로 떨어졌습니다.
// scripts/utils/transcript-parser.ts (1막 핵심)
let cachedTranscript: {
path: string;
size: number; // ← 마지막 파싱 시점의 파일 크기 = 다음 read offset
data: ParsedTranscript;
} | null = null;
여기까지는 이전 글에서 다뤘습니다. 그런데 이게 끝이 아니었습니다.
3.2 1막의 한계 — extract 함수가 여전히 O(n)
증분 파싱은 "파일을 읽는 부분"만 해결합니다. 정작 위젯이 쓰는 추출 함수들은 매번 entries 배열 전체를 처음부터 끝까지 훑고 있었습니다.
// 1막 시점의 extractTodoProgress (개념상)
function extractTodoProgress(transcript: ParsedTranscript) {
// 전체 entries를 거꾸로 훑어서 마지막 TodoWrite 찾기
for (let i = transcript.entries.length - 1; i >= 0; i--) {
const entry = transcript.entries[i];
// ... 조건 매칭 ...
}
}
// getRunningTools, extractAgentStatus도 비슷한 구조
긴 세션의 entries가 수만 개를 넘기 시작하면, 이 부분이 다시 새 병목이 됩니다. 파일은 100배 빨라졌는데 그 다음 줄이 발목을 잡는 클래식한 패턴입니다.
3.3 2막 — 파싱 시점에 상태를 미리 만들어 두기 (v1.16, 89fd5d4)
해결책은 단순했습니다. 위젯이 필요로 하는 상태(running tools, last todo, active agents, tasks)를 파싱할 때 한 번에 구성해 두자는 것입니다. 매번 entries를 다시 훑지 말고요.
ParsedTranscript에 증분 추적용 필드를 추가했습니다.
// scripts/types.ts
export interface ParsedTranscript {
toolUses: Map<string, { name: string; timestamp?: string; input?: unknown }>;
/** Count of completed tools (replaces unbounded Set for memory efficiency) */
completedToolCount: number;
sessionStartTime?: number;
sessionName?: string;
// --- Incremental tracking (updated in processEntries) ---
/** Tool IDs that have been dispatched but not yet returned */
runningToolIds: Set<string>;
/** Last completed TodoWrite input (for extractTodoProgress) */
lastTodoWriteInput: unknown;
/** Active agent (Task) tool IDs */
activeAgentIds: Set<string>;
/** Completed agent count */
completedAgentCount: number;
/** Tasks from TaskCreate/TaskUpdate, keyed by sequential ID */
tasks: Map<string, { subject: string; status: string }>;
// ... pending maps ...
}
그리고 한 군데, processEntries()에서 모든 상태를 한 번에 갱신합니다.
// scripts/utils/transcript-parser.ts
function processEntries(entries: TranscriptEntry[], existing: ParsedTranscript): void {
for (const entry of entries) {
// tool_use 블록 → runningToolIds + activeAgentIds 등록
if (entry.type === 'assistant' && entry.message?.content) {
for (const block of entry.message.content) {
if (block.type === 'tool_use' && block.id && block.name) {
existing.toolUses.set(block.id, {
name: block.name,
timestamp: entry.timestamp,
input: block.input,
});
existing.runningToolIds.add(block.id);
if (block.name === 'Task') {
existing.activeAgentIds.add(block.id);
}
// ... TaskCreate / TaskUpdate 펜딩 등록 ...
}
}
}
// tool_result → 완료 처리
if (entry.type === 'user' && entry.message?.content) {
for (const block of entry.message.content) {
if (block.type === 'tool_result' && block.tool_use_id) {
existing.completedToolCount++;
existing.runningToolIds.delete(block.tool_use_id);
if (existing.activeAgentIds.delete(block.tool_use_id)) {
existing.completedAgentCount++;
}
// 마지막 TodoWrite 갱신
const tool = existing.toolUses.get(block.tool_use_id);
if (tool?.name === 'TodoWrite') {
existing.lastTodoWriteInput = tool.input;
}
// ... 펜딩 Task 적용 ...
}
}
}
}
}
이제 추출 함수는 이렇게 짧아집니다.
// O(n) 전체 스캔 → O(k) 읽기
export function getRunningTools(transcript: ParsedTranscript) {
const running = [];
for (const id of transcript.runningToolIds) { // ← 보통 0~3개
const tool = transcript.toolUses.get(id);
if (!tool) continue;
running.push({ name: tool.name, /* ... */ });
}
return running;
}
export function extractTodoProgress(transcript: ParsedTranscript) {
const lastTodoWrite = transcript.lastTodoWriteInput; // ← 단일 참조
if (!lastTodoWrite) return null;
// ... 작은 todos 배열만 처리 ...
}
같은 작업으로 getRunningTools, extractTodoProgress, extractAgentStatus, extractTaskProgress 네 위젯의 추출 경로가 모두 O(n) → O(k)로 정리됐습니다. 여기서 k는 "지금 동작 중인 항목 개수"인데, 보통 3을 넘는 일이 거의 없습니다.
그리고 한 가지 더 중요한 변화. ParsedTranscript에서 무한 성장하는 entries: TranscriptEntry[] 필드를 통째로 제거했습니다. 더 이상 위젯이 entries를 필요로 하지 않으니, 메모리에 들고 있을 이유가 없습니다.
마지막으로 위젯에서 반복되던 보일러플레이트도 한 줄로 줄였습니다.
// scripts/utils/transcript-parser.ts
export async function getTranscript(ctx: WidgetContext): Promise<ParsedTranscript | null> {
const transcriptPath = ctx.stdin.transcript_path;
if (!transcriptPath) return null;
return parseTranscript(transcriptPath);
}
각 위젯의 getData에서 transcript 경로 null 체크 → 파싱 → 다시 null 체크 4단계가 await getTranscript(ctx) 한 줄이 되었습니다.
3.4 3막 — 메모리 가지치기 (v1.22, 5ab6054, Closes #53)
2막을 넣고 나서, 또 한 가지가 거슬리기 시작했습니다.
// 2막 시점의 ParsedTranscript에는 아직 이게 있었습니다
toolUses: Map<string, { name: string; ... }>;
toolResults: Set<string>; // ← 도구 호출이 누적될수록 무한 성장
두 자료구조 모두 도구 호출 ID마다 계속 항목이 추가됩니다. 한 시간만 작업해도 수천 개씩 쌓이고, 일주일 켜둔 세션이라면 수만 개입니다. 그런데 이미 완료된 도구의 정보는 위젯에서 더 이상 필요 없습니다. UI는 "지금 돌고 있는 것"만 보여주거든요.
해결 방향이 명확해졌습니다.
toolResults: Set<string>를 그냥 정수 카운터로 교체합니다.tool_result가 처리되는 시점에toolUsesMap에서 해당 항목을 즉시 삭제합니다.
// scripts/utils/transcript-parser.ts
function processEntries(entries: TranscriptEntry[], existing: ParsedTranscript): void {
for (const entry of entries) {
// ...
if (entry.type === 'user' && entry.message?.content) {
for (const block of entry.message.content) {
if (block.type === 'tool_result' && block.tool_use_id) {
existing.completedToolCount++; // ← Set 대신 정수
existing.runningToolIds.delete(block.tool_use_id);
// ... agent / todo / task 처리 ...
// ★ 핵심: 완료된 도구는 Map에서 즉시 제거
existing.toolUses.delete(block.tool_use_id);
}
}
}
}
}
그 결과, 세션이 아무리 길어도 toolUses에 들어 있는 항목 수는 "지금 실행 중인 도구 수"와 같아집니다. 보통 0~3개 사이에서 진동합니다.
[2막까지] 세션이 길어질수록 toolUses 가 단조 증가 → 메모리 우상향
[3막 이후] toolUses ≈ 현재 실행 중 도구 수 → 메모리 평탄선
3.5 효과 정리
| 막 | 문제 | 해결 | 영향 |
|---|---|---|---|
| 1막 (v1.12) | 매 렌더마다 전체 파일 파싱 | 바이트 오프셋 증분 파싱 | 8시간+ 세션 ~500ms → ~5ms |
| 2막 (v1.16) | extract 함수가 entries를 매번 O(n) 스캔 | 파싱 시점에 상태 미리 구성 (O(n)→O(k)) | entries 배열 제거 |
| 3막 (v1.22) | toolUses Map / toolResults Set이 무한 성장 | 완료 항목 즉시 제거 + 정수 카운터 | 메모리 사용량이 도구 호출 수와 무관 |
3.6 교훈
병목은 한 곳에만 있지 않습니다. 1막이 I/O 병목을 잡자 2막에서 알고리즘 병목이 드러났고, 그걸 잡으니 3막에서 메모리 병목이 드러났습니다. 같은 핫 패스를 다른 각도(I/O → 알고리즘 → 메모리)에서 세 번 공격하는 일은 절대 낭비가 아닙니다.
4. lastPrompt를 찾아서 (v1.20.0 → v1.24.0)
4.1 새 위젯, 그런데 데이터가 이상함
v1.20에서 사용자가 마지막으로 입력한 프롬프트를 상태줄에 표시하는 lastPrompt 위젯을 추가했습니다. 처음에는 "아, transcript.jsonl을 거꾸로 훑어서 최근 user 메시지 하나만 꺼내면 되지" 정도로 생각하고 짰습니다.
그런데 막상 띄워 보니 결과가 뭔가 이상합니다. 사용자가 분명히 짧게 /blog-from-commits 한 줄만 쳤는데, 상태줄에는 ## 입력 ## 작업 지침 ### 1. 주제 분석 ... 같은 식으로 슬래시 커맨드의 정의 본문이 잔뜩 흘러나오는 것이었습니다.
원인은 단순했습니다. transcript.jsonl의 user 메시지에는 슬래시 커맨드 본문과 스킬 확장 결과가 합쳐져서 들어 있습니다. 사용자가 친 진짜 텍스트가 아니라, 모델에게 보낸 최종 프롬프트가 통째로 user role로 포함되어 있는 것입니다.
4.2 history.jsonl이라는 더 정확한 데이터 소스
Claude Code는 또 다른 파일을 쓰고 있었습니다. ~/.claude/history.jsonl입니다. 이 파일은 사용자의 prompt history만 따로 추적하고, display 필드에는 사용자가 실제로 입력한 텍스트만 들어갑니다. 슬래시 커맨드라면 슬래시 커맨드 한 줄, 스킬 호출이라도 그 호출 표현 한 줄.
문제는 이 파일이 모든 세션의 prompt를 시간순으로 누적한다는 것이었습니다. 그래서 두 가지 보조 작업이 필요했습니다.
- 현재 세션의 sessionId와 매칭해서 다른 세션 prompt를 거르고
- 파일 끝에서부터 역순으로 16KB만 읽어 가장 최근 한 줄을 찾는 것
전체 파일을 읽지 않는 이유는 명확합니다. 10MB짜리 prompt history를 매 렌더마다 읽으면 곤란합니다.
// scripts/utils/history-parser.ts
const HISTORY_PATH = `${homedir()}/.claude/history.jsonl`;
const CHUNK = 16 * 1024;
export async function getLastUserPrompt(
sessionId: string
): Promise<LastPromptData | null> {
try {
const fileStat = await stat(HISTORY_PATH);
// 1) 파일 크기가 그대로면 캐시 그대로 사용
if (historyCache && historyCache.fileSize === fileStat.size) {
const cached = historyCache.results.get(sessionId);
if (cached !== undefined) return cached;
}
if (!historyCache || historyCache.fileSize !== fileStat.size) {
historyCache = { fileSize: fileStat.size, results: new Map() };
}
// 2) 끝에서 16KB만 읽어 옵니다
const size = Math.min(CHUNK, fileStat.size);
const fd = await open(HISTORY_PATH, 'r');
try {
const buffer = Buffer.alloc(size);
await fd.read(buffer, 0, size, fileStat.size - size);
const lines = buffer.toString('utf-8').split('\n');
// 3) 역순 스캔으로 현재 세션의 가장 최근 prompt 찾기
for (let i = lines.length - 1; i >= 0; i--) {
if (!lines[i]) continue;
try {
const entry = JSON.parse(lines[i]) as {
sessionId?: string;
display?: string;
timestamp?: string;
};
if (entry.sessionId === sessionId && entry.display?.trim() && entry.timestamp) {
const result: LastPromptData = {
text: entry.display.replace(/\s+/g, ' ').trim(),
timestamp: entry.timestamp,
};
historyCache.results.set(sessionId, result);
return result;
}
} catch { /* skip malformed lines */ }
}
} finally {
await fd.close();
}
historyCache.results.set(sessionId, null);
} catch { /* file not found or read error */ }
return null;
}
별 거 없어 보이지만, 세 가지 디자인 선택이 들어가 있습니다.
- fileSize 기반 캐시: mtime 대신 size를 비교 키로 씁니다. history.jsonl은 어디까지나 append-only이기 때문에, 크기가 같다면 내용도 같다고 봐도 안전합니다.
- 세션별 결과 캐시: 같은 파일이라도 sessionId가 다르면 다른 결과를 캐시합니다. 한 머신에서 여러 Claude Code 세션이 동시에 돌 수 있으니까요.
- 16KB 끝부분만 읽기: 거대한 history.jsonl 전체를 들고 오지 않고, 끝에서 16KB만 떼와 역순 스캔합니다.
이 접근의 일부는 cc-alchemy 프로젝트가 비슷한 문제를 풀던 방식에서 영감을 받았습니다.
4.3 그런데 또 한 번 — Pasted text 플레이스홀더 (v1.24.0)
v1.20에서 history.jsonl로 갈아탔으니 이제 끝났겠지... 했는데, v1.24에서 또 하나의 디테일이 발견됐습니다.
사용자가 긴 코드 블록을 붙여넣기로 입력한 경우, history.jsonl의 display 필드에는 실제 텍스트가 아니라 플레이스홀더가 들어갑니다.
[Pasted text #1 +47 lines]
진짜 본문은 같은 entry의 pastedContents 객체에 따로 보관됩니다. 그래서 상태줄에는 자꾸 "사용자가 어떤 코드를 붙여넣었는지" 대신 [Pasted text #1 +47 lines]가 표시되는 것이었습니다.
해결은 정규식 한 줄짜리 치환입니다.
// scripts/utils/history-parser.ts
function resolvePastedText(
display: string,
pastedContents?: Record<string, { content?: string }>
): string {
if (!pastedContents) return display;
return display.replace(
/\[Pasted text #(\d+)[^\]]*\]/g,
(match, id: string) => pastedContents[id]?.content ?? match
);
}
pastedContents가 없거나 해당 ID가 없는 경우엔 원본 플레이스홀더를 그대로 두고 fall through합니다. 해석 못 할 때 원본을 깨뜨리지 않는 게 핵심입니다.
위 함수를 entry 파싱 직후에 한 번 끼웁니다.
const text = resolvePastedText(entry.display, entry.pastedContents);
const result: LastPromptData = {
text: text.replace(/\s+/g, ' ').trim(),
timestamp: entry.timestamp,
};
4.4 교훈
"가장 가까운 데이터 소스"가 항상 정답이 아닙니다. transcript.jsonl은 상태줄이 다른 위젯에서 이미 읽고 있는 파일이라 가장 손쉬운 선택지였지만, 사용자 입력의 진짜 모습을 담고 있지는 않았습니다. 같은 정보가 여러 곳에 다른 정확도로 흩어져 있을 때는, 반드시 데이터의 "원본 의도"가 가장 잘 보존된 곳을 찾아야 합니다.
그리고 본문 자체가 이미 후처리되어 있을 가능성도 항상 의심해야 합니다. [Pasted text #N] 같은 플레이스홀더는, 원본 데이터를 만든 쪽 입장에서는 너무 자명해서 굳이 문서에 안 적는 경우가 많습니다.
5. sync 호출 사냥 — 2막 (v1.16, v1.21)
5.1 1막 회상 — git 호출 Promise.all (이전 글)
이전 글에서 이미 다룬 부분을 짧게 회상하면, projectInfo 위젯에서 git 명령을 4번 부를 때 순차로 부르던 걸 Promise.all로 묶어서 worst-case ~2초 → ~1초로 줄였습니다.
// scripts/widgets/project-info.ts (1막)
const [branch, dirty, ab, remoteUrl] = await Promise.all([
getGitBranch(cwd),
isGitDirty(cwd),
getAheadBehind(cwd),
getGitRemoteUrl(cwd),
]);
여기까지가 1막이었습니다. 그런데 1막을 끝내고 나서, 비슷한 동기 호출이 두 군데 더 숨어 있다는 걸 발견했습니다.
5.2 2막 — sync API가 keychain을 막고 있었다 (v1.21, d30bcff)
macOS에서 OAuth 토큰을 가져오려면 security find-generic-password를 호출해야 합니다. 그런데 이걸 sync 버전으로 부르고 있었습니다.
// Before: sync API — 이벤트 루프를 통째로 막음
import { execFileSync as runSync } from 'child_process';
function getCredentialsFromKeychain(): string | null {
const result = runSync(
'security',
['find-generic-password', '-s', 'Claude Code-credentials', '-w'],
{ encoding: 'utf-8', timeout: 3000 }
).trim();
// ...
}
문제는 security 명령이 macOS에서 가끔씩 느리게 응답한다는 것입니다. 보통은 수 ms지만, 백그라운드 권한 상태에 따라 100ms+가 나오는 경우가 있고, 그동안 이벤트 루프가 통째로 멈춥니다. 상태줄은 그 기간 동안 다른 일을 아무것도 하지 못합니다.
콜백 기반 비동기 버전을 Promise로 감쌌습니다.
// scripts/utils/credentials.ts (After)
import { execFile as runAsync } from 'child_process';
function execKeychainAsync(): Promise<string> {
return new Promise((resolve, reject) => {
runAsync(
'security',
['find-generic-password', '-s', 'Claude Code-credentials', '-w'],
{ encoding: 'utf-8', timeout: 3000 },
(error, stdout) => {
if (error) reject(error);
else resolve(stdout.trim());
}
);
});
}
5.3 keychain 실패 시 60초 backoff
이왕 손대는 김에, keychain이 실패했을 때 매번 다시 시도하는 동작도 손봤습니다. macOS에서는 keychain 접근이 실패하면 권한 다이얼로그가 뜰 수 있는데, 1초마다 권한 다이얼로그가 뜨면 그건 사용자 학대입니다.
// scripts/utils/credentials.ts
const KEYCHAIN_BACKOFF_MS = 60_000;
let keychainBackoffAt: number | null = null;
async function getCredentialsFromKeychain(): Promise<string | null> {
// 백오프 중이면 keychain 자체를 건너뛰고 파일 fallback
if (keychainBackoffAt !== null && Date.now() - keychainBackoffAt < KEYCHAIN_BACKOFF_MS) {
return await getCredentialsFromFile();
}
// ... TTL 캐시 체크 ...
try {
const result = await execKeychainAsync();
// ...
keychainBackoffAt = null; // 성공하면 백오프 해제
return token;
} catch {
keychainBackoffAt = Date.now(); // 실패하면 60초 백오프
return await getCredentialsFromFile(); // 그동안 파일 fallback
}
}
핵심은 failure 자체를 60초 동안 기억해 두는 것입니다. 한 번 실패한 keychain 접근을, 같은 60초 안에 다시 시도해 봤자 결과가 달라질 가능성이 매우 낮으니까요.
5.4 라인 렌더링도 병렬로 (v1.16, 905d409)
조금 더 작은 변경 하나. 멀티라인 상태줄을 그릴 때 라인을 순차로 렌더링하던 걸 Promise.all로 병렬화했습니다. 라인 사이에 의존성이 없으니까 안 할 이유가 없습니다.
// scripts/widgets/index.ts
export async function renderAllLines(ctx: WidgetContext): Promise<string[]> {
const lines = getLines(ctx.config);
const rendered = await Promise.all(
lines.map((lineWidgets) => renderLine(lineWidgets, ctx))
);
return rendered.filter((line) => line.length > 0);
}
이걸로 detailed 모드 (5~6 라인)의 worst-case 렌더 시간이 추가로 짧아졌습니다.
5.5 교훈
execFileSync, readFileSync, statSync 같은 동기 API는 렌더 핫 패스에서 절대 쓰면 안 됩니다. 평소에는 빨라 보여도 가끔씩 한 번 멈출 때 그 흔적은 명확하게 사용자 경험에 남습니다. 그리고 한 번 핫 패스를 async로 정리해 두면, 그 위에 negative caching이나 backoff 같은 확장 기능을 추가하기가 훨씬 쉬워집니다.
6. git diff HEAD가 거짓말한 날 (v1.22.0)
6.1 stdin 기반에서 git diff로 회귀했던 맥락
linesChanged 위젯은 처음에는 stdin이 던져 주는 누적 라인 수 필드를 그냥 표시했습니다. 그런데 그 값이 세션 누적이라서 사용자가 기대하는 "지금 작업한 변경량"과 차이가 컸고, 결국 stdin 의존을 버리고 git diff 기반으로 되돌렸습니다.
그 결과 git diff HEAD --shortstat가 unique한 데이터 소스가 됐고, 별 문제 없이 잘 돌고 있는 줄 알았습니다.
6.2 그런데 새 파일은 어디로 갔지?
이슈 리포트가 들어왔습니다. "새 파일을 만들었는데 변경 라인 수에 안 잡혀요." 직접 재현해 보니 정말 그랬습니다.
원인은 정말 단순합니다. git diff HEAD는 추적 중인(tracked) 파일의 변경만 보고합니다. Git에 한 번도 추가된 적 없는 untracked 파일은 통째로 무시됩니다. 새로 만든 100줄짜리 파일이 있어도, git diff HEAD --shortstat는 깔끔하게 0을 돌려줍니다.
6.3 untracked 파일 라인 수를 직접 세기
git status나 git ls-files --others --exclude-standard로 untracked 파일 목록을 얻을 수 있고, 그 안의 라인 수만 따로 세면 됩니다.
// scripts/utils/git.ts
export function countUntrackedLines(cwd: string, timeout: number): Promise<number> {
return new Promise((resolve) => {
runAsync(
'sh',
['-c', "git --no-optional-locks ls-files --others --exclude-standard -z | xargs -0 cat 2>/dev/null | wc -l"],
{ cwd, encoding: 'utf-8', timeout },
(_error, stdout) => {
const match = stdout?.match(/(\d+)/);
resolve(match ? parseInt(match[1], 10) : 0);
},
);
});
}
핵심은 파이프라인 한 줄입니다.
git --no-optional-locks ls-files --others --exclude-standard -z \
| xargs -0 cat 2>/dev/null \
| wc -l
ls-files --others --exclude-standard -z: untracked 파일 목록 (gitignore 적용)xargs -0 cat 2>/dev/null: 그 모든 파일 본문을 stdout으로 흘립니다 (-0은 NUL 구분자)wc -l: 흘러간 줄 수를 한 번에 셉니다
여기서 한 가지 작은 함정. xargs wc -l을 그냥 쓰면 untracked 파일이 많을 때 결과가 잘립니다. xargs가 인자 길이 제한 때문에 명령을 여러 배치로 쪼개고, 각 배치마다 wc -l이 자기 배치의 합계를 출력하기 때문입니다. 마지막 줄(tail -1)만 잡으면 마지막 배치의 합계만 들어옵니다. 그래서 위와 같이 cat으로 본문을 한 스트림으로 흘려서 wc -l이 단 한 번만 카운트하도록 해야 합니다.
6.4 tracked diff와 untracked를 병렬로
이미 1·2막에서 sync 호출을 잡고 다녔으니, 새 호출도 처음부터 병렬로 묶었습니다.
// scripts/widgets/lines-changed.ts
async getData(ctx: WidgetContext): Promise<LinesChangedData | null> {
const cwd = ctx.stdin.workspace?.current_dir;
if (!cwd) return null;
// 10초 TTL 캐시 체크 ...
try {
const [diffOutput, untracked] = await Promise.all([
execGit(['diff', 'HEAD', '--shortstat'], cwd, 1000),
countUntrackedLines(cwd, 1000),
]);
const insertMatch = diffOutput.match(/(\d+) insertion/);
const deleteMatch = diffOutput.match(/(\d+) deletion/);
const tracked = insertMatch ? parseInt(insertMatch[1], 10) : 0;
const removed = deleteMatch ? parseInt(deleteMatch[1], 10) : 0;
const added = tracked + untracked; // ← tracked + untracked 합산
const data = (added === 0 && removed === 0) ? null : { added, removed, untracked };
diffCache = { cwd, data, timestamp: Date.now() };
return data;
} catch {
diffCache = { cwd, data: null, timestamp: Date.now() };
return null;
}
}
6.5 교훈
git diff HEAD는 "워킹 트리 vs HEAD"가 아니라 "추적 중인 파일의 워킹 트리 vs HEAD"입니다. 사람이 직관적으로 기대하는 "방금 만든 모든 변경"과는 한 글자만큼 다릅니다. Git CLI를 위젯의 데이터 소스로 쓸 때는, 예외 케이스(untracked, ignored, submodule, sparse checkout 등)가 어떻게 처리되는지 한 번씩은 직접 확인해 두는 게 좋습니다.
7. 한 글자짜리 버그 — sibling 디렉토리 오탐 (v1.16.x, acbdc9d)
상태줄에는 아주 작은 표시가 하나 있습니다. 현재 작업 디렉토리(CWD)가 프로젝트 루트가 아닌 하위 폴더라면, 프로젝트명 옆에 그 sub path를 표시해 줍니다.
📁 my-project (web/components)
이 sub path를 계산할 때 처음에는 이렇게 짰습니다.
// Before
const subPath = (
projectDir &&
currentDir !== projectDir &&
currentDir.startsWith(projectDir)
) ? relative(projectDir, currentDir) : undefined;
언뜻 보면 멀쩡합니다. 그런데 다음과 같은 디렉토리 구조에서 버그가 납니다.
~/Projects/
├── my-project/ ← projectDir
└── my-project-backup/ ← currentDir
my-project-backup은 my-project의 sibling일 뿐, 하위 폴더가 아닙니다. 그런데 'my-project-backup'.startsWith('my-project')는 true이기 때문에, 이 코드는 my-project-backup을 my-project의 하위 폴더로 잘못 인식하고 sub path를 -backup이라고 계산해 버립니다.
수정은 한 글자입니다. 비교 키 끝에 슬래시 하나만 붙이면 됩니다.
// After
const subPath = (
projectDir &&
currentDir !== projectDir &&
currentDir.startsWith(projectDir + '/') // ← 슬래시 하나 추가
) ? relative(projectDir, currentDir) : undefined;
테스트도 같이 추가했습니다.
// scripts/__tests__/widgets.test.ts
it('subPath: sibling 디렉토리는 prefix처럼 보여도 하위 폴더가 아니다', async () => {
const data = await projectInfoWidget.getData({
stdin: {
workspace: {
current_dir: '/Users/me/Projects/my-project-backup',
project_dir: '/Users/me/Projects/my-project',
},
},
// ...
});
expect(data?.subPath).toBeUndefined();
});
교훈: startsWith로 경로 비교를 할 때는 항상 끝에 디렉토리 구분자를 붙여서 비교해야 합니다. 같은 종류의 함정이 URL prefix 비교, 패키지 이름 비교, namespace 비교에서도 똑같이 나타납니다.
8. mtime 캐싱은 어디로 갔나
이전 글에서 자세히 다룬 mtime 기반 캐싱 패턴은, v1.14 이후에도 계속 살아 있고 오히려 더 많이 쓰입니다. history-parser(파일 size 기반의 사촌 격), gemini-client의 keychain 캐시, credentials.ts의 file fallback 등 여러 모듈이 같은 패턴을 따릅니다. 패턴 자체에 대한 설명은 이전 글로 갈음합니다.
9. 핵심 개념 정리
| 개념 | 적용 위치 | 효과 |
|---|---|---|
| stdin → API 우선순위 역전 | statusline.ts의 parseStdinRateLimits |
Pro 플랜 렌더당 API 호출 0회 |
| 파싱 시점 상태 추적 | transcript-parser.ts의 processEntries |
추출 함수 O(n) → O(k) |
| Map 가지치기 | transcript-parser.ts의 tool_result 처리 |
메모리가 도구 호출 수와 무관 |
| size 기반 파일 캐시 | history-parser.ts |
append-only 파일에 mtime보다 단순 |
| tail-read + 역순 스캔 | history-parser.ts의 getLastUserPrompt |
거대한 history.jsonl에서 16KB만 읽기 |
| sync API → async 콜백 Promise | credentials.ts, git.ts |
이벤트 루프 블로킹 제거 |
| failure backoff | keychainBackoffAt 60초 |
권한 다이얼로그 폭주 방지 |
| Promise.all 라인 렌더링 | widgets/index.ts의 renderAllLines |
멀티라인 worst-case 단축 |
| untracked 라인 합산 | git.ts의 countUntrackedLines |
git diff HEAD의 블라인드 스팟 보완 |
startsWith(prefix + '/') |
project-info.ts의 subPath 계산 |
sibling 디렉토리 오탐 방지 |
10. 버전별 변경 요약 (v1.14~v1.24)
| 버전 | 주요 변경 | 카테고리 |
|---|---|---|
| v1.14 | 시리즈 #4 시동 (token breakdown, performance, forecast, budget 위젯) | 기능 |
| v1.15 | adaptive terminal width / line wrap | 기능 |
| v1.16 | 부드러운 정리 라운드 — sync 호출 사냥 1차, sub path sibling 버그 수정, parallelized line rendering | 안정 |
| v1.17 | TaskCreate/TaskUpdate API 지원 | 기능 |
| v1.18 | UX & 안정성 배치 (Batch B) | UX |
| v1.19 | 429 retry + stale fallback, model-aware default effort | 안정 |
| v1.20 | last prompt 위젯, OSC8 git 브랜치 링크, history.jsonl 이전, history-parser 분리 | 기능/리팩토링 |
| v1.21 | stdin rate_limits 우선, transcript parser 2막 (O(n)→O(k)), keychain async + backoff | 성능/안정 |
| v1.22 | transcript parser 3막 (Map pruning), linesChanged에 untracked 합산, vimMode/apiDuration 위젯, AGENTS.md 카운트 |
성능/기능 |
| v1.23 | linesChanged untracked 정정 등 자잘한 fix | 안정 |
| v1.24 | last prompt의 Pasted text 플레이스홀더 해결 | 버그 수정 |
11. FAQ
Q: 왜 stdin이 있는데도 Max 플랜은 여전히 API를 부르나요?
A: 2026-04 시점 기준, Claude Code stdin의 rate_limits는 5h/7d 두 윈도우만 제공합니다. Max 플랜의 seven_day_sonnet 한도는 stdin에 포함되어 있지 않아서, 그 값만 API 폴백으로 가져옵니다. stdin이 이 필드까지 지원하면 그날 API 호출은 0이 됩니다.
Q: 트랜스크립트 파서가 또 한 번 더 느려질 여지가 남아 있나요?
A: 현재 구조에서는 핫 패스 자체는 거의 평탄해졌습니다. 다음 병목이 생긴다면 파싱이 아니라 정렬이나 ANSI 색상 처리 같은 렌더링 쪽일 가능성이 큽니다. 새 위젯을 추가할 때 이 부분에 stat을 측정해 두는 게 다음 라운드의 시작점일 것 같습니다.
Q: history.jsonl 위치는 표준인가요?
A: Claude Code가 ~/.claude/history.jsonl에 사용자 prompt를 기록하는 동작은 비공식 스펙입니다. 향후 변경될 가능성이 있고, 변경되면 lastPrompt 위젯도 같이 따라가야 합니다.
Q: untracked 라인 카운팅이 거대한 바이너리에서도 안전한가요?
A: cat으로 흘려서 wc -l로 줄 수만 세기 때문에 메모리에 통째로 올리지 않습니다. 다만 매우 큰 바이너리가 untracked로 잡혀 있으면 I/O 자체가 길어질 수 있어서, 위젯에 1초 timeout을 걸고 실패 시 0을 돌려줍니다.
Q: keychain 60초 backoff 동안에는 인증이 어떻게 되나요?
A: 백오프 동안에는 keychain 자체를 건너뛰고 ~/.claude/.credentials.json 파일 폴백 경로를 사용합니다. 둘 다 같은 토큰을 가지고 있어서 결과는 같지만, 권한 다이얼로그를 띄우지 않는다는 차이가 있습니다.
12. 참고 자료
- Claude Code Status Line Documentation
- claude-dashboard GitHub
- Node.js 공식 문서: child_process 모듈
- git ls-files --others
13. 다음 단계
이번 글에서는 v1.14부터 v1.24까지의 변화 중에서 "데이터 소스 변경"과 "성능"에 초점을 맞췄습니다. 그런데 이 11개 릴리스에는 따로 떼어 길게 다룰 만한 두 가지 주제가 더 있습니다.
- OSC8 클릭 가능한 git 브랜치 링크 — 단순해 보이지만 자격증명 stripping과 URL 인코딩이라는 두 가지 함정을 동반합니다.
- API 클라이언트의 negative caching + stale fallback + TypeScript discriminated union — 한 번에 같이 묶어 정리할 만한 패턴 글입니다.
이 두 주제는 별도의 딥다이브 글로 이어집니다.
시리즈 목차:
- Claude Code 상태줄 플러그인 만들기: claude-dashboard 개발기
- Claude Code, Codex, Gemini 사용량을 한 번에 확인하는 CLI 대시보드
- claude-dashboard v1.10~v1.13: 테마, 성능 최적화, 그리고 셸 통합까지
- claude-dashboard v1.14~v1.24: stdin 우선, OSC8 링크, 그리고 파서를 한 번 더 100배 빠르게 ← 현재 글
- (예정) 터미널 상태줄을 클릭 가능하게: OSC8와 두 가지 보안 함정
- (예정) API 클라이언트 회복력을 위한 TypeScript 패턴 — negative caching + discriminated union