Claude Code 옆에 «책상»을 열어 줘요. 할 일 고정, 고친 파일, 사용 한도, 대화 이어가기, 메일과 오늘 일정, 클립보드 기록, 설정 점검, 보내기 전 확인.

Claude Code로 일하다 보면 이런 일이 생겨요.
side-desk는 Claude Code 화면 옆에 작은 창을 열어서 이걸 한 곳에 모아 줘요.
윈도우에서 만들고 시험했어요. WSL(윈도우의 리눅스)에서도 돼요. 맥·리눅스에서는 클립보드 탭만 안 되고, 알림은 윈도우 알림 대신 터미널 알림으로 가요(터미널이 알림을 지원해야 떠요). 이 모드가 따로 서버로 보내지는 않아요. 메일과 일정만 Claude Code에 연결된 Gmail·구글 캘린더 커넥터로 읽어요. 아래 «무엇을 하나요»를 꼭 읽어 보세요.
먼저 버전을 확인하세요. 터미널에 claude --version을 쳐서 2.1.287 이상인지 봐요. 그보다 낮으면 claude update를 먼저 치세요. 낮은 버전에 설치하면 Failed to load hooks ... expected record 오류가 나요.
Claude Code에 아래 두 줄을 한 줄씩 붙여 넣고 엔터를 누르세요.
claude plugin marketplace add daniel-kim-9way/side-desk
claude plugin install side-desk@side-desk

설치가 끝나면 Claude Code를 다시 열거나 /reload-plugins를 치세요. 그다음 입력창에 /desk를 치면 옆에 책상이 열려요.
처음이면 6 설정 탭부터 열어 보세요. 버전·화면·Gmail·캘린더·클립보드 기록·알림을 점검해서, 안 되는 것마다 고치는 법을 한 줄로 알려 줘요. 입력창에 /desk check를 쳐도 같은 점검을 글로 볼 수 있어요.
아래 그림은 설명용 예시 화면이에요. 숫자와 할 일은 예시고, 글자 배치는 실제 화면과 같아요.
할 일 — 오늘·내일 일정, Claude가 쌓은 할 일, 내 답을 기다리는 질문, 이번 대화에서 고친 파일 (일정은 예시예요)

메일 — 받은편지함 제목만 보여 주고, 누르면 본문을 읽어요 (메일 제목은 예시예요)

사용량 — 사용 한도와 대화 길이, 그리고 새 대화로 이어가기

명령 — 자주 쓰는 명령. 누르면 입력창에 들어가요. ×로 빼고, 아래 칸에 써서 넣어요

설정 — 처음 설정 점검 (설정 탭 가운데 부분이에요)

