SLOPSHOPPER

side-desk

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

panerowsguardcommandstatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · side-desk
│ ┃ 책상 ✕ › fix the failing auth test and add an audit log call │ ┃ 1: 할 일 2: 메일 3: 사용량 4: 클립보드 5: 명 │ ┃ ⏺ Read(src/auth.ts) │ ┃ 키보드: Ctrl+X → Tab ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ 일정 오늘·내일 ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ 오늘 일정 없음 ⎿ 3 pass, 1 fail │ ┃ │ ┃ 내가 정해야 하는 것 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ 없음 ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ 이번 대화 할 일 › /desk │ ┃ │ ┃ 없음 │ ┃ │ ┃ 이번 대화에서 고친 파일 3 │ ┃ │ ┃ > cache.ts src │ ┃ > audit.ts src │ ┃ > auth.ts src │ ┃ > 누르면 왜 바꿨는지 물어요 · Edit·Write로 │ ┃ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ side-desk: 5시간 31% · 대화 49%

Draws

Pane · 책상
1: 할 일 2: 메일 3: 사용량 4: 클립보드 5: 명령 6: 설정 키보드: Ctrl+X → Tab 일정 오늘·내일 r: 새로고침 오늘 일정 없음 내가 정해야 하는 것 없음 이번 대화 할 일 없음 이번 대화에서 고친 파일 3 > cache.ts src > audit.ts src > auth.ts src > 누르면 왜 바꿨는지 물어요 · Edit·Write로 고친 것만
README

side-desk — Claude Code 옆에 «책상» 하나

Claude Code로 일하다 보면 이런 일이 생겨요.

  • 일을 여러 단계로 맡겼는데, 대화가 길어지니 지금 어디까지 했는지 놓쳐요.
  • Claude가 물어본 질문이나 «이거 실행해도 될까요?»를 못 보고 자리를 비워요.
  • Claude가 어떤 파일을 고쳤는지 모른 채 넘어가요.
  • 사용 한도가 얼마나 남았는지, 대화가 얼마나 길어졌는지 몰라요.
  • 대화가 너무 길어져서 Claude가 앞 내용을 잊기 시작해요.
  • 새 메일이나 오늘 일정을 보려고 브라우저를 오가요.
  • 방금 복사한 글이나 이미지를 입력창에 넣고 싶은데, 복사해 둔 게 여러 개예요.
  • 메일을 «써 줘»라고 했는데 Claude가 그대로 보내 버릴까 불안해요.

side-desk는 Claude Code 화면 옆에 작은 창을 열어서 이걸 한 곳에 모아 줘요.

윈도우에서 만들고 시험했어요. WSL(윈도우의 리눅스)에서도 돼요. 맥·리눅스에서는 클립보드 탭만 안 되고, 알림은 윈도우 알림 대신 터미널 알림으로 가요(터미널이 알림을 지원해야 떠요). 이 모드가 따로 서버로 보내지는 않아요. 메일과 일정만 Claude Code에 연결된 Gmail·구글 캘린더 커넥터로 읽어요. 아래 «무엇을 하나요»를 꼭 읽어 보세요.

설치 (2줄)

먼저 버전을 확인하세요. 터미널에 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프롬프트 가이드를 켜고 꺼요.

무엇을 하나요 (꼭 읽어 주세요)

이 모드는 내 컴퓨터에서 내 권한으로 돌아요. 그래서 무엇을 하는지 정확히 적어 둬요.

  • PowerShell을 실행해요. 클립보드를 읽고 알림을 띄울 때, 그리고 설정 탭의 점검에서 클립보드 기록이 켜져 있는지 볼 때만이에요. 클립보드 탭을 볼 때 보이지 않는 PowerShell 하나가 클립보드가 바뀌는지만 지켜봐요. 탭을 1분 넘게 안 보면 끝나요.
  • 파일을 써요. 대화를 이어갈 때 쓰는 메모와, 클립보드 이미지를 저장한 파일이에요. 모두 내 폴더/.claude/state/side-desk/ 아래에 있고, 이미지는 하루 뒤 지워져요.
  • 고친 파일 목록을 남겨요. Claude가 Edit·Write·NotebookEdit로 파일을 고칠 때마다 그 경로만 적어 둬요(파일 내용은 안 적어요). Claude Code가 모드에 주는 저장 공간에 대화마다 따로 두고, 최근 30개 대화만 남겨요. 셸 명령으로 바꾼 파일은 안 잡혀요.
  • 이 모드가 따로 서버로 보내지는 않아요. 외부 주소로 직접 요청하는 일이 없어요.
  • 메일과 일정은 Gmail·구글 캘린더 커넥터로 읽기만 해요. Claude Code에 이미 연결해 둔 «claude.ai Gmail»과 «claude.ai Google Calendar» 커넥터를 거쳐요. 받은편지함 제목은 메일 탭을 열 때·«새로고침»을 누를 때·메일 탭을 보는 동안 5분마다, 오늘·내일 일정은 할 일 탭을 열 때·일정 «새로고침»을 누를 때·할 일 탭을 보는 동안 5분마다 읽어요. 메일 본문은 줄을 누를 때만 읽어요. 창이 닫혀 있거나 다른 탭을 보고 있으면 읽지 않아요. 읽음 표시·라벨·답장·삭제·일정 변경은 하지 않고, 읽은 메일과 일정을 따로 저장하지도 않아요.
  • 설정 탭의 점검은 커넥터를 한 번씩 불러 봐요. 설정 탭을 처음 열 때와 «다시 점검»을 누를 때 Gmail과 캘린더에 가장 작은 요청을 하나씩 보내서 연결됐는지만 봐요.
  • 알림은 스위치가 둘이에요. «질문·승인 알림»(기본 켜짐)은 Claude가 질문할 때, «이 도구를 써도 될까요?» 승인 창이나 보내기 전 확인이 떴을 때 알려요. «작업 끝 알림»(기본 꺼짐)은 오래 걸린 일이 끝났을 때 알려요. 윈도우는 PowerShell로 윈도우 알림을, 맥·리눅스는 Claude Code가 터미널로 보내는 알림을 써요.
  • «보내기 전 확인»은 이래요. Claude가 메일 보내기, 광고 변경, 일정 초대, 드라이브 공유 같은 일을 하려 하면 받는 사람·제목·본문을 보여 주고 «보낼까요?»를 물어요. 읽기·검색·초안 저장은 묻지 않아요.
  • «프롬프트 가이드»는 기본이 꺼져 있어요. 켜면 내가 보내는 요청마다 작은 모델을 한 번 더 써서 사용 한도를 조금 더 써요.

클립보드를 쓸 때 조심할 점

윈도우의 클립보드 기록(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를 보세요.

Source 5 files
hooks/register.tsx 2201 lines
1import 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 lines
1// 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> = { '&nbsp;': ' ', '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&apos;': "'" }
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}
786
hooks/send-check.ts 57 lines
1// 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}
57
hooks/mirror.ts 116 lines
1// 핀을 «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}
116
hooks/extras.ts 268 lines
1// 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