개인 생산성 mods: i18n, PR/Linear 연동, 작업 패널, 자동 인수인계 등 8개 모듈

Claude Code용 개인 생산성 mods 플러그인. 모듈 8개를 한 플러그인으로 묶었고, /mods로 모듈별로 켜고 끈다.
| 모듈 | 명령 | 하는 일 |
|---|---|---|
| i18n | – | Spinner·턴 종료 줄 등 내장 UI 일부를 한국어로 표시 |
| integrations | /issue | 현재 브랜치 PR(GitHub, gh)·MR(GitLab, glab) 상태 폴링, Linear 이슈 검색·연결·상태 변경 |
| composer | /coach | 프롬프트 위 band: PR/MR 배지, 다음 작업 후보, 모호한 요청 다듬기 |
| panel | /panel, /pin | 작업 패널(핀·할 일·서브에이전트·사용량·인수인계), 사용량 경고(/panel threshold) |
| handoff | /tk-handoff | 컨텍스트 사용률 임계치(기본 75%)에서 인수인계 문서 자동 작성, 다음 프롬프트에 첨부 |
| browser | /browse | Bash 출력의 localhost URL 감지, 스크린샷·콘솔 에러 수집 |
| edit | /edit | 수정 대상(파일 행 범위·선택 영역·DOM 요소) 지정, 범위 밖 Edit·Write 차단 |
| diagram | – | 응답의 mermaid 블록을 SVG/PNG로 렌더 |
/mods: 모듈 on/off와 명령 등록 상태/mods # 모듈 목록, 켜짐/꺼짐, 각 모듈 명령의 등록 결과
/mods <모듈> on|off # 예: /mods composer off
/mods lang ko|en # UI 언어
목록의 모듈 줄 끝에 그 모듈 명령의 등록 결과가 붙는다.
- panel (작업 패널): 켜짐 · /panel ✓ /pin ✓
- browser (브라우저 미리보기): 켜짐 · /browse ✗ (it is the user's /browse)
명령: ✓ 등록됨 · ✗ 등록 거부(사유) · ? 아직 등록 결과 없음
✗는 같은 이름의 사용자·프로젝트 명령이나 스킬이 있어 등록이 거부된 것이다. 그 이름으로 입력하면 그쪽이 실행된다. 거부되면 세션 시작 때 명령마다 toast로 한 번 알리고, 다른 모듈은 정상 동작한다./clear 뒤에도 보인다. 아직 등록한 적이 없으면 ?로 나온다.| 명령 | 기본값 | 뜻 |
|---|---|---|
/panel threshold | 80% | 현재 사용량 경고 임계치 보기 |
/panel threshold <1-99> | – | 컨텍스트·사용 한도(5시간·7일)가 이 %에 이르면 세션당 한 번 toast로 경고 |
/tk-handoff threshold | 75% | 현재 자동 인수인계 임계치 보기 |
/tk-handoff threshold <1-99> | – | 답변이 끝났을 때 컨텍스트 사용률이 이 % 이상이면 세션당 한 번 인수인계 문서를 자동 작성 |
g/m6/threshold, g/m7/threshold)./panel usage 탭에서도 볼 수 있다./tk-handoff 임계치를 /panel 임계치보다 낮게 둔다.저장소는 public이라 별도 인증 없이 설치할 수 있다. 마켓플레이스는 파일 1개짜리 별도 저장소 astromelon/takgon-marketplace이고, 플러그인 본체는 이 저장소 (astromelon/takgon-mods)다. 마켓플레이스를 분리한 이유는 아래 EPERM 항목을 본다.
Claude Code 터미널 세션의 프롬프트에서:
/plugin install takgon-mods --marketplace astromelon/takgon-marketplace
Add marketplace?에 y, 설치 범위는 user(모든 프로젝트)를 고른다. CLI로는:
claude plugin marketplace add astromelon/takgon-marketplace
claude plugin install takgon-mods@takgon
이전에 astromelon/takgon-mods를 마켓플레이스로 등록했다면 이름이 같은 takgon이라 그대로 동작하고 업데이트도 된다. EPERM이 나던 PC는 claude plugin marketplace remove takgon 후 위 명령으로 다시 등록한다(설치된 플러그인도 함께 제거되므로 install을 다시 한다).
주의: 마켓플레이스 이름을 바꾸거나 store 파일을 직접 만지기 전에는 store를 백업한다. 핀·M5 연결 이슈·M4 작업 목록·M7 인수인계 같은 저장 데이터는
~/.claude/plugins/store/의takgon-mods_<마켓플레이스 이름>-<해시>.json한 파일에 있다. 이름이takgon으로 같으면 소스(로컬 → GitHub)나 경로가 바뀌어도,uninstall·marketplace remove를 해도 같은 파일이 그대로 쓰인다. 이름이 바뀌면 새 파일이 만들어져 기존 데이터를 읽지 못한다. 절차는 store 백업·복원을 따른다. 토큰(GITHUB_TOKEN,LINEAR_API_KEY)은 store에 저장하지 않으므로 영향이 없다.
marketplace add가 EPERM으로 실패할 때파일이 많은 저장소(옛 astromelon/takgon-mods 마켓플레이스 소스)를 add하면 일부 Windows PC에서 아래 오류로 실패했다. 이 PC의 임시 환경에서는 옛 방식이 13회 중 12회 실패했다. 파일 1개짜리 마켓플레이스(astromelon/takgon-marketplace)로 분리한 뒤에는 저부하에서 101회 중 3회(연달아 발생, 이후 91회는 0건)로 줄었지만 없어지지는 않았다. 이후 같은 PC에서 약 61회 중 6회가 4분 안에 몰려 났고(전문 확보), 그 뒤 부하 통제 실험 126회와 5분 간격 8시간 감시 97회에서는 재현되지 않았다. 실패한 add는 Failed to finalize marketplace cache로 시작하고, add 성공 뒤 install을 확인한 17회는 모두 성공했다. 다른 PC의 결과는 확인하지 못했다.
Failed to finalize marketplace cache ... EPERM: operation not permitted, rename '...\astromelon-takgon-mods' -> '...\takgon'
저장소는 이미 받아졌고, 폴더 이름을 바꾸는 순간 다른 프로세스(실시간 검사 백신 등으로 추정)가 폴더 안 파일을 잡고 있어서 난다. 항상 재현되지는 않는다. 아래 순서로 대처한다.
git pull 후 claude plugin marketplace update takgon → claude plugin update takgon-mods@takgon이다. git clone https://github.com/astromelon/takgon-mods.git
claude plugin marketplace add ./takgon-mods
claude plugin install takgon-mods@takgon
~/.claude/plugins)를 넣으면 해결될 수 있다(확인하지 못한 추정).실패가 계속되면 증거를 모아 이슈에 올린다. tools\capture-add-failure.ps1이 실행마다 새 임시 설정 폴더에서 marketplace add를 돌리고, 실패하면 에러 전문(EPERM 포함 여부), plugins\marketplaces 상태, 후보 프로세스(백신·검색 인덱서·OneDrive 등), CPU 사용률 상위, 파일 점유 프로세스(알 수 있을 때)를 증거 파일로 남긴다. 실제 ~/.claude는 건드리지 않는다.
# 실패할 때까지 최대 50회(첫 실패에서 멈춤). 저장소 루트에서
powershell -NoProfile -ExecutionPolicy Bypass -File tools\capture-add-failure.ps1 -Runs 50
# 끝까지 돌려 실패율을 보려면(-File이 아니라 -Command). -LogCsv는 성공한 실행을 포함해 모든 실행의 시스템 CPU·디스크 평균을 기록한다
powershell -NoProfile -ExecutionPolicy Bypass -Command "& 'tools\capture-add-failure.ps1' -Runs 30 -StopOnFail:$false -LogCsv run.csv -Label baseline"
저장소를 받지 않았다면 설치된 플러그인 폴더(~/.claude/plugins/cache/takgon/takgon-mods/<버전>/tools/)에서 같은 명령을 쓴다. 증거 파일에는 사용자명·경로·프로세스 이름이 들어 있으니 올리기 전에 가린다. 한계: 원인을 확정하지는 못하고 정황 증거만 남긴다. 증거 파일의 프로세스별 CPU(WMI 값)는 코어 수를 넘는 값이 나올 수 있어 순위로만 읽는다. 시스템 전체 부하는 -LogCsv의 평균이 정확하다.
이 PC에서 이 스크립트로 약 61회를 돌렸더니 EPERM이 6회 났고, 4분 안에 몰려 연달아 난 적도 있다. 이어서 부하를 통제해 시험했다 (부하 없음·CPU 100% 점유·파일 생성/삭제 반복을 교차해 96회, 새 스크립트 파일을 만든 직후 실행 30회). 126회 모두 성공했다. 즉 CPU·디스크 부하만으로는 재현되지 않았고 원인은 아직 모른다. 네트워크 오류(Empty reply from server) 1회는 EPERM이 아니라 집계에서 뺐다.
마켓플레이스 이름을 바꾸기 전, 또는 store 파일을 직접 만지기 전에 한다. 이름이 takgon인 채로 소스를 바꾸거나 marketplace remove·uninstall을 하는 전환은 백업이 필수는 아니지만 해두면 안전하다.
파일명의 해시는 sha256("<플러그인>@<마켓플레이스 이름>")의 앞 12자리다(takgon-mods@takgon → 2488473fd961, takgon-mods@takgon-alt → 610bad6656c8). 소스와 경로는 해시에 들어가지 않으므로 이름을 바꿀 때만 파일명이 달라진다.
아래 절차는 임시 CLAUDE_CONFIG_DIR에서 실제 세션으로 확인했다(백업 명령은 $env:USERPROFILE\.claude 대신 그 설정 폴더로 바꿔 실행했다).
marketplace remove → add → install을 해도 핀이 그대로 남았다.takgon-alt로 바꾸자 핀이 비었고 새 파일이 생겼으며, 기존 파일은 그대로였다. 백업을 새 파일명으로 복사하자 핀이 돌아왔다.스크립트로 하기 (권장). 열린 Claude Code 세션을 모두 닫고 별도 PowerShell에서, 저장소 루트(또는 설치된 플러그인의 tools/ 폴더)에서 실행한다.
powershell -NoProfile -ExecutionPolicy Bypass -File tools\store-backup.ps1 List # store 파일·백업 폴더 목록(바꾸지 않음)
powershell -NoProfile -ExecutionPolicy Bypass -File tools\store-backup.ps1 Backup # <설정 폴더>\backup-takgon-store-<시각>에 복사 후 해시 확인
powershell -NoProfile -ExecutionPolicy Bypass -File tools\store-backup.ps1 Restore # store 파일이 든 가장 최근 백업을 현재 마켓플레이스 파일명으로 복원(-WhatIf로 미리 보기)
Restore는 설치된 마켓플레이스 이름(installed_plugins.json)으로 파일명을 계산해, 이름이 같으면 덮어쓰고 이름이 바뀌었으면 새 파일명으로 복사한다. 덮어쓰기 전에 현재 파일을 백업 폴더에 before-restore-*로 남기고, 덮어쓴 결과가 백업과 다르면 그 파일로 되돌린다. 다음 경우에는 멈춘다: JSON 객체가 아니거나 4 MiB를 넘는 백업, 여러 파일 중 대상을 못 고르는 백업, 설치된 마켓플레이스를 알 수 없거나 둘 이상인 경우(-Marketplace <이름>으로 지정, 영문·숫자·.·_·-만 허용), 이름이 claude인 프로세스가 있는 경우(-Force로 무시). 마지막 검사는 세션 수가 아니라 claude라는 이름의 프로세스를 세므로 설정 폴더가 다른 세션이나 이름이 같은 다른 프로세스도 센다. 그래서 확인용으로만 믿는다. npm으로 설치한 Claude Code도 세션은 네이티브 claude.exe로 돌아(node.exe는 그것을 띄우는 래퍼) 세어진다 (Windows 2.1.301에서 node cli-wrapper.cjs로 띄워 claude 프로세스 수가 하나 늘어나는 것을 확인했다). -WhatIf는 아무것도 쓰지 않으므로 이 경우 경고만 내고 미리 보기를 계속한다.
가짜 설정 폴더로 하는 시험은 저장소에 있다(tools\test-store-backup.ps1, 34개 점검: 백업·같은 이름 복원·이름 변경 복원·깨진/배열/문자열 백업·경로 구분자가 든 -Marketplace·installed_plugins.json 변형·상대 경로·-WhatIf 등). CI가 Windows PowerShell 5.1과 PowerShell 7에서 돌린다. 이 시험은 실제 Claude Code 세션을 쓰지 않으므로, 임시 CLAUDE_CONFIG_DIR의 실제 세션으로도 한 번 확인했다(Windows PowerShell 5.1, 위 수정본을 저장소에서 직접 실행, 각 단계 1회). /pin으로 핀 1개를 만들어 Backup → 핀을 1개 더 만든 뒤 Restore하자 세션의 /pin list가 다시 1개가 되었다. 마켓플레이스 이름을 takgon-alt로 바꿔 설치하자 핀이 비고 새 파일이 생겼으며, -Marketplace 없이 Restore만 해도 새 파일명으로 복사되어 핀이 돌아왔다(이전 새 파일은 before-restore-*로 남았다). 이 스크립트는 v0.4.8부터 설치된 플러그인의 tools/에 들어 있지만, 위 수정은 다음 릴리스에 들어간다.
수동으로 하려면 (열린 세션을 모두 닫은 뒤)
$store = "$env:USERPROFILE\.claude\plugins\store"
$bak = "$env:USERPROFILE\.claude\backup-takgon-store-$(Get-Date -Format yyyyMMdd-HHmmss)"
New-Item -ItemType Directory $bak | Out-Null
Copy-Item "$store\takgon-mods_*.json" $bak
Get-ChildItem $bak # 파일이 보이는지 확인
전환 후 확인
marketplace add → plugin install)을 마치고 Claude Code를 새로 시작한다./pin list·/panel로 저장 데이터가 남아 있는지 본다. 남아 있으면 끝이다. 백업은 며칠 두었다가 지운다.~/.claude/plugins/store/를 확인한다(엔진이 열려 있는 동안 파일을 덮어쓸 수 있다).store 파일은 JSON 하나이며 4 MiB 상한이 있다. 키는 g/<모듈>/<키>(전역)와 p/<프로젝트 해시>/<모듈>/<키>(프로젝트)다. 프로젝트 해시는 프로젝트 루트 경로로 정해지므로 경로가 바뀐 프로젝트의 데이터는 복원해도 새 경로에서 보이지 않는다.
마켓플레이스 사본을 먼저 갱신한 뒤 플러그인을 업데이트한다. 순서를 바꾸면 이전 버전이 그대로 남는다.
claude plugin marketplace update takgon
claude plugin update takgon-mods@takgon
세션 안에서는 /plugin marketplace update takgon → /plugin update takgon-mods@takgon. 끝나면 열린 세션에서 /reload-plugins(또는 세션 재시작). 설치된 버전은 claude plugin list로 확인한다.
plugin update가 Host key verification failed로 실패할 때marketplace update는 성공하는데 plugin update가 아래 오류로 실패하는 PC가 있었다. 저장소는 public이라 접근 권한 문제가 아니다. 플러그인을 SSH 주소로 클론하는데 이 PC의 ~/.ssh/known_hosts에 github.com 호스트 키가 없어서 난다.
✘ Failed to update plugin "takgon-mods@takgon": Failed to clone repository: ...
No ED25519 host key is known for github.com and you have requested strict checking.
Host key verification failed.
둘 중 하나로 해결한다.
known_hosts는 바뀌지 않는다(이 PC에서 확인됨). GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0="url.https://github.com/.insteadOf" GIT_CONFIG_VALUE_0="git@github.com:" \
claude plugin update takgon-mods@takgon
PowerShell은 같은 값을 먼저 $env:GIT_CONFIG_COUNT=1; $env:GIT_CONFIG_KEY_0="url.https://github.com/.insteadOf"; $env:GIT_CONFIG_VALUE_0="git@github.com:"로 설정한 뒤 명령을 실행한다(git ls-remote로 SSH 주소가 HTTPS로 바뀌어 성공하는 것을 확인했다. claude plugin update 전체를 PowerShell로 돌려 보지는 않았다).
ssh -T git@github.com을 실행하면 ED25519 key fingerprint is: SHA256:...이 나오는데, GitHub 공개 SSH 키 지문의 ED25519 값(SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU)과 같으면 yes로 답한다. 이후 같은 명령은 확인 창 없이 지나간다. GitHub에 SSH 키를 등록해 둔 PC에서만 해결된다. 키가 없으면 호스트 키는 등록돼도 마지막 줄이 git@github.com: Permission denied (publickey).로 끝나고, SSH 클론은 이번엔 이 오류로 실패한다. 키가 없는 PC는 1번을 쓴다. (임시 known_hosts로 이 PC에서 확인한 것은 지문 표시·yes 후 등록·재실행 시 확인 창 없음·키가 없을 때 publickey 오류까지다. SSH 키가 있는 환경에서 plugin update가 성공하는 것까지는 확인하지 못했다.)plugin.json의 version이 그대로면 다른 PC의 plugin update가 새 커밋이 있어도 "이미 최신"으로 판단한다. mods를 고쳐 다른 PC에 반영하려면:
CHANGELOG.md의 [Unreleased]에 기록하고 커밋해 작업 트리를 깨끗하게 만든다.patch·minor·major 또는 0.4.2 같은 정확한 버전). node tools/release.mjs patch # 파일만 수정하고 diff 출력
node tools/release.mjs patch --push # 수정 + 커밋 + 태그 + push (--commit은 커밋·태그까지만)
스크립트는 .claude-plugin/plugin.json의 version을 올리고, [Unreleased] 내용을 ## [x.y.z] - YYYY-MM-DD 절로 옮기며, 커밋 메시지는 build: x.y.z로 버전을 올리고 CHANGELOG 정리로 만든다. --commit·--push에서는 그 커밋에 주석 태그 vx.y.z도 달고, --push는 커밋과 태그를 함께 올린다. 같은 태그가 이미 있으면 파일을 바꾸기 전에 멈춘다.
CI는 node tools/release.mjs --check로 plugin.json 버전과 CHANGELOG 최상단 버전 절이 같은지, --check-tags로 태그 vX.Y.Z마다 그 커밋의 plugin.json·CHANGELOG 버전이 X.Y.Z인지와 현재 버전의 태그가 있는지(HEAD의 조상인지)를 검사한다. --check-tags는 전체 이력이 필요해 CI의 checkout은 fetch-depth: 0이다. 태그를 빠뜨리고 올렸다면 git tag -a vX.Y.Z <릴리스 커밋> -m vX.Y.Z && git push origin vX.Y.Z로 단다.
특정 저장소에서만 끄기: claude plugin disable takgon-mods@takgon --scope local
없으면 해당 기능만 숨기고 원인별로 한 번 알린다.
| 기능 | 필요 | 준비 |
|---|---|---|
| PR 배지(GitHub) | gh | 설치 후 gh auth login |
| MR 배지(GitLab) | glab | 설치 후 glab auth login --hostname <호스트> |
| Linear | 환경변수 LINEAR_API_KEY | Linear 개인 API 키 |
/browse, /edit dom | Node.js, playwright | 플러그인 폴더에서 npm install && npx playwright install chromium(전역 설치는 인식되지 않음) |
| 다이어그램 | mmdc | npm i -g @mermaid-js/mermaid-cli |
claude --plugin-dir <이 폴더> # 저장 시 핫 리로드
claude plugin validate <이 폴더>
claude plugin test <이 폴더>
푸시 전에 CI와 같은 검사(버전·태그 검사 → validate → test → e2e 하네스 단위 테스트 → e2e probe)를 한 번에 돌리려면 저장소 루트에서:
node tools/ci-local.mjs # 첫 실패에서 멈춘다
node tools/ci-local.mjs --keep-going # 실패해도 끝까지 돌려 요약만 낸다
node tools/ci-local.mjs --install-deps # e2e 의존성(pywinpty, pyte)을 먼저 설치한다. 새 PC에서 처음 한 번
node tools/ci-local.mjs --tsc # CI에 없는 타입 체크를 덧붙인다
e2e는 Windows 전용 하네스(의사 터미널)라 Windows에서만 돌고, CI는 Claude Code 2.1.296으로 고정이라 로컬 버전이 다르면 경고가 나온다.
SPEC.mddocs/engine-constraints.mdCHANGELOG.mdsrc/index.ts 31 lines1import type { Register } from 'claude-code'
2
3import { registerCore } from './core'
4import { registerI18n } from './m1-i18n'
5import { registerBrowser } from './m2-browser'
6import { registerEdit } from './m3-edit'
7import { registerComposer } from './m4-composer'
8import { registerIntegrations } from './m5-integrations'
9import { registerPanel } from './m6-panel'
10import { registerHandoff } from './m7-handoff'
11import { registerDiagram } from './m8-diagram'
12
13/**
14 * takgon-mods 단일 진입점. `hooks.json`의 `modules`는 경로 하나만 받으므로 각 모듈의 register를 여기서 묶는다.
15 *
16 * 제약: 엔진은 `$`를 import 경계 너머 함수로 넘기는 것을 거부하고, `read`/`update`의 atom은 같은 파일에서
17 * 선언돼야 한다. 모듈마다 자기 파일 안에서만 `$`를 쓰고, 모듈 사이 데이터는 같은 `plugin`/`key` 리터럴의
18 * atom(`types/index.d.ts` 계약)으로 주고받는다.
19 */
20export const register: Register = on => {
21 registerCore(on)
22 registerI18n(on)
23 registerIntegrations(on)
24 registerComposer(on)
25 registerPanel(on)
26 registerHandoff(on)
27 registerBrowser(on)
28 registerEdit(on)
29 registerDiagram(on)
30}
31src/core.ts 156 lines1import { atom, read, update } from 'claude-code'
2import type { CommandSpec, EngineInterface, On } from 'claude-code'
3
4import type { CommandRegistration, Lang, ModuleFlags } from '../types'
5import { t, toLang } from './shared/i18n'
6import type { MessageKey } from './shared/i18n'
7import { projectId } from './shared/keys'
8import { LANG_KEY, MODULES_KEY, MODULE_COMMANDS, commandsKey, formatCommandStatus, markNotified, parseModsArgs, refusalReason, registerCause, toCommandMap, toFlags } from './shared/settings'
9import { ALL_ON, MODULE_IDS } from './shared/state'
10
11const langAtom = atom({ plugin: 'takgon-mods', key: 'lang' } as const, 'ko')
12const modulesAtom = atom({ plugin: 'takgon-mods', key: 'modules' } as const, ALL_ON)
13const projectIdAtom = atom({ plugin: 'takgon-mods', key: 'projectId' } as const, '')
14const notifiedAtom = atom({ plugin: 'takgon-mods', key: 'notified' } as const, [])
15const commandsAtom = atom({ plugin: 'takgon-mods', key: 'commands' } as const, {})
16const restoredAtom = atom({ plugin: 'takgon-mods', key: 'restored' } as const, [])
17
18/**
19 * 명령을 등록한다. 같은 이름의 사용자·내장 명령 때문에 거부되면 예외를 삼켜 다른 초기화(이 모듈과 뒤쪽 모듈)를 막지 않고,
20 * 명령마다 세션당 1회 toast로 알린다. 결과는 `/mods` 목록용으로 기록한다. `$`는 import 경계를 넘을 수 없어 모듈 파일마다 같은 헬퍼를 둔다.
21 */
22const registerCommand = async ($: EngineInterface, spec: CommandSpec): Promise<void> => {
23 let result: CommandRegistration = { isListed: true }
24 try {
25 await $.command.register(spec)
26 } catch (error) {
27 result = { isListed: false, reason: refusalReason(error) }
28 try {
29 const marked = markNotified(await read($, notifiedAtom), registerCause(spec.name))
30 if (marked !== null) {
31 await update($, notifiedAtom, () => marked)
32 const vars = { name: spec.name, reason: result.reason ?? '' }
33 $.ui.toast(t(await read($, langAtom), 'mods.registerRefused', vars))
34 }
35 } catch {
36 // 알림 실패도 초기화를 막지 않는다.
37 }
38 }
39 // `/mods` 목록이 보여 줄 수 있게 결과를 state와 프로젝트 store(`/clear` 뒤 복원용)에 남긴다.
40 try {
41 await update($, commandsAtom, map => ({ ...map, [spec.name]: result }))
42 const key = commandsKey(projectId(await $.session.root()))
43 await $.store.set(key, { ...toCommandMap(await $.store.get(key)), [spec.name]: result })
44 } catch {
45 // 기록 실패도 초기화를 막지 않는다.
46 }
47}
48
49/** store 기준 현재 설정. 세션 상태가 아직 없을 수 있는 명령 처리에서도 쓴다. */
50const settings = async ($: EngineInterface): Promise<{ flags: ModuleFlags; lang: Lang }> => ({
51 flags: toFlags(await $.store.get(MODULES_KEY)),
52 lang: toLang(await $.store.get(LANG_KEY)),
53})
54
55/** 공통 상태(프로젝트·모듈 on/off·언어)를 store에서 다시 채우고 복원 표시를 남긴다. */
56const restore = async ($: EngineInterface): Promise<void> => {
57 await update($, restoredAtom, list => (list.includes('core') ? list : [...list, 'core']))
58 const root = await $.session.root()
59 const { flags, lang } = await settings($)
60 await update($, projectIdAtom, () => projectId(root))
61 await update($, notifiedAtom, () => [])
62 await update($, modulesAtom, () => flags)
63 await update($, langAtom, () => lang)
64 // 마지막 명령 등록 결과. 이번 세션의 등록이 곧 덮어쓰고, /clear 뒤에는 이 값이 `/mods`에 보인다.
65 const commands = toCommandMap(await $.store.get(commandsKey(projectId(root))))
66 await update($, commandsAtom, () => commands)
67}
68
69/**
70 * `/clear`·`/resume` 뒤에는 state가 비고 `session.start`도 오지 않는다(classic.SessionStart는 내장 보안 플러그인이
71 * user 플러그인을 건너뛰게 해 전달되지 않는다). 복원 표시가 없으면 첫 프롬프트·명령에서 다시 채운다.
72 */
73const ensureRestored = async ($: EngineInterface): Promise<void> => {
74 // 훅이 실패해 `.catch`가 답하면 같은 이벤트의 뒤쪽 훅(다른 명령 처리 등)이 건너뛰어지므로 여기서 삼킨다.
75 try {
76 if (!(await read($, restoredAtom)).includes('core')) {
77 await restore($)
78 }
79 } catch {
80 // 복원 실패 시 기본값으로 계속한다. 다음 진입에서 다시 시도하도록 표시를 되돌린다.
81 await update($, restoredAtom, list => list.filter(one => one !== 'core')).catch(() => undefined)
82 }
83}
84
85/** 세션 시작: 공통 상태 복원과 `/mods` 등록. */
86const start = async ($: EngineInterface): Promise<void> => {
87 await restore($)
88 const { lang } = await settings($)
89 await registerCommand($, {
90 name: 'mods',
91 description: t(lang, 'mods.description'),
92 argumentHint: '[<module> on|off | lang ko|en]',
93 })
94}
95
96/**
97 * 공통 코어: 세션 상태 복원과 `/mods` 명령.
98 *
99 * `$`는 import 경계를 넘길 수 없으므로, 이 파일이 쓰는 store·state 접근은 모두 이 파일 안에 둔다.
100 */
101export const registerCore = (on: On): void => {
102 on('session.start', async ($, e, next) => {
103 await start($)
104 return next(e)
105 })
106
107 // /clear·/resume 뒤 첫 프롬프트·명령에서 복원한다. core가 먼저 등록돼 다른 모듈 훅보다 바깥에서 돈다.
108 // 복원이 실패해도 프롬프트·명령은 그대로 진행한다.
109 on('prompt.submit', async ($, e, next) => {
110 await ensureRestored($)
111 return next(e)
112 }).catch(($, e, next) => next(e))
113
114 on('command.run', async ($, e, next) => {
115 await ensureRestored($)
116 return next(e)
117 }).catch(($, e, next) => next(e))
118
119 on('command.run', { command: 'mods' }, async ($, e) => {
120 const action = parseModsArgs(e.args)
121 const { flags, lang } = await settings($)
122
123 if (action.kind === 'toggle') {
124 const next = { ...flags, [action.module]: action.isOn }
125 await $.store.set(MODULES_KEY, next)
126 await update($, modulesAtom, () => next)
127 const state = t(lang, action.isOn ? 'mods.state.on' : 'mods.state.off')
128 return { text: t(lang, 'mods.toggled', { module: action.module, state }) }
129 }
130 if (action.kind === 'lang') {
131 await $.store.set(LANG_KEY, action.lang)
132 await update($, langAtom, () => action.lang)
133 return { text: t(action.lang, 'mods.langChanged', { lang: action.lang }) }
134 }
135 if (action.kind === 'usage') {
136 return { text: t(lang, 'mods.usage', { modules: MODULE_IDS.join(', ') }) }
137 }
138 return { text: listText(flags, lang, await read($, commandsAtom)) }
139 })
140}
141
142const listText = (
143 flags: ModuleFlags,
144 lang: Lang,
145 commands: Readonly<Record<string, CommandRegistration>>,
146): string => {
147 const rows = MODULE_IDS.map(id => {
148 const state = t(lang, flags[id] ? 'mods.state.on' : 'mods.state.off')
149 const names = MODULE_COMMANDS[id] ?? []
150 const status = names.length > 0 ? ` · ${formatCommandStatus(names, commands)}` : ''
151 return `- ${id} (${t(lang, `module.${id}` as MessageKey)}): ${state}${status}`
152 })
153 const lines = [t(lang, 'mods.title'), ...rows, t(lang, 'mods.commandsLegend'), t(lang, 'mods.lang', { lang })]
154 return lines.join('\n')
155}
156src/m1-i18n/index.tsx 56 lines1import { atom, read } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import { ALL_ON } from '../shared/state'
5import {
6 formatDuration,
7 translateBackgroundHint,
8 translateModes,
9 translatePromptHint,
10 translateSpinnerWord,
11} from './builtin'
12
13const langAtom = atom({ plugin: 'takgon-mods', key: 'lang' } as const, 'ko')
14const modulesAtom = atom({ plugin: 'takgon-mods', key: 'modules' } as const, ALL_ON)
15
16/** i18n 모듈이 켜져 있고 언어가 ko일 때만 내장 UI를 번역한다. */
17const isKorean = async ($: EngineInterface): Promise<boolean> =>
18 (await read($, modulesAtom)).i18n && (await read($, langAtom)) === 'ko'
19
20/**
21 * M1: 내장 UI 요소의 한국어화.
22 *
23 * 교체 가능한 범위는 `ui.render` 대상 중 문자열 prop이 있는 Spinner, TurnDuration, ToolProgress, PromptHint,
24 * SessionMode뿐이다. `/config`·`/help`·슬래시 메뉴·권한 다이얼로그·내장 명령 출력·status line은 교체할 수 없다.
25 * 이 플러그인 자체 UI는 각 모듈이 `t()`로 그린다.
26 */
27export const registerI18n = (on: On): void => {
28 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
29 const word = (await isKorean($)) ? translateSpinnerWord(e.props.word, e.props.mode, e.surface) : undefined
30 return word === undefined ? next(e) : next({ ...e, props: { ...e.props, word } })
31 })
32
33 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
34 if (e.surface !== 'terminal' || !(await isKorean($))) {
35 return next(e)
36 }
37 const { Text } = $.ui.resolve(e)
38 return <Text dimColor>✻ {formatDuration(e.props.durationMs)} 동안 작업</Text>
39 })
40
41 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
42 const hint = (await isKorean($)) ? translateBackgroundHint(e.props.hint) : undefined
43 return hint === undefined ? next(e) : next({ ...e, props: { ...e.props, hint } })
44 })
45
46 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
47 const hint = (await isKorean($)) ? translatePromptHint(e.props.hint) : undefined
48 return hint === undefined ? next(e) : next({ ...e, props: { ...e.props, hint } })
49 })
50
51 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
52 const modes = (await isKorean($)) ? translateModes(e.props.modes) : undefined
53 return modes === undefined ? next(e) : next({ ...e, props: { ...e.props, modes } })
54 })
55}
56src/m2-browser/index.tsx 295 lines1import { atom, read, update } from 'claude-code'
2import type { CommandSpec, EngineInterface, On } from 'claude-code'
3
4import type { BrowserInfo, CommandRegistration } from '../../types'
5import { projectId } from '../shared/keys'
6import { OUTPUT_GITIGNORE, commandsKey, markNotified, outputDir, refusalReason, registerCause, toCommandMap } from '../shared/settings'
7import { t } from '../shared/i18n'
8import { ALL_ON } from '../shared/state'
9import {
10 bashOutputText,
11 consoleContext,
12 extractLocalUrl,
13 HEADLESS_TIMEOUT_MS,
14 headlessArgv,
15 headlessCause,
16 isHttpUrl,
17 MAX_ERRORS,
18 openExternalArgv,
19 PANE_ID,
20 parseHeadlessOutput,
21 platformOf,
22 toBrowserInfo,
23} from './logic'
24import type { HeadlessCause, HeadlessResult } from './logic'
25import { tr } from './messages'
26import type { BrowserKey } from './messages'
27
28const langAtom = atom({ plugin: 'takgon-mods', key: 'lang' } as const, 'ko')
29const modulesAtom = atom({ plugin: 'takgon-mods', key: 'modules' } as const, ALL_ON)
30const notifiedAtom = atom({ plugin: 'takgon-mods', key: 'notified' } as const, [])
31const commandsAtom = atom({ plugin: 'takgon-mods', key: 'commands' } as const, {})
32const browserAtom = atom({ plugin: 'takgon-mods', key: 'browser' } as const, null)
33const attachConsoleAtom = atom({ plugin: 'takgon-mods', key: 'attachConsole' } as const, false)
34
35/**
36 * 명령을 등록한다. 같은 이름의 사용자·내장 명령 때문에 거부되면 예외를 삼켜 다른 초기화(이 모듈과 뒤쪽 모듈)를 막지 않고,
37 * 명령마다 세션당 1회 toast로 알린다. 결과는 `/mods` 목록용으로 기록한다. `$`는 import 경계를 넘을 수 없어 모듈 파일마다 같은 헬퍼를 둔다.
38 */
39const registerCommand = async ($: EngineInterface, spec: CommandSpec): Promise<void> => {
40 let result: CommandRegistration = { isListed: true }
41 try {
42 await $.command.register(spec)
43 } catch (error) {
44 result = { isListed: false, reason: refusalReason(error) }
45 try {
46 const marked = markNotified(await read($, notifiedAtom), registerCause(spec.name))
47 if (marked !== null) {
48 await update($, notifiedAtom, () => marked)
49 const vars = { name: spec.name, reason: result.reason ?? '' }
50 $.ui.toast(t(await read($, langAtom), 'mods.registerRefused', vars))
51 }
52 } catch {
53 // 알림 실패도 초기화를 막지 않는다.
54 }
55 }
56 // `/mods` 목록이 보여 줄 수 있게 결과를 state와 프로젝트 store(`/clear` 뒤 복원용)에 남긴다.
57 try {
58 await update($, commandsAtom, map => ({ ...map, [spec.name]: result }))
59 const key = commandsKey(projectId(await $.session.root()))
60 await $.store.set(key, { ...toCommandMap(await $.store.get(key)), [spec.name]: result })
61 } catch {
62 // 기록 실패도 초기화를 막지 않는다.
63 }
64}
65
66const TOAST_KEYS: Readonly<Record<HeadlessCause | 'open-failed', BrowserKey>> = {
67 'node-missing': 'toast.nodeMissing',
68 'playwright-missing': 'toast.playwrightMissing',
69 'headless-failed': 'toast.headlessFailed',
70 'open-failed': 'toast.openFailed',
71}
72
73const isOn = async ($: EngineInterface): Promise<boolean> => (await read($, modulesAtom)).browser
74
75/** 원인별로 한 번만 toast를 띄운다. */
76const notifyOnce = async ($: EngineInterface, cause: HeadlessCause | 'open-failed'): Promise<void> => {
77 const marked = markNotified(await read($, notifiedAtom), `m2/${cause}`)
78 if (marked === null) {
79 return
80 }
81 await update($, notifiedAtom, () => marked)
82 $.ui.toast(tr(await read($, langAtom), TOAST_KEYS[cause]))
83}
84
85/** 페이지 HTTP 상태. 연결 실패는 0. */
86const httpStatus = async ($: EngineInterface, url: string): Promise<number> => {
87 try {
88 return (await $.http.fetch(url, { method: 'GET' })).status
89 } catch {
90 return 0
91 }
92}
93
94/** 산출물 폴더(`.claude/takgon/browse`)를 정하고, 처음이면 `.gitignore`를 둔다. */
95const browseDir = async ($: EngineInterface): Promise<string> => {
96 const dir = outputDir(await $.session.root())
97 const ignore = `${dir}/.gitignore`
98 if (!(await $.fs.exists(ignore))) {
99 await $.fs.write(ignore, OUTPUT_GITIGNORE)
100 }
101 return `${dir}/browse`
102}
103
104/** headless 스크립트를 1회 실행한다. 실패는 원인으로 돌려준다. */
105const runHeadless = async (
106 $: EngineInterface,
107 url: string,
108): Promise<{ result?: HeadlessResult; cause?: HeadlessCause }> => {
109 const script = `${$.plugin.root.replace(/\\/g, '/')}/tools/headless.mjs`
110 const out = await browseDir($)
111 try {
112 const run = await $.process.run(headlessArgv(script, url, out), { timeoutMs: HEADLESS_TIMEOUT_MS })
113 const result = parseHeadlessOutput(run.stdout)
114 const cause = headlessCause(run.exitCode, result)
115 return cause === undefined ? { result } : { cause }
116 } catch {
117 // 시작 실패(ENOENT)와 시간 초과 모두 여기로 온다. 시간 초과는 드물어 node 없음으로 안내한다.
118 return { cause: 'node-missing' }
119 }
120}
121
122/** URL 하나를 확인해 browser 상태에 반영한다. */
123const browse = async ($: EngineInterface, url: string): Promise<BrowserInfo> => {
124 const status = await httpStatus($, url)
125 const { result, cause } = await runHeadless($, url)
126 if (cause !== undefined) {
127 await notifyOnce($, cause)
128 }
129 const info = toBrowserInfo(url, status, result, await $.clock.now())
130 await update($, browserAtom, () => info)
131 return info
132}
133
134/** 외부 브라우저로 연다. */
135const openExternal = async ($: EngineInterface): Promise<void> => {
136 const info = await read($, browserAtom)
137 if (info === null) {
138 return
139 }
140 try {
141 const run = await $.process.run(openExternalArgv(platformOf($.plugin.root), info.url), {
142 timeoutMs: 10_000,
143 })
144 if (run.exitCode !== 0) {
145 await notifyOnce($, 'open-failed')
146 }
147 } catch {
148 await notifyOnce($, 'open-failed')
149 }
150}
151
152/** pane의 새로고침 버튼: 지금 상태의 URL로 다시 확인한다. */
153const refresh = async ($: EngineInterface): Promise<void> => {
154 const info = await read($, browserAtom)
155 if (info !== null) {
156 await browse($, info.url)
157 }
158}
159
160/**
161 * M2 Inline Browser(범위 축소판).
162 *
163 * Bash 출력에서 로컬 개발 서버 URL을 감지하고, `/browse`가 HTTP 상태 확인과 headless 스크립트
164 * (`tools/headless.mjs`, Playwright) 1회 실행으로 제목·콘솔 에러·스크린샷을 모아 pane에 보인다.
165 * 인라인 페이지 표시는 어느 surface에도 불가하므로 Link와 외부 열기 버튼으로 대신한다.
166 */
167export const registerBrowser = (on: On): void => {
168 on('session.start', { isInteractive: true }, async ($, e, next) => {
169 const lang = await read($, langAtom)
170 await registerCommand($, {
171 name: 'browse',
172 description: tr(lang, 'cmd.description'),
173 argumentHint: '[url]',
174 })
175 return next(e)
176 })
177
178 // 결과를 받은 뒤에만 읽는다. 감지 실패가 도구 결과를 바꾸지 않도록 예외는 삼킨다.
179 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
180 const ran = await next(e)
181 try {
182 if (ran.deny !== undefined || !(await isOn($))) {
183 return ran
184 }
185 const url = extractLocalUrl(bashOutputText(ran))
186 const current = await read($, browserAtom)
187 if (url !== undefined && url !== current?.url) {
188 // checkedAt 0 = 아직 /browse로 확인하지 않음.
189 await update($, browserAtom, () => ({ url, status: 0, consoleErrors: [], checkedAt: 0 }))
190 }
191 } catch {
192 // 감지는 부가 기능이다.
193 }
194 return ran
195 }).catch(($, e, next) => next(e)) // next는 재실행 없이 도구 결과를 돌려준다(replay-safe).
196
197 on('command.run', { command: 'browse' }, async ($, e) => {
198 const lang = await read($, langAtom)
199 if (!(await isOn($))) {
200 return { text: tr(lang, 'cmd.off') }
201 }
202 const url = e.args.trim() || (await read($, browserAtom))?.url
203 if (url === undefined || url === '') {
204 return { text: tr(lang, 'cmd.noUrl') }
205 }
206 if (!isHttpUrl(url)) {
207 return { text: tr(lang, 'cmd.badUrl', { url }) }
208 }
209 const info = await browse($, url)
210 await $.ui.open({ id: PANE_ID, title: info.title ?? tr(lang, 'pane.title') })
211 return {
212 text: tr(lang, 'cmd.done', { url, status: info.status, count: info.consoleErrors.length }),
213 }
214 })
215
216 // 사용자가 직접 친 프롬프트에만, 켜 둔 경우 한 번 콘솔 에러를 붙인다.
217 on('prompt.submit', { origin: { kind: 'composer' } }, async ($, e, next) => {
218 if (!(await isOn($)) || !(await read($, attachConsoleAtom))) {
219 return next(e)
220 }
221 const info = await read($, browserAtom)
222 if (info === null || info.consoleErrors.length === 0) {
223 return next(e)
224 }
225 await update($, attachConsoleAtom, () => false)
226 return next({ ...e, context: [...(e.context ?? []), consoleContext(info)] })
227 }).catch(($, e, next) => next(e))
228
229 // matcher는 리터럴이어야 validate가 읽는다(PANE_ID와 같은 값).
230 on('ui.render', { component: 'Pane', requestId: 'takgon-browser' }, async ($, e) => {
231 const { Box, Text, Button, Link } = $.ui.resolve(e)
232 const lang = await read($, langAtom)
233 const info = await read($, browserAtom)
234 if (info === null) {
235 return <Text dimColor>{tr(lang, 'pane.empty')}</Text>
236 }
237 const isAttach = await read($, attachConsoleAtom)
238 const statusText =
239 info.status > 0
240 ? tr(lang, 'pane.status', { status: info.status })
241 : tr(lang, info.checkedAt === 0 ? 'pane.unchecked' : 'pane.unreachable')
242 const errors = info.consoleErrors.slice(0, MAX_ERRORS)
243 const hidden = info.consoleErrors.length - errors.length
244 const errorsHeader =
245 errors.length > 0
246 ? tr(lang, 'pane.errors', { count: info.consoleErrors.length })
247 : tr(lang, 'pane.noErrors')
248
249 let shot = null
250 if (info.screenshot !== undefined) {
251 if (e.surface === 'terminal') {
252 const { Image } = $.ui.resolve(e)
253 shot = (
254 <Image
255 key="screenshot"
256 source={{ file: info.screenshot, format: 'png', generation: info.checkedAt }}
257 columns={Math.min(100, Math.max(20, (e.viewport?.columns ?? 80) - 4))}
258 rows={Math.min(30, Math.max(6, (e.viewport?.rows ?? 24) - 12))}
259 alt={tr(lang, 'pane.screenshotAlt', { path: info.screenshot })}
260 />
261 )
262 } else {
263 shot = <Text dimColor>{info.screenshot}</Text>
264 }
265 }
266
267 return (
268 <Box flexDirection="column">
269 <Link href={info.url} />
270 <Text>{statusText}</Text>
271 {info.title !== undefined && <Text>{tr(lang, 'pane.pageTitle', { title: info.title })}</Text>}
272 <Text bold>{errorsHeader}</Text>
273 {errors.map(error => (
274 <Text color="red">- {error}</Text>
275 ))}
276 {hidden > 0 && <Text dimColor>{tr(lang, 'pane.more', { count: hidden })}</Text>}
277 {shot}
278 <Box flexDirection="row" gap={1}>
279 <Button
280 key="open-external"
281 label={tr(lang, 'button.openExternal')}
282 onPress={() => openExternal($)}
283 />
284 <Button
285 key="attach-console"
286 label={tr(lang, isAttach ? 'button.attachOn' : 'button.attachOff')}
287 onPress={() => update($, attachConsoleAtom, value => !value)}
288 />
289 <Button key="refresh" label={tr(lang, 'button.refresh')} onPress={() => refresh($)} />
290 </Box>
291 </Box>
292 )
293 })
294}
295src/m3-edit/index.tsx 319 lines1import { atom, read, update } from 'claude-code'
2import type { CommandSpec, EngineInterface, On } from 'claude-code'
3
4import type { CommandRegistration, EditTarget, Lang } from '../../types'
5import { projectId } from '../shared/keys'
6import { OUTPUT_GITIGNORE, commandsKey, markNotified, outputDir, refusalReason, registerCause, toCommandMap } from '../shared/settings'
7import { t } from '../shared/i18n'
8import { ALL_ON } from '../shared/state'
9import {
10 describeForModel,
11 isSamePath,
12 judgeEdit,
13 lineDelta,
14 parseEditArgs,
15 parseHeadlessOutput,
16 resolvePath,
17 sliceLines,
18 summarizeTarget,
19} from './logic'
20import type { EditAction } from './logic'
21import { tr } from './messages'
22
23const langAtom = atom({ plugin: 'takgon-mods', key: 'lang' } as const, 'ko')
24const modulesAtom = atom({ plugin: 'takgon-mods', key: 'modules' } as const, ALL_ON)
25const notifiedAtom = atom({ plugin: 'takgon-mods', key: 'notified' } as const, [])
26const commandsAtom = atom({ plugin: 'takgon-mods', key: 'commands' } as const, {})
27const browserAtom = atom({ plugin: 'takgon-mods', key: 'browser' } as const, null)
28const editTargetAtom = atom({ plugin: 'takgon-mods', key: 'editTarget' } as const, null)
29
30/**
31 * 명령을 등록한다. 같은 이름의 사용자·내장 명령 때문에 거부되면 예외를 삼켜 다른 초기화(이 모듈과 뒤쪽 모듈)를 막지 않고,
32 * 명령마다 세션당 1회 toast로 알린다. 결과는 `/mods` 목록용으로 기록한다. `$`는 import 경계를 넘을 수 없어 모듈 파일마다 같은 헬퍼를 둔다.
33 */
34const registerCommand = async ($: EngineInterface, spec: CommandSpec): Promise<void> => {
35 let result: CommandRegistration = { isListed: true }
36 try {
37 await $.command.register(spec)
38 } catch (error) {
39 result = { isListed: false, reason: refusalReason(error) }
40 try {
41 const marked = markNotified(await read($, notifiedAtom), registerCause(spec.name))
42 if (marked !== null) {
43 await update($, notifiedAtom, () => marked)
44 const vars = { name: spec.name, reason: result.reason ?? '' }
45 $.ui.toast(t(await read($, langAtom), 'mods.registerRefused', vars))
46 }
47 } catch {
48 // 알림 실패도 초기화를 막지 않는다.
49 }
50 }
51 // `/mods` 목록이 보여 줄 수 있게 결과를 state와 프로젝트 store(`/clear` 뒤 복원용)에 남긴다.
52 try {
53 await update($, commandsAtom, map => ({ ...map, [spec.name]: result }))
54 const key = commandsKey(projectId(await $.session.root()))
55 await $.store.set(key, { ...toCommandMap(await $.store.get(key)), [spec.name]: result })
56 } catch {
57 // 기록 실패도 초기화를 막지 않는다.
58 }
59}
60
61/** headless 스크립트 실행 제한 시간. */
62const HEADLESS_TIMEOUT_MS = 90_000
63
64const isOn = async ($: EngineInterface): Promise<boolean> => (await read($, modulesAtom)).edit
65
66/** `/edit` 등록. 세션 시작과 /clear·/resume 복원에서 부른다. */
67const registerEditCommand = async ($: EngineInterface): Promise<void> => {
68 const lang = await read($, langAtom)
69 await registerCommand($, {
70 name: 'edit',
71 description: tr(lang, 'edit.description'),
72 argumentHint: '<path>:<start>-<end> | sel | dom <selector> [url] | clear',
73 })
74}
75
76/** 같은 원인의 toast는 세션당 1회만. */
77const toastOnce = async ($: EngineInterface, cause: string, text: string): Promise<void> => {
78 const marked = markNotified(await read($, notifiedAtom), cause)
79 if (marked === null) {
80 return
81 }
82 await update($, notifiedAtom, () => marked)
83 $.ui.toast(text)
84}
85
86const setTarget = async ($: EngineInterface, lang: Lang, target: EditTarget): Promise<string> => {
87 await update($, editTargetAtom, () => target)
88 return tr(lang, 'edit.set', { target: summarizeTarget(lang, target) })
89}
90
91/** `/edit <path>:<start>-<end>`: 파일을 읽어 범위를 확인하고 대상으로 둔다. */
92const targetRange = async (
93 $: EngineInterface,
94 lang: Lang,
95 action: Extract<EditAction, { kind: 'range' }>,
96): Promise<string> => {
97 const path = resolvePath(await $.session.root(), action.path)
98 let text: string
99 try {
100 text = await $.fs.read(path)
101 } catch {
102 return tr(lang, 'edit.readFailed', { path })
103 }
104 const { lineCount, excerpt } = sliceLines(text, action.start, action.end)
105 if (excerpt === undefined) {
106 return tr(lang, 'edit.outOfRange', { lines: lineCount, start: action.start, end: action.end })
107 }
108 return setTarget($, lang, { kind: 'text', path, startLine: action.start, endLine: action.end, excerpt })
109}
110
111/** `/edit sel`: fullscreen 선택 영역을 발췌로만 쓴다(어느 파일인지 알 수 없다). */
112const targetSelection = async ($: EngineInterface, lang: Lang): Promise<string> => {
113 const selected = await $.ui.selection()
114 const excerpt = selected?.text.trim().slice(0, 2000) ?? ''
115 if (excerpt === '') {
116 return tr(lang, 'edit.noSelection')
117 }
118 return setTarget($, lang, { kind: 'text', path: '', startLine: 0, endLine: 0, excerpt })
119}
120
121/** 산출물 폴더의 `.gitignore`를 처음 한 번 만든다. */
122const ensureOutputDir = async ($: EngineInterface, dir: string): Promise<void> => {
123 const ignore = `${dir}/.gitignore`
124 if (!(await $.fs.exists(ignore))) {
125 await $.fs.write(ignore, OUTPUT_GITIGNORE)
126 }
127}
128
129/** `/edit dom <selector> [url]`: M2 headless 스크립트로 요소 정보를 모은다. */
130const targetDom = async (
131 $: EngineInterface,
132 lang: Lang,
133 action: Extract<EditAction, { kind: 'dom' }>,
134): Promise<string> => {
135 const url = action.url ?? (await read($, browserAtom))?.url
136 if (url === undefined || url === '') {
137 return tr(lang, 'edit.noUrl')
138 }
139 const script = `${$.plugin.root.replace(/\\/g, '/')}/tools/headless.mjs`
140 if (!(await $.fs.exists(script))) {
141 const text = tr(lang, 'edit.headlessMissing')
142 await toastOnce($, 'm3/headless-missing', text)
143 return text
144 }
145 const dir = outputDir(await $.session.root())
146 await ensureOutputDir($, dir)
147 const argv = ['node', script, '--url', url, '--out', `${dir}/browse`, '--selector', action.selector]
148 let stdout = ''
149 let exitCode = 1
150 try {
151 const ran = await $.process.run(argv, { timeoutMs: HEADLESS_TIMEOUT_MS })
152 stdout = ran.stdout
153 exitCode = ran.exitCode
154 } catch (error) {
155 const text = tr(lang, 'edit.headlessFailed', { reason: error instanceof Error ? error.message : 'error' })
156 await toastOnce($, 'm3/headless-failed', text)
157 return text
158 }
159 const result = parseHeadlessOutput(stdout)
160 if (exitCode === 2 || result?.error === 'playwright-missing') {
161 const text = tr(lang, 'edit.playwrightMissing')
162 await toastOnce($, 'm3/playwright-missing', text)
163 return text
164 }
165 const element = result?.ok ? result.element : undefined
166 if (element === undefined) {
167 const text = tr(lang, 'edit.headlessFailed', { reason: result?.error ?? `exit ${exitCode}` })
168 await toastOnce($, 'm3/headless-failed', text)
169 return text
170 }
171 return setTarget($, lang, {
172 kind: 'dom',
173 url,
174 selector: action.selector,
175 computedStyle: element.computedStyle,
176 screenshot: element.screenshot ?? result?.screenshot,
177 })
178}
179
180const runEdit = async ($: EngineInterface, args: string): Promise<string> => {
181 const lang = await read($, langAtom)
182 if (!(await isOn($))) {
183 return tr(lang, 'edit.off')
184 }
185 const action = parseEditArgs(args)
186 if (action.kind === 'range') {
187 return targetRange($, lang, action)
188 }
189 if (action.kind === 'sel') {
190 return targetSelection($, lang)
191 }
192 if (action.kind === 'dom') {
193 return targetDom($, lang, action)
194 }
195 if (action.kind === 'clear') {
196 await update($, editTargetAtom, () => null)
197 return tr(lang, 'edit.cleared')
198 }
199 if (action.kind === 'usage') {
200 return tr(lang, 'edit.usage')
201 }
202 const target = await read($, editTargetAtom)
203 return target === null
204 ? `${tr(lang, 'edit.none')}\n${tr(lang, 'edit.usage')}`
205 : tr(lang, 'edit.current', { target: summarizeTarget(lang, target) })
206}
207
208/** 가드가 볼 활성 파일 범위. 모듈 off·대상 없음·선택 발췌(파일 모름)·다른 파일이면 `undefined`. */
209const guardedRange = async (
210 $: EngineInterface,
211 filePath: string,
212): Promise<Extract<EditTarget, { kind: 'text' }> | undefined> => {
213 if (!(await isOn($))) {
214 return undefined
215 }
216 const target = await read($, editTargetAtom)
217 if (target === null || target.kind !== 'text' || target.path === '') {
218 return undefined
219 }
220 const path = resolvePath(await $.session.root(), filePath)
221 return isSamePath(path, target.path) ? target : undefined
222}
223
224/** Edit 판정. 거부 사유 문자열 또는 허용 시 매치 수. */
225const judgeEditCall = async (
226 $: EngineInterface,
227 target: Extract<EditTarget, { kind: 'text' }>,
228 filePath: string,
229 oldString: string,
230): Promise<{ deny: string } | { matches: number }> => {
231 const lang = await read($, langAtom)
232 const vars = { path: target.path, start: target.startLine, end: target.endLine }
233 let text: string
234 try {
235 text = await $.fs.read(filePath)
236 } catch {
237 return { deny: tr(lang, 'edit.deny.unreadable', vars) }
238 }
239 const verdict = judgeEdit(text, oldString, target.startLine, target.endLine)
240 if (verdict.isAllowed) {
241 return { matches: verdict.matches }
242 }
243 if (verdict.reason === 'not-found') {
244 return { deny: tr(lang, 'edit.deny.notFound', vars) }
245 }
246 const [from, to] = verdict.lines
247 return { deny: tr(lang, 'edit.deny.outside', { ...vars, from, to }) }
248}
249
250/** 허용된 Edit가 끝난 뒤 행 수 변화만큼 대상 범위 끝을 옮긴다. */
251const shiftTarget = async ($: EngineInterface, delta: number): Promise<void> => {
252 if (delta === 0) {
253 return
254 }
255 await update($, editTargetAtom, target =>
256 target?.kind === 'text' ? { ...target, endLine: Math.max(target.startLine, target.endLine + delta) } : target,
257 )
258}
259
260/** 가드 실패 시 거부 사유. catch 안에서 `$` 읽기가 거부될 수 있어 실패하면 ko로 둔다. */
261const guardFailedText = async ($: EngineInterface): Promise<string> => {
262 try {
263 return tr(await read($, langAtom), 'edit.deny.guardFailed')
264 } catch {
265 return tr('ko', 'edit.deny.guardFailed')
266 }
267}
268
269/**
270 * M3 Select-to-Edit.
271 *
272 * `/edit`로 대상(파일 행 범위·fullscreen 선택 발췌·DOM 요소)을 지정하면 사용자가 직접 친 프롬프트마다 대상 지시를
273 * `context`로 붙이고, 파일 대상이면 같은 파일의 범위 밖 Edit와 Write를 `tool.call`에서 거부한다.
274 * 이 빌드에는 MultiEdit 도구가 없어 Edit·Write만 가드한다.
275 */
276export const registerEdit = (on: On): void => {
277 on('session.start', { isInteractive: true }, async ($, e, next) => {
278 await registerEditCommand($)
279 return next(e)
280 })
281
282 on('command.run', { command: 'edit' }, async ($, e) => ({ text: await runEdit($, e.args) }))
283
284 on('prompt.submit', { origin: { kind: 'composer' } }, async ($, e, next) => {
285 const target = await read($, editTargetAtom)
286 if (target === null || !(await isOn($))) {
287 return next(e)
288 }
289 const context = describeForModel(await read($, langAtom), target)
290 return next({ ...e, context: [...(e.context ?? []), context] })
291 }).catch(($, e, next) => next(e))
292
293 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
294 const target = await guardedRange($, e.file_path)
295 if (target === undefined) {
296 return next(e)
297 }
298 const judged = await judgeEditCall($, target, e.file_path, e.old_string)
299 if ('deny' in judged) {
300 return { deny: judged.deny }
301 }
302 const ran = await next(e)
303 if (ran.deny === undefined && ran.isError !== true) {
304 await shiftTarget($, lineDelta(e.old_string, e.new_string, e.replace_all === true ? judged.matches : 1))
305 }
306 return ran
307 }).catch(async ($, e, next) => (next.called ? next(e) : { deny: await guardFailedText($) }))
308
309 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
310 const target = await guardedRange($, e.file_path)
311 if (target === undefined) {
312 return next(e)
313 }
314 const lang = await read($, langAtom)
315 const vars = { path: target.path, start: target.startLine, end: target.endLine }
316 return { deny: tr(lang, 'edit.deny.write', vars) }
317 }).catch(async ($, e, next) => (next.called ? next(e) : { deny: await guardFailedText($) }))
318}
319src/m4-composer/index.tsx 310 lines1import { atom, read, update } from 'claude-code'
2import type { CommandSpec, EngineInterface, On, PromptSubmitInput, PromptSubmitResult } from 'claude-code'
3
4import type { CommandRegistration, TaskItem } from '../../types'
5import { globalKey, projectId, projectKey } from '../shared/keys'
6import { t } from '../shared/i18n'
7import { commandsKey, markNotified, refusalReason, registerCause, toCommandMap } from '../shared/settings'
8import { ALL_ON } from '../shared/state'
9import {
10 buildForkPrompt,
11 fit,
12 formatPrBadge,
13 hadToolUseThisTurn,
14 mergeTasks,
15 needsCoach,
16 openTasks,
17 parseCoachArgs,
18 parseCoachReply,
19 parseNextSteps,
20 shouldFork,
21 toTasks,
22 toggleTask,
23} from './logic'
24import { tr } from './messages'
25
26const langAtom = atom({ plugin: 'takgon-mods', key: 'lang' } as const, 'ko')
27const notifiedAtom = atom({ plugin: 'takgon-mods', key: 'notified' } as const, [])
28const commandsAtom = atom({ plugin: 'takgon-mods', key: 'commands' } as const, {})
29const modulesAtom = atom({ plugin: 'takgon-mods', key: 'modules' } as const, ALL_ON)
30const prAtom = atom({ plugin: 'takgon-mods', key: 'pr' } as const, null)
31const tasksAtom = atom({ plugin: 'takgon-mods', key: 'tasks' } as const, [])
32const coachOnAtom = atom({ plugin: 'takgon-mods', key: 'coachOn' } as const, true)
33const restoredAtom = atom({ plugin: 'takgon-mods', key: 'restored' } as const, [])
34
35/**
36 * 명령을 등록한다. 같은 이름의 사용자·내장 명령 때문에 거부되면 예외를 삼켜 다른 초기화(이 모듈과 뒤쪽 모듈)를 막지 않고,
37 * 명령마다 세션당 1회 toast로 알린다. 결과는 `/mods` 목록용으로 기록한다. `$`는 import 경계를 넘을 수 없어 모듈 파일마다 같은 헬퍼를 둔다.
38 */
39const registerCommand = async ($: EngineInterface, spec: CommandSpec): Promise<void> => {
40 let result: CommandRegistration = { isListed: true }
41 try {
42 await $.command.register(spec)
43 } catch (error) {
44 result = { isListed: false, reason: refusalReason(error) }
45 try {
46 const marked = markNotified(await read($, notifiedAtom), registerCause(spec.name))
47 if (marked !== null) {
48 await update($, notifiedAtom, () => marked)
49 const vars = { name: spec.name, reason: result.reason ?? '' }
50 $.ui.toast(t(await read($, langAtom), 'mods.registerRefused', vars))
51 }
52 } catch {
53 // 알림 실패도 초기화를 막지 않는다.
54 }
55 }
56 // `/mods` 목록이 보여 줄 수 있게 결과를 state와 프로젝트 store(`/clear` 뒤 복원용)에 남긴다.
57 try {
58 await update($, commandsAtom, map => ({ ...map, [spec.name]: result }))
59 const key = commandsKey(projectId(await $.session.root()))
60 await $.store.set(key, { ...toCommandMap(await $.store.get(key)), [spec.name]: result })
61 } catch {
62 // 기록 실패도 초기화를 막지 않는다.
63 }
64}
65
66/** 코치 on/off 전역 store 키. */
67const COACH_KEY = globalKey('m4', 'coach')
68
69/** 코치 판정에 쓰는 작은 모델과 시간 상한. */
70const COACH_MODEL = 'haiku'
71const COACH_TIMEOUT_MS = 4000
72
73/** task 줄에서 버튼 표식·여백이 차지하는 칸. */
74const ROW_CHROME = 8
75
76/** 현재 프로젝트의 task store 키. */
77const tasksKey = async ($: EngineInterface): Promise<string> =>
78 projectKey(projectId(await $.session.root()), 'm4', 'tasks')
79
80/** composer 모듈이 켜져 있는지. */
81const isOn = async ($: EngineInterface): Promise<boolean> => (await read($, modulesAtom)).composer
82
83/** store에서 task 목록과 코치 설정을 불러와 state에 둔다. */
84const restore = async ($: EngineInterface): Promise<void> => {
85 const tasks = toTasks(await $.store.get(await tasksKey($)))
86 const coach = await $.store.get(COACH_KEY)
87 await update($, tasksAtom, () => tasks)
88 await update($, coachOnAtom, () => coach !== false)
89}
90
91/**
92 * 이번 세션에 아직 복원하지 않았으면 store에서 불러온다. `/clear`·`/resume`은 state(복원 표시 포함)를 비우고
93 * `session.start`를 다시 보내지 않으므로, 이 모듈의 훅에 처음 들어올 때 다시 채운다.
94 */
95const ensureRestored = async ($: EngineInterface): Promise<void> => {
96 if ((await read($, restoredAtom)).includes('m4')) {
97 return
98 }
99 await update($, restoredAtom, list => (list.includes('m4') ? list : [...list, 'm4']))
100 try {
101 await restore($)
102 } catch {
103 // 훅 실패로 번지면 같은 이벤트의 뒤쪽 훅이 건너뛰어지므로 삼키고, 다음 진입에서 다시 시도한다.
104 await update($, restoredAtom, list => list.filter(one => one !== 'm4'))
105 return
106 }
107}
108
109/** task 목록을 state와 store에 함께 쓴다. */
110const saveTasks = async ($: EngineInterface, fn: (tasks: TaskItem[]) => TaskItem[]): Promise<void> => {
111 const next = fn(await read($, tasksAtom))
112 await update($, tasksAtom, () => next)
113 await $.store.set(await tasksKey($), next)
114}
115
116/** 체크 토글: 완료 상태를 뒤집어 저장한다. */
117const onToggle = async ($: EngineInterface, id: string): Promise<void> => {
118 await saveTasks($, tasks => toggleTask(tasks, id))
119}
120
121/** task 텍스트를 프롬프트로 제출한다. 사용자가 고른 것이므로 본인 말로 보낸다. */
122const onSubmitTask = async ($: EngineInterface, text: string): Promise<void> => {
123 await $.prompt.submit({ text, asUser: true })
124}
125
126/** 이번 턴에 도구를 썼고 간격이 지났으면 fork로 다음 작업 후보를 만든다. 실패는 조용히 무시한다. */
127const suggestNextSteps = async (
128 $: EngineInterface,
129 lastAt: number | undefined,
130): Promise<number | undefined> => {
131 const now = await $.clock.now()
132 const hadTools = hadToolUseThisTurn(await $.session.messages())
133 if (!shouldFork(hadTools, lastAt, now)) {
134 return lastAt
135 }
136 const lang = await read($, langAtom)
137 const reply = await $.model.fork({ prompt: buildForkPrompt(lang, await read($, tasksAtom)) })
138 if (!reply.isAnswered) {
139 // nothing-to-fork는 요청이 없었으니 간격도 소모하지 않는다.
140 return reply.reason === 'nothing-to-fork' ? lastAt : now
141 }
142 const steps = parseNextSteps(reply.text)
143 if (steps.length > 0) {
144 await saveTasks($, tasks => mergeTasks(tasks, steps, now))
145 }
146 return now
147}
148
149/** 모호한 요청이면 다듬은 버전을 제안하고 사용자가 고른 대로 진행한다. 실패는 원문 통과. */
150const coachPrompt = async (
151 $: EngineInterface,
152 e: PromptSubmitInput,
153 next: (e: PromptSubmitInput) => Promise<PromptSubmitResult>,
154): Promise<PromptSubmitResult> => {
155 const isSkipped =
156 !(await isOn($)) ||
157 !(await read($, coachOnAtom)) ||
158 (e.attachments?.length ?? 0) > 0 ||
159 !needsCoach(e.text)
160 if (isSkipped) {
161 return next(e)
162 }
163 const lang = await read($, langAtom)
164 // 판정 호출이 거부(차단된 모델 등)돼도 훅 실패로 번지지 않게 여기서 잡는다. 훅이 실패해 `.catch`가 답하면
165 // 같은 이벤트에 걸린 다른 모듈(M2·M3·M7의 context 첨부)까지 건너뛰어진다.
166 let verdict: ReturnType<typeof parseCoachReply> = null
167 try {
168 const reply = await $.model.complete({
169 model: COACH_MODEL,
170 system: tr(lang, 'coach.system'),
171 prompt: tr(lang, 'coach.prompt', { text: e.text }),
172 maxTokens: 300,
173 timeoutMs: COACH_TIMEOUT_MS,
174 })
175 verdict = reply.isAnswered ? parseCoachReply(reply.text) : null
176 } catch {
177 return next(e)
178 }
179 if (verdict === null || !verdict.ambiguous || verdict.refined === '' || verdict.refined === e.text.trim()) {
180 return next(e)
181 }
182
183 const keep = tr(lang, 'coach.keep')
184 const refined = tr(lang, 'coach.refined')
185 const cancel = tr(lang, 'coach.cancel')
186 let answer: string
187 try {
188 answer = await $.ui.ask(tr(lang, 'coach.question', { refined: verdict.refined }), {
189 options: [keep, refined, cancel],
190 header: tr(lang, 'coach.header'),
191 })
192 } catch {
193 return next(e)
194 }
195 if (answer === refined) {
196 return next({ ...e, text: verdict.refined })
197 }
198 if (answer === cancel) {
199 return { drop: tr(lang, 'coach.dropped') }
200 }
201 return next(e)
202}
203
204/**
205 * M4: 프롬프트 위 Composer Bar.
206 *
207 * - band(`AbovePrompt`): M5의 PR 배지와 미완료 Next-steps 최대 3개. 표시할 것이 없거나 설문 중이면 엔진에 맡긴다.
208 * - Next-steps: 도구를 쓴 턴이 끝나면(2분 간격) `$.model.fork`로 후보를 만들어 `tasks`(M6와 공유)에 더한다.
209 * - Prompt-coach: 사용자가 직접 친 짧고 모호한 요청만 작은 모델로 판정하고 `$.ui.ask`로 고르게 한다.
210 */
211export const registerComposer = (on: On): void => {
212 let lastForkAt: number | undefined
213 let isForking = false
214
215 on('session.start', { isInteractive: true }, async ($, e, next) => {
216 await ensureRestored($)
217 const lang = await read($, langAtom)
218 await registerCommand($, {
219 name: 'coach',
220 description: tr(lang, 'coach.description'),
221 argumentHint: '[on|off]',
222 })
223 return next(e)
224 })
225
226 on('turn.complete', { reason: 'answer' }, async ($, e, next) => {
227 await ensureRestored($)
228 const result = await next(e)
229 if (e.agentId === undefined && !isForking && (await isOn($))) {
230 // fork는 시간 상한을 줄 수 없어 턴 마무리를 붙잡지 않도록 타이머로 떼어 낸다. 한 번에 하나만 돈다.
231 isForking = true
232 $.clock.after(0, async () => {
233 try {
234 lastForkAt = await suggestNextSteps($, lastForkAt)
235 } catch {
236 // Next-steps는 부가 기능이므로 실패해도 조용히 넘어간다.
237 } finally {
238 isForking = false
239 }
240 })
241 }
242 return result
243 })
244
245 on('prompt.submit', { origin: { kind: 'composer' } }, async ($, e, next) => {
246 await ensureRestored($)
247 return coachPrompt($, e, next)
248 }).catch(($, e, next) => next(e))
249
250 on('command.run', { command: 'coach' }, async ($, e) => {
251 await ensureRestored($)
252 const lang = await read($, langAtom)
253 if (!(await isOn($))) {
254 return { text: tr(lang, 'coach.moduleOff') }
255 }
256 const action = parseCoachArgs(e.args)
257 if (action.kind === 'usage') {
258 return { text: tr(lang, 'coach.usage') }
259 }
260 if (action.kind === 'set') {
261 await $.store.set(COACH_KEY, action.isOn)
262 await update($, coachOnAtom, () => action.isOn)
263 }
264 const isCoachOn = action.kind === 'set' ? action.isOn : await read($, coachOnAtom)
265 return { text: tr(lang, 'coach.state', { state: tr(lang, isCoachOn ? 'coach.on' : 'coach.off') }) }
266 })
267
268 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
269 if (e.props.hasSurvey || !(await isOn($))) {
270 return next(e)
271 }
272 const pr = await read($, prAtom)
273 const tasks = openTasks(await read($, tasksAtom))
274 if (pr === null && tasks.length === 0) {
275 return next(e)
276 }
277 const lang = await read($, langAtom)
278 const width = e.props.bodyColumns
279 const { Box, Button, Text } = $.ui.resolve(e)
280 return (
281 <Box flexDirection="column" width={width}>
282 {pr === null ? null : (
283 <Box key="pr-badge">
284 <Text bold wrap="truncate-end">
285 {formatPrBadge(lang, pr)}
286 </Text>
287 </Box>
288 )}
289 {tasks.map(one => {
290 const chip = one.kind === 'checkbox' ? '' : `[${tr(lang, `task.kind.${one.kind}`)}] `
291 const label = fit(`${chip}${one.text}`, Math.max(width - ROW_CHROME, 8))
292 return (
293 <Box key={`row-${one.id}`} flexDirection="row" gap={1}>
294 {one.kind === 'checkbox' ? (
295 <Button
296 key={`check-${one.id}`}
297 plain
298 label={one.isDone ? '[x]' : '[ ]'}
299 onPress={() => onToggle($, one.id)}
300 />
301 ) : null}
302 <Button key={`task-${one.id}`} plain label={label} onPress={() => onSubmitTask($, one.text)} />
303 </Box>
304 )
305 })}
306 </Box>
307 )
308 })
309}
310src/m5-integrations/index.tsx 464 lines1import { atom, read, update } from 'claude-code'
2import type { CommandSpec, EngineInterface, On, Timer } from 'claude-code'
3
4import type { CommandRegistration, IssueItem, Lang, PrStatus } from '../../types'
5import { t, toLang } from '../shared/i18n'
6import { projectId, projectKey } from '../shared/keys'
7import { LANG_KEY, MODULES_KEY, commandsKey, markNotified, refusalReason, registerCause, toCommandMap, toFlags } from '../shared/settings'
8import { ALL_ON } from '../shared/state'
9import {
10 addLinked,
11 formatIssues,
12 parseIssueArgs,
13 removeLinked,
14 replaceIssue,
15 toIssueList,
16} from './command'
17import { GH_PR_VIEW, GH_TIMEOUT_MS, classifyGhExit, parsePrView, shouldRefresh } from './github'
18import type { GhFailure } from './github'
19import {
20 GIT_REMOTE_URL,
21 GLAB_MR_VIEW,
22 approvalDecision,
23 classifyGlabExit,
24 detectForge,
25 glabApprovals,
26 parseMrView,
27} from './gitlab'
28import type { Forge, GlabFailure } from './gitlab'
29import {
30 LINEAR_URL,
31 LINK_LIMIT,
32 findState,
33 httpFailure,
34 issueBody,
35 parseIssue,
36 parseSearch,
37 parseUpdate,
38 searchBody,
39 updateBody,
40} from './linear'
41import type { IssueLookup } from './linear'
42import { tr } from './messages'
43import type { M5Key } from './messages'
44
45const langAtom = atom({ plugin: 'takgon-mods', key: 'lang' } as const, 'ko')
46const modulesAtom = atom({ plugin: 'takgon-mods', key: 'modules' } as const, ALL_ON)
47const notifiedAtom = atom({ plugin: 'takgon-mods', key: 'notified' } as const, [])
48const commandsAtom = atom({ plugin: 'takgon-mods', key: 'commands' } as const, {})
49const prAtom = atom({ plugin: 'takgon-mods', key: 'pr' } as const, null)
50const issueResultsAtom = atom({ plugin: 'takgon-mods', key: 'issueResults' } as const, [])
51const linkedIssuesAtom = atom({ plugin: 'takgon-mods', key: 'linkedIssues' } as const, [])
52const restoredAtom = atom({ plugin: 'takgon-mods', key: 'restored' } as const, [])
53
54/**
55 * 명령을 등록한다. 같은 이름의 사용자·내장 명령 때문에 거부되면 예외를 삼켜 다른 초기화(이 모듈과 뒤쪽 모듈)를 막지 않고,
56 * 명령마다 세션당 1회 toast로 알린다. 결과는 `/mods` 목록용으로 기록한다. `$`는 import 경계를 넘을 수 없어 모듈 파일마다 같은 헬퍼를 둔다.
57 */
58const registerCommand = async ($: EngineInterface, spec: CommandSpec): Promise<void> => {
59 let result: CommandRegistration = { isListed: true }
60 try {
61 await $.command.register(spec)
62 } catch (error) {
63 result = { isListed: false, reason: refusalReason(error) }
64 try {
65 const marked = markNotified(await read($, notifiedAtom), registerCause(spec.name))
66 if (marked !== null) {
67 await update($, notifiedAtom, () => marked)
68 const vars = { name: spec.name, reason: result.reason ?? '' }
69 $.ui.toast(t(await read($, langAtom), 'mods.registerRefused', vars))
70 }
71 } catch {
72 // 알림 실패도 초기화를 막지 않는다.
73 }
74 }
75 // `/mods` 목록이 보여 줄 수 있게 결과를 state와 프로젝트 store(`/clear` 뒤 복원용)에 남긴다.
76 try {
77 await update($, commandsAtom, map => ({ ...map, [spec.name]: result }))
78 const key = commandsKey(projectId(await $.session.root()))
79 await $.store.set(key, { ...toCommandMap(await $.store.get(key)), [spec.name]: result })
80 } catch {
81 // 기록 실패도 초기화를 막지 않는다.
82 }
83}
84
85/** PR 폴링 주기. */
86const POLL_MS = 60_000
87
88/** turn.complete 갱신을 생략하는 최소 간격. */
89const TURN_GAP_MS = 10_000
90
91/** 진행 중인 폴링 타이머와 직전 갱신 시각(세션 단위). */
92let poller: Timer | null = null
93let lastRefreshAt: number | null = null
94
95/** 원인 키 → toast 문구. */
96const TOAST_OF: Readonly<Record<string, M5Key>> = {
97 'gh-missing': 'toast.ghMissing',
98 'gh-auth': 'toast.ghAuth',
99 'glab-missing': 'toast.glabMissing',
100 'glab-auth': 'toast.glabAuth',
101 'linear-auth': 'toast.linearAuth',
102 'linear-http': 'toast.linearHttp',
103}
104
105/** 명령 처리처럼 세션 상태가 아직 없을 수 있는 곳에서도 store 기준 설정을 읽는다. */
106const settingsOf = async ($: EngineInterface): Promise<{ isOn: boolean; lang: Lang }> => ({
107 isOn: toFlags(await $.store.get(MODULES_KEY)).integrations,
108 lang: toLang(await $.store.get(LANG_KEY)),
109})
110
111/** 이 프로젝트의 연결 이슈 store 키. */
112const linkedKey = async ($: EngineInterface): Promise<string> =>
113 projectKey(projectId(await $.session.root()), 'm5', 'linkedIssues')
114
115/** 원인별 toast 1회. 같은 원인은 세션 동안 다시 알리지 않는다. */
116const notifyOnce = async ($: EngineInterface, cause: string): Promise<void> => {
117 const next = markNotified(await read($, notifiedAtom), cause)
118 const key = TOAST_OF[cause]
119 if (next === null || key === undefined) {
120 return
121 }
122 await update($, notifiedAtom, () => next)
123 $.ui.toast(tr(await read($, langAtom), key))
124}
125
126/** 원격 주소로 forge를 고른다. 원격이 없거나 git을 못 돌리면 null(배지를 조용히 숨긴다). */
127const forgeOf = async ($: EngineInterface): Promise<Forge | null> => {
128 try {
129 const run = await $.process.run(GIT_REMOTE_URL, { timeoutMs: GH_TIMEOUT_MS })
130 return run.exitCode === 0 ? detectForge(run.stdout) : null
131 } catch {
132 return null
133 }
134}
135
136/** `gh pr view`로 현재 브랜치 PR을 읽는다. */
137const githubStatus = async (
138 $: EngineInterface,
139): Promise<{ pr: PrStatus | null; failure: GhFailure | null }> => {
140 try {
141 const run = await $.process.run(GH_PR_VIEW, { timeoutMs: GH_TIMEOUT_MS })
142 return run.exitCode === 0
143 ? { pr: parsePrView(run.stdout), failure: null }
144 : { pr: null, failure: classifyGhExit(run.exitCode, run.stderr) }
145 } catch {
146 return { pr: null, failure: 'gh-missing' }
147 }
148}
149
150/** `glab mr view`로 현재 브랜치 MR을, 승인 API로 리뷰 상태를 읽는다. 승인 조회 실패는 MR 정보만 쓴다. */
151const gitlabStatus = async (
152 $: EngineInterface,
153): Promise<{ pr: PrStatus | null; failure: GlabFailure | null }> => {
154 let mr: PrStatus | null
155 try {
156 const run = await $.process.run(GLAB_MR_VIEW, { timeoutMs: GH_TIMEOUT_MS })
157 if (run.exitCode !== 0) {
158 return { pr: null, failure: classifyGlabExit(run.exitCode, run.stderr) }
159 }
160 mr = parseMrView(run.stdout)
161 } catch {
162 return { pr: null, failure: 'glab-missing' }
163 }
164 if (mr === null) {
165 return { pr: null, failure: null }
166 }
167 try {
168 const run = await $.process.run(glabApprovals(mr.number), { timeoutMs: GH_TIMEOUT_MS })
169 if (run.exitCode === 0) {
170 mr = { ...mr, reviewDecision: approvalDecision(mr.reviewDecision, run.stdout) }
171 }
172 } catch {
173 // 승인 정보는 부가 정보라 MR 배지는 그대로 보인다.
174 }
175 return { pr: mr, failure: null }
176}
177
178/**
179 * getStatus에 해당: 원격이 GitHub면 `gh`, 그 밖(GitLab·사내 셀프호스팅)이면 `glab`으로 현재 브랜치 PR/MR을 읽어
180 * `pr` atom을 갱신한다. PR/MR 없음은 조용히 null, 그 밖의 실패는 원인별 toast 1회.
181 */
182const getStatus = async ($: EngineInterface): Promise<void> => {
183 if (!(await read($, modulesAtom)).integrations) {
184 return
185 }
186 lastRefreshAt = await $.clock.now()
187 const forge = await forgeOf($)
188 if (forge === null) {
189 await update($, prAtom, () => null)
190 return
191 }
192 const { pr, failure } = forge === 'github' ? await githubStatus($) : await gitlabStatus($)
193 await update($, prAtom, () => pr)
194 if (failure !== null && failure !== 'no-pr') {
195 await notifyOnce($, failure)
196 }
197}
198
199/** Linear 키를 읽는다. 값은 요청 헤더에만 쓰고 어디에도 남기지 않는다. */
200const linearKey = async ($: EngineInterface): Promise<string | undefined> => {
201 const key = await $.env.get('LINEAR_API_KEY')
202 return key === undefined || key.trim() === '' ? undefined : key.trim()
203}
204
205/** Linear GraphQL 호출. 실패는 원인별 toast 1회 후 원인 키를 돌려준다. */
206const linearCall = async (
207 $: EngineInterface,
208 key: string,
209 body: string,
210): Promise<{ text: string } | { cause: string }> => {
211 let cause: string
212 try {
213 const res = await $.http.fetch(LINEAR_URL, {
214 method: 'POST',
215 headers: { 'Content-Type': 'application/json', Authorization: key },
216 body,
217 })
218 if (res.ok) {
219 return { text: res.text }
220 }
221 cause = httpFailure(res.status)
222 } catch {
223 cause = 'linear-http'
224 }
225 await notifyOnce($, cause)
226 return { cause }
227}
228
229/** search에 해당: Linear 이슈 검색. 실패는 원인 키. */
230const search = async (
231 $: EngineInterface,
232 key: string,
233 q: string,
234): Promise<IssueItem[] | string> => {
235 const res = await linearCall($, key, searchBody(q))
236 if ('cause' in res) {
237 return res.cause
238 }
239 return parseSearch(res.text) ?? 'linear-http'
240}
241
242/** identifier로 이슈와 팀 workflow states를 조회한다. */
243const lookup = async (
244 $: EngineInterface,
245 key: string,
246 id: string,
247): Promise<IssueLookup | string> => {
248 const res = await linearCall($, key, issueBody(id))
249 return 'cause' in res ? res.cause : parseIssue(res.text)
250}
251
252/** link에 해당: 이슈를 이 프로젝트의 로컬 매핑에 추가하고 store에 저장한다. */
253const link = async ($: EngineInterface, key: string, id: string, lang: Lang): Promise<string> => {
254 const found = await lookup($, key, id)
255 if (typeof found === 'string' || found.kind === 'error') {
256 return tr(lang, 'issue.failed', { cause: typeof found === 'string' ? found : 'linear-http' })
257 }
258 if (found.kind === 'missing') {
259 return tr(lang, 'issue.notFound', { id })
260 }
261 const storeKey = await linkedKey($)
262 const result = addLinked(toIssueList(await $.store.get(storeKey)), found.issue)
263 if (result.kind === 'duplicate') {
264 return tr(lang, 'issue.alreadyLinked', { identifier: found.issue.identifier })
265 }
266 if (result.kind === 'full') {
267 return tr(lang, 'issue.linkLimit', { max: LINK_LIMIT })
268 }
269 await $.store.set(storeKey, result.list)
270 await update($, linkedIssuesAtom, () => result.list)
271 return tr(lang, 'issue.linked', { identifier: found.issue.identifier })
272}
273
274/** 연결 해제. */
275const unlink = async ($: EngineInterface, id: string, lang: Lang): Promise<string> => {
276 const storeKey = await linkedKey($)
277 const next = removeLinked(toIssueList(await $.store.get(storeKey)), id)
278 if (next === null) {
279 return tr(lang, 'issue.notLinked', { identifier: id })
280 }
281 await $.store.set(storeKey, next)
282 await update($, linkedIssuesAtom, () => next)
283 return tr(lang, 'issue.unlinked', { identifier: id })
284}
285
286/** 상태 변경. 쓰기 작업이므로 `$.ui.ask`로 확인하고, 거절·dismiss면 취소한다. */
287const setStatus = async (
288 $: EngineInterface,
289 key: string,
290 id: string,
291 stateName: string,
292 lang: Lang,
293): Promise<string> => {
294 const found = await lookup($, key, id)
295 if (typeof found === 'string' || found.kind === 'error') {
296 return tr(lang, 'issue.failed', { cause: typeof found === 'string' ? found : 'linear-http' })
297 }
298 if (found.kind === 'missing') {
299 return tr(lang, 'issue.notFound', { id })
300 }
301 const target = findState(found.states, stateName)
302 if (target === undefined) {
303 const states = found.states.map(s => s.name).join(', ')
304 return tr(lang, 'issue.stateNotFound', { state: stateName, states })
305 }
306 const yes = tr(lang, 'issue.confirmYes')
307 const question = tr(lang, 'issue.confirmStatus', {
308 identifier: found.issue.identifier,
309 from: found.issue.state,
310 to: target.name,
311 })
312 let answer: string
313 try {
314 answer = await $.ui.ask(question, [yes, tr(lang, 'issue.confirmNo')])
315 } catch {
316 return tr(lang, 'issue.cancelled')
317 }
318 if (answer !== yes) {
319 return tr(lang, 'issue.cancelled')
320 }
321 const res = await linearCall($, key, updateBody(found.issue.id, target.id))
322 const changed = 'cause' in res ? null : parseUpdate(res.text)
323 if (changed === null) {
324 return tr(lang, 'issue.failed', { cause: 'cause' in res ? res.cause : 'linear-http' })
325 }
326 const storeKey = await linkedKey($)
327 const linked = toIssueList(await $.store.get(storeKey))
328 if (linked.some(one => one.identifier === changed.identifier)) {
329 const next = replaceIssue(linked, changed)
330 await $.store.set(storeKey, next)
331 await update($, linkedIssuesAtom, () => next)
332 }
333 await update($, issueResultsAtom, list => replaceIssue(list, changed))
334 return tr(lang, 'issue.statusChanged', { identifier: changed.identifier, state: changed.state })
335}
336
337/** `/issue` 처리. */
338const runIssue = async ($: EngineInterface, args: string): Promise<string> => {
339 const { isOn, lang } = await settingsOf($)
340 if (!isOn) {
341 return tr(lang, 'issue.moduleOff')
342 }
343 const key = await linearKey($)
344 if (key === undefined) {
345 return tr(lang, 'issue.noKey')
346 }
347 const action = parseIssueArgs(args)
348 if (action.kind === 'usage') {
349 return tr(lang, 'issue.usage')
350 }
351 if (action.kind === 'link') {
352 return link($, key, action.id, lang)
353 }
354 if (action.kind === 'unlink') {
355 return unlink($, action.id, lang)
356 }
357 if (action.kind === 'status') {
358 return setStatus($, key, action.id, action.state, lang)
359 }
360 if (action.kind === 'search') {
361 const found = await search($, key, action.q)
362 if (typeof found === 'string') {
363 return tr(lang, 'issue.failed', { cause: found })
364 }
365 await update($, issueResultsAtom, () => found)
366 return found.length === 0
367 ? tr(lang, 'issue.searchEmpty', { q: action.q })
368 : formatIssues(
369 lang,
370 tr(lang, 'issue.searchTitle', { q: action.q, count: found.length }),
371 found,
372 )
373 }
374 const linked = toIssueList(await $.store.get(await linkedKey($)))
375 await update($, linkedIssuesAtom, () => linked)
376 return linked.length === 0
377 ? tr(lang, 'issue.linkedEmpty')
378 : formatIssues(lang, tr(lang, 'issue.linkedTitle', { count: linked.length }), linked)
379}
380
381/** 연결 이슈를 store에서 복원한다. Linear 키가 없으면 Linear 요소는 숨긴다(빈 목록). */
382const restore = async ($: EngineInterface): Promise<void> => {
383 const hasKey = (await linearKey($)) !== undefined
384 const linked = hasKey ? toIssueList(await $.store.get(await linkedKey($))) : []
385 await update($, linkedIssuesAtom, () => linked)
386 await update($, issueResultsAtom, () => [])
387}
388
389/**
390 * 이번 세션에 아직 복원하지 않았으면 연결 이슈를 불러오고 PR 상태를 곧바로 다시 읽는다. `/clear`·`/resume`은
391 * state를 비우고 `session.start`를 다시 보내지 않으므로(폴링 타이머는 남는다), 훅에 처음 들어올 때 채운다.
392 */
393const ensureRestored = async ($: EngineInterface): Promise<void> => {
394 if ((await read($, restoredAtom)).includes('m5')) {
395 return
396 }
397 await update($, restoredAtom, list => (list.includes('m5') ? list : [...list, 'm5']))
398 try {
399 await restore($)
400 } catch {
401 // 훅 실패로 번지면 같은 이벤트의 뒤쪽 훅이 건너뛰어지므로 삼키고, 다음 진입에서 다시 시도한다.
402 await update($, restoredAtom, list => list.filter(one => one !== 'm5'))
403 return
404 }
405 $.clock.after(0, () => void getStatus($))
406}
407
408/** 세션 초기화: 명령 등록, 상태 복원, PR 폴링 시작. */
409const init = async ($: EngineInterface): Promise<void> => {
410 const { lang } = await settingsOf($)
411 // 등록이 거부돼도(이름 충돌 등) PR 폴링과 상태 복원은 계속한다.
412 await registerCommand($, {
413 name: 'issue',
414 description: tr(lang, 'issue.description'),
415 argumentHint: tr(lang, 'issue.argumentHint'),
416 })
417 await update($, restoredAtom, list => (list.includes('m5') ? list : [...list, 'm5']))
418 await restore($)
419 await update($, prAtom, () => null)
420 poller?.cancel()
421 lastRefreshAt = null
422 // 첫 갱신은 세션 시작을 막지 않도록 타이머로 미룬다(gh 응답 최대 15초).
423 $.clock.after(0, () => void getStatus($))
424 poller = $.clock.every(POLL_MS, () => void getStatus($))
425}
426
427/**
428 * M5: GitHub PR 상태와 Linear 이슈 연동.
429 *
430 * Provider 인터페이스(`shared/providers.ts`)는 `$`를 import 경계 너머로 넘겨야 해 그대로 쓸 수 없다.
431 * 같은 의미의 getStatus·search·link를 이 파일 최상위에 두고, 쿼리·해석은 `github.ts`·`linear.ts`로 분리했다.
432 */
433export const registerIntegrations = (on: On): void => {
434 on('session.start', { isInteractive: true }, async ($, e, next) => {
435 const result = await next(e)
436 await init($)
437 return result
438 })
439
440 // M7 인수인계가 연결 이슈를 읽으므로, /clear 뒤 첫 프롬프트에서 미리 복원해 둔다.
441 on('prompt.submit', { origin: { kind: 'composer' } }, async ($, e, next) => {
442 await ensureRestored($)
443 return next(e)
444 }).catch(($, e, next) => next(e))
445
446 on('turn.complete', { reason: 'answer' }, async ($, e, next) => {
447 await ensureRestored($)
448 const result = await next(e)
449 if (
450 (await read($, modulesAtom)).integrations &&
451 shouldRefresh(await $.clock.now(), lastRefreshAt, TURN_GAP_MS)
452 ) {
453 lastRefreshAt = await $.clock.now()
454 $.clock.after(0, () => void getStatus($))
455 }
456 return result
457 })
458
459 on('command.run', { command: 'issue' }, async ($, e) => {
460 await ensureRestored($)
461 return { text: await runIssue($, e.args) }
462 })
463}
464src/m6-panel/index.tsx 435 lines1import { atom, read, update } from 'claude-code'
2import type { CommandSpec, EngineInterface, On } from 'claude-code'
3
4import type { AgentRun, CommandRegistration, Lang, PanelTab, Pin, TaskItem, UsageSnapshot } from '../../types'
5import { t, toLang } from '../shared/i18n'
6import { globalKey, projectId, projectKey } from '../shared/keys'
7import { commandsKey, markNotified, refusalReason, registerCause, toCommandMap, toPercent } from '../shared/settings'
8import { ALL_ON } from '../shared/state'
9import {
10 PANE_ID,
11 TABS,
12 USAGE_THRESHOLD,
13 addPin,
14 fit,
15 formatTokens,
16 lastAssistantText,
17 parsePanelArgs,
18 parsePinArgs,
19 removePin,
20 applyAgentStatuses,
21 startAgent,
22 toPins,
23 toSnapshot,
24 toTasks,
25 toggleTask,
26 usageAlerts,
27 visible,
28} from './logic'
29import type { UsageReading } from './logic'
30import { tr } from './messages'
31
32const langAtom = atom({ plugin: 'takgon-mods', key: 'lang' } as const, 'ko')
33const modulesAtom = atom({ plugin: 'takgon-mods', key: 'modules' } as const, ALL_ON)
34const projectIdAtom = atom({ plugin: 'takgon-mods', key: 'projectId' } as const, '')
35const notifiedAtom = atom({ plugin: 'takgon-mods', key: 'notified' } as const, [])
36const commandsAtom = atom({ plugin: 'takgon-mods', key: 'commands' } as const, {})
37const panelTabAtom = atom({ plugin: 'takgon-mods', key: 'panelTab' } as const, 'pinboard')
38const pinsAtom = atom({ plugin: 'takgon-mods', key: 'pins' } as const, [])
39const tasksAtom = atom({ plugin: 'takgon-mods', key: 'tasks' } as const, [])
40const agentsAtom = atom({ plugin: 'takgon-mods', key: 'agents' } as const, [])
41const usageAtom = atom({ plugin: 'takgon-mods', key: 'usage' } as const, null)
42const usageThresholdAtom = atom({ plugin: 'takgon-mods', key: 'usageThreshold' } as const, USAGE_THRESHOLD)
43const handoffAtom = atom({ plugin: 'takgon-mods', key: 'handoff' } as const, null)
44const restoredAtom = atom({ plugin: 'takgon-mods', key: 'restored' } as const, [])
45
46/**
47 * 명령을 등록한다. 같은 이름의 사용자·내장 명령 때문에 거부되면 예외를 삼켜 다른 초기화(이 모듈과 뒤쪽 모듈)를 막지 않고,
48 * 명령마다 세션당 1회 toast로 알린다. 결과는 `/mods` 목록용으로 기록한다. `$`는 import 경계를 넘을 수 없어 모듈 파일마다 같은 헬퍼를 둔다.
49 */
50const registerCommand = async ($: EngineInterface, spec: CommandSpec): Promise<void> => {
51 let result: CommandRegistration = { isListed: true }
52 try {
53 await $.command.register(spec)
54 } catch (error) {
55 result = { isListed: false, reason: refusalReason(error) }
56 try {
57 const marked = markNotified(await read($, notifiedAtom), registerCause(spec.name))
58 if (marked !== null) {
59 await update($, notifiedAtom, () => marked)
60 const vars = { name: spec.name, reason: result.reason ?? '' }
61 $.ui.toast(t(await read($, langAtom), 'mods.registerRefused', vars))
62 }
63 } catch {
64 // 알림 실패도 초기화를 막지 않는다.
65 }
66 }
67 // `/mods` 목록이 보여 줄 수 있게 결과를 state와 프로젝트 store(`/clear` 뒤 복원용)에 남긴다.
68 try {
69 await update($, commandsAtom, map => ({ ...map, [spec.name]: result }))
70 const key = commandsKey(projectId(await $.session.root()))
71 await $.store.set(key, { ...toCommandMap(await $.store.get(key)), [spec.name]: result })
72 } catch {
73 // 기록 실패도 초기화를 막지 않는다.
74 }
75}
76
77const isOn = async ($: EngineInterface): Promise<boolean> => (await read($, modulesAtom)).panel
78
79const langOf = async ($: EngineInterface): Promise<Lang> => toLang(await read($, langAtom))
80
81/** 프로젝트 식별자. core가 아직 채우지 않았으면 루트에서 직접 계산한다. */
82const pidOf = async ($: EngineInterface): Promise<string> => {
83 const pid = await read($, projectIdAtom)
84 return pid !== '' ? pid : projectId(await $.session.root())
85}
86
87const pinsKey = (pid: string): string => projectKey(pid, 'm6', 'pins')
88
89/** M4와 같은 키. */
90const tasksKey = (pid: string): string => projectKey(pid, 'm4', 'tasks')
91
92/** 사용량 경고 임계치(%) 전역 store 키. 없으면 80. */
93const THRESHOLD_KEY = globalKey('m6', 'threshold')
94
95/** 핀·할 일을 store에서 다시 읽는다. 에이전트·사용량은 세션 범위라 복원하지 않는다. */
96const restore = async ($: EngineInterface): Promise<void> => {
97 const pid = await pidOf($)
98 const pins = toPins(await $.store.get(pinsKey(pid)))
99 const tasks = toTasks(await $.store.get(tasksKey(pid)))
100 const threshold = toPercent(await $.store.get(THRESHOLD_KEY), USAGE_THRESHOLD)
101 await update($, pinsAtom, () => pins)
102 await update($, tasksAtom, () => tasks)
103 await update($, usageThresholdAtom, () => threshold)
104}
105
106/**
107 * 이번 세션에 아직 복원하지 않았으면 store에서 불러온다. `/clear`·`/resume`은 state(복원 표시 포함)를 비우고
108 * `session.start`를 다시 보내지 않으므로, 이 모듈의 훅에 처음 들어올 때 다시 채운다. 실패는 삼키고 다음에 재시도한다.
109 */
110const ensureRestored = async ($: EngineInterface): Promise<void> => {
111 if ((await read($, restoredAtom)).includes('m6')) {
112 return
113 }
114 await update($, restoredAtom, list => (list.includes('m6') ? list : [...list, 'm6']))
115 try {
116 await restore($)
117 } catch {
118 await update($, restoredAtom, list => list.filter(one => one !== 'm6'))
119 }
120}
121
122/** 세션 시작: 명령 등록과 복원. */
123const init = async ($: EngineInterface): Promise<void> => {
124 const lang = await langOf($)
125 await registerCommand($, { name: 'panel', description: tr(lang, 'panel.description'), argumentHint: '[close | pinboard | tasks | agents | usage | handoff | threshold <1-99>]' })
126 await registerCommand($, { name: 'pin', description: tr(lang, 'pin.description'), argumentHint: '[<text>|list]' })
127 await ensureRestored($)
128}
129
130/** `$.agent.list()`로 서브에이전트 상태(완료·실패 포함)를 갱신한다. 목록을 못 읽으면 그대로 둔다. */
131const refreshAgents = async ($: EngineInterface): Promise<void> => {
132 try {
133 const listing = (await $.agent.list()).map(one => ({ id: one.id, type: one.type, status: one.status }))
134 const now = await $.clock.now()
135 await update($, agentsAtom, list => applyAgentStatuses(list, listing, now))
136 } catch {
137 // 상태 갱신은 부가 정보라 실패해도 넘어간다.
138 }
139}
140
141const savePins = async ($: EngineInterface, next: Pin[]): Promise<void> => {
142 await update($, pinsAtom, () => next)
143 await $.store.set(pinsKey(await pidOf($)), next)
144}
145
146/** 핀 추가. id는 시각 + 같은 시각 핀 개수로 만든다(난수 없이 고유). */
147const pinText = async ($: EngineInterface, text: string): Promise<void> => {
148 const pins = await read($, pinsAtom)
149 const now = await $.clock.now()
150 const base = `pin-${now.toString(36)}`
151 const id = `${base}-${pins.filter(pin => pin.id.startsWith(base)).length}`
152 await savePins($, addPin(pins, { id, text, createdAt: now }))
153}
154
155const unpin = async ($: EngineInterface, id: string): Promise<void> => {
156 await savePins($, removePin(await read($, pinsAtom), id))
157}
158
159/** 체크 토글 → atom과 M4 store 키에 같이 쓴다. */
160const toggle = async ($: EngineInterface, id: string): Promise<void> => {
161 const next = toggleTask(await read($, tasksAtom), id)
162 await update($, tasksAtom, () => next)
163 await $.store.set(tasksKey(await pidOf($)), next)
164}
165
166/** 사용량 상태를 갱신하고 80% 이상 항목을 원인별 세션당 1회 toast한다. */
167const measure = async ($: EngineInterface, reading: UsageReading): Promise<void> => {
168 const snapshot = toSnapshot(reading, await $.clock.now())
169 await update($, usageAtom, () => snapshot)
170 const lang = await langOf($)
171 const threshold = toPercent(await $.store.get(THRESHOLD_KEY), USAGE_THRESHOLD)
172 for (const alert of usageAlerts(snapshot, threshold)) {
173 const marked = markNotified(await read($, notifiedAtom), alert.cause)
174 if (marked === null) {
175 continue
176 }
177 await update($, notifiedAtom, () => marked)
178 $.ui.toast(
179 alert.kind === 'context'
180 ? tr(lang, 'usage.contextToast', { percent: alert.percent })
181 : tr(lang, 'usage.rateToast', { kind: alert.limit, percent: alert.percent }),
182 )
183 }
184}
185
186const STATUS_KEY = { running: 'agents.running', done: 'agents.done', failed: 'agents.failed' } as const
187
188const usageLines = (lang: Lang, usage: UsageSnapshot | null, threshold: number): string[] => {
189 const limit = tr(lang, 'usage.threshold', { percent: threshold })
190 if (usage === null) {
191 return [tr(lang, 'usage.empty'), limit]
192 }
193 const lines = [
194 tr(lang, 'usage.context', {
195 percent: usage.contextPercent ?? '-',
196 tokens: usage.contextTokens === undefined ? '-' : formatTokens(usage.contextTokens),
197 window: formatTokens(usage.contextWindow),
198 }),
199 ]
200 for (const rate of usage.rateLimits) {
201 const vars = { kind: rate.kind, percent: rate.percentUsed, resetsAt: rate.resetsAt ?? '' }
202 lines.push(tr(lang, rate.resetsAt === undefined ? 'usage.rate' : 'usage.rateReset', vars))
203 }
204 if (usage.costUsd !== undefined) {
205 lines.push(tr(lang, 'usage.cost', { usd: usage.costUsd.toFixed(2) }))
206 }
207 lines.push(limit)
208 return lines
209}
210
211const agentLine = (lang: Lang, run: AgentRun): string => {
212 const seconds = run.endedAt === undefined ? '' : ` ${Math.round((run.endedAt - run.startedAt) / 1000)}s`
213 return `${run.status === 'running' ? '●' : '○'} ${run.agentType} · ${tr(lang, STATUS_KEY[run.status])}${seconds}`
214}
215
216/**
217 * M6 Work Panel: `/panel`로 여는 pane(핀·할 일·에이전트·사용량·인수인계 탭)과 `/pin` 명령.
218 *
219 * 사용량 임계치는 `session.measure`(턴마다 push)에서 판정하고, 서브에이전트 실행은 `agent.spawn`과 `$.agent.list()`로
220 * 세션 범위에만 기록한다. 할 일은 M4와 같은 `tasks` atom·store 키를 공유한다.
221 */
222export const registerPanel = (on: On): void => {
223 on('session.start', { isInteractive: true }, async ($, e, next) => {
224 await init($)
225 return next(e)
226 })
227
228 // M7 인수인계가 핀을 읽으므로, /clear 뒤 첫 프롬프트에서 미리 복원해 둔다.
229 on('prompt.submit', { origin: { kind: 'composer' } }, async ($, e, next) => {
230 await ensureRestored($)
231 return next(e)
232 }).catch(($, e, next) => next(e))
233
234
235 on('command.run', { command: 'panel' }, async ($, e) => {
236 await ensureRestored($)
237 const lang = await langOf($)
238 if (!(await isOn($))) {
239 return { text: tr(lang, 'panel.off') }
240 }
241 const action = parsePanelArgs(e.args)
242 if (action === 'help') {
243 return { text: tr(lang, 'panel.usage') }
244 }
245 if (action === 'close') {
246 await $.ui.close({ id: 'takgon-panel' })
247 return { text: tr(lang, 'panel.closed') }
248 }
249 if (typeof action === 'object') {
250 if (action.value !== null) {
251 const value = action.value
252 await $.store.set(THRESHOLD_KEY, value)
253 await update($, usageThresholdAtom, () => value)
254 }
255 const value = action.value ?? toPercent(await $.store.get(THRESHOLD_KEY), USAGE_THRESHOLD)
256 return { text: tr(lang, 'panel.threshold', { value }) }
257 }
258 if (action !== 'open') {
259 await update($, panelTabAtom, () => action)
260 }
261 // 첫 measure 전이라도 Usage 탭이 비지 않게 한 번 채운다(breakdown 없이 무료 호출).
262 if ((await read($, usageAtom)) === null) {
263 try {
264 await measure($, await $.session.usage())
265 } catch {
266 // 사용량을 못 읽어도 패널은 연다.
267 }
268 }
269 await refreshAgents($)
270 await $.ui.open({ id: 'takgon-panel', title: tr(lang, 'panel.title') })
271 return { text: tr(lang, 'panel.opened') }
272 })
273
274 on('command.run', { command: 'pin' }, async ($, e) => {
275 await ensureRestored($)
276 const lang = await langOf($)
277 if (!(await isOn($))) {
278 return { text: tr(lang, 'panel.off') }
279 }
280 const action = parsePinArgs(e.args)
281 if (action.kind === 'list') {
282 const pins = await read($, pinsAtom)
283 if (pins.length === 0) {
284 return { text: tr(lang, 'pin.empty') }
285 }
286 const rows = pins.map((pin, i) => `${i + 1}. ${fit(pin.text, 200)}`)
287 return { text: [tr(lang, 'pin.listTitle', { count: pins.length }), ...rows].join('\n') }
288 }
289 let text = action.kind === 'add' ? action.text : undefined
290 if (action.kind === 'last') {
291 const rows = await $.session.messages()
292 text = Array.isArray(rows) ? lastAssistantText(rows) : undefined
293 }
294 if (text === undefined) {
295 return { text: tr(lang, 'pin.noReply') }
296 }
297 await pinText($, text)
298 return { text: tr(lang, 'pin.added', { text: fit(text, 80) }) }
299 })
300
301 // classic.SubagentStart/Stop은 내장 보안 플러그인이 user 플러그인을 건너뛰게 해 전달되지 않는다.
302 // 시작은 agent.spawn에서, 완료·실패는 $.agent.list()로 갱신한다.
303 on('agent.spawn', async ($, e, next) => {
304 const result = await next(e)
305 if (result.agentId !== undefined && (await isOn($))) {
306 const now = await $.clock.now()
307 await update($, agentsAtom, list => startAgent(list, result.agentId ?? '', e.subagentType, now))
308 }
309 return result
310 }).catch(($, e, next) => next(e))
311
312 on('session.measure', async ($, e, next) => {
313 if (await isOn($)) {
314 await ensureRestored($)
315 await measure($, e)
316 await refreshAgents($)
317 }
318 return next(e)
319 }).catch(($, e, next) => next(e))
320
321 on('ui.render', { component: 'Pane', requestId: 'takgon-panel' }, async ($, e) => {
322 const { Box, Text, Button } = $.ui.resolve(e)
323 const lang = await langOf($)
324 if (!(await isOn($))) {
325 return <Text dimColor>{tr(lang, 'panel.off')}</Text>
326 }
327 const tab: PanelTab = await read($, panelTabAtom)
328 const columns = Math.max(10, e.props.bodyColumns)
329 // 탭 줄과 여백 2줄을 뺀 나머지가 목록 높이다.
330 const rows = Math.max(1, (e.viewport?.rows ?? e.props.scroll.bodyRows) - 2)
331 const more = (hidden: number) =>
332 hidden > 0 ? <Text dimColor>{tr(lang, 'more', { count: hidden })}</Text> : null
333
334 const tabs = (
335 <Box flexDirection="row">
336 {TABS.map((one, i) => (
337 <Button
338 key={`tab-${one}`}
339 hotkey={String(i + 1)}
340 variant={one === tab ? 'primary' : 'secondary'}
341 onPress={() => update($, panelTabAtom, () => one)}
342 >
343 {tr(lang, `tab.${one}`)}
344 </Button>
345 ))}
346 </Box>
347 )
348
349 let body
350 if (tab === 'pinboard') {
351 const pins = [...(await read($, pinsAtom))].reverse()
352 const { shown, hidden } = visible(pins, rows)
353 body =
354 pins.length === 0 ? (
355 <Text dimColor>{fit(tr(lang, 'pin.empty'), columns)}</Text>
356 ) : (
357 <Box flexDirection="column">
358 {shown.map(pin => (
359 <Box flexDirection="row">
360 <Button key={`unpin-${pin.id}`} onPress={() => unpin($, pin.id)}>
361 {tr(lang, 'pin.remove')}
362 </Button>
363 <Text>{fit(pin.text, columns - 8)}</Text>
364 </Box>
365 ))}
366 {more(hidden)}
367 </Box>
368 )
369 } else if (tab === 'tasks') {
370 const tasks: TaskItem[] = await read($, tasksAtom)
371 const { shown, hidden } = visible(tasks, rows)
372 body =
373 tasks.length === 0 ? (
374 <Text dimColor>{tr(lang, 'tasks.empty')}</Text>
375 ) : (
376 <Box flexDirection="column">
377 {shown.map(task => (
378 <Button key={`task-${task.id}`} plain onPress={() => toggle($, task.id)}>
379 {fit(`${task.isDone ? '[x]' : '[ ]'} ${task.text}`, columns)}
380 </Button>
381 ))}
382 {more(hidden)}
383 </Box>
384 )
385 } else if (tab === 'agents') {
386 const agents = [...(await read($, agentsAtom))].reverse()
387 const { shown, hidden } = visible(agents, rows)
388 body =
389 agents.length === 0 ? (
390 <Text dimColor>{fit(tr(lang, 'agents.empty'), columns)}</Text>
391 ) : (
392 <Box flexDirection="column">
393 {shown.map(run => (
394 <Text dimColor={run.status !== 'running'}>{fit(agentLine(lang, run), columns)}</Text>
395 ))}
396 {more(hidden)}
397 </Box>
398 )
399 } else if (tab === 'usage') {
400 const { shown, hidden } = visible(usageLines(lang, await read($, usageAtom), await read($, usageThresholdAtom)), rows)
401 body = (
402 <Box flexDirection="column">
403 {shown.map(line => (
404 <Text>{fit(line, columns)}</Text>
405 ))}
406 {more(hidden)}
407 </Box>
408 )
409 } else {
410 const handoff = await read($, handoffAtom)
411 const lines = handoff === null ? [] : handoff.summary.split('\n').filter(line => line.trim() !== '')
412 const { shown, hidden } = visible(lines, rows - 1)
413 body =
414 handoff === null ? (
415 <Text dimColor>{fit(tr(lang, 'handoff.empty'), columns)}</Text>
416 ) : (
417 <Box flexDirection="column">
418 <Text dimColor>{fit(tr(lang, 'handoff.path', { path: handoff.path }), columns)}</Text>
419 {shown.map(line => (
420 <Text>{fit(line, columns)}</Text>
421 ))}
422 {more(hidden)}
423 </Box>
424 )
425 }
426
427 return (
428 <Box flexDirection="column">
429 {tabs}
430 {body}
431 </Box>
432 )
433 })
434}
435src/m7-handoff/index.tsx 288 lines1import { atom, read, update } from 'claude-code'
2import type { CommandSpec, EngineInterface, On } from 'claude-code'
3
4import type { CommandRegistration, HandoffInfo, Lang } from '../../types'
5import { t, toLang } from '../shared/i18n'
6import { globalKey, projectId, projectKey } from '../shared/keys'
7import { LANG_KEY, MODULES_KEY, OUTPUT_GITIGNORE, commandsKey, markNotified, outputDir, refusalReason, registerCause, toCommandMap, toFlags } from '../shared/settings'
8import { ALL_ON } from '../shared/state'
9import {
10 MAX_SUFFIX,
11 buildForkPrompt,
12 formatStamp,
13 formatTime,
14 handoffFileName,
15 parseHandoffArgs,
16 shouldTrigger,
17 summarize,
18 toHandoffInfo,
19 toThreshold,
20} from './logic'
21import { tr } from './messages'
22
23const langAtom = atom({ plugin: 'takgon-mods', key: 'lang' } as const, 'ko')
24const notifiedAtom = atom({ plugin: 'takgon-mods', key: 'notified' } as const, [])
25const commandsAtom = atom({ plugin: 'takgon-mods', key: 'commands' } as const, {})
26const modulesAtom = atom({ plugin: 'takgon-mods', key: 'modules' } as const, ALL_ON)
27const pinsAtom = atom({ plugin: 'takgon-mods', key: 'pins' } as const, [])
28const prAtom = atom({ plugin: 'takgon-mods', key: 'pr' } as const, null)
29const linkedIssuesAtom = atom({ plugin: 'takgon-mods', key: 'linkedIssues' } as const, [])
30const tasksAtom = atom({ plugin: 'takgon-mods', key: 'tasks' } as const, [])
31const handoffAtom = atom({ plugin: 'takgon-mods', key: 'handoff' } as const, null)
32const pendingAtom = atom({ plugin: 'takgon-mods', key: 'handoffPending' } as const, false)
33const firedAtom = atom({ plugin: 'takgon-mods', key: 'handoffFired' } as const, false)
34const restoredAtom = atom({ plugin: 'takgon-mods', key: 'restored' } as const, [])
35
36/**
37 * 명령을 등록한다. 같은 이름의 사용자·내장 명령 때문에 거부되면 예외를 삼켜 다른 초기화(이 모듈과 뒤쪽 모듈)를 막지 않고,
38 * 명령마다 세션당 1회 toast로 알린다. 결과는 `/mods` 목록용으로 기록한다. `$`는 import 경계를 넘을 수 없어 모듈 파일마다 같은 헬퍼를 둔다.
39 */
40const registerCommand = async ($: EngineInterface, spec: CommandSpec): Promise<void> => {
41 let result: CommandRegistration = { isListed: true }
42 try {
43 await $.command.register(spec)
44 } catch (error) {
45 result = { isListed: false, reason: refusalReason(error) }
46 try {
47 const marked = markNotified(await read($, notifiedAtom), registerCause(spec.name))
48 if (marked !== null) {
49 await update($, notifiedAtom, () => marked)
50 const vars = { name: spec.name, reason: result.reason ?? '' }
51 $.ui.toast(t(await read($, langAtom), 'mods.registerRefused', vars))
52 }
53 } catch {
54 // 알림 실패도 초기화를 막지 않는다.
55 }
56 }
57 // `/mods` 목록이 보여 줄 수 있게 결과를 state와 프로젝트 store(`/clear` 뒤 복원용)에 남긴다.
58 try {
59 await update($, commandsAtom, map => ({ ...map, [spec.name]: result }))
60 const key = commandsKey(projectId(await $.session.root()))
61 await $.store.set(key, { ...toCommandMap(await $.store.get(key)), [spec.name]: result })
62 } catch {
63 // 기록 실패도 초기화를 막지 않는다.
64 }
65}
66
67/** 최신본 store 키: `p/{pid}/m7/latest`. */
68const latestKey = async ($: EngineInterface): Promise<string> =>
69 projectKey(projectId(await $.session.root()), 'm7', 'latest')
70
71/**
72 * 모듈 on/off·언어. 초기화 훅은 core보다 먼저 돌 수 있으므로 store를 직접 읽고,
73 * 그 밖의 훅은 core가 채운 atom을 읽는다.
74 */
75const settingsFromStore = async ($: EngineInterface): Promise<{ isOn: boolean; lang: Lang }> => ({
76 isOn: toFlags(await $.store.get(MODULES_KEY)).handoff,
77 lang: toLang(await $.store.get(LANG_KEY)),
78})
79
80const isOn = async ($: EngineInterface): Promise<boolean> => (await read($, modulesAtom)).handoff
81
82/** 같은 분 파일이 있으면 `-2`, `-3`…을 붙인 첫 빈 경로. */
83const freePath = async ($: EngineInterface, dir: string, stamp: string): Promise<string> => {
84 for (let n = 1; n < MAX_SUFFIX; n += 1) {
85 const path = `${dir}/${handoffFileName(stamp, n)}`
86 if (!(await $.fs.exists(path))) {
87 return path
88 }
89 }
90 return `${dir}/${handoffFileName(stamp, MAX_SUFFIX)}`
91}
92
93/** fork로 인수인계 문서를 만들어 파일·atom·store에 남긴다. 실패하면 원인을 돌려준다. */
94const generate = async (
95 $: EngineInterface,
96): Promise<{ isSaved: true; info: HandoffInfo } | { isSaved: false; reason: string }> => {
97 const lang = await read($, langAtom)
98 const prompt = buildForkPrompt(lang, {
99 pins: await read($, pinsAtom),
100 pr: await read($, prAtom),
101 linkedIssues: await read($, linkedIssuesAtom),
102 tasks: await read($, tasksAtom),
103 })
104 const reply = await $.model.fork({ prompt })
105 if (!reply.isAnswered) {
106 return { isSaved: false, reason: reply.reason }
107 }
108
109 const dir = outputDir(await $.session.root())
110 if (!(await $.fs.exists(`${dir}/.gitignore`))) {
111 await $.fs.write(`${dir}/.gitignore`, OUTPUT_GITIGNORE)
112 }
113 const now = await $.clock.now()
114 const path = await freePath($, `${dir}/handoff`, formatStamp(now))
115 await $.fs.write(path, reply.text)
116
117 const info: HandoffInfo = { path, summary: summarize(reply.text), createdAt: now }
118 await update($, handoffAtom, () => info)
119 await $.store.set(await latestKey($), info)
120 return { isSaved: true, info }
121}
122
123/** 최신본 정보: 세션 상태, 없으면 store. */
124const latest = async ($: EngineInterface): Promise<HandoffInfo | null> =>
125 (await read($, handoffAtom)) ?? toHandoffInfo(await $.store.get(await latestKey($)))
126
127/** 최신본이 있으면 상태를 채우고 toast로 `/tk-handoff load`를 안내한다. */
128const restore = async ($: EngineInterface): Promise<void> => {
129 const { isOn: isEnabled, lang } = await settingsFromStore($)
130 if (!isEnabled) {
131 return
132 }
133 const info = toHandoffInfo(await $.store.get(await latestKey($)))
134 if (info === null || !(await $.fs.exists(info.path))) {
135 return
136 }
137 await update($, handoffAtom, () => info)
138 $.ui.toast(tr(lang, 'toast.available', { time: formatTime(info.createdAt) }))
139}
140
141/**
142 * 이번 세션에 아직 복원하지 않았으면 최신본을 불러온다. `/clear`·`/resume`은 state(복원 표시·세션당 1회 플래그 포함)를
143 * 비우고 `session.start`를 다시 보내지 않으므로, 이 모듈의 훅에 처음 들어올 때 다시 채우고 안내한다.
144 */
145const ensureRestored = async ($: EngineInterface): Promise<void> => {
146 if ((await read($, restoredAtom)).includes('m7')) {
147 return
148 }
149 await update($, restoredAtom, list => (list.includes('m7') ? list : [...list, 'm7']))
150 try {
151 await restore($)
152 } catch {
153 // 훅 실패로 번지면 같은 이벤트의 뒤쪽 훅이 건너뛰어지므로 삼키고, 다음 진입에서 다시 시도한다.
154 await update($, restoredAtom, list => list.filter(one => one !== 'm7'))
155 }
156}
157
158/** 세션 시작: 명령 등록과 복원. */
159const start = async ($: EngineInterface): Promise<void> => {
160 const { lang } = await settingsFromStore($)
161 await registerCommand($, {
162 name: 'tk-handoff',
163 description: tr(lang, 'cmd.description'),
164 argumentHint: '[show | load | threshold <1-99>]',
165 })
166 await ensureRestored($)
167}
168
169/** `/tk-handoff [show|load]` 처리. */
170/** 자동 인수인계 임계치(컨텍스트 %) 전역 store 키. 없으면 75. */
171const THRESHOLD_KEY = globalKey('m7', 'threshold')
172
173const runCommand = async ($: EngineInterface, args: string): Promise<string> => {
174 const lang = await read($, langAtom)
175 if (!(await isOn($))) {
176 return tr(lang, 'cmd.off')
177 }
178 const action = parseHandoffArgs(args)
179 if (action === 'usage') {
180 return tr(lang, 'cmd.usage')
181 }
182 if (typeof action === 'object') {
183 if (action.value !== null) {
184 await $.store.set(THRESHOLD_KEY, action.value)
185 }
186 const value = action.value ?? toThreshold(await $.store.get(THRESHOLD_KEY))
187 return tr(lang, 'cmd.threshold', { value })
188 }
189 if (action === 'create') {
190 const result = await generate($)
191 return result.isSaved
192 ? tr(lang, 'cmd.saved', { path: result.info.path })
193 : tr(lang, 'cmd.failed', { reason: result.reason })
194 }
195
196 const info = await latest($)
197 if (info === null) {
198 return tr(lang, 'cmd.none')
199 }
200 if (action === 'show') {
201 return tr(lang, 'cmd.show', { time: formatTime(info.createdAt), path: info.path, summary: info.summary })
202 }
203 if (!(await $.fs.exists(info.path))) {
204 return tr(lang, 'cmd.missing', { path: info.path })
205 }
206 await update($, handoffAtom, () => info)
207 await update($, pendingAtom, () => true)
208 return tr(lang, 'cmd.pending', { path: info.path })
209}
210
211/** 턴 종료 뒤 컨텍스트 사용률을 보고 세션당 1회 자동 생성한다. 실패는 조용히 넘긴다. */
212const autoHandoff = async ($: EngineInterface): Promise<void> => {
213 if (!(await isOn($)) || (await read($, firedAtom))) {
214 return
215 }
216 const usage = await $.session.usage()
217 const threshold = toThreshold(await $.store.get(THRESHOLD_KEY))
218 if (!shouldTrigger(usage.context.percent, false, threshold)) {
219 return
220 }
221 await update($, firedAtom, () => true)
222 const result = await generate($)
223 if (result.isSaved) {
224 $.ui.toast(tr(await read($, langAtom), 'toast.saved', { path: result.info.path }))
225 }
226}
227
228/** load 대기 중이면 이번 프롬프트에 붙일 파일 내용을, 아니면 null(대기는 1회로 해제). */
229const takePending = async ($: EngineInterface): Promise<string | null> => {
230 if (!(await read($, pendingAtom)) || !(await isOn($))) {
231 return null
232 }
233 await update($, pendingAtom, () => false)
234 const info = await read($, handoffAtom)
235 if (info === null || !(await $.fs.exists(info.path))) {
236 return null
237 }
238 const text = await $.fs.read(info.path)
239 return text.trim() === '' ? null : text
240}
241
242/**
243 * M7 Auto-handoff.
244 *
245 * 컨텍스트 사용률이 75% 이상이 된 첫 답변 뒤(세션당 1회) 또는 `/tk-handoff`로 `$.model.fork`에 인수인계 문서를 쓰게 해
246 * `{project}/.claude/takgon/handoff/`에 저장한다. 새 세션에서는 최신본을 안내하고, `/tk-handoff load`면 다음 프롬프트
247 * 1회에 문서를 context로 붙인다. 임계 판정은 `session.measure`(M6 전용) 대신 `turn.complete`에서 한다.
248 */
249export const registerHandoff = (on: On): void => {
250 let isGenerating = false
251
252 on('session.start', { isInteractive: true }, async ($, e, next) => {
253 await start($)
254 return next(e)
255 }).catch(($, e, next) => next(e))
256
257 on('command.run', { command: 'tk-handoff' }, async ($, e) => {
258 await ensureRestored($)
259 return { text: await runCommand($, e.args) }
260 })
261
262 on('turn.complete', { reason: 'answer' }, async ($, e, next) => {
263 await ensureRestored($)
264 const result = await next(e)
265 if (e.agentId === undefined && !isGenerating) {
266 // fork는 시간 상한을 줄 수 없어 턴 마무리를 붙잡지 않도록 타이머로 떼어 낸다. 한 번에 하나만 돈다.
267 isGenerating = true
268 $.clock.after(0, async () => {
269 try {
270 await autoHandoff($)
271 } catch {
272 // 자동 생성 실패는 답변 흐름에 영향을 주지 않는다.
273 } finally {
274 isGenerating = false
275 }
276 })
277 }
278 return result
279 })
280
281 // load 대기 중이면 사용자가 직접 친 다음 프롬프트 1회에 문서를 붙인다. 실패하면 프롬프트를 그대로 보낸다.
282 on('prompt.submit', { origin: { kind: 'composer' } }, async ($, e, next) => {
283 await ensureRestored($)
284 const text = await takePending($)
285 return text === null ? next(e) : next({ ...e, context: [...(e.context ?? []), text] })
286 }).catch(($, e, next) => next(e))
287}
288src/m8-diagram/index.tsx 258 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, ProcessRunResult } from 'claude-code'
3
4import type { DiagramRender } from '../../types'
5import { OUTPUT_GITIGNORE, markNotified, outputDir } from '../shared/settings'
6import { ALL_ON } from '../shared/state'
7import {
8 SVG_MAX_CHARS,
9 extractMermaid,
10 failureReason,
11 imageCells,
12 isCommandMissing,
13 keepRecent,
14 slimSvg,
15 splitMermaid,
16} from './mermaid'
17import type { MermaidBlock } from './mermaid'
18import { tr } from './messages'
19
20const langAtom = atom({ plugin: 'takgon-mods', key: 'lang' } as const, 'ko')
21const modulesAtom = atom({ plugin: 'takgon-mods', key: 'modules' } as const, ALL_ON)
22const notifiedAtom = atom({ plugin: 'takgon-mods', key: 'notified' } as const, [])
23const diagramsAtom = atom({ plugin: 'takgon-mods', key: 'diagrams' } as const, {})
24const diagramSourceAtom = atom({ plugin: 'takgon-mods', key: 'diagramSource' } as const, [])
25
26/** mmdc 한 번의 실행 시간 상한. */
27const MMDC_TIMEOUT_MS = 60_000
28
29/** toast 원인 키. */
30const CAUSE_MISSING = 'm8:mmdc-missing'
31const CAUSE_FAILED = 'm8:mmdc-failed'
32
33/** mmdc 실행 결과: 끝까지 돌았음(종료 코드 무관) 또는 실행 자체 불가. */
34type MmdcOutcome = { kind: 'ran'; result: ProcessRunResult } | { kind: 'missing' }
35
36/**
37 * `mmdc`를 argv로 실행한다. Windows의 npm 전역 설치는 `mmdc.cmd`라 shell 없이 시작되지 않으므로,
38 * 시작에 실패하면 `cmd /c mmdc ...`로 1회 재시도한다. 그것도 실패하거나 "명령 없음"이면 missing.
39 */
40const runMmdc = async ($: EngineInterface, args: readonly string[]): Promise<MmdcOutcome> => {
41 try {
42 return { kind: 'ran', result: await $.process.run(['mmdc', ...args], { timeoutMs: MMDC_TIMEOUT_MS }) }
43 } catch {
44 // 아래 cmd 재시도로 넘어간다.
45 }
46 try {
47 const result = await $.process.run(['cmd', '/c', 'mmdc', ...args], { timeoutMs: MMDC_TIMEOUT_MS })
48 return isCommandMissing(result.exitCode, result.stderr) ? { kind: 'missing' } : { kind: 'ran', result }
49 } catch {
50 return { kind: 'missing' }
51 }
52}
53
54/** 원인별 1회 toast. */
55const notifyOnce = async ($: EngineInterface, cause: string, text: string): Promise<void> => {
56 const marked = markNotified(await read($, notifiedAtom), cause)
57 if (marked !== null) {
58 await update($, notifiedAtom, () => marked)
59 $.ui.toast(text)
60 }
61}
62
63/** 산출물 폴더에 `.gitignore`가 없으면 만든다. */
64const ensureGitignore = async ($: EngineInterface, base: string): Promise<void> => {
65 const path = `${base}/.gitignore`
66 if (!(await $.fs.exists(path))) {
67 await $.fs.write(path, OUTPUT_GITIGNORE)
68 }
69}
70
71/** svg 원문을 읽어 내장 폰트를 뺀 뒤 desktop 표시 한도 이하일 때만 돌려준다. */
72const readSvg = async ($: EngineInterface, path: string): Promise<string | undefined> => {
73 try {
74 const text = slimSvg(await $.fs.read(path))
75 return text.length <= SVG_MAX_CHARS ? text : undefined
76 } catch {
77 return undefined
78 }
79}
80
81/**
82 * 블록 하나를 `{dir}/{hash}.svg|png`로 렌더한다. 이미 파일이 있으면(이전 세션 캐시) 다시 돌리지 않는다.
83 * mmdc를 실행할 수 없으면 `null`(결과를 남기지 않아 설치 후 다음 턴에 다시 시도된다).
84 */
85const renderBlock = async (
86 $: EngineInterface,
87 dir: string,
88 block: MermaidBlock,
89): Promise<DiagramRender | null> => {
90 const mmdPath = `${dir}/${block.hash}.mmd`
91 const svgPath = `${dir}/${block.hash}.svg`
92 const pngPath = `${dir}/${block.hash}.png`
93 const hasSvg = await $.fs.exists(svgPath)
94 const hasPng = await $.fs.exists(pngPath)
95 if (!hasSvg || !hasPng) {
96 await $.fs.write(mmdPath, block.code)
97 }
98 if (!hasSvg) {
99 const outcome = await runMmdc($, ['-i', mmdPath, '-o', svgPath])
100 if (outcome.kind === 'missing') {
101 return null
102 }
103 if (outcome.result.exitCode !== 0) {
104 const error = failureReason(outcome.result.stderr, outcome.result.exitCode)
105 await notifyOnce($, CAUSE_FAILED, tr(await read($, langAtom), 'diagram.renderFailed', { reason: error }))
106 return { hash: block.hash, error }
107 }
108 }
109 let isPngReady = hasPng
110 if (!hasPng) {
111 const outcome = await runMmdc($, ['-i', mmdPath, '-o', pngPath])
112 isPngReady = outcome.kind === 'ran' && outcome.result.exitCode === 0
113 }
114 return {
115 hash: block.hash,
116 svgPath,
117 pngPath: isPngReady ? pngPath : undefined,
118 svg: await readSvg($, svgPath),
119 }
120}
121
122/** 응답 텍스트: 이벤트의 answer, 비었으면 대화의 마지막 assistant 메시지. */
123const answerText = async ($: EngineInterface, answer: string): Promise<string> => {
124 if (answer !== '') {
125 return answer
126 }
127 const messages = await $.session.messages()
128 return messages.filter(message => message.role === 'assistant').at(-1)?.text ?? ''
129}
130
131/** 응답의 새 mermaid 블록을 미리 렌더해 `diagrams` atom에 넣는다. */
132const prerender = async ($: EngineInterface, answer: string): Promise<void> => {
133 const known = await read($, diagramsAtom)
134 const blocks = extractMermaid(await answerText($, answer)).filter(block => known[block.hash] === undefined)
135 if (blocks.length === 0) {
136 return
137 }
138 const base = outputDir(await $.session.root())
139 await ensureGitignore($, base)
140 const rendered: DiagramRender[] = []
141 for (const block of blocks) {
142 const result = await renderBlock($, `${base}/diagram`, block)
143 if (result === null) {
144 await notifyOnce($, CAUSE_MISSING, tr(await read($, langAtom), 'diagram.mmdcMissing'))
145 break
146 }
147 rendered.push(result)
148 }
149 if (rendered.length > 0) {
150 await update($, diagramsAtom, current => {
151 const next: Record<string, DiagramRender> = { ...current }
152 for (const one of rendered) {
153 next[one.hash] = one
154 }
155 return keepRecent(next)
156 })
157 }
158}
159
160/**
161 * M8 Diagram Render.
162 *
163 * - `turn.complete {reason:'answer'}`: 응답의 mermaid 블록을 `mmdc`로 `.claude/takgon/diagram/{hash}.svg|png`에
164 * 미리 렌더해 `diagrams` atom에 둔다. 무거운 일은 여기서만 한다.
165 * - `ui.render AssistantMessage`: 본문을 mermaid 블록 기준으로 나눠 나머지 조각은 엔진 그림(`next`)을
166 * 그대로 쓰고, 블록 자리에만 Svg(desktop·vscode·mobile) / Image(terminal) / 원본 Code를 넣는다. 읽기만 한다.
167 */
168export const registerDiagram = (on: On): void => {
169 on('turn.complete', { reason: 'answer' }, async ($, e, next) => {
170 const result = await next(e)
171 if ((await read($, modulesAtom)).diagram) {
172 await prerender($, e.answer)
173 }
174 return result
175 })
176
177 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
178 if (e.props.isSummary === true || !(await read($, modulesAtom)).diagram) {
179 return next(e)
180 }
181 const segments = splitMermaid(e.props.text)
182 if (!segments.some(segment => segment.kind === 'mermaid')) {
183 return next(e)
184 }
185 const lang = await read($, langAtom)
186 const diagrams = await read($, diagramsAtom)
187 const sources = await read($, diagramSourceAtom)
188 const { Box, Button, Code } = $.ui.resolve(e)
189 const cells = imageCells(e.viewport?.columns)
190 const seen = new Map<string, number>()
191 let isFirst = e.props.isFirstOfReply
192
193 const parts = []
194 for (const [index, segment] of segments.entries()) {
195 if (segment.kind === 'markdown') {
196 parts.push(await next({ ...e, props: { ...e.props, text: segment.text, isFirstOfReply: isFirst } }))
197 isFirst = false
198 continue
199 }
200 // 같은 블록이 한 메시지에 두 번 나오면 key가 겹치지 않게 순번을 붙인다.
201 const count = seen.get(segment.hash) ?? 0
202 seen.set(segment.hash, count + 1)
203 const suffix = count === 0 ? segment.hash : `${segment.hash}-${count}`
204 const code = <Code key={`code-${suffix}`} source={segment.code} language="mermaid" />
205 const render = diagrams[segment.hash]
206 const filePath = e.surface === 'terminal' ? render?.pngPath : (render?.svg ? render.svgPath : undefined)
207 if (render === undefined || render.error !== undefined || filePath === undefined) {
208 parts.push(code)
209 continue
210 }
211 const isSource = sources.includes(segment.hash)
212 let picture
213 if (e.surface === 'terminal') {
214 // kitty 계열만 그림을 그리고, 그 외 터미널은 엔진이 alt를 그린다.
215 const { Image } = $.ui.resolve(e)
216 picture = (
217 <Image
218 key={`img-${suffix}`}
219 source={{ file: filePath, format: 'png' }}
220 columns={cells.columns}
221 rows={cells.rows}
222 alt={tr(lang, 'diagram.altTerminal', { path: filePath })}
223 />
224 )
225 } else {
226 const { Svg } = $.ui.resolve(e)
227 picture = <Svg source={render.svg ?? ''} alt={tr(lang, 'diagram.alt')} />
228 }
229 parts.push(
230 <Box key={`diagram-${index}`} flexDirection="column">
231 {isSource ? code : picture}
232 <Box flexDirection="row" gap={1}>
233 <Button
234 key={`src-${suffix}`}
235 onPress={() => update($, diagramSourceAtom, list =>
236 list.includes(segment.hash) ? list.filter(h => h !== segment.hash) : [...list, segment.hash])}
237 >
238 {tr(lang, isSource ? 'diagram.showDiagram' : 'diagram.showSource')}
239 </Button>
240 <Button
241 key={`copy-${suffix}`}
242 onPress={async press => {
243 const copied = await $.ui.copy({ text: filePath, surface: press.surface })
244 $.ui.toast(copied.isCopied
245 ? tr(lang, 'diagram.copied', { path: filePath })
246 : tr(lang, 'diagram.copyFailed', { reason: copied.reason }))
247 }}
248 >
249 {tr(lang, 'diagram.copyPath')}
250 </Button>
251 </Box>
252 </Box>,
253 )
254 }
255 return <Box flexDirection="column">{parts}</Box>
256 })
257}
258src/shared/i18n.ts 80 lines1import type { Lang } from '../../types'
2
3/** 기본(ko) 사전. 키 목록의 단일 출처다. */
4const ko = {
5 'mods.title': 'takgon-mods 모듈',
6 'mods.state.on': '켜짐',
7 'mods.state.off': '꺼짐',
8 'mods.lang': '언어: {lang}',
9 'mods.commandsLegend': '명령: ✓ 등록됨 · ✗ 등록 거부(사유) · ? 아직 등록 결과 없음',
10 'mods.toggled': '{module} 모듈: {state}',
11 'mods.langChanged': '언어를 {lang}(으)로 바꿨습니다.',
12 'mods.usage': '사용법: /mods | /mods <모듈> on|off | /mods lang ko|en\n모듈: {modules}',
13 'mods.description': 'takgon-mods 모듈 목록·토글·언어 설정',
14 'mods.registerRefused': '/{name} 명령을 등록하지 못했습니다({reason}). 같은 이름의 명령·스킬이 대신 실행됩니다.',
15 'module.i18n': '다국어',
16 'module.integrations': 'GitHub PR·Linear 연동',
17 'module.composer': '프롬프트 위 작업 바',
18 'module.panel': '작업 패널',
19 'module.handoff': '자동 인수인계',
20 'module.browser': '브라우저 미리보기',
21 'module.edit': '선택 영역 수정',
22 'module.diagram': '다이어그램 렌더링',
23} as const
24
25/** 메시지 키. */
26export type MessageKey = keyof typeof ko
27
28const en: Record<MessageKey, string> = {
29 'mods.title': 'takgon-mods modules',
30 'mods.state.on': 'on',
31 'mods.state.off': 'off',
32 'mods.lang': 'Language: {lang}',
33 'mods.commandsLegend': 'Commands: ✓ registered · ✗ refused (reason) · ? no result yet',
34 'mods.toggled': '{module} module: {state}',
35 'mods.langChanged': 'Language set to {lang}.',
36 'mods.usage': 'Usage: /mods | /mods <module> on|off | /mods lang ko|en\nModules: {modules}',
37 'mods.description': 'List and toggle takgon-mods modules, set language',
38 'mods.registerRefused': 'Could not register /{name} ({reason}). A command or skill with the same name runs instead.',
39 'module.i18n': 'Localization',
40 'module.integrations': 'GitHub PR / Linear',
41 'module.composer': 'Composer bar',
42 'module.panel': 'Work panel',
43 'module.handoff': 'Auto handoff',
44 'module.browser': 'Browser preview',
45 'module.edit': 'Select to edit',
46 'module.diagram': 'Diagram render',
47}
48
49const dictionaries: Record<Lang, Record<MessageKey, string>> = { ko, en }
50
51/** 지원 언어. */
52export const LANGS: readonly Lang[] = ['ko', 'en']
53
54/** 기본 언어. */
55export const DEFAULT_LANG: Lang = 'ko'
56
57/** 자리표시자 값. */
58export type MessageVars = Readonly<Record<string, string | number>>
59
60/** `{name}` 자리표시자를 채운다. */
61export const format = (template: string, vars: MessageVars = {}): string =>
62 template.replace(/\{(\w+)\}/g, (whole, name: string) => (name in vars ? String(vars[name]) : whole))
63
64/** 코어 사전의 `key`를 `lang`으로 번역한다. */
65export const t = (lang: Lang, key: MessageKey, vars: MessageVars = {}): string =>
66 format(dictionaries[lang][key], vars)
67
68/**
69 * 모듈 전용 사전으로 번역기를 만든다. 각 모듈은 `src/m<N>-<name>/messages.ts`에 ko(키의 단일 출처)와 en을 두고
70 * `export const tr = createTranslator(ko, en)`으로 쓴다. 사전을 모듈별로 나눠 병렬 작업 시 충돌을 피한다.
71 */
72export const createTranslator =
73 <K extends string>(ko: Readonly<Record<K, string>>, en: Readonly<Record<K, string>>) =>
74 (lang: Lang, key: K, vars: MessageVars = {}): string =>
75 format((lang === 'en' ? en : ko)[key], vars)
76
77/** 저장된 값이 지원 언어면 그대로, 아니면 기본 언어. */
78export const toLang = (value: unknown): Lang =>
79 LANGS.includes(value as Lang) ? (value as Lang) : DEFAULT_LANG
80src/shared/keys.ts 31 lines1/**
2 * `$.store` 키 네임스페이스.
3 *
4 * store는 플러그인 전용이지만 모든 프로젝트가 한 파일을 공유한다. Windows 경로(`C:\...`)의 콜론과
5 * 섞이지 않도록 구분자는 `/`를 쓰고, 프로젝트는 루트 경로 해시로 식별한다.
6 */
7
8/** 루트 경로를 표기 차이(대소문자, 구분자, 끝 슬래시)와 무관하게 맞춘다. */
9export const normalizeRoot = (root: string): string =>
10 root.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
11
12/** FNV-1a 32bit 해시를 8자리 hex로 낸다. */
13const fnv1a = (text: string): string => {
14 let hash = 0x811c9dc5
15 for (let i = 0; i < text.length; i += 1) {
16 hash ^= text.charCodeAt(i)
17 hash = Math.imul(hash, 0x01000193)
18 }
19 return (hash >>> 0).toString(16).padStart(8, '0')
20}
21
22/** 프로젝트 루트 경로 → 프로젝트 식별자. */
23export const projectId = (root: string): string => fnv1a(normalizeRoot(root))
24
25/** 프로젝트 범위 키: `p/{projectId}/{module}/{key}`. */
26export const projectKey = (pid: string, module: string, key: string): string =>
27 `p/${pid}/${module}/${key}`
28
29/** 전역 키: `g/{module}/{key}`. */
30export const globalKey = (module: string, key: string): string => `g/${module}/${key}`
31