| 탭 | 하는 일 |
|---|---|
| 1 할 일 | 맨 위에 구글 캘린더의 오늘·내일 일정을 보여 줘요(읽기만 해요). 그 아래 Claude가 쌓아 둔 이번 대화 할 일, 그리고 내가 정해야 하는 것을 고정해 둬요. 대화가 길어져도 안 밀려나요. 아래에는 이번 대화에서 Claude가 고친 파일이 최근 것부터 보여요. >를 누르면 «이 파일 왜 바꿨는지 설명해 줘»를 입력창에 채워요. |
| 2 메일 | Claude Code에 연결된 Gmail 받은편지함의 최근 20개를 제목만 보여 줘요. 줄을 누르면 그때 본문을 불러와요. >를 누르면 «이 메일을 읽고 내가 할 일을 찾아 줘»라는 글을 입력창에 채워요(보내기는 엔터로 해요). ↗는 브라우저의 Gmail로 열어요. 읽기만 해요. |
| 3 사용량 | 5시간·7일 사용 한도, 초기화까지 남은 시간, 대화 길이, 이 대화의 비용. 한도가 80%를 넘으면 알려 줘요. 대화가 길어지면 다음 말을 보낼 때 새 대화로 넘길지 물어봐요. 넘기면 지금 대화의 Claude가 이어가기 메모를 쓰고, 새 대화가 내가 보낸 그 말에 바로 답해요. |
| 4 클립보드 | 최근에 복사한 글·이미지 목록(윈도우 Win+V 기록). 줄을 누르면 입력창에 들어가요. |
| 5 명령 | /clear·/compact·/resume·/mcp 같은 자주 쓰는 명령을 쉬운 말로 풀어 뒀어요. 누르면 입력창에 넣기만 하고, 실행은 엔터로 해요. 줄 끝 ×로 빼고, 아래 칸에 /명령 설명을 쓰고 엔터를 누르면 넣어요(예: /capcut 릴스 편집, 인자가 있으면 /tui fullscreen - 설명). «처음 목록으로»를 누르면 되돌려요. |
| 6 설정 | 보내기 전 확인 · 질문·승인 알림 · 작업 끝 알림 · 프롬프트 가이드를 켜고 꺼요. 처음 설정 점검(✓/✗와 고치는 법)과 «알림 시험»이 있어요. |
옆 창이 좁으면 탭 이름이 «할일 · 메일 · 한도 · 클립 · 명령 · 설정»으로 줄고, 그래도 넘치면 두 줄로 나뉘어요. 긴 제목은 …로 자르고, 여러 줄로 넘어가는 설명은 같은 들여쓰기로 이어져요.
입력창에서 바로 쓰는 명령도 있어요.
| 명령 | 하는 일 |
|---|---|
/desk | 책상을 열어요. /desk text는 글로 요약해서 보여 주고, /desk check는 처음 설정 점검을 글로 보여 줘요. |
/clip | 지금 클립보드의 글을 입력창에 붙여요. 이미지는 파일로 저장하고 그 경로를 붙여요. |
/desk-handoff | 대화를 지금 새 대화로 넘겨요. /handoff가 아니라 /desk-handoff예요(다른 플러그인의 /handoff와 안 겹치게). |
/coach | 프롬프트 가이드를 켜고 꺼요. |
이 모드는 내 컴퓨터에서 내 권한으로 돌아요. 그래서 무엇을 하는지 정확히 적어 둬요.
내 폴더/.claude/state/side-desk/ 아래에 있고, 이미지는 하루 뒤 지워져요.Edit·Write·NotebookEdit로 파일을 고칠 때마다 그 경로만 적어 둬요(파일 내용은 안 적어요). Claude Code가 모드에 주는 저장 공간에 대화마다 따로 두고, 최근 30개 대화만 남겨요. 셸 명령으로 바꾼 파일은 안 잡혀요.윈도우의 클립보드 기록(Win+V)에는 비밀번호처럼 복사해 둔 글도 남아요. 클립보드 탭은 그걸 그대로 보여 주고, 줄을 누르면 입력창에 넣어요. 보내기 전에 눈으로 확인하세요. 이 모드는 클립보드 내용을 따로 저장하지 않아요.
설치했더니 Failed to load hooks ... "expected": "record" ... "path": ["hooks"] 오류가 나요. Claude Code가 2.1.287보다 낮은 버전이라 이 모드를 못 읽는 거예요. 터미널에서 claude update를 치고 Claude Code를 다시 여세요. claude --version이 2.1.287 이상이면 오류가 사라져요. 다시 설치할 필요는 없어요.
옆 창이 안 열려요. 터미널 폭이 144칸보다 좁으면 자동으로는 열리지 않아요. /desk로 열면 돼요. /reload-plugins를 치고 다시 해 보세요.
뭐가 안 되는지 모르겠어요. 6 설정 탭의 «점검»을 보세요. ✗가 붙은 줄 아래에 고치는 법이 있어요. 입력창에 /desk check를 쳐도 돼요.
일정이 안 보여요. claude.ai 설정 > 커넥터에서 구글 캘린더를 먼저 추가하고, /mcp에서 «claude.ai Google Calendar»에 로그인하세요. «캘린더를 읽는 권한을 아직 안 받았어요»가 나오면 /permissions에서 mcp__claude_ai_Google_Calendar__list_events를 허용하세요. 기본 캘린더만 보여요.
클립보드 탭이 «읽는 중…»에서 멈춰요. 설정 > 시스템 > 클립보드에서 «클립보드 기록»을 켜세요. 꺼져 있으면 지금 클립보드 한 개만 보여요. 그래도 안 되면 탭 아래에 이유가 한 줄 나와요.
새 대화로 넘기는 게 싫어요. 3 사용량 탭의 «새 대화로 이어가기»에서 «바꾸기»를 눌러 «끔»을 고르세요.
새 대화로 넘어가면 앞 이야기를 잊지 않나요? 넘기기 직전에 지금 대화의 Claude가 이어가기 메모를 직접 써요. 정한 것, 바로잡은 것, 막힌 길, 남은 일, 돌고 있는 작업을 적어요. 메모에는 내가 한 말 원문(최근 40개)과 Claude의 마지막 답 전부도 붙어요. 새 대화에는 이 메모가 지침 파일(CLAUDE.md)처럼 붙어서, 대화를 압축한 뒤에도 다시 붙어요. 새 대화는 메모의 «남은 일»을 내가 시키기 전에는 시작하지 않아요. 넘기는 동안(30~90초) Esc를 누르면 넘기지 않고, 보낸 말은 입력창에 돌아와요.
메일 탭에 «연결하기»만 보여요. claude.ai 설정 > 커넥터에서 Gmail을 먼저 추가하세요. 그다음 «연결하기»를 누르면 /mcp 창이 열려요. «claude.ai Gmail»을 골라 로그인하고 «다시 시도»를 누르세요. «메일을 읽는 권한을 아직 안 받았어요»가 나오면 /permissions에서 mcp__claude_ai_Gmail__search_threads와 mcp__claude_ai_Gmail__get_thread를 허용하세요.
너무 자주 알림이 와요. 6 설정 탭에서 «작업 끝 알림»이나 «질문·승인 알림»을 끄세요. 작업 끝 알림은 처음부터 꺼져 있어요.
전부 끄고 싶어요. 환경 변수 SIDE_DESK_OFF=1을 두고 Claude Code를 여세요. 아니면 /plugin에서 side-desk를 끄세요.
Claude Code 2.1.287 이상이 필요해요. 2.1.300에서 시험했어요. 모드 기능은 버전마다 바뀔 수 있어요.
할 일·사용량·프롬프트 가이드·이어가기의 구조는 아래 오픈소스 모드에서 참고했어요. 자세한 표기는 NOTICE에 있어요.
MIT. LICENSE를 보세요.
hooks/register.tsx 2201 lines1import type { EngineInterface, PromptSubmitInput, Register } from 'claude-code'
2import {
3 CLIP_LIST_SCRIPT, CLIP_SCRIPT, CLIP_WATCH_SCRIPT, COACH_SKIP, COACH_SYSTEM, WINDOW_LABEL, clipInsertText, clipItemScript, clipRowText, parseClip, parseClipList,
4 applyUpdate, assembleBrief, bar, briefRequest, burn, describeBoard, extractFacts, lastAnswer, seedContext, userMessages,
5 cellWidth, hoursText, isValidBrief, k, keptShare, padCells, parseVerdict, recentTurns, seedText, untilText,
6 GMAIL_SERVER, MAIL_PAGE, mailAccount, mailActionPrompt, mailTime, mcpErrorText, mcpJson, parseThread, parseThreadList, safeMailUrl, senderName,
7} from './lib.ts'
8import type { Board, Clip, ClipItem, Decision, Facts, MailRow, MailThread, Todo, Update, UsageWindow, Verdict } from './lib.ts'
9import { judgeSend, summarizeArgs } from './send-check.ts'
10import { MIRRORED_TOOLS, mirrorTool, trailingQuestion, withAutoDecision } from './mirror.ts'
11import {
12 CALENDAR_SERVER, CHECK_MARK, EDIT_TOOLS, MIN_VERSION, addChange, calendarArgs, changeLabel, changePrompt, changesFromMessages,
13 connectorCheck, editedPath, effectiveGuide, eventEnded, eventTime, guideCommand, guideKey, isDefaultGuide, parseEvents, parseGuideInput,
14 permissionNote, versionAtLeast,
15} from './extras.ts'
16import type { CalEvent, Change, Check, Guide } from './extras.ts'
17
18// side-desk: Claude Code 옆에 «책상» 하나를 열어 줘요.
19// · 할 일 : 이번 대화의 할 일과, 내가 정해야 하는 것을 옆에 고정해 둬요
20// · 사용량 : 사용 한도와 대화 길이, 비용을 한눈에. 대화가 길어지면 이어가기 메모를 쓰고 새 대화로 넘겨요 (인터넷 전송·웹 서버 없음)
21// · 메일 : 연결된 Gmail 받은편지함의 제목 목록, 고르면 본문 (읽기만). 맨 위에 구글 캘린더의 오늘·내일 일정
22// · 고친 파일: 할 일 탭에 이번 대화에서 Claude가 고친 파일 목록, «>»로 왜 바꿨는지 묻기
23// · 클립 : 윈도우 클립보드 기록을 보고 입력창에 바로 붙여요
24// · 설정 : 발송 전 확인 · 알림 · 프롬프트 가이드 · 처음 설정 점검 · 자주 쓰는 명령
25// · 알림 : 긴 작업이 끝났을 때, 질문·승인을 기다릴 때. 윈도우·WSL은 윈도우 알림, 맥·리눅스는 터미널 알림
26// 화면이 있는 세션에서만 묻고 움직여요. SIDE_DESK_OFF=1 이면 전부 꺼요.
27
28const PANE = 'side-desk'
29const TOOL = 'mcp__side-desk__pin'
30const BRIEF_DIR = '.claude/state/side-desk/briefs'
31
32type TabName = 'pin' | 'usage' | 'mail' | 'clip' | 'cmds' | 'settings'
33type Mode = 'ask' | 'auto' | 'off'
34type Snapshot = {
35 windows: UsageWindow[]
36 costUsd: number | null
37 startedAt: number
38 updatedAt: number
39 ctxTokens?: number
40 ctxWindow?: number
41 ctxPercent?: number
42}
43type Pending = { oldSession: string; briefPath: string; brief: string; tokens: number; board: Board; problem?: string; carry?: string }
44// 지금 대화의 Claude가 메모를 쓰는 동안 들고 있는 것. 원문(내 말·마지막 답)은 넘기기를 시작한 순간에 떠 둬요.
45type Writing = {
46 oldSession: string
47 path: string
48 request: string
49 submitted: boolean
50 carry?: string
51 tokens: number
52 facts: Facts
53 users: string[]
54 last: string
55 prev?: string
56 board: Board
57}
58type Cfg = {
59 warnAt: number
60 handoffMode: Mode
61 handoffTokens: number
62 handoffPercent: number
63 coach: boolean
64 coachModel: string
65 sendConfirm: boolean
66 notify: boolean
67 notifyDone: boolean
68 notifyMinSeconds: number
69 autoOpen: 'always' | 'off'
70 paneColumns: number
71}
72
73const DEFAULTS: Cfg = {
74 warnAt: 80,
75 handoffMode: 'ask',
76 handoffTokens: 400_000,
77 handoffPercent: 50,
78 coach: false,
79 coachModel: 'haiku',
80 sendConfirm: true,
81 notify: true,
82 notifyDone: false,
83 notifyMinSeconds: 60,
84 autoOpen: 'off',
85 paneColumns: 40,
86}
87
88const num = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : fallback)
89const str = (v: unknown, fallback: string) => (typeof v === 'string' && v.trim() ? v.trim() : fallback)
90const asMode = (v: unknown, fallback: Mode): Mode => (v === 'ask' || v === 'auto' || v === 'off' ? v : fallback)
91
92function parseConfig(o: Record<string, unknown>): Cfg {
93 return {
94 warnAt: num(o.warnAt, DEFAULTS.warnAt),
95 handoffMode: asMode(typeof o.handoffMode === 'string' ? o.handoffMode.trim() : undefined, DEFAULTS.handoffMode),
96 handoffTokens: num(o.handoffTokens, DEFAULTS.handoffTokens),
97 handoffPercent: num(o.handoffPercent, DEFAULTS.handoffPercent),
98 coach: str(o.coach, 'off').toLowerCase() === 'on',
99 coachModel: str(o.coachModel, DEFAULTS.coachModel),
100 sendConfirm: str(o.sendConfirm, 'on').toLowerCase() !== 'off',
101 notify: str(o.notify, 'on').toLowerCase() !== 'off',
102 notifyDone: str(o.notifyDone, 'off').toLowerCase() === 'on',
103 notifyMinSeconds: num(o.notifyMinSeconds, DEFAULTS.notifyMinSeconds),
104 autoOpen: str(o.autoOpen, 'off').toLowerCase() === 'always' ? 'always' : 'off',
105 paneColumns: Math.max(30, Math.min(120, Math.round(num(o.paneColumns, DEFAULTS.paneColumns)))),
106 }
107}
108
109// ───────────── 모듈 변수 (/clear 를 건너 살아남아요) ─────────────
110let cfg: Cfg = DEFAULTS
111let mode: Mode = DEFAULTS.handoffMode
112let coachOn = false
113let sendConfirmOn = true
114let notifyOn = true // 질문·승인 알림
115let notifyDoneOn = false // 작업 끝 알림(기본 꺼짐)
116let tab: TabName = 'pin'
117// 시작할 때 저절로 열린 창인지. 내가 /desk 로 연 창은 아니에요.
118let openedByUser = false // /desk 로 직접 연 적이 있는지
119let inlineClosed = false // 입력창 위에 열린 창을 이미 한 번 닫았는지
120let snap: Snapshot | undefined
121let baseline: { at: number; windows: UsageWindow[] } | null = null
122let turnCount = 0
123const warned = new Set<string>()
124let board: Board = { todos: [], decisions: [] }
125let pending: Pending | undefined
126let writing: Writing | undefined
127// 기준을 넘긴 대화. 이 대화에서 다음 말을 보내면 그 말을 들고 새 대화로 넘어가요.
128let dueSession: string | undefined
129// 이어가기로 연 대화와 거기 붙일 메모. prompt.context 훅이 첫 메시지(압축 뒤에도)에 붙여요.
130let seedBrief: { session: string; text: string } | undefined
131let lastBrief: { path: string; at: number; tokens: number; problem?: string } | undefined
132// 클립보드: «클립» 탭을 보고 있는 동안에만 감시하고, 따로 기록하지 않아요.
133// 이전 항목은 윈도우가 가진 클립보드 기록(Win+V)을 읽어서 보여 줘요.
134let clip: Clip | undefined
135let clipAt = 0
136let clipList: ClipItem[] = []
137let clipNote: string | undefined
138let clipBusy = false
139let clipSig = ''
140let clipWatching = false
141// 메일: «메일» 탭을 열 때, «새로고침»을 누를 때, 메일 탭을 보고 있는 동안 5분마다 Gmail 커넥터를 불러요.
142// · 창이 닫혀 있거나 다른 탭을 보고 있으면 부르지 않아요. 따로 저장하지 않고 읽기만 해요.
143// · 목록은 제목만 보여 주고, 줄을 누르면 그 메일의 본문을 불러와요.
144let mailState: 'idle' | 'loading' | 'ok' | 'failed' = 'idle'
145let mailRows: MailRow[] = []
146let mailAt = 0
147let mailWho = '' // 연결된 계정 주소(메일 링크의 authuser)
148let mailProblem: string | undefined
149let mailView: MailThread | undefined // 지금 펼쳐 둔 메일
150let mailOpening: string | undefined // 본문을 불러오는 중인 스레드
151let mailNote: string | undefined // 본문을 못 불러온 이유
152const MAIL_STALE_MS = 2 * 60_000
153const MAIL_AUTO_MS = 5 * 60_000
154// 일정: 메일과 같은 때(탭을 열 때·새로고침·5분마다) 구글 캘린더 커넥터로 오늘·내일 일정을 읽어요. 읽기만 해요.
155let calState: 'idle' | 'loading' | 'ok' | 'failed' = 'idle'
156let calEvents: CalEvent[] = []
157let calAt = 0
158let calProblem: string | undefined
159let calLoaded = false // 한 번이라도 읽은 적이 있는지
160// 이번 대화에서 고친 파일(Edit·Write·NotebookEdit). 대화마다 따로 저장해요.
161let changes: Change[] = []
162let rootDir = ''
163// 처음 설정 점검: 설정 탭을 처음 열 때 한 번, «다시 점검»을 누를 때 해요.
164let checks: Check[] = []
165let checking = false
166let checked = false // 이 프로세스에서 한 번 점검했는지
167let checkAt = 0
168// 옆 창이 마지막으로 그려진 모양. 점검의 «화면» 줄이 써요.
169let lastPlacement: string | undefined
170// 승인 대기 알림이 같은 내용으로 연달아 뜨지 않게
171let lastPermNote = { text: '', at: 0 }
172// 명령 탭: 내가 넣은 명령과 기본 목록에서 뺀 명령. 다른 대화에서도 그대로예요($.store).
173let cmdAdded: Guide[] = []
174let cmdHidden: string[] = []
175let cmdNote: string | undefined // 넣지 못한 이유
176let cmdInputSeq = 0 // 넣을 때마다 올려서 입력 칸을 새로 그려요
177// 이어가기로 시작한 대화와, 그 대화의 시작 크기(토큰)
178let seededSession: string | undefined
179let seedFloor: number | undefined
180const MIN_GROWTH = 150_000
181// 발송 확인 기록. 최근 30개만 둬요. allow=내가 허락 · refuse=내가 거절 · unattended=화면이 없어서 보내지 않음
182type SendEntry = { at: number; kind: 'allow' | 'refuse' | 'unattended'; label: string; detail: string }
183let sendLog: SendEntry[] = []
184
185const emptyBoard = (): Board => ({ todos: [], decisions: [] })
186const copyBoard = (b: Board): Board => JSON.parse(JSON.stringify(b)) as Board
187const pad = (n: number) => String(n).padStart(2, '0')
188const hhmm = (ms: number) => `${pad(new Date(ms).getHours())}:${pad(new Date(ms).getMinutes())}`
189const MODE_LABEL: Record<Mode, string> = { ask: '물어보고 넘기기', auto: '자동으로 넘기기', off: '끔' }
190const nextMode = (m: Mode): Mode => (m === 'ask' ? 'auto' : m === 'auto' ? 'off' : 'ask')
191const SEND_KIND: Record<SendEntry['kind'], string> = { allow: '보냄', refuse: '보내지 않음', unattended: '화면 없어서 막음' }
192
193// ───────────── `$`를 받는 함수들 (`$`는 이 파일 안의 최상위 함수에만 넘겨요) ─────────────
194
195// 화면이 없는 실행(claude -p)이나 SIDE_DESK_OFF 가 걸린 곳에서는 묻지 않고 조용히 있어요.
196async function isInteractive($: EngineInterface): Promise<boolean> {
197 if (await $.env.get('SIDE_DESK_OFF')) return false
198 try {
199 const surfaces = await $.session.surfaces()
200 return surfaces.some((s: string) => s === 'terminal' || s === 'desktop')
201 } catch {
202 return false
203 }
204}
205
206// 클립보드를 읽어요. 윈도우는 powershell, WSL은 윈도우 쪽 powershell.exe 가 읽어요.
207// 이미지는 윈도우의 ~/.claude/state/side-desk/clips 에 png 로 저장해요(하루 뒤 지워져요).
208async function readClipboard($: EngineInterface): Promise<Clip> {
209 try {
210 const ps = await powershellFor($)
211 if (!ps) {
212 clip = { kind: 'error', message: '이 환경에서는 클립보드를 읽지 못해요(윈도우에서만 돼요)' }
213 } else {
214 const run = await $.process.run([ps, '-NoProfile', '-STA', '-Command', CLIP_SCRIPT], { timeoutMs: 15000 })
215 clip = run.exitCode === 0 ? parseClip(run.stdout) : { kind: 'error', message: (run.stderr || `종료 코드 ${run.exitCode}`).trim().slice(0, 160) }
216 }
217 } catch (err) {
218 clip = { kind: 'error', message: String(err).slice(0, 160) }
219 }
220 clipAt = Date.now()
221 return clip
222}
223
224// 어디서 돌고 있는지. WSL 안인지는 WSL이 스스로 넣는 WSL_DISTRO_NAME 으로 먼저 봐요.
225// 터미널 앱이 윈도우의 환경 변수(OS=Windows_NT 등)를 WSL 셸에 그대로 넘기는 일이 있어서, OS 변수만으로는 믿을 수 없어요.
226async function hostKind($: EngineInterface): Promise<'windows' | 'wsl' | 'other'> {
227 if (await $.env.get('WSL_DISTRO_NAME')) return 'wsl'
228 return (await $.env.get('OS')) === 'Windows_NT' ? 'windows' : 'other'
229}
230
231// 윈도우 쪽 PowerShell 실행 파일. 윈도우는 powershell, WSL은 윈도우 쪽 powershell.exe(PATH가 막혀 있어도 되게 전체 경로를 먼저 써요).
232async function powershellFor($: EngineInterface): Promise<string | undefined> {
233 const kind = await hostKind($)
234 if (kind === 'windows') return 'powershell'
235 if (kind !== 'wsl') return undefined
236 const abs = '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'
237 try {
238 if (await $.fs.exists(abs)) return abs
239 } catch {
240 // 확인이 안 되면 PATH 에 있는 이름을 써 봐요.
241 }
242 return 'powershell.exe'
243}
244
245// 읽어 온 기록(parsed)을 목록에 반영해요. 바뀌었으면 true.
246// 윈도우 기록이 꺼져 있거나 못 읽었으면 지금 클립보드 한 개만 보여 줘요.
247async function applyClipParsed($: EngineInterface, parsed: { items: ClipItem[]; problem?: string }): Promise<boolean> {
248 if (parsed.items.length > 0) {
249 clipList = parsed.items
250 clipNote = undefined
251 } else {
252 const cur = await readClipboard($)
253 const at = new Date()
254 const stamp = `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`
255 clipList =
256 cur.kind === 'text'
257 ? [{ id: 'current', at: stamp, kind: 'text', chars: cur.chars, preview: cur.text.slice(0, 300) }]
258 : cur.kind === 'image'
259 ? [{ id: 'current', at: stamp, kind: 'image' }]
260 : []
261 clipNote = parsed.problem
262 ? '윈도우 클립보드 기록을 못 읽어서 지금 것만 보여요. (설정 > 시스템 > 클립보드 > 클립보드 기록을 켜면 목록이 나와요)'
263 : '클립보드가 비어 있어요.'
264 }
265 clipAt = Date.now()
266 const sig = clipList.map(i => i.id).join(',') + '|' + (clipNote ?? '')
267 const changed = sig !== clipSig
268 clipSig = sig
269 return changed
270}
271
272// «새로 읽기»를 눌렀을 때 한 번만 읽어요. 자동으로는 부르지 않아요.
273async function readClipList($: EngineInterface): Promise<boolean> {
274 if (clipBusy) return false
275 clipBusy = true
276 try {
277 const ps = await powershellFor($)
278 if (!ps) {
279 clipList = []
280 clipNote = '이 환경에서는 클립보드를 읽지 못해요. (윈도우에서만 돼요)'
281 return true
282 }
283 const run = await $.process.run([ps, '-NoProfile', '-STA', '-Command', CLIP_LIST_SCRIPT], { timeoutMs: 15000 })
284 const parsed = run.exitCode === 0 ? parseClipList(run.stdout) : { items: [] as ClipItem[], problem: (run.stderr || `종료 코드 ${run.exitCode}`).trim().slice(0, 120) }
285 return await applyClipParsed($, parsed)
286 } catch (err) {
287 clipList = []
288 clipNote = `못 읽었어요: ${String(err).slice(0, 100)}`
289 return true
290 } finally {
291 clipBusy = false
292 }
293}
294
295// 목록의 한 항목을 입력창에 붙여요. 전체 내용은 이때 따로 가져와요(목록에는 앞 300자만 있어요).
296async function pasteItem($: EngineInterface, item: ClipItem): Promise<void> {
297 if (item.kind === 'other') {
298 $.ui.toast('글이나 이미지가 아니라서 붙일 수 없어요.', { timeoutMs: 5000 })
299 return
300 }
301 if (item.id === 'current') {
302 await pasteClip($, await readClipboard($))
303 return
304 }
305 const script = clipItemScript(item.id)
306 const ps = await powershellFor($)
307 if (!script || !ps) {
308 $.ui.toast('이 항목은 붙일 수 없어요.', { timeoutMs: 5000 })
309 return
310 }
311 try {
312 const run = await $.process.run([ps, '-NoProfile', '-STA', '-Command', script], { timeoutMs: 20000 })
313 await pasteClip($, run.exitCode === 0 ? parseClip(run.stdout) : { kind: 'error', message: (run.stderr || `종료 코드 ${run.exitCode}`).trim().slice(0, 120) })
314 } catch (err) {
315 $.ui.toast(`붙이지 못했어요: ${String(err).slice(0, 100)}`, { timeoutMs: 6000 })
316 }
317}
318
319// 클립보드가 바뀌는 걸 «듣는» 감시 프로세스를 한 번만 띄워요. (프로세스를 계속 새로 만들지 않아요)
320// · 프로세스 하나가 클립보드 번호만 0.7초마다 확인하고, 바뀌었을 때만 목록을 내보내요.
321// · 옆 창이 안 보이거나 «클립» 탭이 아닌 채로 1분이 지나면 이 반복문을 끝내고, 그러면 프로세스도 같이 끝나요.
322// · 모드가 내려가거나 세션이 끝나도 프로세스는 같이 끝나요.
323async function watchClipboard($: EngineInterface): Promise<void> {
324 if (clipWatching) return
325 clipWatching = true
326 let idle = 0
327 try {
328 const ps = await powershellFor($)
329 if (!ps) return
330 let buf = ''
331 let acc: string[] | undefined
332 for await (const piece of $.process.spawn({ argv: [ps, '-NoProfile', '-STA', '-WindowStyle', 'Hidden', '-Command', CLIP_WATCH_SCRIPT] })) {
333 if (piece.stream !== 'stdout') continue
334 buf += piece.text
335 for (;;) {
336 const nl = buf.indexOf('\n')
337 if (nl < 0) break
338 const line = buf.slice(0, nl).replace(/\r$/, '')
339 buf = buf.slice(nl + 1)
340 if (line === 'BEGIN') {
341 acc = []
342 } else if (line === 'END') {
343 if (acc && (await applyClipParsed($, parseClipList(acc.join('\n'))))) $.ui.invalidate('ui.render')
344 acc = undefined
345 } else if (line === 'TICK') {
346 // 10초마다: 옆 창이 보이고 «클립» 탭이 선택돼 있는지 물어요. 1분(6번) 동안 아니면 끝내요.
347 const panes = await $.ui.panes().catch(() => [])
348 const visible = tab === 'clip' && panes.some(p => p.id === PANE && p.isShown && p.isPlaced)
349 idle = visible ? 0 : idle + 1
350 if (idle >= 6) return
351 } else if (acc) {
352 acc.push(line)
353 }
354 }
355 }
356 } catch (err) {
357 // 감시를 못 띄워도 «새로 읽기»는 돼요. 화면이 «읽는 중…»에서 멈추지 않게 이유를 보여 줘요.
358 clipNote = `클립보드 감시를 시작하지 못했어요: ${String(err).slice(0, 120)}`
359 $.ui.invalidate('ui.render')
360 } finally {
361 clipWatching = false
362 if (clipAt === 0 && clipNote === undefined) {
363 clipNote = '클립보드 목록을 받지 못했어요. «새로 읽기»를 눌러 보세요.'
364 $.ui.invalidate('ui.render')
365 }
366 }
367}
368
369// 읽어 둔 클립보드를 입력창의 커서 자리에 끼워 넣어요. 이미지는 파일 경로가 들어가요.
370async function pasteClip($: EngineInterface, c: Clip): Promise<boolean> {
371 if (c.kind === 'empty' || c.kind === 'error') {
372 $.ui.toast(c.kind === 'empty' ? '클립보드가 비어 있어요.' : `클립보드를 못 읽었어요: ${c.message}`, { timeoutMs: 6000 })
373 return false
374 }
375 const inWsl = (await hostKind($)) === 'wsl'
376 // Claude가 읽을 수 있는 경로로: 윈도우는 슬래시로, WSL은 C:\… 를 /mnt/c/… 로 바꿔요.
377 const toHost = (p: string) => {
378 const slashed = p.replace(/\\/g, '/')
379 if (!inWsl) return slashed
380 const m = /^([A-Za-z]):\/(.*)$/.exec(slashed)
381 return m ? `/mnt/${m[1]!.toLowerCase()}/${m[2]}` : slashed
382 }
383 const text = clipInsertText(c, toHost)
384 const filled = await $.prompt.fill({ text, mode: 'insert' })
385 if (!filled.isFilled) $.ui.toast('입력창에 못 넣었어요. 창이 열려 있을 때 다시 해 주세요.', { timeoutMs: 6000 })
386 return filled.isFilled
387}
388
389// 세션을 시작할 때 책상을 옆에 열어 둬요. 기본은 꺼져 있어요(autoOpen: always 로 켤 수 있어요).
390// 키보드 포커스는 가져오지 않아서, 바로 입력창에 쓸 수 있어요.
391let autoTried = false // 이 프로세스에서 자동으로 한 번 열어 봤는지. 닫은 걸 다시 열지 않으려고요.
392async function autoOpenDesk($: EngineInterface) {
393 try {
394 if (autoTried) return
395 if (cfg.autoOpen === 'off') return
396 if (!(await isInteractive($))) return
397 autoTried = true
398 await refreshUsage($)
399 updateStatus($)
400 if (tab === 'clip') void watchClipboard($)
401 if (tab === 'pin') void calOnTab($)
402 if (tab === 'mail') void mailOnTab($)
403 if (tab === 'settings') settingsOnTab($)
404 const opened = await $.ui.open({ id: PANE, title: '책상', columns: cfg.paneColumns, closeOnEscape: true })
405 if (opened && opened.isPlaced === false) {
406 // 저절로 연 창은 터미널이 144칸 미만이면 그려지지 않고 기다려요. 이유를 보여 줘요.
407 const why = String((opened as { reason?: unknown }).reason ?? '')
408 $.ui.toast(`책상을 아직 못 그렸어요(${why}). /desk 를 치면 바로 열려요.`, { timeoutMs: 15000 })
409 }
410 } catch (err) {
411 // 옆 창이 안 열려도 대화는 그대로 해요.
412 $.ui.log(`책상을 자동으로 열지 못했어요: ${String(err)}`)
413 $.ui.toast(`책상을 자동으로 열지 못했어요: ${String(err)}`, { timeoutMs: 15000 })
414 }
415}
416
417// 윈도우는 HOME 이 아니라 USERPROFILE 을 써요.
418async function homeDir($: EngineInterface): Promise<string> {
419 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
420 return home.replace(/\\/g, '/')
421}
422
423// ───────────── 알림 · 발송 전 확인 ─────────────
424
425function recordSend(kind: SendEntry['kind'], label: string, detail: string) {
426 sendLog = [{ at: Date.now(), kind, label, detail: detail.slice(0, 160) }, ...sendLog].slice(0, 30)
427}
428
429// 알림. 화면이 있는 세션에서, 알림이 켜져 있을 때만 띄워요.
430// · 윈도우·WSL: 윈도우 알림(풍선)
431// · 맥·리눅스: Claude Code 가 터미널로 보내는 알림($.ui.notify). 터미널이 알림을 받아 줘야 떠요.
432// · on: 이 알림을 띄울지. 기본은 «질문·승인 알림» 스위치예요. 작업 끝 알림과 «알림 시험»은 따로 정해서 넘겨요.
433async function osNotify($: EngineInterface, title: string, body: string, on: boolean = notifyOn) {
434 // 기다리지 않고 부르는 함수라서 어떤 오류도 밖으로 새면 안 돼요. 알림이 안 떠도 일은 그대로 해요.
435 try {
436 if (!on || !(await isInteractive($))) return
437 const exe = await powershellFor($)
438 if (!exe) {
439 void $.ui.notify(body || title, { title }).catch(() => {})
440 return
441 }
442 // 알림 글이 PowerShell 문자열을 깨지 않도록 따옴표·달러·괄호 등을 빼요.
443 const safe = (s: string) => s.replace(/[\r\n]+/g, ' ').replace(/["'`$\\;|&<>(){}]/g, ' ').slice(0, 120)
444 const script = [
445 'Add-Type -AssemblyName System.Windows.Forms',
446 '$n = New-Object System.Windows.Forms.NotifyIcon',
447 '$n.Icon = [System.Drawing.SystemIcons]::Information',
448 '$n.Visible = $true',
449 `$n.ShowBalloonTip(6000, '${safe(title)}', '${safe(body) || ' '}', 'Info')`,
450 'Start-Sleep -Seconds 7',
451 '$n.Dispose()',
452 ].join('; ')
453 void $.process.run([exe, '-NoProfile', '-WindowStyle', 'Hidden', '-Command', script], { timeoutMs: 20000 }).catch(() => {})
454 } catch {
455 // 위와 같아요.
456 }
457}
458
459// «보내기/보내지 않기»를 물어요. 창을 닫거나 다른 답을 쓰면 보내지 않는 쪽으로 봐요.
460let asking = false
461async function confirmWith($: EngineInterface, question: string, yes: string, no: string): Promise<boolean> {
462 void osNotify($, '확인이 필요해요', question.split('\n')[0] ?? '')
463 asking = true
464 try {
465 const answer = await $.ui.ask(question, { header: '발송 확인', options: [yes, no] })
466 return answer === yes
467 } catch {
468 return false
469 } finally {
470 asking = false
471 }
472}
473
474// 도구 호출 한 건이 «바깥으로 나가는 일»이면 보내기 전에 물어요. 막을 때만 { deny }를 돌려줘요.
475async function sendCheck($: EngineInterface, e: Record<string, unknown>): Promise<{ deny: string } | undefined> {
476 if (!sendConfirmOn) return undefined
477 const j = judgeSend(String(e.tool ?? ''))
478 if (!j) return undefined
479 if (!(await isInteractive($))) {
480 // 화면 없는 실행(예약 작업 등): 확인해 줄 사람이 없으니 보내지 않아요.
481 recordSend('unattended', j.label, j.detail)
482 return { deny: `${j.reason} 확인해 줄 사람이 화면에 없어서 보내지 않았어요.` }
483 }
484 const question = `바깥으로 나가요. 보낼까요?\n\n${j.label}\n${summarizeArgs(e) || j.detail}\n\n${j.reason}`
485 const ok = await confirmWith($, question, '보내기', '보내지 않기')
486 recordSend(ok ? 'allow' : 'refuse', j.label, j.detail)
487 if (ok) return undefined
488 return { deny: '사용자가 보내지 않기로 했어요. 같은 내용을 다시 보내지 말고, 무엇을 고칠지 먼저 물어보세요.' }
489}
490
491async function refreshUsage($: EngineInterface) {
492 try {
493 const usage = await $.session.usage()
494 const now = await $.clock.now()
495 const windows: UsageWindow[] = usage.rateLimits.map((w: UsageWindow) => ({ kind: w.kind, percentUsed: w.percentUsed, resetsAt: w.resetsAt }))
496 snap = {
497 windows,
498 costUsd: usage.cost?.usd ?? null,
499 startedAt: usage.startedAt,
500 updatedAt: now,
501 ctxTokens: usage.context?.tokens,
502 ctxWindow: usage.context?.window,
503 ctxPercent: usage.context?.percent,
504 }
505 baseline ??= { at: now, windows }
506 } catch {
507 // 사용량을 못 읽으면 지난 값을 그대로 둬요.
508 }
509}
510
511function statusLine(): string {
512 const parts: string[] = []
513 const five = snap?.windows.find(w => w.kind === 'five_hour')
514 if (five) parts.push(`5시간 ${Math.round(five.percentUsed)}%`)
515 if (snap?.ctxPercent !== undefined) parts.push(`대화 ${Math.round(snap.ctxPercent)}%`)
516 const open = board.todos.filter(t => !t.isDone).length
517 if (open > 0) parts.push(`할 일 ${open}`)
518 if (board.decisions.length > 0) parts.push(`결정 ${board.decisions.length}`)
519 if (writing) parts.push('메모 쓰는 중')
520 else if (dueSession !== undefined) parts.push('다음 말에 새 대화로')
521 return parts.join(' · ')
522}
523
524function updateStatus($: EngineInterface) {
525 const line = statusLine()
526 $.ui.status(line === '' ? undefined : line)
527}
528
529// 핀은 대화(세션)마다 따로 두고, 가장 최근 30개만 남겨요.
530type StoredBoards = Record<string, { at: number; todos: Todo[]; decisions: Decision[] }>
531
532async function loadBoard($: EngineInterface) {
533 try {
534 const id = await $.session.id()
535 const all = ((await $.store.get('boards')) ?? {}) as StoredBoards
536 const b = all[id]
537 board = b ? { todos: b.todos ?? [], decisions: b.decisions ?? [] } : emptyBoard()
538 } catch {
539 board = emptyBoard()
540 }
541}
542
543async function saveBoard($: EngineInterface) {
544 try {
545 const id = await $.session.id()
546 // 다른 대화가 방금 쓴 것을 덮지 않도록 쓰기 직전에 다시 읽어요.
547 const all = ((await $.store.get('boards')) ?? {}) as StoredBoards
548 all[id] = { at: await $.clock.now(), todos: board.todos, decisions: board.decisions }
549 const keep = Object.entries(all).sort((a, b) => b[1].at - a[1].at).slice(0, 30)
550 await $.store.set('boards', Object.fromEntries(keep))
551 } catch {
552 // 저장이 안 돼도 화면은 그대로 써요.
553 }
554}
555
556// 고친 파일 목록도 대화마다 따로 두고, 가장 최근 30개 대화만 남겨요.
557type StoredChanges = Record<string, { at: number; files: Change[] }>
558
559// 저장해 둔 목록이 없으면(이 기능이 생기기 전에 시작한 대화) 대화 기록에서 한 번 만들어요.
560async function loadChanges($: EngineInterface) {
561 try {
562 const id = await $.session.id()
563 const all = ((await $.store.get('changes')) ?? {}) as StoredChanges
564 const stored = all[id]?.files
565 if (Array.isArray(stored)) {
566 changes = stored
567 return
568 }
569 const messages = await $.session.messages().catch(() => [])
570 changes = Array.isArray(messages) ? changesFromMessages(messages) : []
571 } catch {
572 changes = []
573 }
574}
575
576async function saveChanges($: EngineInterface) {
577 try {
578 const id = await $.session.id()
579 const all = ((await $.store.get('changes')) ?? {}) as StoredChanges
580 all[id] = { at: await $.clock.now(), files: changes }
581 const keep = Object.entries(all).sort((a, b) => b[1].at - a[1].at).slice(0, 30)
582 await $.store.set('changes', Object.fromEntries(keep))
583 } catch {
584 // 저장이 안 돼도 화면은 그대로 써요.
585 }
586}
587
588// 입력창 위에 열린 책상을 닫아요. 닫기가 거절되면 다음에 내가 말을 보낼 때 한 번 더 해요.
589let inlinePending = false
590async function closeInlineDesk($: EngineInterface) {
591 try {
592 await $.ui.close({ id: PANE })
593 inlinePending = false
594 } catch (err) {
595 inlinePending = true
596 $.ui.log(`입력창 위에 열린 책상을 닫지 못했어요: ${String(err)}`)
597 }
598}
599
600async function registerCommand($: EngineInterface, spec: { name: string; description: string; argumentHint?: string; immediate?: true }) {
601 try {
602 await $.command.register(spec)
603 } catch {
604 // 이미 쓰는 이름이면 «desk-» 를 붙여 다시 등록해요. 그래도 안 되면 로그만 남겨요.
605 try {
606 if (spec.name.startsWith('desk')) throw new Error('이름 겹침')
607 await $.command.register({ ...spec, name: `desk-${spec.name}` })
608 $.ui.log(`/${spec.name} 은(는) 이미 쓰는 이름이라 /desk-${spec.name} 으로 등록했어요.`)
609 } catch (err) {
610 $.ui.log(`/${spec.name} 명령을 등록하지 못했어요: ${String(err)}`)
611 }
612 }
613}
614
615// 글로 보여 주는 요약. /desk text 와 점검에 써요.
616async function deskText($: EngineInterface): Promise<string> {
617 await refreshUsage($)
618 updateStatus($)
619 const lines: string[] = []
620 if (snap) {
621 for (const w of snap.windows) lines.push(`${WINDOW_LABEL[w.kind] ?? w.kind}: ${Math.round(w.percentUsed)}% ${untilText(w.resetsAt, snap.updatedAt)}`.trim())
622 lines.push(`대화 길이: ${snap.ctxPercent !== undefined ? Math.round(snap.ctxPercent) + '%' : '?'} (${snap.ctxTokens !== undefined ? k(snap.ctxTokens) : '?'} / ${snap.ctxWindow !== undefined ? k(snap.ctxWindow) : '?'})`)
623 }
624 lines.push(`이어가기: ${MODE_LABEL[mode]} · 프롬프트 가이드: ${coachOn ? '켜짐' : '꺼짐'}`)
625 lines.push(`발송 전 확인 ${sendConfirmOn ? '켜짐' : '꺼짐'} · 질문·승인 알림 ${notifyOn ? '켜짐' : '꺼짐'} · 작업 끝 알림 ${notifyDoneOn ? '켜짐' : '꺼짐'}`)
626 for (const g of sendLog.slice(0, 3)) lines.push(` ${hhmm(g.at)} ${SEND_KIND[g.kind]} ${g.label}`)
627 try {
628 lines.push(`화면: ${(await $.session.surfaces()).join(', ')}`)
629 } catch {
630 lines.push('화면: 알 수 없음')
631 }
632 lines.push(describeBoard(board))
633 lines.push(`고친 파일: ${changes.length}개` + (changes.length ? ` (최근: ${changes.slice(0, 3).map(c => changeLabel(c.path, rootDir).name).join(', ')})` : ''))
634 lines.push(`명령어 안내: ${effectiveGuide(cmdAdded, cmdHidden).map(guideCommand).join(' ')}`)
635 await loadCalendar($)
636 const now = await $.clock.now()
637 const todayEvents = calEvents.filter(ev => ev.day === 'today').map(ev => `${eventTime(ev, now)} ${ev.title}`)
638 lines.push(calState === 'ok' ? `오늘 일정: ${todayEvents.join(' · ') || '없음'} · 내일 ${calEvents.filter(ev => ev.day === 'tomorrow').length}개` : `일정: 못 읽었어요 (${calProblem ?? '이유 모름'})`)
639 return lines.join('\n')
640}
641
642// ───────────── 메일 (claude.ai Gmail 커넥터, 읽기만) ─────────────
643
644// 받은편지함 최근 메일의 제목을 읽어요. 연결이 안 돼 있거나 로그인이 풀렸으면 이유를 남겨요.
645// · quiet: 저절로 다시 읽을 때예요. 실패해도 오류 화면으로 바꾸지 않고 보던 목록을 그대로 둬요.
646async function loadMailList($: EngineInterface, opts: { quiet?: boolean } = {}): Promise<void> {
647 if (mailState === 'loading') return
648 const quiet = opts.quiet === true && mailState === 'ok'
649 mailState = 'loading'
650 $.ui.invalidate('ui.render')
651 try {
652 const r = await $.mcp.call(GMAIL_SERVER, 'search_threads', { query: 'in:inbox', pageSize: MAIL_PAGE, view: 'THREAD_VIEW_MINIMAL' })
653 const data = r.isError ? undefined : mcpJson(r)
654 if (data === undefined) {
655 mailState = quiet ? 'ok' : 'failed'
656 if (!quiet) mailProblem = r.isError ? mcpErrorText(r) : 'Gmail 답을 읽지 못했어요'
657 return
658 }
659 mailRows = parseThreadList(data)
660 const firstUrl = (data as { threads?: { viewUrl?: unknown }[] }).threads?.[0]?.viewUrl
661 mailWho = mailAccount(typeof firstUrl === 'string' ? firstUrl : undefined) || mailWho
662 mailState = 'ok'
663 mailProblem = undefined
664 } catch (err) {
665 mailState = quiet ? 'ok' : 'failed'
666 if (!quiet) mailProblem = String(err).replace(/\s+/g, ' ').slice(0, 160)
667 } finally {
668 mailAt = await $.clock.now()
669 $.ui.invalidate('ui.render')
670 }
671}
672
673// 오늘·내일 일정을 읽어요. 캘린더가 연결 안 돼 있어도 메일 목록은 그대로 보여요.
674// · quiet: 저절로 다시 읽을 때예요. 실패해도 보던 일정을 그대로 둬요.
675async function loadCalendar($: EngineInterface, opts: { quiet?: boolean } = {}): Promise<void> {
676 if (calState === 'loading') return
677 const quiet = opts.quiet === true && calState === 'ok'
678 calState = 'loading'
679 try {
680 const now = await $.clock.now()
681 const r = await $.mcp.call(CALENDAR_SERVER, 'list_events', calendarArgs(now))
682 const data = r.isError ? undefined : mcpJson(r)
683 if (data === undefined) {
684 calState = quiet ? 'ok' : 'failed'
685 if (!quiet) calProblem = r.isError ? mcpErrorText(r) : '캘린더 답을 읽지 못했어요'
686 return
687 }
688 calEvents = parseEvents(data, now)
689 calState = 'ok'
690 calLoaded = true
691 calProblem = undefined
692 } catch (err) {
693 calState = quiet ? 'ok' : 'failed'
694 if (!quiet) calProblem = String(err).replace(/\s+/g, ' ').slice(0, 160)
695 } finally {
696 calAt = await $.clock.now()
697 $.ui.invalidate('ui.render')
698 }
699}
700
701const stale = (state: string, at: number, now: number) => state === 'idle' || state === 'failed' || (state === 'ok' && now - at > MAIL_STALE_MS)
702
703// 메일 탭을 열 때: 처음이거나, 실패했거나, 2분 넘게 지난 목록이면 다시 읽어요.
704async function mailOnTab($: EngineInterface) {
705 if (stale(mailState, mailAt, await $.clock.now())) await loadMailList($)
706}
707
708// 할 일 탭을 열 때: 일정도 같은 기준으로 다시 읽어요.
709async function calOnTab($: EngineInterface) {
710 if (stale(calState, calAt, await $.clock.now())) await loadCalendar($)
711}
712
713// 1분마다 불려요. 옆 창이 보이고 «메일» 탭을 보고 있을 때만, 읽은 지 5분이 지났으면 목록을 다시 읽어요.
714// · 실패한 상태(연결 안 됨·권한 없음)에서는 저절로 다시 부르지 않아요. «다시 시도»를 누르면 돼요.
715async function mailTick($: EngineInterface) {
716 if (tab !== 'mail' || mailState !== 'ok') return
717 if ((await $.clock.now()) - mailAt < MAIL_AUTO_MS) return
718 const panes = await $.ui.panes().catch(() => [])
719 if (!panes.some(p => p.id === PANE && p.isShown && p.isPlaced)) return
720 await loadMailList($, { quiet: true })
721}
722
723// 1분마다 불려요. 옆 창이 보이고 «할 일» 탭을 보고 있을 때만, 읽은 지 5분이 지났으면 일정을 다시 읽어요.
724async function calTick($: EngineInterface) {
725 if (tab !== 'pin' || calState !== 'ok') return
726 if ((await $.clock.now()) - calAt < MAIL_AUTO_MS) return
727 const panes = await $.ui.panes().catch(() => [])
728 if (!panes.some(p => p.id === PANE && p.isShown && p.isPlaced)) return
729 await loadCalendar($, { quiet: true })
730}
731
732// 줄을 누르면 그 메일(스레드)의 본문을 불러와요. Gmail 의 «읽음» 표시는 바꾸지 않아요.
733async function openMail($: EngineInterface, row: MailRow): Promise<void> {
734 if (mailOpening) return
735 mailOpening = row.id
736 mailNote = undefined
737 $.ui.invalidate('ui.render')
738 try {
739 const r = await $.mcp.call(GMAIL_SERVER, 'get_thread', { threadId: row.id, messageFormat: 'PLAIN_TEXT' })
740 const t = r.isError ? undefined : parseThread(mcpJson(r))
741 if (!t) {
742 mailNote = `본문을 못 불러왔어요: ${r.isError ? mcpErrorText(r) : '답을 읽지 못했어요'}`
743 return
744 }
745 mailView = t
746 $.ui.scroll({ in: PANE, to: 'start' }).catch(() => {})
747 } catch (err) {
748 mailNote = `본문을 못 불러왔어요: ${String(err).replace(/\s+/g, ' ').slice(0, 120)}`
749 } finally {
750 mailOpening = undefined
751 $.ui.invalidate('ui.render')
752 }
753}
754
755// 연결하기: /mcp 창을 열어요. 거기서 «claude.ai Gmail»을 골라 로그인해요.
756function connectGmail($: EngineInterface) {
757 $.command.run({ command: 'mcp' }).catch((err: unknown) => {
758 $.ui.toast(`/mcp 창을 열지 못했어요. 입력창에 /mcp 를 직접 쳐 주세요. (${String(err).slice(0, 80)})`, { timeoutMs: 10000 })
759 })
760}
761
762// 펼친 메일을 브라우저의 Gmail 로 열어요. 누를 때만 PowerShell 을 한 번 띄워요.
763async function openMailInBrowser($: EngineInterface, url: string | undefined) {
764 const safe = safeMailUrl(url)
765 const ps = await powershellFor($)
766 if (!safe || !ps) {
767 $.ui.toast('이 메일은 브라우저로 열 수 없어요.', { timeoutMs: 5000 })
768 return
769 }
770 try {
771 await $.process.run([ps, '-NoProfile', '-WindowStyle', 'Hidden', '-Command', `Start-Process '${safe}'`], { timeoutMs: 15000 })
772 } catch (err) {
773 $.ui.toast(`브라우저를 열지 못했어요: ${String(err).slice(0, 100)}`, { timeoutMs: 6000 })
774 }
775}
776
777// ───────────── 처음 설정 점검 · 명령어 안내 · 승인 대기 알림 ─────────────
778
779// 명령 탭에서 쓴 글을 목록에 넣어요. 기본 명령을 뺐다가 다시 쓰면 뺀 표시만 풀어요.
780// · 설명을 안 썼으면 Claude Code 가 그 명령에 붙여 둔 한 줄 설명을 가져와요(없으면 빈칸).
781async function addGuide($: EngineInterface, text: string) {
782 const r = parseGuideInput(text)
783 if ('error' in r) {
784 cmdNote = r.error
785 $.ui.invalidate('ui.render')
786 return
787 }
788 const g: Guide = { ...r.guide }
789 const cmd = guideCommand(g)
790 if (effectiveGuide(cmdAdded, cmdHidden).some(x => guideCommand(x) === cmd)) {
791 cmdNote = `${cmd} 는 이미 목록에 있어요.`
792 $.ui.invalidate('ui.render')
793 return
794 }
795 if (isDefaultGuide(g)) {
796 cmdHidden = cmdHidden.filter(x => x !== cmd)
797 } else {
798 if (!g.text) {
799 try {
800 const found = (await $.command.list()).find(c => c.name === g.name)
801 if (found?.description) g.text = found.description.slice(0, 80)
802 } catch {
803 // 설명이 없어도 넣어요.
804 }
805 }
806 cmdAdded = [...cmdAdded, g].slice(-30)
807 }
808 cmdNote = undefined
809 cmdInputSeq++
810 await saveGuide($)
811 $.ui.toast(`${cmd} 를 넣었어요.`, { timeoutMs: 4000 })
812 $.ui.invalidate('ui.render')
813}
814
815async function saveGuide($: EngineInterface) {
816 try {
817 await $.store.set('cmdAdded', cmdAdded)
818 await $.store.set('cmdHidden', cmdHidden)
819 } catch {
820 // 저장이 안 돼도 이번 대화에서는 보여요.
821 }
822}
823
824
825// 커넥터를 가볍게 한 번 불러 봐요. 됐으면 undefined, 아니면 이유.
826async function probeConnector($: EngineInterface, server: string, tool: string, args: Record<string, unknown>): Promise<string | undefined> {
827 try {
828 const r = await $.mcp.call(server, tool, args)
829 return r.isError ? mcpErrorText(r) : undefined
830 } catch (err) {
831 return String(err).replace(/\s+/g, ' ').slice(0, 160)
832 }
833}
834
835// 새 PC에 깔면 바로 부딪히는 것들을 한 번에 봐요. 설정 탭을 처음 열 때와 «다시 점검»을 누를 때만 해요.
836// · 클립보드 기록 확인은 PowerShell 을 한 번 띄워요(윈도우·WSL만). 주기적으로는 안 해요.
837async function runChecks($: EngineInterface): Promise<void> {
838 if (checking) return
839 checking = true
840 $.ui.invalidate('ui.render')
841 try {
842 const host = await hostKind($)
843 const out: Check[] = []
844
845 const ver = await $.session.version().catch(() => undefined)
846 const verOk = ver ? versionAtLeast(ver.base ?? ver.version, MIN_VERSION) : undefined
847 out.push(
848 verOk === false
849 ? { id: 'version', label: 'Claude Code 버전', state: 'bad', detail: `${ver!.version} · ${MIN_VERSION} 이상이 필요해요`, fix: '터미널에서 claude update' }
850 : { id: 'version', label: 'Claude Code 버전', state: verOk ? 'ok' : 'na', detail: ver ? ver.version : '확인하지 못했어요' },
851 )
852
853 out.push(
854 lastPlacement === undefined
855 ? { id: 'screen', label: '화면', state: 'na', detail: '책상을 열면 확인해요' }
856 : lastPlacement === 'inline'
857 ? { id: 'screen', label: '화면', state: 'bad', detail: '책상이 입력창 위에 열려서 마우스로 안 눌려요', fix: '/tui fullscreen 을 한 번 치기' }
858 : { id: 'screen', label: '화면', state: 'ok', detail: '옆 창 · 마우스로 눌러요' },
859 )
860
861 const [gmail, cal] = await Promise.all([
862 probeConnector($, GMAIL_SERVER, 'search_threads', { query: 'in:inbox', pageSize: 1, view: 'THREAD_VIEW_MINIMAL' }),
863 probeConnector($, CALENDAR_SERVER, 'list_events', { ...calendarArgs(await $.clock.now()), pageSize: 1 }),
864 ])
865 out.push(connectorCheck('gmail', 'Gmail (메일 탭)', GMAIL_SERVER, ['mcp__claude_ai_Gmail__search_threads', 'mcp__claude_ai_Gmail__get_thread'], gmail))
866 out.push(connectorCheck('calendar', '구글 캘린더 (일정)', CALENDAR_SERVER, ['mcp__claude_ai_Google_Calendar__list_events'], cal))
867
868 const ps = await powershellFor($)
869 if (!ps) {
870 out.push({ id: 'clip', label: '클립보드 기록', state: 'na', detail: '윈도우에서만 써요' })
871 out.push({ id: 'notify', label: '알림', state: 'ok', detail: '터미널 알림을 써요. 안 뜨면 터미널의 알림 설정을 보세요' })
872 } else {
873 let psProblem: string | undefined
874 if (host === 'wsl' && ps === 'powershell.exe') psProblem = 'WSL에서 윈도우 PowerShell 을 못 찾았어요'
875 let clipProblem: string | undefined
876 if (!psProblem) {
877 try {
878 const run = await $.process.run([ps, '-NoProfile', '-STA', '-Command', CLIP_LIST_SCRIPT], { timeoutMs: 15000 })
879 clipProblem = run.exitCode === 0 ? parseClipList(run.stdout).problem : (run.stderr || `종료 코드 ${run.exitCode}`).trim().slice(0, 120)
880 } catch (err) {
881 psProblem = `PowerShell 을 실행하지 못했어요: ${String(err).slice(0, 100)}`
882 }
883 }
884 out.push(
885 psProblem
886 ? { id: 'clip', label: '클립보드 기록', state: 'bad', detail: psProblem, fix: '클립보드 탭과 알림이 안 돼요. /mnt/c 가 보이는지 확인' }
887 : clipProblem
888 ? { id: 'clip', label: '클립보드 기록', state: 'bad', detail: '윈도우 클립보드 기록이 꺼져 있어요', fix: '윈도우 설정 > 시스템 > 클립보드 > 클립보드 기록 켜기' }
889 : { id: 'clip', label: '클립보드 기록', state: 'ok', detail: '켜져 있어요' },
890 )
891 out.push(psProblem ? { id: 'notify', label: '알림', state: 'bad', detail: psProblem } : { id: 'notify', label: '알림', state: 'ok', detail: '윈도우 알림을 써요' })
892 }
893 if (!notifyOn && !notifyDoneOn) {
894 const i = out.findIndex(c => c.id === 'notify')
895 if (i >= 0) out[i] = { id: 'notify', label: '알림', state: 'na', detail: '두 알림 다 꺼 뒀어요 (위 스위치)' }
896 }
897
898 checks = out
899 checked = true
900 } catch (err) {
901 checks = [{ id: 'error', label: '점검', state: 'bad', detail: `점검하다 멈췄어요: ${String(err).slice(0, 120)}` }]
902 } finally {
903 checking = false
904 checkAt = await $.clock.now()
905 $.ui.invalidate('ui.render')
906 }
907}
908
909// 설정 탭을 열 때: 점검은 처음 한 번만 해요.
910function settingsOnTab($: EngineInterface) {
911 if (!checked && !checking) void runChecks($)
912}
913
914// Claude Code 가 «이 도구를 써도 될까요?» 창을 띄우면 알려요. 같은 내용이 10초 안에 또 오면 한 번만 알려요.
915async function notifyPermission($: EngineInterface, tool: string, input: unknown) {
916 try {
917 const note = permissionNote(tool, input)
918 if (!note) return
919 const now = await $.clock.now()
920 const text = note.title + note.body
921 if (text === lastPermNote.text && now - lastPermNote.at < 10_000) return
922 lastPermNote = { text, at: now }
923 await osNotify($, note.title, note.body)
924 } catch {
925 // 알림이 안 떠도 승인 창은 그대로 떠요.
926 }
927}
928
929// ───────────── 이어가기(인수인계) ─────────────
930// 순서: ① 턴이 끝날 때 기준을 넘겼는지 보고 «다음 말에 넘김»으로 표시해요(dueSession).
931// ② 내가 다음 말을 보내면 그 말을 잠깐 붙잡고, 지금 대화의 Claude에게 이어가기 메모를 써 달라고 해요(startHandoff).
932// ③ 그 턴이 끝나면 코드가 원문을 붙여 메모를 마무리하고 /clear 해요(finishHandoff).
933// ④ 새 대화가 열리면 내가 보낸 말을 그대로 넣고, 메모는 모델만 보게 붙여요(classic.SessionStart·prompt.context).
934// 답이 끝나자마자 넘기면 답을 읽는 사이에 화면이 비고, 새 대화가 혼자 일을 시작했어요.
935
936const briefStamp = (ms: number) => {
937 const d = new Date(ms)
938 return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
939}
940
941// carry = 새 대화로 옮길 내 말. «지금 넘기기»로 넘길 때는 없어요.
942async function startHandoff($: EngineInterface, carry?: string): Promise<boolean> {
943 if (writing || pending) return false
944 try {
945 const sessionId = await $.session.id()
946 const usage = await $.session.usage()
947 const tokens = usage.context?.tokens ?? 0
948 const messages = await $.session.messages()
949 const facts = extractFacts(messages)
950 facts.tokens = tokens
951 facts.depth = (Number(await $.store.get(`depth:${sessionId}`)) || 0) + 1
952 const prevStored = await $.store.get(`brief:${sessionId}`)
953 const prev = typeof prevStored === 'string' ? prevStored : undefined
954 const path = `${await homeDir($)}/${BRIEF_DIR}/${sessionId}-${briefStamp(Date.now())}.md`
955 const request = briefRequest(path, carry, prev)
956 writing = {
957 oldSession: sessionId, path, request, submitted: false, carry, tokens, facts,
958 users: userMessages(messages), last: lastAnswer(messages), prev, board: copyBoard(board),
959 }
960 dueSession = undefined
961 updateStatus($)
962 $.ui.invalidate('ui.render')
963 $.ui.toast('대화가 길어져서 새 대화로 넘어가요. 지금 대화의 Claude가 이어가기 메모를 쓰는 중이에요…', { timeoutMs: 10000 })
964 // 보내기 훅 안에서는 새 메시지를 보낼 수 없어요(서로 기다리게 돼요). 훅이 끝난 뒤에 보내요.
965 // 지금 대화가 한가해지면 돌아요.
966 $.clock.after(0, () => {
967 if (writing?.request !== request) return // 그새 그만뒀어요
968 // 이 다음에 끝나는 턴이 메모를 쓰는 턴이에요. (엔진은 우리가 보낸 메시지를 우리 훅에 다시 보여 주지 않아요)
969 writing.submitted = true
970 $.prompt.submit({ text: request }).catch((err: unknown) => {
971 void cancelHandoff($, `메모를 부탁하지 못했어요 (${String(err).slice(0, 80)})`)
972 })
973 })
974 return true
975 } catch (err) {
976 writing = undefined
977 $.ui.toast(`새 대화로 넘기지 못했어요. 이 대화를 이어 가요. (${String(err).slice(0, 80)})`, { timeoutMs: 15000 })
978 return false
979 }
980}
981
982// 넘기기를 그만둬요. 붙잡아 둔 내 말은 입력창에 다시 넣어요.
983async function cancelHandoff($: EngineInterface, why: string) {
984 const w = writing
985 writing = undefined
986 if (!w) return
987 await $.store.set(`fired:${w.oldSession}`, 'cancelled')
988 if (w.carry !== undefined) void $.prompt.fill({ text: w.carry })
989 updateStatus($)
990 $.ui.invalidate('ui.render')
991 $.ui.toast(`${why}. 이 대화를 이어 가요.${w.carry !== undefined ? ' 보낸 말은 입력창에 다시 넣었어요.' : ''}`, { timeoutMs: 15000 })
992}
993
994// 메모를 써 달라는 턴이 끝났을 때: 코드가 원문을 붙여 마무리하고 /clear 해요.
995async function finishHandoff($: EngineInterface, aborted: boolean) {
996 const w = writing
997 if (!w) return
998 if (aborted) {
999 await cancelHandoff($, '새 대화로 넘기기를 멈췄어요')
1000 return
1001 }
1002 writing = undefined
1003 let narrative: string | undefined
1004 let problem: string | undefined
1005 try {
1006 if (await $.fs.exists(w.path)) {
1007 const text = await $.fs.read(w.path)
1008 if (isValidBrief(text)) narrative = text
1009 else problem = '소제목이 빠진 글을 썼어요'
1010 } else problem = '파일을 쓰지 않았어요'
1011 } catch (err) {
1012 problem = `파일을 못 읽었어요: ${String(err).slice(0, 60)}`
1013 }
1014 try {
1015 const cwd = await $.session.cwd()
1016 const brief = assembleBrief(
1017 { sessionId: w.oldSession, cwd, at: new Date().toISOString(), transcript: `~/.claude/projects/${cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${w.oldSession}.jsonl` },
1018 w.facts,
1019 w.board,
1020 narrative,
1021 { users: w.users, last: w.last, prev: w.prev, problem },
1022 )
1023 await $.fs.write(w.path, brief)
1024 lastBrief = { path: w.path, at: await $.clock.now(), tokens: w.tokens, problem }
1025 await $.store.set(`fired:${w.oldSession}`, 'briefed')
1026 await $.store.set('depth:next', (Number(await $.store.get(`depth:${w.oldSession}`)) || 0) + 1)
1027 pending = { oldSession: w.oldSession, briefPath: w.path, brief, tokens: w.tokens, board: w.board, problem, carry: w.carry }
1028 $.command.run({ command: 'clear' }).catch((err: unknown) => {
1029 const p = pending
1030 pending = undefined
1031 if (p?.carry !== undefined) void $.prompt.fill({ text: p.carry })
1032 $.ui.toast(`새 대화로 넘기지 못했어요. 이 대화를 이어 가요. 메모: ${w.path}`, { timeoutMs: 15000 })
1033 $.ui.log(`clear 거절: ${String(err)}`)
1034 })
1035 } catch (err) {
1036 pending = undefined
1037 if (w.carry !== undefined) void $.prompt.fill({ text: w.carry })
1038 $.ui.toast(`새 대화로 넘기지 못했어요. 이 대화를 이어 가요. (${String(err).slice(0, 80)})`, { timeoutMs: 15000 })
1039 }
1040}
1041
1042// 턴이 끝날 때 기준을 넘겼는지 봐요. 넘겼으면 바로 넘기지 않고 «다음 말에 넘김»으로만 표시해요.
1043async function maybeHandoff($: EngineInterface) {
1044 if (mode === 'off' || dueSession !== undefined || writing || pending) return
1045 if (!(await isInteractive($))) return
1046 const usage = await $.session.usage()
1047 const tokens = usage.context?.tokens
1048 if (tokens === undefined) return
1049 const sessionId = await $.session.id()
1050 // 이어가기로 시작한 대화는 시작 크기(규칙 문서·메모)가 이미 커요. 첫 턴의 크기를 바닥으로 잡고,
1051 // 그 위로 MIN_GROWTH 만큼 자라기 전에는 넘기지 않아요.
1052 if (sessionId === seededSession && seedFloor === undefined) seedFloor = tokens
1053 const percent = usage.context?.percent
1054 const over = tokens >= cfg.handoffTokens || (percent !== undefined && percent >= cfg.handoffPercent)
1055 if (!over) return
1056 if (sessionId === seededSession && seedFloor !== undefined && tokens < seedFloor + MIN_GROWTH) return
1057 if (await $.store.get(`fired:${sessionId}`)) return
1058 dueSession = sessionId
1059 updateStatus($)
1060 $.ui.invalidate('ui.render')
1061 $.ui.toast(
1062 mode === 'auto'
1063 ? '대화가 꽤 길어졌어요. 다음 말을 보내면 그 말을 들고 새 대화로 넘어가요.'
1064 : '대화가 꽤 길어졌어요. 다음 말을 보낼 때 새 대화로 넘길지 물어볼게요.',
1065 { timeoutMs: 12000 },
1066 )
1067}
1068
1069const HANDOFF_GO = '새 대화로 넘기기'
1070const HANDOFF_STAY = '이 대화에서 계속'
1071
1072// 내가 말을 보낼 때: 넘길 차례면 그 말을 붙잡고 넘기기를 시작해요. 넘기면 { drop } 을 돌려줘요.
1073async function handoffOnSubmit($: EngineInterface, e: PromptSubmitInput): Promise<{ drop: string } | undefined> {
1074 if (mode === 'off' || dueSession === undefined || writing || pending) return undefined
1075 // 명령, 일하는 중에 끼워 넣은 말, 사진이 붙은 말은 옮길 수 없어서 이번엔 넘기지 않아요.
1076 if (e.command || e.turnId !== undefined || e.isMeta || (e.attachments?.length ?? 0) > 0 || !e.text.trim()) return undefined
1077 if ((await $.session.id()) !== dueSession) {
1078 dueSession = undefined
1079 return undefined
1080 }
1081 if (!(await isInteractive($))) return undefined
1082 if (mode === 'ask') {
1083 let choice = ''
1084 try {
1085 choice = await $.ui.ask(
1086 '대화가 꽤 길어졌어요. 이 말을 들고 새 대화로 넘어갈까요?\n지금 대화의 Claude가 이어가기 메모를 쓰고(30~90초), 새 대화가 이 말에 답해요.',
1087 { header: '이어가기', options: [HANDOFF_GO, HANDOFF_STAY] },
1088 )
1089 } catch {
1090 return undefined // 창을 닫으면 이번엔 이 대화로 보내고, 다음 말에 다시 물어요.
1091 }
1092 if (choice !== HANDOFF_GO) {
1093 await $.store.set(`fired:${dueSession}`, 'declined')
1094 dueSession = undefined
1095 updateStatus($)
1096 $.ui.invalidate('ui.render')
1097 return undefined
1098 }
1099 }
1100 return (await startHandoff($, e.text)) ? { drop: '대화가 길어져서 새 대화로 넘어가요. 보낸 말은 새 대화에 그대로 옮겨요.' } : undefined
1101}
1102
1103// ───────────── 프롬프트 가이드 ─────────────
1104
1105async function coachReview($: EngineInterface, prompt: string): Promise<Verdict | undefined> {
1106 const messages = await $.session.messages().catch(() => [])
1107 const conversation = Array.isArray(messages) ? recentTurns(messages) : ''
1108 const project = (await $.session.root()).split(/[\\/]/).at(-1) ?? ''
1109 const stop = new AbortController()
1110 const timer = $.clock.after(15000, () => stop.abort())
1111 const result = await $.model.complete(
1112 {
1113 model: cfg.coachModel,
1114 system: COACH_SYSTEM,
1115 maxTokens: 700,
1116 prompt: `작업 폴더: ${project}\n최근 대화(오래된 것부터, 비어 있을 수 있음):\n"""${conversation}"""\n\n사용자의 새 요청:\n"""${prompt}"""`,
1117 },
1118 { signal: stop.signal },
1119 )
1120 timer.cancel()
1121 const verdict = result.isAnswered ? parseVerdict(result.text) : undefined
1122 // 원래 글의 낱말을 절반 넘게 버린 고침은 다른 요청이에요. 조용히 둬요.
1123 if (verdict?.verdict === 'improve' && keptShare(prompt, verdict.rewrite) < 0.5) return { verdict: 'fine' }
1124 return verdict
1125}
1126
1127// ───────────── 화면: 탭 다섯 개 ─────────────
1128
1129type Els = { Box: any; Text: any; Button: any; Input?: any }
1130
1131// 소제목: 굵게 + 밑줄로 본문과 구분하고, 아래에 한 줄을 띄워요. 옆의 작은 글(개수·기준)은 흐리게, 밑줄 없이.
1132// 색은 쓰지 않아요(경고 색과 섞이면 지저분해져요).
1133function heading(els: Els, title: string, count?: string) {
1134 const { Box, Text } = els
1135 return (
1136 <Box paddingBottom={1} flexShrink={1}>
1137 <Text wrap="truncate-end">
1138 <Text bold underline>{title}</Text>
1139 <Text dimColor>{count ? ' ' + count : ''}</Text>
1140 </Text>
1141 </Box>
1142 )
1143}
1144
1145function line(els: Els, inner: number, bullet: string, text: string, opts: { dim?: boolean; color?: string } = {}) {
1146 const { Box, Text } = els
1147 return (
1148 <Box flexDirection="row" width={inner}>
1149 <Text dimColor={opts.dim} color={opts.color}>{' ' + bullet + ' '}</Text>
1150 <Box flexShrink={1} flexGrow={1}>
1151 <Text dimColor={opts.dim} color={opts.color} wrap="wrap">{text}</Text>
1152 </Box>
1153 </Box>
1154 )
1155}
1156
1157// 여러 줄로 넘어가는 설명. 들여쓰기를 빈칸 글자가 아니라 여백으로 줘서, 넘어간 줄도 같은 자리에서 시작해요.
1158// (빈칸 글자로 들여 쓰면 둘째 줄부터 맨 왼쪽으로 붙어요)
1159function para(els: Els, inner: number, text: string, opts: { indent?: number; dim?: boolean; color?: string } = {}) {
1160 const { Box, Text } = els
1161 return (
1162 <Box paddingLeft={opts.indent ?? 2} width={inner}>
1163 <Text dimColor={opts.dim} color={opts.color} wrap="wrap">{text}</Text>
1164 </Box>
1165 )
1166}
1167
1168type PinHandlers = { explain: (path: string) => void; refreshCal: () => void }
1169
1170const CHANGES_SHOWN = 6
1171
1172// 이번 대화에서 고친 파일. 최근에 고친 것이 위. «>»를 누르면 왜 바꿨는지 묻는 글을 입력창에 넣어요.
1173function changesBlock(els: Els, inner: number, act: PinHandlers) {
1174 const { Box, Text, Button } = els
1175 const rows = changes.slice(0, CHANGES_SHOWN)
1176 return (
1177 <Box flexDirection="column">
1178 {heading(els, '이번 대화에서 고친 파일', changes.length ? String(changes.length) : '')}
1179 {changes.length === 0 && <Text dimColor>{' 없음'}</Text>}
1180 {rows.map((c, i) => {
1181 const { name, dir } = changeLabel(c.path, rootDir)
1182 return (
1183 <Box flexDirection="row" width={inner} columnGap={1}>
1184 <Button key={'chg-' + i} label=">" plain onPress={() => act.explain(c.path)} />
1185 <Box flexShrink={1} flexGrow={1}>
1186 <Text wrap="truncate-end">
1187 {name}
1188 <Text dimColor>{(c.count > 1 ? ` ×${c.count}` : '') + (dir ? ' ' + dir : '')}</Text>
1189 </Text>
1190 </Box>
1191 </Box>
1192 )
1193 })}
1194 {changes.length > CHANGES_SHOWN && <Text dimColor>{` … 외 ${changes.length - CHANGES_SHOWN}개`}</Text>}
1195 {changes.length > 0 && para(els, inner, '> 누르면 왜 바꿨는지 물어요 · Edit·Write로 고친 것만', { dim: true })}
1196 </Box>
1197 )
1198}
1199
1200function pinTab(els: Els, inner: number, now: number, act: PinHandlers) {hooks/lib.ts 786 lines1// side-desk 순수 함수 모음. `$`를 쓰지 않아서 register.tsx 어디서든 불러 쓸 수 있고, 테스트도 쉬워요.
2
3// ───────────── 핀보드: 이번 대화의 할 일과 결정 ─────────────
4
5export type Todo = { id: string; text: string; isDone: boolean; isActive?: boolean; ref?: string }
6export type Decision = { id: string; text: string }
7export type Board = { todos: Todo[]; decisions: Decision[] }
8export type Update = {
9 add_todos?: string[]
10 start_todo?: string
11 done_todos?: string[]
12 remove_todos?: string[]
13 open_decisions?: string[]
14 decide?: { id: string; answer: string }[]
15}
16
17const nextId = (prefix: string, ids: string[]) =>
18 prefix + (Math.max(0, ...ids.map(id => Number(id.slice(prefix.length)) || 0)) + 1)
19
20export function applyUpdate(board: Board, change: Update): Board {
21 let t = board.todos
22 let d = board.decisions
23 for (const text of change.add_todos ?? []) t = [...t, { id: nextId('t', t.map(x => x.id)), text, isDone: false }]
24 for (const text of change.open_decisions ?? []) d = [...d, { id: nextId('d', d.map(x => x.id)), text }]
25 const done = new Set(change.done_todos ?? [])
26 const removed = new Set(change.remove_todos ?? [])
27 const decided = new Set((change.decide ?? []).map(x => x.id))
28 t = t.filter(x => !removed.has(x.id)).map(x => (done.has(x.id) ? { ...x, isDone: true } : x))
29 // 진행 중인 할 일은 하나만. 끝나면 진행 표시도 내려요.
30 if (change.start_todo) t = t.map(x => ({ ...x, isActive: x.id === change.start_todo }))
31 t = t.map(x => (x.isDone && x.isActive ? { ...x, isActive: false } : x))
32 d = d.filter(x => !decided.has(x.id))
33 return { todos: t.slice(-40), decisions: d.slice(-20) }
34}
35
36export function describeBoard(board: Board): string {
37 if (board.todos.length + board.decisions.length === 0) return 'side-desk 핀보드: 비어 있음'
38 return [
39 'side-desk 핀보드:',
40 ...board.todos.map(t => `${t.id} [${t.isDone ? 'x' : t.isActive ? '>' : ' '}] ${t.text}`),
41 ...board.decisions.map(d => `${d.id} [?] ${d.text}`),
42 ].join('\n')
43}
44
45// ───────────── 사용량 ─────────────
46
47export type UsageWindow = { kind: string; percentUsed: number; resetsAt?: string }
48export const WINDOW_LABEL: Record<string, string> = { five_hour: '5시간', seven_day: '7일', spend_limit: '지출 한도' }
49
50/** 터미널에서 차지하는 칸 수. 한글·한자·전각 글자는 두 칸이에요. */
51const WIDE = /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/
52export const cellWidth = (s: string) => [...s].reduce((w, ch) => w + (WIDE.test(ch) ? 2 : 1), 0)
53/** 칸 수로 오른쪽을 채워요. (padEnd 는 글자 수로 채워서 한글이 섞이면 줄이 어긋나요) */
54export const padCells = (s: string, cells: number) => s + ' '.repeat(Math.max(0, cells - cellWidth(s)))
55
56export const k = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(Math.round(n)))
57
58export const bar = (percent: number, width: number) => {
59 const filled = Math.max(0, Math.min(width, Math.round((percent / 100) * width)))
60 return '█'.repeat(filled) + '░'.repeat(width - filled)
61}
62
63export function untilText(iso: string | undefined, now: number): string {
64 if (iso === undefined) return ''
65 const m = Math.max(0, Math.round((Date.parse(iso) - now) / 60000))
66 if (m >= 60 * 24) return `${Math.round(m / 60 / 24)}일 뒤 초기화`
67 if (m >= 60) return `${Math.floor(m / 60)}시간 ${m % 60}분 뒤 초기화`
68 return `${m}분 뒤 초기화`
69}
70
71/** 이 대화를 시작한 뒤의 소모 속도. 지금 속도로 몇 시간 뒤에 100%가 되는지 같이 줘요. */
72export function burn(w: UsageWindow, base: { at: number; windows: UsageWindow[] } | null, now: number) {
73 const start = base?.windows.find(x => x.kind === w.kind)
74 if (start === undefined || base === null) return null
75 const hours = (now - base.at) / 3_600_000
76 const used = w.percentUsed - start.percentUsed
77 if (hours < 0.05 || used <= 0) return null
78 const perHour = used / hours
79 return { perHour, hoursLeft: (100 - w.percentUsed) / perHour }
80}
81
82export const hoursText = (h: number) => (h < 1 ? `${Math.round(h * 60)}분` : `${h.toFixed(1)}시간`)
83
84// ───────────── 이어가기 메모 ─────────────
85// 대화가 길어지면 내가 다음 말을 보낼 때 새 대화로 넘겨요.
86// 1. 지금 대화의 Claude가 메모를 직접 써요(앞 메모·압축 요약·도구 결과·돌고 있는 작업을 다 아는 쪽이라서요).
87// 2. 코드가 내가 한 말 원문, Claude 마지막 답 원문, 고친 파일·커밋을 붙여요.
88// 3. 새 대화에는 내가 보낸 말을 그대로 넣고, 메모는 모델만 보는 첨부로 같이 넣어요.
89
90export type Msg = {
91 role: string
92 text: string
93 toolUses: { tool: string; input: Record<string, unknown>; isError?: boolean; text?: string }[]
94}
95
96export type Facts = {
97 filesModified: string[]
98 commits: string[]
99 lastUserMessage?: string
100 tokens?: number
101 depth?: number
102}
103
104// 사용자 말이 아니라 하네스가 사용자 메시지 자리에 넣는 신호들
105const META_PREFIXES = [
106 'Stop hook feedback', '[side-desk]', '[Image', '<system-reminder>', '<command-name>', '<command-message>', '<local-command',
107 '[auto-handoff]', '<task-notification>', '[Request interrupted', 'This session is being continued', '<teammate-message',
108 'Another Claude session sent a message', 'Caveat:',
109]
110// 플러그인이 보낸 메시지에 엔진이 씌우는 틀
111const PLUGIN_FRAME = /^The [\w.-]+ plugin sent a message:\s*/
112const PLUGIN_FRAME_TAIL = /\s*This is how Claude Code surfaces a prompt a plugin submits[\s\S]*$/
113/** 새 대화에 붙이는 메모의 첫 줄. 다음 이어가기 때 사용자 말에서 잘라 내요. */
114export const BRIEF_MARK = '[side-desk 이어가기 메모]'
115const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
116const GIT_COMMIT_OUTPUT = /^\[[\w./-]+(?: \(root-commit\))? ([0-9a-f]{7,})\] (.+)$/m
117
118/** 엔진의 틀과 붙여 둔 메모를 걷어 낸 사용자 메시지 글 */
119function bareUserText(text: string): string {
120 let t = text.trim()
121 if (PLUGIN_FRAME.test(t)) t = t.replace(PLUGIN_FRAME, '').replace(PLUGIN_FRAME_TAIL, '').trim()
122 const mark = t.indexOf(BRIEF_MARK)
123 if (mark >= 0) t = t.slice(0, mark).trim()
124 return t
125}
126
127/** 사용자가 한 말이면 그 글을, 하네스·플러그인 신호면 undefined 를 줘요. */
128export function userText(m: Msg): string | undefined {
129 if (m.role !== 'user') return undefined
130 const raw = bareUserText(m.text)
131 if (!raw) return undefined
132 return META_PREFIXES.some(p => raw.startsWith(p)) ? undefined : raw
133}
134
135function commitSubject(command: string): string | undefined {
136 if (!/\bgit\b[^\n|;&]*\bcommit\b/.test(command)) return undefined
137 const heredoc = command.match(/<<\s*'?(\w+)'?\n([\s\S]*?)\n\1/)
138 if (heredoc) return heredoc[2]?.trim().split('\n')[0]
139 return command.match(/-m\s+(["'])(.+?)\1/)?.[2]
140}
141
142export function extractFacts(messages: readonly Msg[]): Facts {
143 const files = new Set<string>()
144 const commits: string[] = []
145 let lastUserMessage: string | undefined
146 for (const m of messages) {
147 const said = userText(m)
148 if (said) lastUserMessage = said
149 for (const t of m.toolUses) {
150 const path = t.input.file_path ?? t.input.notebook_path
151 if (EDIT_TOOLS.has(t.tool) && typeof path === 'string' && !t.isError) files.add(path)
152 if (t.tool !== 'Bash' || typeof t.input.command !== 'string' || t.isError) continue
153 const out = t.text?.match(GIT_COMMIT_OUTPUT)
154 const subject = out ? `${out[2]} (${out[1]})` : t.text === undefined ? commitSubject(t.input.command) : undefined
155 if (subject && !commits.includes(subject)) commits.push(subject)
156 }
157 }
158 const filesModified = [...files].slice(-25)
159 return {
160 filesModified,
161 commits: commits.slice(-10),
162 lastUserMessage,
163 }
164}
165
166/** 긴 글은 앞과 뒤를 남기고 가운데를 줄여요. 끝에 결론이 오는 일이 많아서 뒤도 남겨요. */
167export function trimMiddle(text: string, max: number): string {
168 if (text.length <= max) return text
169 const head = Math.round(max * 0.7)
170 const tail = max - head
171 return `${text.slice(0, head)}\n…(가운데 ${text.length - max}자 줄임)…\n${text.slice(-tail)}`
172}
173
174/** 이 대화에서 사용자가 한 말. 오래된 것부터, 최근 max 개. 긴 말은 앞뒤만 남겨요. */
175export function userMessages(messages: readonly Msg[], max = 40, perMessage = 800): string[] {
176 const said: string[] = []
177 for (const m of messages) {
178 const t = userText(m)
179 if (t) said.push(trimMiddle(t, perMessage))
180 }
181 return said.slice(-max)
182}
183
184/** 사용자의 마지막 말 뒤에 Claude가 쓴 글 전부. 사용자는 이걸 읽고 다음 말을 보내요. */
185export function lastAnswer(messages: readonly Msg[], max = 12_000): string {
186 let start = 0
187 messages.forEach((m, i) => {
188 if (userText(m)) start = i + 1
189 })
190 const texts = messages.slice(start).filter(m => m.role === 'assistant' && m.text.trim()).map(m => m.text.trim())
191 return trimMiddle(texts.join('\n\n'), max)
192}
193
194/** 지금 대화의 Claude가 쓰는 소제목. 새 대화가 이 순서로 읽어요. */
195export const BRIEF_HEADINGS = [
196 '## 지금 하던 일', '## 사용자가 정한 것·바로잡은 것', '## 해 봤는데 막힌 길', '## 사용자에게 받아야 할 답',
197 '## 남은 일', '## 돌고 있는 작업', '## 건드리기 전에 확인할 것',
198] as const
199
200/** 소제목이 셋 이상 있으면 제대로 쓴 메모로 봐요. */
201export const isValidBrief = (text: string) => BRIEF_HEADINGS.filter(h => text.includes(h)).length >= 3
202
203/** 지금 대화의 Claude에게 보내는 «이어가기 메모를 써 달라»는 메시지 */
204export function briefRequest(path: string, carry: string | undefined, prevBrief: string | undefined): string {
205 const why = carry !== undefined
206 ? `사용자가 방금 아래 말을 보냈어요. 이 말은 새 대화로 그대로 옮겨서 새 대화가 답해요. 지금은 답하지 말고, 새 대화가 이 말에 바로 답할 수 있게 필요한 사정을 메모에 담아 주세요.\n«${trimMiddle(carry, 1_500)}»`
207 : '사용자가 직접 «지금 넘기기»를 골랐어요. 새 대화는 메모를 읽고 지금 상태부터 알려 줘요.'
208 return [
209 '[side-desk] 대화가 길어져서 새 대화로 넘어가요. 새 대화의 Claude는 이 대화를 전혀 못 봐요.',
210 `그 Claude가 이어받을 이어가기 메모를 써서 Write 도구로 이 경로에 새 파일로 저장해 주세요: ${path}`,
211 '저장한 뒤에는 «메모를 썼어요.» 한 줄로만 답하세요. 다른 일은 하지 마세요.',
212 '',
213 why,
214 '',
215 '쓰는 법',
216 `- 소제목은 이 일곱 개를 이 순서·이름 그대로 써요. 비는 곳에는 «없음».\n ${BRIEF_HEADINGS.join(' / ')}`,
217 '- «사용자가 정한 것·바로잡은 것»이 가장 중요해요. 사용자가 정한 것과 바로잡은 것, 언짢아한 일은 무엇을 잘못 알았고 사용자가 원한 게 무엇인지 사용자 말을 따옴표로 그대로 옮겨 적어요.',
218 `- 이 대화가 이어가기 메모로 시작했다면 그 메모의 정한 것 중 지금도 맞는 것을 옮겨요.${prevBrief ? ` (그 메모: ${prevBrief})` : ''}`,
219 '- 끝난 일과 안 끝난 일을 섞지 않아요. 끝난 일은 무엇으로 확인했는지 같이 적어요.',
220 '- «남은 일»에는 사용자가 시켰는데 아직 안 한 일만 적어요. 하면 좋겠다고 제가 생각한 일은 넣지 않아요.',
221 '- «돌고 있는 작업»에는 백그라운드로 띄워 둔 에이전트·셸·모니터를 이름, 하는 일, 결과가 나올 곳과 함께 적어요. 끝나면 알림이 새 대화로 가요.',
222 '- 파일 경로·커밋 번호는 실제 값으로 적어요.',
223 '- 사용자 말 원문, 마지막 답 원문, 고친 파일, 커밋 목록은 코드가 따로 붙여요. 옮겨 적지 않아도 돼요.',
224 '- 쉬운 말로 짧게 써요.',
225 ].join('\n')
226}
227
228const list = (items: string[], empty: string) => (items.length ? items.map(i => `- ${i}`).join('\n') : empty)
229
230export function factsBlock(f: Facts, board: Board): string {
231 return [
232 '## 코드로 뽑은 사실',
233 '',
234 `- 넘길 때 컨텍스트: ${f.tokens !== undefined ? k(f.tokens) + ' 토큰' : '알 수 없음'}`,
235 f.depth !== undefined ? `- 이번이 ${f.depth}번째 이어가기` : '',
236 '',
237 '### 고친 파일 (Edit·Write)',
238 list(f.filesModified, '없음'),
239 '',
240 '### 이 대화의 커밋',
241 list(f.commits, '없음'),
242 '',
243 '### 핀보드',
244 describeBoard(board),
245 ].join('\n')
246}
247
248export type BriefContext = { sessionId: string; cwd: string; transcript: string; at: string }
249export type BriefSource = { users: string[]; last: string; prev?: string; problem?: string }
250
251/** 이어가기 메모 한 장: 머리 + 지금 대화의 Claude가 쓴 글 + 코드로 붙인 원문 + 사실 */
252export function assembleBrief(ctx: BriefContext, facts: Facts, board: Board, narrative: string | undefined, src: BriefSource): string {
253 const head = [
254 '# 이어가기 메모',
255 '',
256 `- 이전 대화: ${ctx.sessionId}`,
257 `- 작업 폴더: ${ctx.cwd}`,
258 `- 대화 기록: ${ctx.transcript}`,
259 `- 쓴 때: ${ctx.at}`,
260 '',
261 '## 읽는 법',
262 narrative
263 ? '이전 대화의 Claude가 넘기기 직전에 직접 썼어요. 그 아래 «코드로 붙인 원문»은 대화에서 그대로 옮긴 거예요.'
264 : `이전 대화의 Claude가 메모를 쓰지 못했어요(${src.problem ?? '이유 모름'}). 아래 원문과 사실만 있어요. 더 필요하면 대화 기록을 직접 읽으세요.`,
265 '여기 적힌 건 출발점이에요. 쓰기 전에 `git status`나 파일을 열어 한 번 확인하세요.',
266 '이 폴더에 지침 파일(CLAUDE.md)이 있다면 새 대화가 이미 읽었어요. 거기 적힌 규칙이 이 글보다 앞서요.',
267 ].join('\n')
268 // Claude가 맨 위에 제목을 또 달았으면 하나만 남겨요.
269 const body = narrative?.trim().replace(/^#\s+[^\n]*\n+/, '')
270 const verbatim = [
271 '## 코드로 붙인 원문',
272 '',
273 `### 사용자가 한 말 (이 대화, 최근 ${src.users.length}개, 오래된 것부터)`,
274 src.users.length ? src.users.map((u, i) => `${i + 1}. «${u}»`).join('\n') : '없음',
275 '',
276 '### Claude의 마지막 답 (사용자가 이 답을 보고 다음 말을 보냈어요)',
277 src.last.trim() || '없음',
278 '',
279 '### 이 대화가 이어받은 메모',
280 src.prev ?? '없음',
281 ].join('\n')
282 return [head, body, verbatim, factsBlock(facts, board)].filter(Boolean).join('\n\n') + '\n'
283}
284
285export const shortId = (sessionId: string) => sessionId.slice(0, 8)
286
287/** 이어가기로 연 대화의 첫 메시지에 붙는 글(CLAUDE.md 와 같은 자리, 모델만 봐요). 압축 뒤에도 다시 붙어서 언제 읽어도 맞는 말로 써요. */
288export function seedContext(oldSession: string, briefPath: string, brief: string): string {
289 return [
290 `${BRIEF_MARK} 이 대화는 이전 대화(${shortId(oldSession)})가 길어져서 이어 연 대화예요. 아래는 그때 넘겨받은 메모예요. 사용자는 앞 대화에 이어서 말하고 있어요.`,
291 '- 사용자 메시지에 답하세요. 메모는 앞 사정을 알려 주는 글이에요.',
292 '- 메모의 «남은 일»은 사용자가 시키기 전에는 시작하지 마세요.',
293 '- 적힌 파일·커밋은 쓰기 전에 열어서 지금 상태를 확인하세요.',
294 `- 이 대화의 첫 답 맨 위에만 한 번 "↪ 이어가기 받음 (${shortId(oldSession)})"라고 적으세요.`,
295 `- 메모 파일: ${briefPath}`,
296 '',
297 brief,
298 ].join('\n')
299}
300
301/** 사용자가 «지금 넘기기»로 넘겼을 때(옮길 말이 없을 때)의 첫 메시지 */
302export function seedText(oldSession: string): string {
303 return `[side-desk] ↪ 이어가기 ${shortId(oldSession)}. 이전 대화가 길어져서 새 대화로 넘어왔어요. ` +
304 '이 대화에 붙은 이어가기 메모(side-desk-handoff)를 읽고, 지금 어디까지 왔는지와 사용자에게 받아야 할 답을 세 줄 안으로 알려 주세요. ' +
305 '사용자가 시키기 전에는 새 일을 시작하지 마세요.'
306}
307
308// ───────────── 프롬프트 코치 ─────────────
309
310export const COACH_SYSTEM = `너는 사용자가 Claude Code에 보내려는 요청을 보내기 직전에 한 번 읽어 보는 검수자다.
311고쳐 보내면 결과가 분명히 나아질 때만 고친다. 대부분의 요청은 그대로 괜찮다.
312
313고칠 만한 경우는 이것뿐이다.
314- 무엇을 하라는지 흐릿하다. ("이거 고쳐줘"인데 대상이 안 보인다)
315- 끝났다고 볼 기준이 없다.
316- 서로 상관없는 요청이 한 문장에 뭉쳐 있다.
317- 건드리면 안 되는 범위를 알려야 하는데 빠졌다.
318
319사용자는 음성으로 말해서 입력할 때가 많다. 문장이 끊기거나 말투가 흐트러져 있을 수 있다. 뜻이 통하면 괜찮은 요청이다.
320최근 대화를 보고 "이거", "그거", "아까 그 파일"이 무엇인지 풀 수 있으면 괜찮다. 글만 따로 보면 흐릿해도 대화로 분명하면 문제가 아니다.
321
322고칠 때는 가장 작게 고친다.
323- 사용자가 쓴 낱말과 순서를 그대로 두고, 빠진 것(대상 파일이나 영역, 끝났다고 볼 기준, 건드리지 말 것)만 덧붙인다.
324- 고친 글은 사용자가 쓴 글에 몇 마디 더한 것처럼 읽혀야 한다. 새 요청이 되면 안 된다.
325- 요청의 종류를 바꾸지 않는다. 오류 제보는 제보로, 질문은 질문으로, 부탁은 부탁으로 둔다.
326- 사용자가 하지 않은 질문, 요구, 산출물을 새로 넣지 않는다.
327- "일단", "먼저", "만" 같은 범위 말과 사용자가 밝힌 조건·취향은 그대로 둔다.
328- 고친 글은 한국어 입말로 쓴다. 번역투와 영어식 문장 구조는 쓰지 않는다.
329- "네", "좋아", "진행해"처럼 대화에 답하는 말은 괜찮다.
330
331답은 JSON 하나로만 한다.
332{"verdict":"fine"} 또는 {"verdict":"improve","why":"<30자 안쪽 이유>","rewrite":"<고친 요청>"}`
333
334export type Verdict = { verdict: 'fine' } | { verdict: 'improve'; why: string; rewrite: string }
335
336/** 프롬프트 가이드를 건너뛰는 요청: 명령어, 짧은 대답, 링크만 있는 글 */
337export const COACH_SKIP =
338 /^\s*([/!#]|https?:\/\/\S+\s*$|(네|예|응|아니|아니요|좋아|좋아요|그래|그래요|그렇게|맞아|맞아요|진행|진행해|진행해줘|계속|계속해|해줘|ok|okay|yes|no|y|n)[\s.!요?]*$)/i
339
340export function parseVerdict(text: string): Verdict | undefined {
341 const json = /\{[\s\S]*\}/.exec(text)?.[0]
342 if (json === undefined) return undefined
343 try {
344 const v = JSON.parse(json) as Partial<{ verdict: string; why: string; rewrite: string }>
345 if (v.verdict === 'improve' && typeof v.rewrite === 'string' && v.rewrite.trim() !== '') {
346 return { verdict: 'improve', why: v.why ?? '', rewrite: v.rewrite.trim() }
347 }
348 return { verdict: 'fine' }
349 } catch {
350 return undefined
351 }
352}
353
354/** 고친 글이 원래 글의 낱말을 얼마나 남겼는지. 조사가 달라져도 맞도록 앞 두 글자로 견줘요. */
355export function keptShare(prompt: string, rewrite: string): number {
356 const stems = (s: string) => new Set((s.toLowerCase().match(/[\p{L}\p{N}_./-]{2,}/gu) ?? []).map(w => w.slice(0, 2)))
357 const original = stems(prompt)
358 if (original.size < 3) return 1
359 const kept = stems(rewrite)
360 let hits = 0
361 for (const w of original) if (kept.has(w)) hits++
362 return hits / original.size
363}
364
365export function recentTurns(messages: readonly { role: string; text: string }[], max = 6, perMessage = 700): string {
366 return messages
367 .filter(m => m.text.trim() !== '')
368 .slice(-max)
369 .map(m => `${m.role === 'user' ? '사용자' : 'Claude'}: ${m.text.length > perMessage ? `…${m.text.slice(-perMessage)}` : m.text}`)
370 .join('\n\n')
371}
372
373// ───────────── 클립보드 ─────────────
374// 모드에는 클립보드를 «읽는» 기능이 없어서(쓰기만 있어요) PowerShell로 읽어요.
375// 사용자가 열어 보거나 /clip 을 쓴 순간에만 읽고, 따로 기록해 두지 않아요.
376
377export type Clip =
378 | { kind: 'empty' }
379 | { kind: 'error'; message: string }
380 | { kind: 'text'; text: string; chars: number; truncated: boolean }
381 | { kind: 'image'; path: string; size: string; bytes: number }
382 | { kind: 'files'; files: string[] }
383
384export const CLIP_MAX = 30_000
385
386/** 윈도우 PowerShell에서 실행해요. 첫 줄이 종류(TEXT·IMAGE·FILES·EMPTY·ERROR), 나머지가 내용이에요. */
387export const CLIP_SCRIPT = [
388 'try {',
389 "Add-Type -AssemblyName System.Windows.Forms,System.Drawing;",
390 '[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false);',
391 'if ([System.Windows.Forms.Clipboard]::ContainsImage()) {',
392 " $dir = Join-Path $env:USERPROFILE '.claude\\state\\side-desk\\clips';",
393 ' New-Item -ItemType Directory -Force -Path $dir | Out-Null;',
394 " Get-ChildItem $dir -Filter 'clip-*.png' | Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-1) } | Remove-Item -Force -ErrorAction SilentlyContinue;",
395 ' $img = [System.Windows.Forms.Clipboard]::GetImage();',
396 " $p = Join-Path $dir ('clip-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.png');",
397 ' $img.Save($p, [System.Drawing.Imaging.ImageFormat]::Png);',
398 " 'IMAGE'; $p; ('' + $img.Width + 'x' + $img.Height); (Get-Item $p).Length",
399 '} elseif ([System.Windows.Forms.Clipboard]::ContainsFileDropList()) {',
400 " 'FILES'; [System.Windows.Forms.Clipboard]::GetFileDropList() | ForEach-Object { $_ }",
401 '} elseif ([System.Windows.Forms.Clipboard]::ContainsText()) {',
402 " 'TEXT'; [System.Windows.Forms.Clipboard]::GetText()",
403 "} else { 'EMPTY' }",
404 "} catch { 'ERROR'; $_.Exception.Message }",
405].join('\n')
406
407export function parseClip(stdout: string): Clip {
408 const lines = stdout.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
409 const kind = (lines[0] ?? '').replace(/^\uFEFF/, '').trim()
410 const rest = lines.slice(1)
411 if (kind === 'TEXT') {
412 const full = rest.join('\n').replace(/\n+$/, '')
413 if (!full.trim()) return { kind: 'empty' }
414 return { kind: 'text', text: full.slice(0, CLIP_MAX), chars: full.length, truncated: full.length > CLIP_MAX }
415 }
416 if (kind === 'IMAGE') {
417 const [path, size, bytes] = rest.map(s => s.trim())
418 if (!path) return { kind: 'error', message: '이미지 경로를 못 받았어요' }
419 return { kind: 'image', path, size: size ?? '', bytes: Number(bytes) || 0 }
420 }
421 if (kind === 'FILES') {
422 const files = rest.map(s => s.trim()).filter(Boolean)
423 return files.length > 0 ? { kind: 'files', files } : { kind: 'empty' }
424 }
425 if (kind === 'ERROR') return { kind: 'error', message: rest.join(' ').trim().slice(0, 160) || '알 수 없는 오류' }
426 return { kind: 'empty' }
427}
428
429/** 입력창에 넣을 글. 이미지는 파일 경로를 넣어요(클로드가 그 경로로 이미지를 읽어요). */
430export function clipInsertText(c: Clip, toHost: (p: string) => string): string {
431 if (c.kind === 'text') return c.text + (c.truncated ? `\n…(${CLIP_MAX.toLocaleString('en-US')}자까지만 넣었어요)` : '')
432 if (c.kind === 'image') return `[클립보드 이미지] ${toHost(c.path)}`
433 if (c.kind === 'files') return c.files.map(toHost).join('\n')
434 return ''
435}
436
437/** 화면에 보여 줄 한 줄 설명 */
438export function clipTitle(c: Clip | undefined): string {
439 if (!c) return '아직 안 읽었어요'
440 if (c.kind === 'text') return `글 · ${c.chars.toLocaleString('en-US')}자`
441 if (c.kind === 'image') return `이미지 · ${c.size} · ${Math.max(1, Math.round(c.bytes / 1024))}KB`
442 if (c.kind === 'files') return `파일 ${c.files.length}개`
443 if (c.kind === 'error') return `못 읽었어요: ${c.message}`
444 return '비어 있어요'
445}
446
447// ───────────── 클립보드 기록 (윈도우 Win+V) ─────────────
448// 이 모드가 따로 기록하지 않아요. 윈도우가 이미 가진 기록(최근 25개)을 읽어서 보여 줘요.
449// 기록이 꺼져 있으면(설정 > 시스템 > 클립보드) 지금 클립보드 한 개만 보여 줘요.
450
451export type ClipItem = { id: string; at: string; kind: 'text' | 'image' | 'files' | 'other'; chars?: number; preview?: string }
452
453const WINRT_SETUP = [
454 'Add-Type -AssemblyName System.Runtime.WindowsRuntime',
455 '[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)',
456 '$null = [Windows.ApplicationModel.DataTransfer.Clipboard, Windows.ApplicationModel.DataTransfer, ContentType=WindowsRuntime]',
457 '$null = [Windows.ApplicationModel.DataTransfer.ClipboardHistoryItemsResult, Windows.ApplicationModel.DataTransfer, ContentType=WindowsRuntime]',
458 '$null = [Windows.Storage.Streams.RandomAccessStreamReference, Windows.Storage.Streams, ContentType=WindowsRuntime]',
459 '$null = [Windows.Storage.Streams.IRandomAccessStreamWithContentType, Windows.Storage.Streams, ContentType=WindowsRuntime]',
460 "$asTask = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' })[0]",
461 'function Await($op, $type) { $t = $asTask.MakeGenericMethod($type).Invoke($null, @($op)); [void]$t.Wait(5000); $t.Result }',
462 '$res = Await ([Windows.ApplicationModel.DataTransfer.Clipboard]::GetHistoryItemsAsync()) ([Windows.ApplicationModel.DataTransfer.ClipboardHistoryItemsResult])',
463 "if ($res.Status -ne 'Success') { 'STATUS ' + $res.Status; return }",
464]
465
466/**
467 * 변화를 «듣는» 감시 스크립트. 프로세스를 한 번만 띄우고, 클립보드 번호(GetClipboardSequenceNumber)가
468 * 바뀔 때만 기록 목록을 BEGIN…END로 내보내요. 10초마다 TICK 한 줄을 보내서 모드가 «아직 필요한가»를 볼 수 있게 해요.
469 * 번호 확인은 숫자 하나를 읽는 일이라 새 프로세스도, 창도, 눈에 띄는 부하도 없어요.
470 */
471export const CLIP_WATCH_SCRIPT = [
472 "$ErrorActionPreference = 'Stop'",
473 'Add-Type -AssemblyName System.Runtime.WindowsRuntime',
474 "Add-Type -Namespace Ft -Name Clip -MemberDefinition '[System.Runtime.InteropServices.DllImport(\"user32.dll\")] public static extern uint GetClipboardSequenceNumber();'",
475 '[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)',
476 '$null = [Windows.ApplicationModel.DataTransfer.Clipboard, Windows.ApplicationModel.DataTransfer, ContentType=WindowsRuntime]',
477 '$null = [Windows.ApplicationModel.DataTransfer.ClipboardHistoryItemsResult, Windows.ApplicationModel.DataTransfer, ContentType=WindowsRuntime]',
478 "$asTask = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' })[0]",
479 'function Await($op, $type) { $t = $asTask.MakeGenericMethod($type).Invoke($null, @($op)); [void]$t.Wait(5000); $t.Result }',
480 'function Emit {',
481 ' try {',
482 ' $res = Await ([Windows.ApplicationModel.DataTransfer.Clipboard]::GetHistoryItemsAsync()) ([Windows.ApplicationModel.DataTransfer.ClipboardHistoryItemsResult])',
483 " 'BEGIN'",
484 " if ($res.Status -ne 'Success') { 'STATUS ' + $res.Status }",
485 ' else {',
486 ' $n = 0',
487 ' foreach ($it in $res.Items) {',
488 ' $n++; if ($n -gt 12) { break }',
489 ' $c = $it.Content',
490 " $o = [ordered]@{ id = [string]$it.Timestamp.UtcTicks; at = $it.Timestamp.ToLocalTime().ToString('HH:mm:ss'); kind = 'other' }",
491 " if ($c.Contains('Bitmap')) { $o.kind = 'image' }",
492 " elseif ($c.Contains('Text')) {",
493 " $o.kind = 'text'",
494 ' $s = Await ($c.GetTextAsync()) ([string])',
495 ' $o.chars = $s.Length',
496 ' $o.preview = $s.Substring(0, [Math]::Min(300, $s.Length))',
497 ' }',
498 ' $o | ConvertTo-Json -Compress',
499 ' }',
500 ' }',
501 " 'END'",
502 " } catch { 'BEGIN'; 'ERROR ' + $_.Exception.Message; 'END' }",
503 '}',
504 '$last = -1; $tick = 0',
505 'while ($true) {',
506 ' $s = [Ft.Clip]::GetClipboardSequenceNumber()',
507 ' if ($s -ne $last) { $last = $s; Start-Sleep -Milliseconds 150; Emit }',
508 ' Start-Sleep -Milliseconds 700',
509 " $tick++; if ($tick % 14 -eq 0) { 'TICK' }",
510 '}',
511].join('\n')
512
513/** 기록 목록. 한 줄에 항목 하나(JSON). 글은 앞 300자만 보내요. 상태 문제는 «STATUS …» 한 줄이에요. */
514export const CLIP_LIST_SCRIPT = [
515 'try {',
516 ...WINRT_SETUP,
517 '$n = 0',
518 'foreach ($it in $res.Items) {',
519 ' $n++; if ($n -gt 12) { break }',
520 ' $c = $it.Content',
521 " $o = [ordered]@{ id = [string]$it.Timestamp.UtcTicks; at = $it.Timestamp.ToLocalTime().ToString('HH:mm:ss'); kind = 'other' }",
522 " if ($c.Contains('Bitmap')) { $o.kind = 'image' }",
523 " elseif ($c.Contains('Text')) {",
524 " $o.kind = 'text'",
525 ' $s = Await ($c.GetTextAsync()) ([string])',
526 ' $o.chars = $s.Length',
527 ' $o.preview = $s.Substring(0, [Math]::Min(300, $s.Length))',
528 ' }',
529 ' $o | ConvertTo-Json -Compress',
530 '}',
531 "} catch { 'ERROR ' + $_.Exception.Message }",
532].join('\n')
533
534/** 기록 항목 하나의 전체 내용. `__ID__`를 숫자 id로 바꿔 쓰고, 출력 모양은 CLIP_SCRIPT와 같아요. */
535export const CLIP_ITEM_SCRIPT = [
536 'try {',
537 ...WINRT_SETUP,
538 '$found = $null',
539 "foreach ($it in $res.Items) { if ([string]$it.Timestamp.UtcTicks -eq '__ID__') { $found = $it; break } }",
540 "if (-not $found) { 'EMPTY'; return }",
541 '$c = $found.Content',
542 "if ($c.Contains('Bitmap')) {",
543 ' Add-Type -AssemblyName System.Drawing',
544 ' $ref = Await ($c.GetBitmapAsync()) ([Windows.Storage.Streams.RandomAccessStreamReference])',
545 ' $rs = Await ($ref.OpenReadAsync()) ([Windows.Storage.Streams.IRandomAccessStreamWithContentType])',
546 // PowerShell이 WinRT 스트림을 형식으로 인식하지 못해서 리플렉션으로 불러요(캐스트는 안 돼요).
547 " $mi = [System.IO.WindowsRuntimeStreamExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsStreamForRead' -and $_.GetParameters().Count -eq 1 } | Select-Object -First 1",
548 ' $net = $mi.Invoke($null, @($rs))',
549 ' $img = [System.Drawing.Image]::FromStream($net)',
550 " $dir = Join-Path $env:USERPROFILE '.claude\\state\\side-desk\\clips'",
551 ' New-Item -ItemType Directory -Force -Path $dir | Out-Null',
552 " Get-ChildItem $dir -Filter 'clip-*.png' | Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-1) } | Remove-Item -Force -ErrorAction SilentlyContinue",
553 " $p = Join-Path $dir ('clip-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.png')",
554 ' $img.Save($p, [System.Drawing.Imaging.ImageFormat]::Png)',
555 " 'IMAGE'; $p; ('' + $img.Width + 'x' + $img.Height); (Get-Item $p).Length",
556 "} elseif ($c.Contains('Text')) {",
557 " 'TEXT'; Await ($c.GetTextAsync()) ([string])",
558 "} else { 'EMPTY' }",
559 "} catch { 'ERROR'; $_.Exception.Message }",
560].join('\n')
561
562/** 숫자만 허용해서 스크립트에 끼워 넣어요. 숫자가 아니면 undefined. */
563export function clipItemScript(id: string): string | undefined {
564 return /^\d{1,20}$/.test(id) ? CLIP_ITEM_SCRIPT.replace('__ID__', id) : undefined
565}
566
567export function parseClipList(stdout: string): { items: ClipItem[]; problem?: string } {
568 const items: ClipItem[] = []
569 let problem: string | undefined
570 for (const raw of stdout.replace(/\r\n/g, '\n').split('\n')) {
571 const line = raw.replace(/^/, '').trim()
572 if (!line) continue
573 if (line.startsWith('STATUS ') || line.startsWith('ERROR ')) {
574 problem = line.slice(line.indexOf(' ') + 1).slice(0, 120)
575 continue
576 }
577 if (!line.startsWith('{')) continue
578 try {
579 const o = JSON.parse(line) as Partial<ClipItem>
580 if (typeof o.id === 'string' && typeof o.at === 'string') {
581 items.push({ id: o.id, at: o.at, kind: o.kind === 'text' || o.kind === 'image' || o.kind === 'files' ? o.kind : 'other', chars: o.chars, preview: o.preview })
582 }
583 } catch {
584 // 깨진 줄은 건너뛰어요.
585 }
586 }
587 return { items, problem }
588}
589
590/** 목록 한 줄: «13:57 글 · 앞부분…» */
591export function clipRowText(it: ClipItem): string {
592 const when = it.at.slice(0, 5)
593 if (it.kind === 'image') return `${when} 이미지`
594 if (it.kind === 'text') {
595 const first = (it.preview ?? '').replace(/\s+/g, ' ').trim()
596 return `${when} 글 ${it.chars ?? 0}자 · ${first || '(공백)'}`
597 }
598 return `${when} 기타 (붙일 수 없어요)`
599}
600
601// ───────────── 메일 (claude.ai Gmail 커넥터, 읽기만) ─────────────
602
603/** Claude Code 에 연결된 Gmail 커넥터 이름. /mcp 에 이 이름으로 보여요. */
604export const GMAIL_SERVER = 'claude.ai Gmail'
605export const MAIL_PAGE = 20
606export const MAIL_BODY_MAX = 4000
607export const MAIL_SHOW_MESSAGES = 5
608
609export type MailRow = { id: string; subject: string; from: string; date: string; unread: boolean; count: number; url?: string }
610export type MailMsg = { from: string; date: string; body: string; cut: boolean }
611export type MailThread = { id: string; subject: string; url?: string; messages: MailMsg[]; total: number }
612
613type McpBlock = { type: string; text?: string }
614type McpResultLike = { content?: readonly McpBlock[]; isError?: boolean; structuredContent?: unknown }
615
616/** MCP 결과에서 JSON 을 꺼내요. structuredContent 가 있으면 그걸, 없으면 글 블록을 JSON 으로 읽어요. */
617export function mcpJson(r: McpResultLike): unknown {
618 if (r.structuredContent !== undefined && r.structuredContent !== null && typeof r.structuredContent === 'object') return r.structuredContent
619 const text = (r.content ?? []).filter(b => b.type === 'text' && typeof b.text === 'string').map(b => b.text).join('')
620 if (!text.trim()) return {}
621 try {
622 return JSON.parse(text) as unknown
623 } catch {
624 return undefined
625 }
626}
627
628/** 실패한 MCP 결과의 글을 한 줄로 줄여요. */
629export function mcpErrorText(r: McpResultLike): string {
630 const text = (r.content ?? []).map(b => b.text ?? '').join(' ').replace(/\s+/g, ' ').trim()
631 return (text || '이유를 알려 주지 않았어요').slice(0, 160)
632}
633
634const asStr = (v: unknown) => (typeof v === 'string' ? v : '')
635type RawMsg = { date?: unknown; sender?: unknown; subject?: unknown; labelIds?: unknown; plaintextBody?: unknown; snippet?: unknown }
636const rawMessages = (t: Record<string, unknown>): RawMsg[] => (Array.isArray(t.messages) ? (t.messages as RawMsg[]) : [])
637const byDate = (a: RawMsg, b: RawMsg) => asStr(a.date).localeCompare(asStr(b.date))
638
639/** search_threads 결과 → 목록 줄. 스레드마다 제목은 첫 메일, 보낸 사람·시각은 마지막 메일 것이에요. */
640export function parseThreadList(data: unknown): MailRow[] {
641 const threads = (data as { threads?: unknown } | undefined)?.threads
642 if (!Array.isArray(threads)) return []
643 const rows: MailRow[] = []
644 for (const t of threads as Record<string, unknown>[]) {
645 const id = asStr(t.id)
646 const msgs = rawMessages(t).slice().sort(byDate)
647 if (!id || msgs.length === 0) continue
648 const first = msgs[0]!
649 const last = msgs[msgs.length - 1]!
650 rows.push({
651 id,
652 subject: asStr(first.subject).trim() || '(제목 없음)',
653 from: asStr(last.sender),
654 date: asStr(last.date),
655 unread: msgs.some(m => Array.isArray(m.labelIds) && (m.labelIds as unknown[]).includes('UNREAD')),
656 count: typeof t.messageCount === 'number' ? t.messageCount : msgs.length,
657 url: asStr(t.viewUrl) || undefined,
658 })
659 }
660 return rows
661}
662
663const ENTITIES: Record<string, string> = { ' ': ' ', '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'" }
664
665/** 링크 주소는 길어서 옆 창을 다 차지해요. 도메인만 ‹…›로 남겨요. */
666function shortUrl(url: string): string {
667 const host = /^https?:\/\/([^/?#\s]+)/i.exec(url)?.[1] ?? ''
668 return host ? `‹${host.replace(/^www\./, '')}›` : ''
669}
670
671/**
672 * Gmail 이 HTML 메일을 글로 바꾼 본문을 옆 창에서 읽기 좋게 다듬어요.
673 * · 표(`| 칸 | 칸 |`)는 칸을 « · »로 잇고, 두 칸짜리 짧은 머리는 «머리: 값»으로 써요. 빈 칸만 있는 줄은 지워요.
674 * · 링크 `[글](주소)`는 글만 남기고, 글이 없는 링크(추적 주소)는 지워요. 맨 주소는 도메인만 남겨요.
675 * · 굵게(**)·제목(#)·이미지 표시, 구분선, 겹친 빈 줄, 줄 안의 겹친 빈칸을 지워요.
676 * · 답장 메일의 인용(> 로 시작하는 줄)은 한 줄 안내로 접어요. 앞 메일은 위아래에 따로 보여요.
677 */
678export function cleanMailBody(raw: string): string {
679 const out: string[] = []
680 let quoted = 0
681 const flushQuote = () => {
682 if (quoted > 0) out.push(`(앞 메일 인용 ${quoted}줄은 접었어요)`)
683 quoted = 0
684 }
685 const lines = raw
686 .replace(/\r\n?/g, '\n')
687 .replace(/[\u200b-\u200d\ufeff\u00ad]/g, '')
688 .replace(/\u00a0/g, ' ')
689 .replace(/&(nbsp|amp|lt|gt|quot|#39|apos);/g, m => ENTITIES[m] ?? m)
690 .split('\n')
691 for (let i = 0; i < lines.length; i++) {
692 let line = lines[i]!
693 if (/^\s*>/.test(line)) {
694 quoted++
695 continue
696 }
697 // «… wrote:» / «…님이 작성:» 바로 뒤가 인용이면 그 줄도 접어요.
698 if (/(wrote|작성)\s*:\s*$/.test(line) && /^\s*>/.test(lines.slice(i + 1).find(l => l.trim()) ?? '')) continue
699 flushQuote()
700 line = line
701 .replace(/!\[[^\]]*\]\([^)]*\)/g, '') // 이미지
702 .replace(/\[([^\]]*)\]\((?:https?:|mailto:)[^)]*\)/gi, '$1') // 링크는 글만
703 .replace(/<?(https?:\/\/[^\s<>|)]+)>?/gi, (_m, url: string) => shortUrl(url)) // 맨 주소는 도메인만
704 .replace(/(\*\*|__)(.+?)\1/g, '$2')
705 .replace(/^\s*#{1,6}\s+/, '')
706 let text: string
707 if (/^\s*\|/.test(line) || /\|\s*$/.test(line)) {
708 const cells = line.split('|').map(c => c.replace(/\s+/g, ' ').trim()).filter(c => c && !/^:?-{2,}:?$/.test(c))
709 if (cells.length === 0) continue
710 text = cells.length === 2 && cells[0]!.length <= 8 ? `${cells[0]}: ${cells[1]}` : cells.join(' · ')
711 } else {
712 text = line.replace(/[ \t]+/g, ' ').trim()
713 }
714 text = text.replace(/\s*(→|->|▶|»)\s*$/, '').trim() // 링크를 지우고 남은 화살표
715 if (/^[-=_*~·•.\s]{3,}$/.test(text)) continue // 구분선
716 if (text === '' && (out.length === 0 || out[out.length - 1] === '')) continue
717 out.push(text)
718 }
719 flushQuote()
720 while (out.length > 0 && out[out.length - 1] === '') out.pop()
721 return out.join('\n')
722}
723
724/** get_thread 결과 → 본문 보기. 최신 메일이 위로 오고, 너무 긴 본문은 잘라요. */
725export function parseThread(data: unknown): MailThread | undefined {
726 const t = data as Record<string, unknown> | undefined
727 if (!t || typeof t !== 'object' || !asStr(t.id)) return undefined
728 const msgs = rawMessages(t).slice().sort(byDate)
729 const messages = msgs
730 .slice()
731 .reverse()
732 .slice(0, MAIL_SHOW_MESSAGES)
733 .map(m => {
734 const body = cleanMailBody(asStr(m.plaintextBody) || asStr(m.snippet))
735 return { from: asStr(m.sender), date: asStr(m.date), body: body.slice(0, MAIL_BODY_MAX), cut: body.length > MAIL_BODY_MAX }
736 })
737 return {
738 id: asStr(t.id),
739 subject: asStr(msgs[0]?.subject).trim() || '(제목 없음)',
740 url: asStr(t.viewUrl) || undefined,
741 messages,
742 total: msgs.length,
743 }
744}
745
746/** «홍길동 <a@b.com>» → 홍길동, «a@b.com» → a */
747export function senderName(s: string): string {
748 const m = /^\s*"?([^"<]*?)"?\s*<[^>]+>\s*$/.exec(s)
749 if (m && m[1]!.trim()) return m[1]!.trim()
750 const addr = /<([^>]+)>/.exec(s)?.[1] ?? s
751 return addr.split('@')[0]!.trim() || s.trim()
752}
753
754/** 오늘 받은 메일이면 «14:05», 아니면 «10/13» */
755export function mailTime(iso: string, now: number): string {
756 const d = new Date(iso)
757 if (Number.isNaN(d.getTime())) return ''
758 const n = new Date(now)
759 const pad2 = (x: number) => String(x).padStart(2, '0')
760 const sameDay = d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate()
761 return sameDay ? `${pad2(d.getHours())}:${pad2(d.getMinutes())}` : `${d.getMonth() + 1}/${d.getDate()}`
762}
763
764/** 메일 링크에서 연결된 계정 주소를 읽어요(authuser=…). 없으면 빈 글. */
765export function mailAccount(url: string | undefined): string {
766 const m = /[?&]authuser=([^&#]+)/.exec(url ?? '')
767 if (!m) return ''
768 try {
769 return decodeURIComponent(m[1]!)
770 } catch {
771 return m[1]!
772 }
773}
774
775/** Gmail 주소만 브라우저로 열어요. 다른 주소나 따옴표가 섞이면 undefined. */
776export function safeMailUrl(url: string | undefined): string | undefined {
777 if (!url || !url.startsWith('https://mail.google.com/')) return undefined
778 return /['"`\s]/.test(url) ? undefined : url
779}
780
781/** «>» 로 입력창에 넣는 글: 메일을 Gmail 에서 다시 찾아 읽고 할 일을 찾게 해요. 본문은 Claude 가 스레드 번호로 직접 읽어요. */
782export function mailActionPrompt(m: { id: string; subject: string; from: string; date: string }): string {
783 const parts = [m.from.trim(), m.date.slice(0, 10)].filter(Boolean)
784 return `메일 「${m.subject}」(${parts.length ? parts.join(', ') + ', ' : ''}Gmail 스레드 ${m.id})을 Gmail에서 읽고 내용을 확인한 뒤, 내가 해야 할 조치사항을 찾아 줘.`
785}
786hooks/send-check.ts 57 lines1// side-desk 발송 전 확인. `$`를 쓰지 않는 순수 함수라서 테스트가 쉬워요.
2// 메일·광고·일정·드라이브 공유처럼 «바깥으로 나가는 도구»를 부르기 직전에 알아봐요.
3
4export type SendJudgement = { label: string; detail: string; reason: string }
5
6/** 읽기·조회로 시작하는 이름은 건드리지 않아요. */
7const READONLY = /^(get|list|read|search|show|find|describe|check|preview|resolve|whoami|help|query|fetch|download|capture|view|explore|balance|models|jobs)_?/i
8/** 이름 안에 이 말이 있으면 바깥으로 나가는 동작이에요. */
9const OUTWARD = /(^|_)(send|reply|forward|post|publish|share|invite|deploy|boost|activate|respond|comment|broadcast|tweet)(_|$)/i
10const CHANGE_VERB = /(create|activate|update|boost|delete|upload|finalize|connect|disconnect)/i
11
12const make = (label: string, detail: string, reason: string): SendJudgement => ({ label, detail: detail.slice(0, 300), reason })
13
14/**
15 * 도구 이름(`mcp__서버__도구`)만 보고 판단해요. 바깥으로 나가는 도구면 설명을 돌려주고, 아니면 undefined예요.
16 * side-desk 자신의 도구는 건드리지 않아요.
17 */
18export function judgeSend(tool: string): SendJudgement | undefined {
19 const m = /^mcp__(.+?)__(.+)$/.exec(tool)
20 if (!m) return undefined
21 const server = m[1]!
22 const name = m[2]!
23 if (server === 'side-desk') return undefined
24 const human = `${server.replace(/^(claude_ai_|plugin_)/, '')} · ${name}`
25 if (/meta/i.test(server) && /^ads_/.test(name)) {
26 return CHANGE_VERB.test(name) ? make('광고 계정 변경', human, '광고 계정을 바꾸는 도구예요. 돈이 나가거나 광고가 바로 나갈 수 있어요.') : undefined
27 }
28 if (/calendar/i.test(server)) {
29 return /(create|update|delete|respond)/i.test(name) ? make('캘린더 변경', human, '참석자에게 초대나 변경 알림이 갈 수 있어요.') : undefined
30 }
31 if (/drive/i.test(server)) {
32 return /(share|trash)/i.test(name) ? make('드라이브 공유·삭제', human, '파일을 다른 사람에게 공유하거나 휴지통으로 보내요.') : undefined
33 }
34 if (READONLY.test(name)) return undefined
35 if (OUTWARD.test(name) || /^edit_message$/.test(name)) {
36 return make('바깥으로 보내기', human, '메일·메시지·게시처럼 바깥으로 나가는 일이에요. 보내고 나면 되돌릴 수 없어요.')
37 }
38 return undefined
39}
40
41/** 확인창에 보여 줄 인자 요약. 받는 사람·제목·본문 위주로 뽑아요. */
42export function summarizeArgs(input: Record<string, unknown>): string {
43 const SKIP = new Set(['tool', 'agentId'])
44 const PRIORITY = ['to', 'cc', 'bcc', 'subject', 'title', 'name', 'channel', 'text', 'body', 'message', 'content', 'summary', 'description', 'start', 'end', 'campaign_name']
45 const lines: string[] = []
46 const put = (k: string, v: unknown) => {
47 const s = typeof v === 'string' ? v : JSON.stringify(v)
48 if (s) lines.push(`${k}: ${s.replace(/\s+/g, ' ').slice(0, 200)}`)
49 }
50 for (const k of PRIORITY) if (k in input && !SKIP.has(k)) put(k, input[k])
51 if (lines.length === 0) {
52 const rest = Object.fromEntries(Object.entries(input).filter(([k]) => !SKIP.has(k)))
53 put('내용', rest)
54 }
55 return lines.slice(0, 7).join('\n')
56}
57hooks/mirror.ts 116 lines1// 핀을 «Claude가 우리 도구를 불러 줘야만» 채우지 않으려고, Claude가 평소 쓰는 할 일 도구를 옆에서 엿보고
2// 답변 끝의 질문을 자동으로 «결정 대기»에 올려요. `$`를 쓰지 않는 순수 함수라 테스트가 쉬워요.
3import type { Board, Todo } from './lib.ts'
4
5const MAX_TODOS = 40
6export const AUTO_DECISION_ID = 'auto'
7
8/** 엿볼 내장 도구들 */
9export const MIRRORED_TOOLS = ['TodoWrite', 'TaskCreate', 'TaskUpdate'] as const
10
11/** TodoWrite 는 목록 «전체»를 보내요. 그대로 옮겨요. */
12function fromTodoWrite(input: Record<string, unknown>): Todo[] | undefined {
13 const list = input.todos
14 if (!Array.isArray(list)) return undefined
15 const out: Todo[] = []
16 list.forEach((t: unknown, i: number) => {
17 const o = t as { content?: unknown; status?: unknown }
18 if (o && typeof o.content === 'string') {
19 out.push({ id: `t${i + 1}`, text: o.content, isDone: o.status === 'completed', isActive: o.status === 'in_progress' })
20 }
21 })
22 return out.slice(-MAX_TODOS)
23}
24
25/** TaskCreate 결과에서 엔진이 붙인 번호를 찾아요. 모양을 몰라도 되게 글로 바꿔서 찾아요. 못 찾으면 undefined. */
26export function findTaskId(result: unknown): string | undefined {
27 let text = ''
28 try {
29 text = typeof result === 'string' ? result : JSON.stringify(result ?? '')
30 } catch {
31 return undefined
32 }
33 return /"id"\s*:\s*"?(\d+)"?/.exec(text)?.[1] ?? /#(\d{1,6})/.exec(text)?.[1]
34}
35
36/** 번호가 겹치지 않게 다음 할 일 id 를 만들어요. */
37const nextId = (todos: Todo[]) => 't' + (Math.max(0, ...todos.map(t => Number(t.id.slice(1)) || 0)) + 1)
38
39/**
40 * 도구 호출 하나를 핀에 반영해요. 바뀐 게 없으면 undefined.
41 * · TodoWrite : 목록 전체 교체
42 * · TaskCreate : 할 일 하나 추가 (엔진 번호는 결과에서 읽고, 못 읽으면 만든 순서를 써요)
43 * · TaskUpdate : taskId 로 찾아서 상태·제목 갱신 (deleted 면 뺌)
44 */
45export function mirrorTool(board: Board, tool: string, input: Record<string, unknown>, result: unknown): Board | undefined {
46 if (tool === 'TodoWrite') {
47 const todos = fromTodoWrite(input)
48 return todos ? { ...board, todos } : undefined
49 }
50 if (tool === 'TaskCreate') {
51 const subject = typeof input.subject === 'string' ? input.subject.trim() : ''
52 if (!subject) return undefined
53 const ref = findTaskId(result) ?? String(board.todos.filter(t => t.ref !== undefined).length + 1)
54 const todo: Todo = { id: nextId(board.todos), text: subject, isDone: false, ref }
55 return { ...board, todos: [...board.todos, todo].slice(-MAX_TODOS) }
56 }
57 if (tool === 'TaskUpdate') {
58 const ref = input.taskId === undefined ? '' : String(input.taskId)
59 const i = board.todos.findIndex(t => t.ref === ref)
60 if (i < 0) return undefined
61 const status = input.status
62 let todos = board.todos.slice()
63 if (status === 'deleted') {
64 todos = todos.filter((_, j) => j !== i)
65 } else {
66 const cur = todos[i]!
67 const next: Todo = { ...cur }
68 if (typeof input.subject === 'string' && input.subject.trim()) next.text = input.subject.trim()
69 if (status === 'completed') {
70 next.isDone = true
71 next.isActive = false
72 } else if (status === 'in_progress') {
73 next.isDone = false
74 next.isActive = true
75 // 진행 중인 건 하나만 보여요.
76 todos = todos.map((t, j) => (j === i ? t : { ...t, isActive: false }))
77 } else if (status === 'pending') {
78 next.isDone = false
79 next.isActive = false
80 }
81 todos[i] = next
82 }
83 return { ...board, todos }
84 }
85 return undefined
86}
87
88const QUESTION_END = /[??]\s*[*_`)"'\]»」』]*$/
89// «~해 주세요», «~해 주시면 …할게요», «~할까요», «승인 후에» 처럼 내 답이나 결정을 기다리는 말
90const REQUEST = /(주세요|주시면|주실래요|주시겠어요|할까요|드릴까요|괜찮을까요|어때요|승인해|결정해)/
91const SCAN_LINES = 14
92const MAX_ITEMS = 3
93
94/**
95 * 답변이 «내 답을 기다리는 말»을 하는지 봐요. 끝의 열네 줄 안에서 질문(물음표로 끝남)이나 요청(«~해 주세요» 따위)을
96 * 최대 세 줄 찾아 한 줄로 묶어 돌려줘요. 코드 블록 안은 보지 않아요. 없으면 undefined.
97 */
98export function trailingQuestion(answer: string): string | undefined {
99 const prose = answer.replace(/```[\s\S]*?```/g, '')
100 const lines = prose.split('\n').map(l => l.trim()).filter(Boolean).slice(-SCAN_LINES)
101 const picked = lines.filter(l => QUESTION_END.test(l) || REQUEST.test(l)).slice(-MAX_ITEMS)
102 if (picked.length === 0) return undefined
103 const clean = (l: string) => {
104 const t = l.replace(/^[-*•>\d.)\s]+/, '').replace(/[*_`]/g, '')
105 return t.length > 90 ? t.slice(0, 87) + '…' : t
106 }
107 const text = picked.map(clean).join(' / ')
108 return text.length > 200 ? text.slice(0, 197) + '…' : text
109}
110
111/** 자동으로 올린 «결정 대기»를 갱신해요. question 이 없으면 자동 항목만 지워요. */
112export function withAutoDecision(board: Board, question: string | undefined): Board {
113 const manual = board.decisions.filter(d => d.id !== AUTO_DECISION_ID)
114 return { ...board, decisions: question ? [...manual, { id: AUTO_DECISION_ID, text: question }] : manual }
115}
116hooks/extras.ts 268 lines1// 0.3.0 에 더한 순수 함수들: 오늘 일정 · 이번 대화에서 고친 파일 · 명령어 안내 · 처음 설정 점검 · 승인 대기 알림.
2// ft-desk 와 side-desk 가 이 파일을 똑같이 써요. 한쪽을 고치면 다른 쪽에도 그대로 복사해요.
3// `$`를 쓰지 않아서 테스트가 쉬워요.
4
5const pad2 = (n: number) => String(n).padStart(2, '0')
6const dayText = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
7
8// ───────────── 오늘 일정 (claude.ai Google Calendar 커넥터, 읽기만) ─────────────
9
10/** Claude Code 에 연결된 구글 캘린더 커넥터 이름. /mcp 에 이 이름으로 보여요. */
11export const CALENDAR_SERVER = 'claude.ai Google Calendar'
12
13export type CalEvent = {
14 id: string
15 title: string
16 day: 'today' | 'tomorrow'
17 allDay: boolean
18 start?: number
19 end?: number
20 location: string
21}
22
23/** now 가 속한 날(addDays 만큼 옮긴 날)의 0시, ms */
24function midnight(now: number, addDays = 0): Date {
25 const d = new Date(now)
26 d.setHours(0, 0, 0, 0)
27 d.setDate(d.getDate() + addDays)
28 return d
29}
30
31/** 그날 0시를 «2026-10-15T00:00:00»처럼 시간대 없이 써요. 커넥터가 계정의 시간대로 읽어요. */
32export const localMidnight = (now: number, addDays = 0) => `${dayText(midnight(now, addDays))}T00:00:00`
33
34/** list_events 에 넘길 값: 오늘 0시부터 모레 0시까지(오늘·내일), 시작 시각 순. */
35export function calendarArgs(now: number) {
36 return { startTime: localMidnight(now), endTime: localMidnight(now, 2), orderBy: 'startTime', pageSize: 30 }
37}
38
39/**
40 * list_events 결과 → 오늘·내일 일정. 취소된 일정은 빼요.
41 * 어제 시작해서 오늘까지 이어지는 일정(며칠짜리 종일 일정 포함)은 오늘 일정으로 봐요.
42 * 순서: 오늘 먼저, 같은 날 안에서는 종일 일정 먼저, 그다음 시작 시각 순.
43 */
44export function parseEvents(data: unknown, now: number): CalEvent[] {
45 const list = (data as { events?: unknown } | undefined)?.events
46 if (!Array.isArray(list)) return []
47 const today = dayText(midnight(now))
48 const tomorrow = dayText(midnight(now, 1))
49 const todayStart = midnight(now).getTime()
50 const out: CalEvent[] = []
51 for (const raw of list as Record<string, unknown>[]) {
52 if (!raw || typeof raw !== 'object' || raw.status === 'cancelled') continue
53 const s = (raw.start ?? {}) as { dateTime?: unknown; date?: unknown }
54 const e = (raw.end ?? {}) as { dateTime?: unknown; date?: unknown }
55 let day: string
56 let start: number | undefined
57 let end: number | undefined
58 const allDay = typeof s.dateTime !== 'string' && typeof s.date === 'string'
59 if (allDay) {
60 // 커넥터는 종일 일정의 날짜를 «2026-10-15»가 아니라 «2026-10-15T00:00:00Z»처럼 시각을 붙여서 줘요. 앞 10자(날짜)만 봐요.
61 day = (s.date as string).slice(0, 10)
62 // 종일 일정의 end.date 는 끝난 «다음 날»이에요.
63 const endDay = typeof e.date === 'string' ? e.date.slice(0, 10) : ''
64 if (day < today && endDay > today) day = today
65 } else if (typeof s.dateTime === 'string') {
66 start = Date.parse(s.dateTime)
67 if (Number.isNaN(start)) continue
68 const parsedEnd = typeof e.dateTime === 'string' ? Date.parse(e.dateTime) : NaN
69 end = Number.isNaN(parsedEnd) ? undefined : parsedEnd
70 day = dayText(new Date(start))
71 if (day < today && end !== undefined && end > todayStart) day = today
72 } else {
73 continue
74 }
75 const which = day === today ? 'today' : day === tomorrow ? 'tomorrow' : undefined
76 if (which === undefined) continue
77 const title = typeof raw.summary === 'string' && raw.summary.trim() ? raw.summary.trim() : '(제목 없음)'
78 out.push({ id: String(raw.id ?? ''), title, day: which, allDay, start, end, location: typeof raw.location === 'string' ? raw.location.trim() : '' })
79 }
80 const rank = (ev: CalEvent) => (ev.day === 'today' ? 0 : 2) + (ev.allDay ? 0 : 1)
81 return out.sort((a, b) => rank(a) - rank(b) || (a.start ?? 0) - (b.start ?? 0))
82}
83
84/** «09:30» 또는 «종일». 오늘 시작하지 않고 어제부터 이어지는 일정은 «~11:00»(끝나는 시각)이에요. */
85export function eventTime(ev: CalEvent, now: number): string {
86 if (ev.allDay || ev.start === undefined) return '종일'
87 const hm = (ms: number) => `${pad2(new Date(ms).getHours())}:${pad2(new Date(ms).getMinutes())}`
88 if (ev.day === 'today' && ev.start < midnight(now).getTime()) return ev.end !== undefined ? `~${hm(ev.end)}` : '종일'
89 return hm(ev.start)
90}
91
92/** 끝난 일정인지(흐리게 그려요) */
93export const eventEnded = (ev: CalEvent, now: number) => ev.end !== undefined && ev.end <= now
94
95// ───────────── 이번 대화에서 고친 파일 ─────────────
96
97export type Change = { path: string; count: number; at: number }
98
99/** 파일을 고친 도구들 */
100export const EDIT_TOOLS = ['Edit', 'Write', 'NotebookEdit'] as const
101
102/** 고친 도구 호출의 경로. 경로가 없으면 undefined. */
103export function editedPath(input: unknown): string | undefined {
104 const i = (input ?? {}) as { file_path?: unknown; notebook_path?: unknown }
105 const p = typeof i.file_path === 'string' ? i.file_path : typeof i.notebook_path === 'string' ? i.notebook_path : ''
106 return p.trim() ? p.replace(/\\/g, '/') : undefined
107}
108
109/** 고친 파일 하나를 목록 맨 위로 올려요. 같은 파일은 한 줄로 합치고 횟수를 세요. 50개까지만 둬요. */
110export function addChange(list: readonly Change[], path: string, at: number): Change[] {
111 const norm = path.replace(/\\/g, '/')
112 const key = norm.toLowerCase()
113 const prev = list.find(c => c.path.toLowerCase() === key)
114 return [{ path: norm, count: (prev?.count ?? 0) + 1, at }, ...list.filter(c => c.path.toLowerCase() !== key)].slice(0, 50)
115}
116
117type MsgLike = { toolUses: readonly { tool: string; input: Record<string, unknown>; isError?: boolean }[] }
118
119/** 저장해 둔 목록이 없을 때(이 기능이 생기기 전 대화) 대화 기록에서 한 번 만들어요. 시각은 모르니 0이에요. */
120export function changesFromMessages(messages: readonly MsgLike[]): Change[] {
121 let out: Change[] = []
122 for (const m of messages) {
123 for (const t of m.toolUses ?? []) {
124 if (!(EDIT_TOOLS as readonly string[]).includes(t.tool) || t.isError) continue
125 const p = editedPath(t.input)
126 if (p) out = addChange(out, p, 0)
127 }
128 }
129 return out
130}
131
132/** 화면에 쓸 이름: 파일 이름과 폴더. 작업 폴더 안이면 폴더를 그 안의 상대 경로로 줄여요. */
133export function changeLabel(path: string, root: string): { name: string; dir: string } {
134 const p = path.replace(/\\/g, '/')
135 const r = root.replace(/\\/g, '/').replace(/\/+$/, '')
136 const cut = p.lastIndexOf('/')
137 const name = cut >= 0 ? p.slice(cut + 1) : p
138 let dir = cut >= 0 ? p.slice(0, cut) : ''
139 if (r !== '') {
140 const low = dir.toLowerCase()
141 const rl = r.toLowerCase()
142 if (low === rl) dir = ''
143 else if (low.startsWith(rl + '/')) dir = dir.slice(r.length + 1)
144 }
145 return { name, dir }
146}
147
148/** «>» 를 누르면 입력창에 넣는 글 */
149export const changePrompt = (path: string) => `이번 대화에서 ${path} 파일을 왜, 어떻게 바꿨는지 쉽게 설명해 줘.`
150
151// ───────────── 명령어 안내 ─────────────
152
153export type Guide = { name: string; arg?: string; text: string }
154
155/**
156 * 처음 쓰는 사람이 자주 찾는 명령. 모두 이 모드가 요구하는 버전(MIN_VERSION)부터 있는 기본 명령이에요.
157 * ($.command.list 로 거르지 않아요. 화면 없는 실행에서는 /resume·/rewind 같은 대화형 명령이 목록에서 빠져서요)
158 * 누르면 입력창에 넣기만 하고, 실행은 엔터로 해요(/clear 처럼 되돌릴 수 없는 것도 있어서요).
159 */
160export const COMMAND_GUIDE: readonly Guide[] = [
161 { name: 'clear', text: '대화를 비우고 새로 시작해요' },
162 { name: 'compact', text: '지금까지 대화를 요약해서 줄여요' },
163 { name: 'resume', text: '지난 대화를 골라서 이어 가요' },
164 { name: 'rewind', text: '대화와 고친 파일을 앞 시점으로 되돌려요' },
165 { name: 'model', text: '쓰는 모델을 바꿔요' },
166 { name: 'context', text: '대화 용량을 무엇이 차지하는지 봐요' },
167 { name: 'usage', text: '요금제 한도를 얼마나 썼는지 봐요' },
168 { name: 'mcp', text: 'Gmail·캘린더 같은 커넥터를 연결하고 로그인해요' },
169 { name: 'permissions', text: '묻지 않고 쓸 도구를 정해요' },
170 { name: 'memory', text: 'Claude가 늘 읽는 지침 파일(CLAUDE.md)을 고쳐요' },
171 { name: 'tui', arg: 'fullscreen', text: '책상을 옆에 붙이고 마우스로 누를 수 있게 해요' },
172 { name: 'reload-plugins', text: '고친 모드나 새로 설치한 플러그인을 껐다 켜지 않고 바로 불러와요' },
173 { name: 'doctor', text: '설치에 문제가 없는지 점검해요' },
174 { name: 'help', text: '명령 전체 목록을 봐요' },
175]
176
177export const guideCommand = (g: Guide) => `/${g.name}${g.arg ? ' ' + g.arg : ''}`
178
179/** 버튼 key: «/tui fullscreen» → cmd-tui-fullscreen */
180export const guideKey = (g: Guide) => 'cmd-' + guideCommand(g).slice(1).replace(/[^A-Za-z0-9:_.-]+/g, '-')
181
182/**
183 * 내가 고친 목록 = 기본 목록에서 뺀 것(hidden)을 지우고, 넣은 것(added)을 뒤에 붙여요.
184 * 기본 목록을 통째로 저장하지 않아서, 다음 버전에 기본 명령이 늘면 그것도 보여요.
185 */
186export function effectiveGuide(added: readonly Guide[], hidden: readonly string[]): Guide[] {
187 const off = new Set(hidden)
188 const base = COMMAND_GUIDE.filter(g => !off.has(guideCommand(g)))
189 const seen = new Set(base.map(guideCommand))
190 return [...base, ...added.filter(g => !off.has(guideCommand(g)) && !seen.has(guideCommand(g)))]
191}
192
193export const isDefaultGuide = (g: Guide) => COMMAND_GUIDE.some(d => guideCommand(d) === guideCommand(g))
194
195/**
196 * 명령 탭의 «추가» 칸에 쓴 글 → 명령 하나. 잘못 쓰면 error 에 이유를 줘요.
197 * · «/capcut 릴스 편집» → /capcut, 설명 «릴스 편집»
198 * · «/tui fullscreen - 옆에 붙이기» → 인자가 있으면 « - »로 나눠요
199 * · «capcut» → 앞의 / 는 없어도 돼요
200 */
201export function parseGuideInput(text: string): { guide: Guide } | { error: string } {
202 const raw = text.replace(/\s+/g, ' ').trim()
203 if (!raw) return { error: '명령을 써 주세요. 예: /capcut 릴스 편집' }
204 const dash = raw.search(/ [-–—] /)
205 const head = (dash >= 0 ? raw.slice(0, dash) : raw).trim()
206 let desc = dash >= 0 ? raw.slice(dash + 3).trim() : ''
207 const parts = head.replace(/^\//, '').split(' ')
208 const name = parts[0] ?? ''
209 if (!/^[A-Za-z0-9][A-Za-z0-9:_.-]{0,59}$/.test(name)) return { error: `«${parts[0] ?? ''}»는 명령 이름으로 쓸 수 없어요. 영어·숫자·-·:만 돼요` }
210 let arg: string | undefined
211 if (dash >= 0) arg = parts.slice(1).join(' ') || undefined
212 else desc = parts.slice(1).join(' ')
213 if (arg && arg.length > 60) return { error: '인자가 너무 길어요(60자까지)' }
214 return { guide: { name, ...(arg ? { arg } : {}), text: desc.slice(0, 80) } }
215}
216
217// ───────────── 처음 설정 점검 ─────────────
218
219/** 이 모드가 기대는 기능(옆 창·훅 모양)이 들어온 첫 Claude Code 버전 */
220export const MIN_VERSION = '2.1.287'
221
222/** v 가 min 이상인지. 버전을 못 읽으면 undefined. */
223export function versionAtLeast(v: string, min: string): boolean | undefined {
224 const parse = (s: string) => /(\d+)\.(\d+)\.(\d+)/.exec(s)?.slice(1).map(Number)
225 const a = parse(v)
226 const b = parse(min)
227 if (!a || !b) return undefined
228 for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i]! > b[i]!
229 return true
230}
231
232/** ok = ✓ · bad = ✗(고치는 법이 있어요) · na = 이 환경에선 해당 없음 · wait = 확인 중 */
233export type Check = { id: string; label: string; state: 'ok' | 'bad' | 'na' | 'wait'; detail: string; fix?: string }
234
235export const CHECK_MARK: Record<Check['state'], string> = { ok: '✓', bad: '✗', na: '–', wait: '…' }
236
237/**
238 * 커넥터(Gmail·캘린더)를 불러 본 결과 → 점검 한 줄. problem 이 없으면 됨.
239 * 거절 이유로 «연결 안 됨 / 권한 없음 / 로그인 풀림»을 가려서 고치는 법을 달리 적어요.
240 */
241export function connectorCheck(id: string, label: string, server: string, tools: readonly string[], problem: string | undefined, okDetail = '연결됨'): Check {
242 if (problem === undefined) return { id, label, state: 'ok', detail: okDetail }
243 const p = problem.replace(/\s+/g, ' ').trim()
244 if (/haven't granted|permission|권한/i.test(p)) {
245 return { id, label, state: 'bad', detail: '연결은 됐는데 읽는 권한을 아직 안 받았어요', fix: `/permissions 에서 ${tools.join(', ')} 허용` }
246 }
247 if (/auth|login|token|expired|로그인/i.test(p) && !/not connected|no connected/i.test(p)) {
248 return { id, label, state: 'bad', detail: '로그인이 풀렸어요', fix: `/mcp 에서 «${server}»를 골라 다시 로그인` }
249 }
250 return { id, label, state: 'bad', detail: '연결이 안 돼 있어요', fix: `/mcp 에서 «${server}» 연결 (목록에 없으면 claude.ai 설정 > 커넥터에서 먼저 추가)` }
251}
252
253// ───────────── 승인 기다릴 때 알림 ─────────────
254
255/**
256 * Claude Code 가 «이 도구를 써도 될까요?» 창을 띄울 때 보낼 알림. 알림을 안 보낼 도구면 undefined.
257 * · AskUserQuestion 은 질문 알림이 따로 있어서 빼요.
258 */
259export function permissionNote(tool: string, input: unknown): { title: string; body: string } | undefined {
260 if (tool === 'AskUserQuestion') return undefined
261 if (tool === 'ExitPlanMode') return { title: '계획 승인을 기다려요', body: 'Claude가 세운 계획을 보고 승인해 주세요' }
262 const i = (input ?? {}) as Record<string, unknown>
263 const what = [i.command, i.file_path, i.notebook_path, i.url, i.pattern].find(v => typeof v === 'string' && v.trim()) as string | undefined
264 const name = tool.startsWith('mcp__') ? tool.split('__').slice(1).join(' · ') : tool
265 const body = name + (what ? `: ${what.replace(/\s+/g, ' ').trim()}` : '')
266 return { title: '승인을 기다려요', body: body.length > 120 ? body.slice(0, 117) + '…' : body }
267}
268