답 형식(첫 문단·접히는 설명·내 차례), 내 차례 모으기, 옆 패널(내 차례·곁대화), 토론. /baton on으로 고른 터미널 대화형 세션에서만 켜진다. 명령: /baton, /baton-side, /baton-e, /baton-debate

Claude Code 터미널 세션에 붙이는 mod 네 개를 묶은 플러그인. /baton on으로 고른 대화형 세션에서만 켜진다. 고르지 않은 세션에는 /baton 명령 하나만 보이고, 나머지 hook은 아무것도 하지 않는다. 외울 것은 /baton 하나다: 인자 없이 치면(/baton 도움말처럼 on·off가 아닌 말을 붙여도 같다) 상태와 쓸 수 있는 것을 화면별로 보여 준다. 다른 명령도 모두 /baton으로 시작해서, 켠 세션에서 입력창에 /baton까지 치면 넷이 목록에 함께 뜬다.
명령은 훅 모듈이 $.command.register로 등록한다. 이 방법은 플러그인 이름을 붙이지 않고(/플러그인:이름은 마크다운 명령·스킬에만 붙는다) 이름에 콜론을 받지 않아서, 플러그인 이름을 하이픈 머리말로 붙인다.
설명 목차: … 줄이 붙고, 버튼이나 /baton-e로 펼친다.↩ 내 차례 N으로 보여 준다. 답할 때마다 판정 모델(judge_model)을 한 번 부른다.tui: fullscreen)의 옆 패널에 내 차례와 곁대화를 둔다. 내 차례 항목마다 [답하기]·[지우기]가 있고, 지운 직후에는 [되돌리기]가 뜬다. 곁대화는 도구 없이 답하고, 곁질문 자동 분리는 패널의 [자동 분리 켜기]로 켠다./baton-debate만 치면 지금 대화를 이어받은 모델($.model.fork, 본 대화 모델로 한 번 호출)이 주제·두 편 이름·배경을 골라 바로 시작한다. 결과는 패널의 [본 대화로 보내기]를 눌렀을 때만 본 대화에 들어간다./plugin install baton --marketplace chenjingdev/chenjing-plugins
설치한 뒤 쓰려는 세션에서 /baton on을 한다. 고른 세션은 --resume으로 돌아와도 켜져 있다. -p, SDK, 데스크톱 앱 세션에서는 켜지지 않는다.
0.6.0까지는 이름이 chenjing-mods였다(명령 /mods·/side·/e·/debate). 그 이름으로 설치했다면 claude plugin uninstall chenjing-mods@chenjing-plugins 뒤 claude plugin install baton@chenjing-plugins로 바꾼다. 켜 둔 세션 목록과 지난 세션의 내 차례는 이름을 따라오지 않으니, 쓰려는 세션에서 /baton on을 다시 한다.
| 명령 | 하는 일 |
|---|---|
/baton on, /baton off, /baton | 이 세션에서 켜기, 끄기. 그 밖(인자 없이, 도움말 등)은 상태와 쓸 수 있는 것 전부 |
/baton-e, /baton-e 2 | 마지막 답(2면 그 전 답)의 설명 펼치기·접기 |
/baton-side, /baton-side 질문 | 옆 패널 열기, 곁대화에 바로 묻기 |
/baton-debate, /baton-debate [라운드] 주제, /baton-debate stop | 지금 대화에서 주제를 골라 토론, 주제를 직접 줘서 토론(라운드 기본·최대 10), 중지 |
나머지는 패널 버튼과 단축키로 한다: 내 차례의 답하기·[지우기]·되돌리기·보내기(s), 곁대화의 곁질문(q)·본 대화로 보내기·[자동 분리 켜기], 토론 탭의 [토론 중지]·본 대화로 보내기. 입력창에서 패널로는 ctrl+x tab. 예전 하위 명령(/baton-side main, list, drop N, clear, undo, auto on|off, status)도 그대로 받는다.
/config에서 바꾸거나 settings.json의 pluginConfigs["baton@chenjing-plugins"].options에 적는다.
| 항목 | 기본값 | 뜻 |
|---|---|---|
judge_model | sonnet | 내 차례를 뽑고 닫는 판정 모델 |
classify_model | haiku | 곁질문인지 가르는 모델 |
side_auto | off | 작업과 관계없어 보이는 질문을 곁대화로 자동으로 보낼지 |
debate_dir | ~/.claude/baton/debates | 토론 기록(.md)을 쓰는 폴더 |
debate_work_dir | 비움 | 토론자 세션이 도는 폴더. 비우면 토론을 연 세션의 작업 폴더. 신뢰한 폴더여야 하고, 토론자는 그 폴더의 CLAUDE.md를 읽는다 |
prompt.compose hook이 내장 cc-plugin-sec-default에 건너뛰어져 모델에 닿지 않는다(debug 로그의 prompt.compose bypassed by cc-plugin-sec-default). 접기와 내 차례는 답이 그 형식일 때만 제대로 붙는다..claude-plugin/marketplace.json이 그 용도다: claude plugin marketplace add <이 폴더>, claude plugin install baton@baton. 고친 뒤 /reload-plugins를 하면 새 버전 없이 들어간다.hooks/register.tsx가 바뀌어야 모듈을 다시 불러온다. 다른 파일만 고쳤으면 register.tsx 주석의 버전도 올린다. plugin.json과 register.tsx를 한 번에 고치면 열려 있던 세션이 옛 코드로 남을 수 있어서, 그때는 세션을 다시 연다.npx tsc -p ., claude plugin test ., claude plugin validate ..claude-plugin/types/는 엔진이 만드는 파일이라 저장소에 넣지 않는다. 처음 받은 뒤에는 한 번 불러와야 tsc가 돈다.hooks/register.tsx 20 lines1import type { Register } from 'claude-code'
2
3import { registerBlocks } from './answer-blocks'
4import { registerDebate } from './debate'
5import { registerEntry } from './entry'
6import { registerAsks } from './open-asks'
7import { registerSide } from './side-panel'
8
9// 네 mod를 한 플러그인으로 묶는다: answer-blocks(답 형식), open-asks(내 차례 모으기), side-panel(옆 패널), debate(토론).
10// 엔진은 register.tsx가 바뀌어야 다시 불러온다: 다른 파일만 고쳤으면 이 파일도 건드린다(0.7.0, 이름을 chenjing-mods에서 baton으로 바꾸고 명령도 /baton·/baton-side·/baton-e·/baton-debate로 묶음. 2026-10-10 사용자 "mods가 이것만쓰는게 아니잖아", "side도 너무 범용적인 이름이야").
11// 대화형이고 /baton on으로 고른 세션에서만 켜는 것은 각 hook 첫 줄의 live($)/enter()가 맡는다(live.ts, entry.tsx).
12// 한 플러그인은 같은 이벤트를 matcher 없이 두 번 등록할 수 없어서, 여러 mod가 쓰는 이벤트(session.start, turn.complete, prompt.submit)는 matcher를 붙여 등록한다.
13export const register: Register = (on, options) => {
14 registerBlocks(on)
15 registerSide(on, options)
16 registerAsks(on, options)
17 registerDebate(on, options)
18 registerEntry(on, options)
19}
20hooks/answer-blocks.tsx 185 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import { enter, isKnown, isLive, OFF_TEXT, SESSIONS, settle } from './live'
5// 답을 세 덩어리로 쓰게 한다: 물은 것에 대한 답, 접혀 보이는 "설명:", 사용자 차례(물을 것·볼 것·다르게 한 것).
6// 길이 제한은 두지 않는다: 설명은 접혀 있고, 사용자 차례는 본문에서 빠져 옆 패널(side-panel)의 내 차례에 뜬다.
7// 2026-10-06 벤치마크에서 접힌 설명이 다음 판단에 필요한 단서·숫자를 숨겨서, 그것들은 첫 문단에 쓰게 했다.
8// 2026-10-08 첫 문단 끝에 설명에 든 것의 이름을 대게 했다: 필요한 것이 접혔을 때 주제를 알린 비율 2/24 → 19/21(A3 벤치).
9const EXPANDED = { plugin: 'baton', key: 'expanded' } as const
10const expanded = atom(EXPANDED, [])
11
12export function ruleText(): string {
13 return [
14 '# 답 형식 (answer-blocks)',
15 '- 답은 세 덩어리로 쓴다. 해당 없는 덩어리는 쓰지 않는다. 길이 제한은 없다.',
16 '- 첫 문단: 사용자가 물은 것에 답한다. 결론부터, 보통 1~3문장. 물은 것과 상관없는 내용은 넣지 않는다.',
17 '- 사용자는 접힌 설명을 펼치지 않고 첫 문단과 사용자 차례 줄만 보고 다음 말을 정한다. 그래서 결론에 붙는 단서(확인 못 함·추정·실패·위험)와 사용자가 판단에 쓸 숫자는 첫 문단에 쓴다. 물을 것에 답하는 데 필요한 선택지별 차이는 첫 문단이나 그 물을 것 줄에 쓴다.',
18 '- "설명:"으로 시작하는 문단: 그 밖의 것(근거, 과정, 실제로 확인한 것과 결과, 배경). 사용자 화면에서는 접혀 있어 펼쳐야 보인다. 설명은 "- 이름: 내용" 항목으로 쓴다. 화면에서는 접힌 설명 위에 그 이름들이 목차로 뜨니, 첫 문단에 "설명에는 …이 있어요" 같은 목차 문장은 쓰지 않는다.',
19 '- 마지막 줄들은 사용자 차례다: "물을 것:"(사용자가 답하거나 고를 것), "볼 것:"(사용자가 직접 보고 판단할 것), "다르게 한 것:"(요청과 다르게 했거나 요청 밖에서 한 일). 해당하는 줄만, 줄마다 한 문장.',
20 '- 사용자 차례 줄은 본문에는 보이지 않고 옆 패널의 "내 차례"에만 따로 뜬다. 그래서 그 줄만 읽어도 무엇에 관한 것인지 알도록 대상 이름을 넣고, "위·아래·이것"처럼 본문을 가리키는 말을 쓰지 않는다.',
21 '- 굵은 글씨와 제목은 쓰지 않는다. 단서와 근거는 지우지 않는다.',
22 '- 작업 중간에 쓰는 글은 한 줄로 쓴다.',
23 ].join('\n')
24}
25
26const TURN_LABEL = /^\s*(?:[-*•]\s*)?(?:\*\*)?(?:물을 것|볼 것|다르게 한 것)(?:\*\*)?\s*[::]/
27const EXPLAIN_LABEL = /^\s*(?:\*\*)?설명(?:\*\*)?\s*[::]\s*(?:\*\*)?\s*/
28
29export type Blocks = { answer: string; explain: string; turn: string }
30
31// 첫 표시 줄 앞은 답. 그 뒤로는 "설명:" 줄부터 설명, 사용자 차례 이름이 붙은 줄부터 사용자 차례. 코드 블록 안은 나누지 않는다
32export function splitBlocks(text: string): Blocks {
33 const out: Record<keyof Blocks, string[]> = { answer: [], explain: [], turn: [] }
34 let section: keyof Blocks = 'answer'
35 let inCode = false
36 for (const line of text.split('\n')) {
37 if (!inCode && EXPLAIN_LABEL.test(line)) {
38 section = 'explain'
39 const rest = line.replace(EXPLAIN_LABEL, '')
40 if (rest.trim()) out.explain.push(rest)
41 continue
42 }
43 if (!inCode && TURN_LABEL.test(line)) section = 'turn'
44 if (/^\s*```/.test(line)) inCode = !inCode
45 out[section].push(line)
46 }
47 return { answer: out.answer.join('\n').trim(), explain: out.explain.join('\n').trim(), turn: out.turn.join('\n').trim() }
48}
49
50// 본문에서 뺀 사용자 차례 줄을 대신하는 한 줄: 무엇이 몇 개 옆 패널로 갔는지
51export function turnNote(turn: string): string {
52 const counts: Record<string, number> = {}
53 for (const line of turn.split('\n')) {
54 const m = line.match(/^\s*(?:[-*•]\s*)?(?:\*\*)?(물을 것|볼 것|다르게 한 것)(?:\*\*)?\s*[::]/)
55 if (m?.[1]) counts[m[1]] = (counts[m[1]] ?? 0) + 1
56 }
57 const parts = Object.entries(counts).map(([k, n]) => `${k} ${n}`)
58 return `↩ ${parts.length ? parts.join(' · ') : '사용자 차례'} → 옆 패널 내 차례 (/baton-side)`
59}
60
61// 접힌 설명의 목차: 설명의 맨 앞 단계 항목 이름("- 확인한 것: …"이나 "1. 확인한 것: …"이면 "확인한 것")을 글에서 그대로 뽑는다.
62// 예전에는 모델이 첫 문단 끝에 "설명에는 …이 있어요"를 썼는데 항목을 빠뜨린 적이 있어서(2026-10-09 "확인한 것" 누락), 코드가 뽑는 이 줄로 바꿨다(2026-10-09 그 문장은 규칙에서 뺌)
63export function explainIndex(explain: string): string[] {
64 const name = (raw: string) => {
65 const item = raw.replace(/\*\*/g, '').replace(/`/g, '').trim()
66 const head = item.match(/^([^::.。]{1,24})[::]/)?.[1]?.trim()
67 return head ?? (item.length > 18 ? `${item.slice(0, 18)}…` : item)
68 }
69 const items: string[] = []
70 const paras: string[] = []
71 let inCode = false
72 let prevBlank = true
73 for (const line of explain.split('\n')) {
74 if (/^\s*```/.test(line)) inCode = !inCode
75 if (inCode || /^\s*```/.test(line)) continue
76 const m = line.match(/^(?:[-*•]|\d+[.)])\s+(.*)$/)
77 if (m?.[1]) items.push(name(m[1]))
78 else if (prevBlank && line.trim() && !/^\s/.test(line)) paras.push(name(line))
79 prevBlank = !line.trim()
80 }
81 // 목록이 없으면(문단만 쓴 설명) 문단마다 첫머리를 댄다
82 return items.length ? items : paras
83}
84
85export function countChars(text: string): number {
86 return text.replace(/```[\s\S]*?```/g, ' ').replace(/\s+/g, ' ').trim().length
87}
88
89export function textKey(text: string): string {
90 const t = text.replace(/\s+/g, ' ').trim()
91 let h = 5381
92 for (let i = 0; i < t.length; i++) h = ((h << 5) + h + t.charCodeAt(i)) | 0
93 return `${t.length}:${(h >>> 0).toString(36)}`
94}
95
96// 설명이 있는 최근 답의 key, 새것이 끝: /baton-e가 키보드로 펼치고 접는다. 모듈 변수라 다시 불러오면 비고, 그때는 그려진 순서로 채운다
97let recent: string[] = []
98const remember = (key: string) => {
99 recent = [...recent.filter(k => k !== key), key].slice(-20)
100}
101
102// /baton-e N: 끝에서 N번째 답(1이 마지막)의 설명을 펼치거나 접는다
103export function pickRecent(list: readonly string[], arg: string): string | undefined {
104 const n = arg.trim() ? Number(arg.trim()) : 1
105 if (!Number.isInteger(n) || n < 1) return undefined
106 return list[list.length - n]
107}
108
109const MD_MAX = 9500
110const clip = (s: string) => (s.length > MD_MAX ? `${s.slice(0, MD_MAX)}\n…(ctrl+o로 원문)` : s)
111
112export const E_COMMAND = { name: 'baton-e', description: '마지막 답의 설명 펼치기/접기 (/baton-e 2: 그 전 답)' }
113
114// 이 세션에서 켜졌는지(live.ts). $를 쓰는 함수라 hook과 같은 파일에 둔다
115async function live($: EngineInterface): Promise<boolean> {
116 if (isKnown()) return isLive()
117 // 읽다가 실패하면 이번만 꺼진 것으로 보고, 정하지 않은 채 두어 다음에 다시 읽는다
118 const id = await $.session.id().catch(() => '')
119 const list = id ? await $.store.get(SESSIONS).catch(() => null) : null
120 return list === null ? false : settle(list, id)
121}
122
123export function registerBlocks(on: On): void {
124 on('prompt.compose', async ($, e, next) => {
125 if (!(await live($))) return next(e)
126 const r = await next(e)
127 return { sections: [...r.sections, { id: 'answer-blocks:rule', text: ruleText(), scope: 'session' as const }] }
128 })
129
130 // 설명 덩어리는 접어서 한 줄 버튼으로만 보인다. 설명이 없는 답은 엔진이 그대로 그린다
131 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
132 if (!(await live($))) return next(e)
133 const b = splitBlocks(e.props.text)
134 if (!b.explain && !b.turn) return next(e)
135 const key = textKey(e.props.text)
136 if (b.explain && !recent.includes(key)) remember(key)
137 const isOpen = (await read($, expanded)).includes(key)
138 const toggle = () =>
139 update($, expanded, (l: string[]): string[] => (l.includes(key) ? l.filter(k => k !== key) : [...l, key].slice(-40)))
140 const { Box, Text, Markdown, Button } = $.ui.resolve(e)
141 const parts = []
142 if (b.answer) parts.push(<Markdown key="answer" text={clip(b.answer)} />)
143 if (b.explain && isOpen) {
144 parts.push(<Markdown key="explain" text={clip(b.explain)} dimColor />)
145 parts.push(<Button key={`close-${key}`} label="설명 접기" onPress={toggle} />)
146 } else if (b.explain) {
147 const index = explainIndex(b.explain)
148 if (index.length) parts.push(<Text key="index" dimColor wrap="wrap">{`설명 목차: ${index.join(' · ')}`}</Text>)
149 parts.push(<Button key={`open-${key}`} label={`설명 펼치기 (${countChars(b.explain)}자) /baton-e`} variant="primary" onPress={toggle} />)
150 }
151 // 사용자 차례 줄은 옆 패널(side-panel)의 내 차례로 보내고, 본문에는 한 줄만 남긴다
152 if (b.turn) parts.push(<Text key="turn" dimColor>{turnNote(b.turn)}</Text>)
153 return <Box flexDirection="column">{parts}</Box>
154 })
155
156 on('turn.complete', { reason: 'answer' }, async ($, e, next) => {
157 if (!(await live($))) return next(e)
158 const done = await next(e)
159 if (e.agentId || e.reason !== 'answer' || !e.answer.trim()) return done
160 const b = splitBlocks(e.answer)
161 if (b.explain) remember(textKey(e.answer))
162 return done
163 })
164
165 // matcher 없는 session.start는 이것 하나다: -p·SDK에서도 돌아 대화형인지 기록한다(live.ts). 다른 mod의 session.start는 isInteractive: true에만 돈다
166 on('session.start', async ($, e, next) => {
167 if (!enter(e) || !(await live($))) return next(e)
168 const started = await next(e)
169 await $.command.register(E_COMMAND)
170 return started
171 })
172
173 on('command.run', { command: 'baton-e' }, async ($, e) => {
174 if (!(await live($))) return { text: OFF_TEXT }
175 const key = pickRecent(recent, e.args)
176 if (!key) return { text: recent.length ? `설명이 있는 답은 최근 ${recent.length}개예요. /baton-e 1~${recent.length}로 골라요.` : '펼칠 설명이 있는 답이 아직 없어요.' }
177 let isOpen = false
178 await update($, expanded, (l: string[]): string[] => {
179 isOpen = !l.includes(key)
180 return isOpen ? [...l, key].slice(-40) : l.filter(k => k !== key)
181 })
182 return { text: isOpen ? '설명을 펼쳤어요.' : '설명을 접었어요.' }
183 })
184}
185hooks/debate.tsx 629 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, PluginOptions, ToolSpec } from 'claude-code'
3
4import { enter, isKnown, isLive, OFF_TEXT, SESSIONS, settle } from './live'
5import type { Debate, DebateQA, Side, Turn } from '../types'
6
7const PANE = 'debate'
8export const DEBATE_PANE = PANE
9const CURRENT = { plugin: 'baton', key: 'debate' } as const
10const current = atom(CURRENT, null)
11const DEFAULT_REPORT_DIR = '~/.claude/baton/debates'
12const DEBATER_TOOLS = 'SendMessage,WebSearch,WebFetch,Read,Grep,Glob'
13const DEBATER_MODEL = 'sonnet'
14const TOOL = 'start_debate'
15const DEFAULT_LABELS: Record<Side, string> = { pro: '찬성', con: '반대' }
16// 라운드는 기본도 최대도 10이다(2026-10-09 사용자 "기본 라운드 10라운드로". 그 전에는 기본 3, 최대 5)
17const DEFAULT_ROUNDS = 10
18const MAX_ROUNDS = 10
19
20// "/baton-debate [라운드] [주제]": 주제가 비면(topic '') 지금 대화에서 고른다
21export function parseArgs(args: string): { maxRounds: number; topic: string } {
22 const [first = '', ...rest] = args.trim().split(/\s+/)
23 const n = Number(first)
24 const isRounds = first !== '' && Number.isInteger(n) && n > 0 && n <= MAX_ROUNDS
25 const topic = (isRounds ? rest : [first, ...rest]).join(' ').trim()
26 return { maxRounds: isRounds ? n : DEFAULT_ROUNDS, topic }
27}
28
29// /baton-debate만 쳤을 때 주제를 고르는 포크에 붙이는 말. 포크는 이 대화 전부(도구 결과 포함)를 보고 도구 없이 답한다(2026-10-09 사용자 "/baton-debate 만 치면 자동으로 실행되게")
30export function topicPrompt(previous: string): string {
31 return [
32 '[토론 주제 고르기] 사용자가 /baton-debate만 쳤다. 지금까지 이 대화를 보고, 두 Claude가 두 편으로 나눠 토론할 주제를 하나 골라라. 이 답은 사용자에게 보이지 않고 토론 준비에만 쓰인다. 도구는 쓰지 않는다.',
33 '- 따져 볼 값어치가 가장 큰 쟁점을 고른다. 사용자가 아직 정하지 않은 선택, Claude가 최근 답에서 권하거나 단정한 주장, 방금 한 작업이 제대로 됐는지 순으로 보고, 최근 대화를 먼저 본다.',
34 ...(previous ? [`- 직전 토론 주제는 "${previous}"였다. 그 뒤로 다시 따질 일이 생긴 게 아니면 다른 쟁점을 고른다.`] : []),
35 '- topic: 두 편이 갈리는 한 문장 명제, 60자 안.',
36 '- pro, con: 두 편의 이름, 각 8자 안(예: "A안"과 "B안", "현행 유지"와 "바꾸기", "옹호"와 "반박"). pro가 topic을 지지한다.',
37 '- brief: 토론자는 이 대화를 볼 수 없다. 쟁점이 생긴 배경, 양쪽이 기댈 근거, 확인된 사실과 숫자, 아직 모르는 것을 1500자 안에서 적는다. 대화에 없는 것은 지어내지 않는다.',
38 'JSON 한 줄만 출력한다: {"topic":"…","pro":"…","con":"…","brief":"…"}',
39 ].join('\n')
40}
41
42export type PickedTopic = { topic: string; labels: Record<Side, string>; brief: string }
43
44// 포크의 답에서 주제를 읽는다: 앞뒤 글이나 코드 울타리가 붙어도 JSON 부분만 본다
45export function parseTopic(text: string): PickedTopic | undefined {
46 const m = text.match(/\{[\s\S]*\}/)
47 if (!m) return undefined
48 try {
49 const j = JSON.parse(m[0]) as Record<string, unknown>
50 const line = (v: unknown, max: number) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, max) : '')
51 const topic = line(j.topic, 120)
52 if (!topic) return undefined
53 return {
54 topic,
55 labels: { pro: line(j.pro, 12) || DEFAULT_LABELS.pro, con: line(j.con, 12) || DEFAULT_LABELS.con },
56 brief: typeof j.brief === 'string' ? j.brief.trim().slice(0, 4000) : '',
57 }
58 } catch {
59 return undefined
60 }
61}
62
63// /baton이 보여 주는 토론 상태 한 단어
64export function debateWord(d: Debate | null | undefined): string {
65 if (!isCurrent(d)) return '없음'
66 if (isStalePick(d)) return '끊김'
67 return { picking: '주제 고르는 중', starting: '시작하는 중', running: '진행 중', judging: '판정 중', done: '끝남', failed: '실패' }[d.status]
68}
69
70// 패널 제목: 주제를 고르는 동안은 주제가 비어 있다
71export function paneTitle(d: Pick<Debate, 'topic'>): string {
72 return `토론: ${d.topic || '주제 고르는 중'}`.slice(0, 40)
73}
74
75// 토론 기록 폴더(userConfig debate_dir)와 토론자 세션이 도는 폴더(debate_work_dir). ~는 홈 폴더다.
76// 토론자는 도는 폴더의 CLAUDE.md를 읽으므로 기록 폴더와 따로 둘 수 있게 했다(2026-10-09).
77// 토론자 폴더는 신뢰된 폴더여야 해서(아니면 claude --bg가 "Workspace not trusted."로 끝난다), 비워 두면 토론을 연 세션의 작업 폴더에서 돈다.
78export type DebateDirs = { reportDir: string; workDir: string }
79
80export function debateDirs(o: PluginOptions, home: string, cwd: string): DebateDirs {
81 const dir = (v: unknown, fallback: string) => {
82 const s = typeof v === 'string' && v.trim() ? v.trim() : fallback
83 if (s !== '~' && !s.startsWith('~/')) return s.replace(/(.)\/+$/, '$1')
84 if (!home) throw new Error(`홈 폴더를 몰라 ${s}를 풀지 못했어요`)
85 return (home + s.slice(1)).replace(/(.)\/+$/, '$1')
86 }
87 return { reportDir: dir(o.debate_dir, DEFAULT_REPORT_DIR), workDir: dir(o.debate_work_dir, cwd) }
88}
89
90// "backgrounded · 9446dcda · debate-pro-x (idle …)" → 9446dcda
91export function parseBgId(stdout: string): string | null {
92 return stdout.match(/backgrounded\s*·\s*([0-9a-f]{6,})/)?.[1] ?? null
93}
94
95// <cross-session-message from="…" from-name="debate-pro-x" …>본문</cross-session-message>
96export function parsePeer(text: string): { fromName: string | null; body: string } {
97 const fromName = text.match(/from-name="([^"]+)"/)?.[1] ?? null
98 const body = text
99 .replace(/<\/?cross-session-message[^>]*>/g, '')
100 .replace(/\n{3,}/g, '\n\n')
101 .trim()
102 return { fromName, body }
103}
104
105export function rules(d: Pick<Debate, 'topic' | 'brief' | 'labels' | 'maxRounds'>, side: Side): string {
106 const other: Side = side === 'pro' ? 'con' : 'pro'
107 return [
108 `토론 참가 안내: 주제는 "${d.topic}". 너는 ${d.labels[side]} 측, 상대는 ${d.labels[other]} 측이다.`,
109 d.brief ? `\n배경 자료(사용자와 다른 Claude가 나눈 대화 요약):\n${d.brief}\n` : '',
110 `상대 주장이 이 세션을 통해 전달되면, 그것을 짚어 반박하고 새 근거를 하나 더해라. 총 ${d.maxRounds}번 발언한다.`,
111 '4문장 이내, 한국어, 인사나 메타 설명 없이 주장만. 사실 주장은 가능하면 WebSearch로 확인하고, 확인 못 한 건 추정이라고 밝혀라.',
112 '발언은 반드시 이 메시지의 from 주소로 SendMessage를 보내는 것으로 한다. 일반 텍스트 출력은 상대에게 안 보인다.',
113 ].join('\n')
114}
115
116export function toMarkdown(d: Debate): string {
117 const turns = d.log.map((t, i) => `### ${i + 1}. ${d.labels[t.side]}\n\n${t.text}\n`).join('\n')
118 return [
119 `# 토론: ${d.topic}`,
120 '',
121 `${d.labels.pro}: ${d.pro.name} · ${d.labels.con}: ${d.con.name} · ${d.maxRounds}라운드`,
122 d.brief ? `\n## 배경 자료\n\n${d.brief}\n` : '',
123 '## 발언',
124 '',
125 turns,
126 '## 판정',
127 '',
128 d.verdict || '(없음)',
129 '',
130 ].join('\n')
131}
132
133// 이전 버전이 남긴 상태(모양이 다름)는 없는 것으로 본다; labels 등 새 필드만 빠진 건 upgrade로 채움
134function isCurrent(d: Debate | null | undefined): d is Debate {
135 return !!d && typeof d.pro?.name === 'string' && typeof d.con?.name === 'string' && !!d.labels
136}
137
138export function upgrade(d: Partial<Debate> | null | undefined): Debate | null {
139 if (!d || typeof d.pro?.name !== 'string' || typeof d.con?.name !== 'string') return null
140 return {
141 topic: d.topic ?? '',
142 brief: d.brief ?? '',
143 labels: d.labels ?? DEFAULT_LABELS,
144 maxRounds: d.maxRounds ?? 3,
145 status: d.status ?? 'done',
146 pro: d.pro,
147 con: d.con,
148 log: d.log ?? [],
149 verdict: d.verdict ?? '',
150 error: d.error ?? '',
151 reportPath: d.reportPath ?? '',
152 qa: d.qa ?? [],
153 sent: d.sent ?? false,
154 }
155}
156
157// 이 모듈이 주제를 고르고 있는 토론의 토론자 이름. 다시 불러오면 비므로, 그때 state에 남은 'picking'은 끊긴 것이다
158let pickingFor = ''
159
160function isStalePick(d: Debate): boolean {
161 return d.status === 'picking' && pickingFor !== d.pro.name
162}
163
164function isActive(d: Debate | null | undefined): boolean {
165 if (!isCurrent(d)) return false
166 if (d.status === 'picking') return !isStalePick(d)
167 return d.status === 'starting' || d.status === 'running' || d.status === 'judging'
168}
169
170function nextSide(log: readonly Turn[]): Side {
171 return log.at(-1)?.side === 'pro' ? 'con' : 'pro'
172}
173
174async function patch($: EngineInterface, fn: (d: Debate) => Debate) {
175 await update($, current, (d: Debate | null): Debate | null => (d ? fn(d) : d))
176}
177
178async function spawnDebater($: EngineInterface, name: string, workDir: string): Promise<string> {
179 const { exitCode, stdout, stderr } = await $.process.run(
180 ['claude', '--bg', '-n', name, '--permission-mode', 'bypassPermissions', '--model', DEBATER_MODEL, '--tools', DEBATER_TOOLS],
181 { cwd: workDir, timeoutMs: 60_000 },
182 )
183 const id = parseBgId(stdout)
184 if (exitCode === 0 && id) return id
185 const out = (stderr || stdout).trim()
186 const hint = /not trusted/i.test(out) ? ` 토론자 폴더(${workDir})를 신뢰하지 않았어요. 그 폴더에서 claude를 한 번 열어 신뢰하거나, /config에서 debate_work_dir를 비워 이 세션 폴더에서 돌게 해요.` : ''
187 throw new Error(`${name} 시작 실패: ${out.slice(0, 200)}${hint}`)
188}
189
190// 홈 폴더와 이 세션의 작업 폴더를 읽어 토론 폴더를 정한다. $를 쓰는 함수라 hook과 같은 파일에 둔다
191async function dirsFor($: EngineInterface, o: PluginOptions): Promise<DebateDirs> {
192 const home = await $.process.run(['printenv', 'HOME'], { timeoutMs: 5_000 }).then(r => r.stdout.trim(), () => '')
193 return debateDirs(o, home, await $.session.cwd())
194}
195
196// --bg는 세션이 피어로 등록되기 전에 돌아오므로, 이름이 보일 때까지 기다림
197async function waitForPeers($: EngineInterface, names: readonly string[]) {
198 for (let i = 0; i < 30; i++) {
199 const { stdout } = await $.process.run(['claude', 'agents', '--json'], { timeoutMs: 15_000 })
200 const seen = new Set<string>()
201 try {
202 for (const a of JSON.parse(stdout) as { name?: string }[]) if (a.name) seen.add(a.name)
203 } catch {}
204 if (names.every(n => seen.has(n))) return
205 await $.clock.sleep(1000)
206 }
207 throw new Error(`토론자 세션이 30초 안에 등록되지 않음: ${names.join(', ')}`)
208}
209
210async function sendOrThrow($: EngineInterface, to: string, text: string) {
211 for (let i = 0; i < 3; i++) {
212 const sent = await $.session.send({ to, text })
213 if (sent.isDelivered) return
214 if (i === 2) throw new Error(`${to}에게 전송 실패: ${sent.reason}`)
215 await $.clock.sleep(2000)
216 }
217}
218
219async function stopDebaters($: EngineInterface, d: Debate) {
220 for (const id of [d.pro.id, d.con.id]) {
221 if (id) await $.process.run(['claude', 'stop', id], { timeoutMs: 20_000 }).catch(() => undefined)
222 }
223}
224
225async function start($: EngineInterface, o: PluginOptions) {
226 const { value: d } = await $.state.get(CURRENT)
227 if (!isCurrent(d)) return
228 try {
229 const { workDir } = await dirsFor($, o)
230 // 없는 폴더만 만든다: 기본값(이 세션 폴더)에 파일을 남기지 않는다
231 if (!(await $.fs.exists(workDir))) await $.fs.write(`${workDir}/.keep`, '')
232 const [proId, conId] = await Promise.all([spawnDebater($, d.pro.name, workDir), spawnDebater($, d.con.name, workDir)])
233 await patch($, cur => ({ ...cur, pro: { ...cur.pro, id: proId }, con: { ...cur.con, id: conId } }))
234 await waitForPeers($, [d.pro.name, d.con.name])
235 await patch($, cur => ({ ...cur, status: 'running' }))
236 await sendOrThrow($, d.pro.name, `${rules(d, 'pro')}\n\n첫 발언을 지금 해라.`)
237 } catch (err) {
238 await patch($, cur => ({ ...cur, status: 'failed', error: String(err) }))
239 await stopDebaters($, (await $.state.get(CURRENT)).value ?? d)
240 }
241}
242
243// 토론 패널 아래 입력칸의 질문과 답(곁대화처럼)과 보냄 표시는 토론 state(qa, sent)에 둔다: 다시 불러와도 남고, 새 토론을 열면 비워진다
244
245// 본 대화로 보낼 글: 판정(양측 핵심·승부처·판정)과 패널에서 물은 것, 전문은 경로만. 사용자가 누른 버튼이라 사용자의 말로 보낸다
246export function reportText(d: Pick<Debate, 'topic' | 'verdict' | 'reportPath'>, asked: readonly DebateQA[]): string {
247 const qs = asked.filter(x => x.a).slice(-4).map(x => `Q. ${x.q}\nA. ${(x.a ?? '').slice(0, 1500)}`)
248 return [
249 `↩ 토론 결과: "${d.topic}"`,
250 d.verdict,
251 ...(qs.length ? ['', '토론 패널에서 물은 것:', ...qs] : []),
252 ...(d.reportPath ? ['', `전문: ${d.reportPath}`] : []),
253 '',
254 '이 토론 결과로 원래 대화의 주장 중 무엇이 버티고 무엇이 흔들렸는지 짧게 정리해 줘. 토론자 주장도 그대로 사실로 받아들이지 말고.',
255 ].join('\n')
256}
257
258async function sendReport($: EngineInterface) {
259 const { value: d } = await $.state.get(CURRENT)
260 if (!isCurrent(d) || d.status !== 'done' || !d.verdict || d.sent) return
261 await patch($, cur => ({ ...cur, sent: true }))
262 const r = await $.prompt.submit({ text: reportText(d, d.qa ?? []), asUser: true }).catch((err: unknown) => ({ drop: String(err) }))
263 if (r.drop === undefined) return
264 await patch($, cur => ({ ...cur, sent: false }))
265 $.ui.toast(`본 대화로 못 보냈어요: ${r.drop}`, { timeoutMs: 8000 })
266}
267
268export function askPrompt(d: Pick<Debate, 'topic' | 'brief' | 'labels' | 'log' | 'verdict'>, history: readonly DebateQA[], question: string): string {
269 const transcript = d.log.map(t => `[${d.labels[t.side]}] ${t.text}`).join('\n\n')
270 const past = history.filter(x => x.a).slice(-4).map(x => `Q: ${x.q}\nA: ${(x.a ?? '').slice(0, 1500)}`)
271 return [
272 `[토론 주제] ${d.topic}`,
273 ...(d.brief ? ['', '[토론자에게 준 배경]', d.brief] : []),
274 '',
275 '[토론 기록]',
276 transcript || '(아직 발언 없음)',
277 ...(d.verdict ? ['', '[심판 판정]', d.verdict] : []),
278 ...(past.length ? ['', '[앞선 질문]', ...past] : []),
279 '',
280 '[사용자 질문]',
281 question.trim(),
282 ].join('\n')
283}
284
285async function askDebate($: EngineInterface, question: string) {
286 const q = question.trim()
287 if (!q) return
288 const { value: d } = await $.state.get(CURRENT)
289 if (!isCurrent(d)) return
290 const item: DebateQA = { id: Math.random().toString(36).slice(2, 10), q }
291 const history = d.qa ?? []
292 await patch($, cur => ({ ...cur, qa: [...(cur.qa ?? []), item].slice(-12) }))
293 const r = await $.model.complete({
294 model: 'sonnet',
295 maxTokens: 1500,
296 system: '너는 이 토론을 지켜본 해설자다. 토론 기록과 판정만 근거로 한국어로 짧게 답한다. 토론자 주장도 검증 대상이라 그대로 사실로 말하지 않고, 기록에 없는 것은 모른다고 말한다.',
297 prompt: askPrompt(d, history, q),
298 })
299 const done: DebateQA = r.isAnswered ? { ...item, a: r.text.trim() } : { ...item, err: `답을 받지 못했어요: ${r.reason}` }
300 // 답을 기다리는 사이 새 토론이 열렸으면 그 토론에는 넣지 않는다
301 await patch($, cur => (cur.qa?.some(x => x.id === item.id) ? { ...cur, qa: cur.qa.map(x => (x.id === item.id ? done : x)) } : cur))
302}
303
304// launch의 글은 모델에게 하는 말이라, 사용자가 /baton-debate로 시작했을 때는 사용자에게 할 말로 바꾼다(2026-10-09 "사용자에게 시작했다고만 짧게 알려라"가 사용자 화면에 떴다)
305export function commandText(said: string, topic: string, rounds: number): string {
306 if (said.startsWith('이미 토론이')) return BUSY_TEXT
307 if (!said.startsWith('토론을 시작')) return said
308 const n = Math.min(MAX_ROUNDS, Math.max(1, rounds))
309 return `토론을 시작했어요: "${topic.trim()}" (${n}라운드${rounds > n ? `, ${rounds}라운드는 ${n}라운드로 줄였어요` : ''}). 토론 탭에서 진행을 보고, 끝나면 [본 대화로 보내기](m)로 결과를 이 대화에 넣을 수 있어요.`
310}
311
312// /baton-debate로 시작하면 토론자는 이 대화를 못 보므로 최근 요청과 답을 배경으로 넘긴다(2026-10-09 토론자가 "지금 작업"을 몰라 엉뚱한 파일을 두고 다퉜다)
313export function recentBrief(msgs: readonly { role: string; text: string }[]): string {
314 const out: string[] = []
315 for (let i = msgs.length - 1; i >= 0 && out.length < 4; i--) {
316 const m = msgs[i]
317 if (!m?.text.trim() || (m.role !== 'user' && m.role !== 'assistant')) continue
318 out.unshift(`${m.role === 'user' ? '사용자' : 'Claude'}: ${m.text.trim().slice(0, 1500)}`)
319 }
320 return out.length ? `토론을 연 대화의 최근 내용(사용자 요청과 Claude 답):\n${out.join('\n\n')}` : ''
321}
322
323// 판정 → 파일 저장 → 메인 대화로 결과 전달
324async function finish($: EngineInterface, d: Debate, o: PluginOptions) {
325 await stopDebaters($, d)
326 const transcript = d.log.map(t => `[${d.labels[t.side]}] ${t.text}`).join('\n\n')
327 const r = await $.model.complete({
328 model: 'haiku',
329 effort: 'low',
330 // 500이면 판정 문장이 중간에 잘린 적이 있다(2026-10-02 earlier-m5ys.md "…우월한")
331 maxTokens: 1500,
332 system: '너는 공정한 토론 심판이다. 한국어로 답한다.',
333 prompt: `주제: ${d.topic}\n\n${transcript}\n\n양측 핵심 주장을 한 줄씩, 승부처를 한 줄, 판정(어느 쪽 우세/무승부와 이유)을 한 줄로. 총 4줄.`,
334 })
335 const verdict = r.isAnswered ? r.text.trim() : `판정 실패 (${r.reason})`
336 const stamp = new Date(await $.clock.now()).toISOString().slice(0, 16).replace(/[-:T]/g, '')
337 const { reportDir } = await dirsFor($, o)
338 const reportPath = `${reportDir}/${stamp}-${d.pro.name.split('-').pop()}.md`
339 const done: Debate = { ...d, status: 'done', verdict, reportPath }
340 await $.fs.write(reportPath, toMarkdown(done))
341 await patch($, cur => ({ ...cur, status: 'done', verdict, reportPath }))
342 // 본 대화에는 자동으로 넣지 않는다: 토론 패널의 [본 대화로 보내기]를 눌렀을 때만 보낸다(2026-10-09 사용자 "버튼으로 넣어줘. 자동으로 들어가게 하지말고")
343 $.ui.toast('토론이 끝났어요. 토론 탭의 [본 대화로 보내기]로 결과를 본 대화에 넣을 수 있어요.', { timeoutMs: 8000 })
344}
345
346type LaunchInput = { topic: string; brief?: string; rounds?: number; proLabel?: string; conLabel?: string }
347
348function newDebate(input: LaunchInput, status: Debate['status']): Debate {
349 const tag = Math.random().toString(36).slice(2, 6)
350 return {
351 topic: input.topic.trim(),
352 brief: (input.brief ?? '').trim(),
353 labels: { pro: input.proLabel?.trim() || DEFAULT_LABELS.pro, con: input.conLabel?.trim() || DEFAULT_LABELS.con },
354 maxRounds: Math.min(MAX_ROUNDS, Math.max(1, Math.round(input.rounds ?? DEFAULT_ROUNDS))),
355 status,
356 pro: { name: `debate-pro-${tag}`, id: null },
357 con: { name: `debate-con-${tag}`, id: null },
358 log: [],
359 verdict: '',
360 error: '',
361 reportPath: '',
362 qa: [],
363 sent: false,
364 }
365}
366
367async function launch($: EngineInterface, input: LaunchInput, o: PluginOptions): Promise<string> {
368 const { value: prev } = await $.state.get(CURRENT)
369 if (isActive(prev)) return '이미 토론이 진행 중이다. 끝나거나 /baton-debate stop 후 다시 시작해야 한다.'
370 const debate = newDebate(input, 'starting')
371 await update($, current, (): Debate | null => debate)
372 await $.ui.open({ id: PANE, title: paneTitle(debate) })
373 void start($, o)
374 return `토론을 시작했다: "${debate.topic}" (${debate.maxRounds}라운드, ${debate.labels.pro} vs ${debate.labels.con}). 패널에서 진행이 보이고, 끝나면 사용자가 토론 패널의 [본 대화로 보내기]를 눌렀을 때만 결과가 이 대화에 들어온다. 사용자에게 시작했다고만 짧게 알려라.`
375}
376
377const BUSY_TEXT = '이미 토론이 진행 중이에요. 끝나거나 /baton-debate stop 뒤에 다시 시작하세요.'
378export const NOTHING_YET = '토론할 대화가 아직 없어요. 대화를 조금 한 뒤 /baton-debate를 치거나, /baton-debate 주제로 직접 주세요.'
379
380// 주제를 고르다 실패하면 그 토론이 아직 고르는 중일 때만 실패로 적는다
381async function failPick($: EngineInterface, name: string, why: string) {
382 await update($, current, (cur: Debate | null): Debate | null =>
383 cur && cur.pro.name === name && cur.status === 'picking' ? { ...cur, status: 'failed', error: `주제를 고르지 못했어요(${why}). /baton-debate 주제로 직접 주세요.` } : cur,
384 )
385}
386
387async function pick($: EngineInterface, name: string, previous: string, o: PluginOptions) {
388 const r = await $.model.fork({ prompt: topicPrompt(previous) })
389 const picked = r.isAnswered ? parseTopic(r.text) : undefined
390 if (!picked) {
391 const why = r.isAnswered ? '답 형식이 달랐어요' : r.reason === 'nothing-to-fork' ? '아직 고를 대화가 없어요' : `모델 답 없음: ${r.reason}`
392 return failPick($, name, why)
393 }
394 // 고르는 사이 중지했거나 새 토론을 열었으면 시작하지 않는다
395 let isMine = false
396 await update($, current, (cur: Debate | null): Debate | null => {
397 isMine = !!cur && cur.pro.name === name && cur.status === 'picking'
398 return cur && isMine ? { ...cur, topic: picked.topic, brief: picked.brief, labels: picked.labels, status: 'starting' } : cur
399 })
400 if (!isMine) return
401 await $.ui.open({ id: PANE, title: paneTitle(picked) }).catch(() => undefined)
402 $.ui.toast(`토론 주제: ${picked.topic}`, { timeoutMs: 8000 })
403 await start($, o)
404}
405
406// /baton-debate만 쳤을 때: 패널을 먼저 열어 "주제 고르는 중"을 보이고, 지금 대화를 이어받은 포크($.model.fork)가 고른 주제로 시작한다.
407// /baton-debate 주제처럼 최근 메시지 몇 개만 넘기는 대신 포크가 brief를 쓰므로, 토론자가 받는 배경도 대화 전체에서 나온다
408async function launchPicked($: EngineInterface, rounds: number, o: PluginOptions): Promise<string> {
409 const { value: prev } = await $.state.get(CURRENT)
410 if (isActive(prev)) return BUSY_TEXT
411 const msgs = await $.session.messages().catch(() => [])
412 if (!msgs.some(m => m.role === 'assistant' && m.text.trim())) return NOTHING_YET
413 const debate = newDebate({ topic: '', rounds }, 'picking')
414 const name = debate.pro.name
415 pickingFor = name
416 await update($, current, (): Debate | null => debate)
417 // 패널을 못 열어도 고르기는 한다: 여기서 멈추면 'picking'이 남아 다음 /baton-debate를 막는다
418 await $.ui.open({ id: PANE, title: paneTitle(debate) }).catch(() => undefined)
419 void pick($, name, isCurrent(prev) ? prev.topic : '', o)
420 .catch((err: unknown) => failPick($, name, String(err)))
421 .finally(() => {
422 if (pickingFor === name) pickingFor = ''
423 })
424 const cut = rounds > debate.maxRounds ? `, ${rounds}라운드는 ${debate.maxRounds}라운드로 줄였어요` : ''
425 return `지금 대화에서 토론 주제를 고르고 있어요(${debate.maxRounds}라운드${cut}). 고르면 바로 시작하고, 주제와 진행은 토론 탭에서 봐요.`
426}
427
428async function stopCurrent($: EngineInterface): Promise<{ text: string }> {
429 const { value: d } = await $.state.get(CURRENT)
430 if (!isCurrent(d)) return { text: '진행 중인 토론이 없어요.' }
431 await patch($, cur => ({ ...cur, status: 'done' }))
432 await stopDebaters($, d)
433 return { text: d.status === 'picking' ? '주제 고르기를 멈췄어요.' : '토론을 멈추고 두 세션을 종료했어요.' }
434}
435
436export const DEBATE_COMMAND = {
437 name: 'baton-debate',
438 description: '두 Claude가 토론해요. /baton-debate만 치면 지금 대화에서 주제를 골라 시작하고, /baton-debate [라운드] 주제로 직접 줄 수도 있어요. /baton-debate stop: 중지',
439 argumentHint: '[라운드] [주제 (비우면 지금 대화에서 골라요)]',
440}
441
442export const DEBATE_TOOL: ToolSpec = {
443 name: TOOL,
444 description: [
445 '별도의 Claude 세션 두 개를 띄워 주제를 놓고 라운드제 토론을 시키고, 사용자에게 패널로 중계한다.',
446 '사용자가 "토론시켜 봐", "토론으로 검증해 보자", "반론 세워서 따져 봐"처럼 요청할 때 쓴다.',
447 '토론자는 이 대화를 못 보므로, 검증할 주장과 필요한 사실·맥락을 brief에 충분히 적어 넘겨라.',
448 '바로 돌아온다. 토론이 끝나도 결과는 자동으로 오지 않고, 사용자가 토론 패널의 [본 대화로 보내기]를 누르면 판정이 사용자 메시지로 들어온다.',
449 ].join(' '),
450 inputSchema: {
451 type: 'object',
452 properties: {
453 topic: { type: 'string', description: '토론 주제 또는 검증할 명제 (한 문장)' },
454 brief: { type: 'string', description: '토론자에게 줄 배경: 지금까지 대화의 핵심 주장, 근거, 결정 사항 요약' },
455 rounds: { type: 'integer', minimum: 1, maximum: MAX_ROUNDS, description: `각 측 발언 횟수 (기본 ${DEFAULT_ROUNDS})` },
456 pro_label: { type: 'string', description: '찬성 측 이름 (예: 옹호, 현행 유지). 기본 찬성' },
457 con_label: { type: 'string', description: '반대 측 이름 (예: 반박, 비판). 기본 반대' },
458 },
459 required: ['topic'],
460 },
461}
462
463// 이 세션에서 켜졌는지(live.ts). $를 쓰는 함수라 hook과 같은 파일에 둔다
464async function live($: EngineInterface): Promise<boolean> {
465 if (isKnown()) return isLive()
466 // 읽다가 실패하면 이번만 꺼진 것으로 보고, 정하지 않은 채 두어 다음에 다시 읽는다
467 const id = await $.session.id().catch(() => '')
468 const list = id ? await $.store.get(SESSIONS).catch(() => null) : null
469 return list === null ? false : settle(list, id)
470}
471
472export function registerDebate(on: On, options: PluginOptions): void {
473 on('session.start', { isInteractive: true }, async ($, e, next) => {
474 if (!enter(e) || !(await live($))) return next(e)
475 const started = await next(e)
476 // 이전 버전 상태를 새 모양으로 옮기고, 끝난 토론인데 파일이 없으면 저장
477 const { value: old } = await $.state.get(CURRENT)
478 const up = upgrade(old as Partial<Debate> | null)
479 if (up && !isCurrent(old)) await update($, current, (): Debate | null => up)
480 if (up && up.status === 'done' && !up.reportPath && up.log.length > 0) {
481 const dirs = await dirsFor($, options).catch(() => null)
482 if (dirs) {
483 const reportPath = `${dirs.reportDir}/earlier-${up.pro.name.split('-').pop()}.md`
484 await $.fs.write(reportPath, toMarkdown({ ...up, reportPath }))
485 await patch($, cur => ({ ...cur, reportPath }))
486 }
487 }
488 await $.command.register(DEBATE_COMMAND)
489 await $.tool.register(DEBATE_TOOL)
490 return started
491 })
492
493 // 등록한 도구는 내장 도구 표에 없어 matcher 타입이 안 맞으므로 이름으로 비교
494 on('tool.call', async ($, e, next) => {
495 if (!String(e.tool).endsWith(`__${TOOL}`)) return next(e)
496 if (!(await live($))) return { result: { started: false }, text: `${OFF_TEXT} 그래서 토론을 시작하지 않았다.` }
497 const input = e as unknown as { topic?: unknown; brief?: unknown; rounds?: unknown; pro_label?: unknown; con_label?: unknown }
498 const str = (v: unknown) => (typeof v === 'string' ? v : undefined)
499 const topic = str(input.topic)
500 if (!topic?.trim()) return { result: { started: false }, text: 'topic이 필요하다.' }
501 const text = await launch($, {
502 topic,
503 brief: str(input.brief),
504 rounds: typeof input.rounds === 'number' ? input.rounds : undefined,
505 proLabel: str(input.pro_label),
506 conLabel: str(input.con_label),
507 }, options)
508 return { result: { started: text.startsWith('토론을 시작') }, text }
509 })
510
511 on('command.run', { command: 'baton-debate' }, async ($, e) => {
512 if (!(await live($))) return { text: OFF_TEXT }
513 if (e.args.trim() === 'stop') return stopCurrent($)
514 const { maxRounds, topic } = parseArgs(e.args)
515 if (!topic) return { text: await launchPicked($, maxRounds, options) }
516 const brief = recentBrief(await $.session.messages().catch(() => []))
517 const said = await launch($, { topic, rounds: maxRounds, brief }, options)
518 return { text: commandText(said, topic, maxRounds) }
519 })
520
521 // 토론자 발언은 메인 모델에게 보내지 않고(consumed) 패널에 기록 후 상대에게 중계
522 on('session.receive', async ($, e, next) => {
523 if (!(await live($))) return next(e)
524 const { value: d } = await $.state.get(CURRENT)
525 if (!isCurrent(d) || d.status !== 'running' || e.agentId) return next(e)
526 const { fromName, body } = parsePeer(e.text)
527 const side: Side | null = fromName === d.pro.name ? 'pro' : fromName === d.con.name ? 'con' : null
528 if (!side) return next(e)
529
530 const log = [...d.log, { side, text: body }]
531 const isOver = log.length >= d.maxRounds * 2
532 await patch($, cur => ({ ...cur, log, status: isOver ? 'judging' : cur.status }))
533
534 if (isOver) {
535 void finish($, { ...d, log }, options).catch(err => patch($, cur => ({ ...cur, status: 'failed', error: String(err) })))
536 } else {
537 const other: Side = side === 'pro' ? 'con' : 'pro'
538 const isFirstForOther = !d.log.some(t => t.side === other)
539 const intro = isFirstForOther ? `${rules(d, other)}\n\n` : ''
540 void sendOrThrow(
541 $,
542 d[other].name,
543 `${intro}${d.labels[side]} 측 발언:\n${body}\n\n이제 너(${d.labels[other]})의 차례다.`,
544 ).catch(err => patch($, cur => ({ ...cur, status: 'failed', error: String(err) })))
545 }
546 return { consumed: `debate: ${d.labels[side]} 발언 중계` }
547 })
548
549 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
550 const els = $.ui.resolve(e)
551 const { Box, Text, Button, Markdown } = els
552 if (!(await live($))) return <Text dimColor>{OFF_TEXT}</Text>
553 const Input = 'Input' in els ? els.Input : undefined
554 const d = await read($, current)
555 if (!isCurrent(d)) return <Text dimColor>진행 중인 토론이 없어요. /baton-debate [라운드] <주제></Text>
556
557 const status = {
558 picking: isStalePick(d) ? '주제를 고르다 플러그인을 다시 불러와 멈췄어요. /baton-debate를 다시 쳐요.' : '지금 대화에서 토론 주제를 고르는 중…',
559 starting: '토론자 세션 띄우는 중…',
560 running: `${d.labels[nextSide(d.log)]} 측 생각 중…`,
561 judging: '심판이 판정 중…',
562 done: d.reportPath ? `종료 · 전문: ${d.reportPath}` : '종료',
563 failed: `실패: ${d.error}`,
564 }[d.status]
565 const isPicking = d.status === 'picking' && !isStalePick(d)
566
567 return (
568 <Box flexDirection="column">
569 <Text bold>{d.topic || (isPicking ? '(주제 고르는 중)' : '(주제 없음)')}</Text>
570 {d.topic && (
571 <Text dimColor>
572 {d.labels.pro} vs {d.labels.con} · 발언 {d.log.length}/{d.maxRounds * 2}
573 </Text>
574 )}
575 {d.log.slice(-8).map((turn, i) => (
576 <Box key={`t-${i}`} flexDirection="column" marginTop={1}>
577 <Text bold color={turn.side === 'pro' ? 'cyan' : 'magenta'}>
578 {turn.side === 'pro' ? '▶' : '◀'} {d.labels[turn.side]}
579 </Text>
580 <Text>{turn.text}</Text>
581 </Box>
582 ))}
583 {d.verdict && (
584 <Box flexDirection="column" marginTop={1}>
585 <Text bold color="yellow">⚖ 판정</Text>
586 <Text>{d.verdict}</Text>
587 </Box>
588 )}
589 <Text color={d.status === 'failed' ? 'red' : undefined} dimColor={d.status !== 'failed'}>
590 {status}
591 </Text>
592 {(d.qa ?? []).slice(-6).map(x => (
593 <Box key={`qa-${x.id}`} flexDirection="column" marginTop={1}>
594 <Text bold>{`Q. ${x.q}`}</Text>
595 {x.a ? <Markdown text={x.a.slice(0, 9500)} /> : <Text dimColor>{x.err ?? '답 쓰는 중…'}</Text>}
596 </Box>
597 ))}
598 {d.status === 'done' && d.verdict && (
599 d.sent ? (
600 <Text key="sent" dimColor>[ 본 대화로 보냄 ]</Text>
601 ) : (
602 // 패널에 들어오면 링이 이 버튼에서 시작한다: 입력칸이 먼저 받으면 m이 글자로 들어갔다(2026-10-09 tmux로 확인)
603 <Button key="send-report" label="본 대화로 보내기 (m)" hotkey="m" variant="primary" autoFocus onPress={() => void sendReport($).catch(() => undefined)} />
604 )
605 )}
606 {Input && (
607 <Input
608 key="debate-ask"
609 placeholder="토론에 질문 (기록과 판정을 보고 답해요)"
610 submitLabel="묻기"
611 {...(d.status === 'done' && d.verdict && !d.sent ? {} : { autoFocus: true as const })}
612 onSubmit={v => void askDebate($, v).catch(() => undefined)}
613 />
614 )}
615 {(isPicking || d.status === 'starting' || d.status === 'running') && (
616 <Button
617 key="stop"
618 label="토론 중지"
619 onPress={async () => {
620 await patch($, cur => ({ ...cur, status: 'done' }))
621 await stopDebaters($, d)
622 }}
623 />
624 )}
625 </Box>
626 )
627 })
628}
629hooks/entry.tsx 133 lines1import type { EngineInterface, On, PluginOptions } from 'claude-code'
2
3import { E_COMMAND } from './answer-blocks'
4import { DEBATE_COMMAND, DEBATE_PANE, DEBATE_TOOL, debateWord } from './debate'
5import { choose, forget, idsFrom, isKnown, isLive, SESSIONS, settle, withId } from './live'
6import { SIDE_COMMAND, SIDE_PANE, settingsFrom as sideSettingsFrom } from './side-panel'
7
8// baton을 켤 세션을 고른다(/baton on|off). 고른 세션 id는 store(SESSIONS)에 남아 --resume으로 돌아와도 켜져 있다.
9// 고르지 않은 세션에는 /baton 하나만 보이고, 답 접기·내 차례·옆 패널·토론과 그에 딸린 모델 호출은 모두 쉰다.
10export const ENTRY_COMMAND = {
11 name: 'baton',
12 description: 'baton(답 접기·내 차례·옆 패널·토론): /baton on, /baton off, /baton(상태와 쓸 수 있는 것 전부)',
13 argumentHint: '[on | off]',
14}
15
16// /baton이 보여 주는 지금 상태
17export type Now = { todo: number; chat: number; auto: boolean; judged: number; failed: number; debate: string }
18
19// 모르는 기능은 쓰지 못하니(2026-10-09 사용자 "안쓴거는 몰라서 안쓴건데"), /baton 하나만 알면 나머지를 찾게 화면별로 적는다. 꺼진 세션(now 없음)에는 켜면 무엇이 생기는지 적는다
20export function helpText(sessions: number, now: Now | undefined): string {
21 if (!now)
22 return [
23 `이 세션에서 꺼짐 · 켜 둔 세션 ${sessions}개`,
24 '/baton on으로 켜면 이렇게 돼요.',
25 '- 답의 설명이 접혀요. 펼치기는 버튼이나 /baton-e',
26 '- 옆 패널에 내 차례(답이 남긴 물을 것·볼 것)와 곁대화가 생겨요. 열기는 /baton-side',
27 '- /baton-debate만 치면 지금 대화에서 주제를 골라 두 Claude가 토론해요',
28 ].join('\n')
29 return [
30 `이 세션에서 켜짐 · 켜 둔 세션 ${sessions}개 · 끄기는 /baton off`,
31 '- 답: 설명은 접혀 있어요. 버튼이나 /baton-e로 펼쳐요(/baton-e 2는 그 전 답).',
32 '- 옆 패널(/baton-side): 위는 내 차례(답이 남긴 물을 것·볼 것, 입력창 아래 "↩ 내 차례 N"이 그 개수), 아래는 곁대화예요. [답하기]로 답을 써 두었다가 한 번에 보내요. 지우기·되돌리기와 자동 분리(작업과 관계없는 질문을 곁대화로 보내기)도 패널 버튼이에요. /baton-side 질문은 곁대화에 바로 물어요.',
33 '- 패널 키: ctrl+x tab 들어가기 · 숫자 답하기 · s 보내기 · q 곁질문 · m 본 대화로 · u 되돌리기 · Esc 나가기',
34 '- 토론(/baton-debate): 그냥 치면 지금 대화에서 주제를 골라 시작해요. /baton-debate 주제로 직접 줄 수도 있어요. 중지는 토론 탭의 버튼이에요.',
35 `- 지금: 내 차례 ${now.todo}개 · 곁대화 ${now.chat}개 · 자동 분리 ${now.auto ? '켜짐' : '꺼짐'} · 판정 ${now.judged}번(실패 ${now.failed}) · 토론 ${now.debate}`,
36 ].join('\n')
37}
38
39// /baton on의 답: 어디에 무엇이 있는지 세 줄로
40export function onText(saved: boolean): string {
41 return [
42 `이 세션에서 baton을 켰어요. ${saved ? '--resume으로 돌아와도 켜져 있어요.' : '다만 목록 저장에 실패해서 이 창에서만 켜져 있어요.'}`,
43 '- 답의 설명은 접혀요. 펼치기는 버튼이나 /baton-e',
44 '- 할 일과 곁질문은 옆 패널에 모여요. 열기는 /baton-side',
45 '- /baton-debate만 치면 지금 대화로 두 Claude가 토론해요',
46 // 빈 줄이 없으면 화면이 이 줄을 위 목록 항목에 이어 붙여 들여 쓴다(2026-10-09 tmux로 확인)
47 '',
48 '모르면 /baton',
49 ].join('\n')
50}
51
52// 마지막으로 목록에 있는 것을 확인한 세션 id: /clear 뒤 새 id만 store에 더한다
53let listedId = ''
54
55// 이 세션에서 켜졌는지(live.ts). $를 쓰는 함수라 hook과 같은 파일에 둔다
56async function live($: EngineInterface): Promise<boolean> {
57 if (isKnown()) return isLive()
58 // 읽다가 실패하면 이번만 꺼진 것으로 보고, 정하지 않은 채 두어 다음에 다시 읽는다
59 const id = await $.session.id().catch(() => '')
60 const list = id ? await $.store.get(SESSIONS).catch(() => null) : null
61 return list === null ? false : settle(list, id)
62}
63
64// on·off 말고는(도움말, help 등) 모두 도움말과 상태를 보인다: 2026-10-09 사용자가 /baton 도움말을 쳤다가 사용법 한 줄만 받았다
65export function entryReply(arg: string): 'on' | 'off' | 'status' {
66 const a = arg.trim().toLowerCase()
67 return a === 'on' || a === 'off' ? a : 'status'
68}
69
70// 다른 mod가 state에 둔 값을 읽어 /baton에 보일 지금 상태를 만든다. $를 쓰는 함수라 hook과 같은 파일에 둔다
71async function nowOf($: EngineInterface, autoDefault: boolean): Promise<Now> {
72 const { value: book } = await $.state.get({ plugin: 'baton', key: 'book' })
73 const { value: stats } = await $.state.get({ plugin: 'baton', key: 'asksStats' })
74 const { value: keep } = await $.state.get({ plugin: 'baton', key: 'sideKeep' })
75 const { value: auto = autoDefault } = await $.state.get({ plugin: 'baton', key: 'sideEnabled' })
76 const { value: debate } = await $.state.get({ plugin: 'baton', key: 'debate' })
77 return { todo: book?.items.length ?? 0, chat: keep?.chat.length ?? 0, auto, judged: stats?.judged ?? 0, failed: stats?.failed ?? 0, debate: debateWord(debate) }
78}
79
80export function registerEntry(on: On, options: PluginOptions): void {
81 const autoDefault = sideSettingsFrom(options).auto
82 on('session.start', { isInteractive: true }, async ($, e, next) => {
83 const started = await next(e)
84 await $.command.register(ENTRY_COMMAND)
85 return started
86 })
87
88 on('command.run', { command: 'baton' }, async ($, e) => {
89 const want = entryReply(e.args)
90 const id = await $.session.id().catch(() => '')
91 if (!id) return { text: '이 세션의 id를 읽지 못했어요. 잠시 뒤 다시 해 주세요.' }
92 const list = idsFrom(await $.store.get(SESSIONS).catch(() => []))
93 if (want === 'status') {
94 const isOn = await live($)
95 // 엔진이 출력 앞에 'baton: '를 붙인다
96 return { text: helpText(list.length, isOn ? await nowOf($, autoDefault) : undefined) }
97 }
98 const isOn = want === 'on'
99 const saved = await $.store.set(SESSIONS, withId(list, id, isOn)).then(() => true, () => false)
100 choose(isOn)
101 listedId = isOn ? id : ''
102 if (isOn) {
103 // 이미 시작한 세션이라 각 mod의 session.start가 하던 등록을 여기서 한다
104 for (const c of [E_COMMAND, SIDE_COMMAND, DEBATE_COMMAND]) await $.command.register(c).catch(() => undefined)
105 await $.tool.register(DEBATE_TOOL).catch(() => undefined)
106 return { text: onText(saved) }
107 }
108 await $.ui.close({ id: SIDE_PANE }).catch(() => undefined)
109 await $.ui.close({ id: DEBATE_PANE }).catch(() => undefined)
110 return { text: `이 세션에서 baton을 껐어요. /baton-side·/baton-e·/baton-debate는 목록에 남지만 꺼져 있다고만 답해요.${saved ? '' : ' 다만 목록 저장에 실패해서 --resume하면 다시 켜질 수 있어요.'}` }
111 })
112
113 // /clear는 session.start 없이 새 id로 이어진다: 켜 둔 탭이면 새 id도 목록에 더해 --resume으로 돌아와도 켜지게 한다
114 on('prompt.submit', { text: /[\s\S]/ }, async ($, e, next) => {
115 if (!(await live($))) return next(e)
116 const id = await $.session.id().catch(() => listedId)
117 if (id !== listedId) {
118 const list = idsFrom(await $.store.get(SESSIONS).catch(() => [id]))
119 const isListed = list.includes(id) || (await $.store.set(SESSIONS, withId(list, id, true)).then(() => true, () => false))
120 if (isListed) listedId = id
121 }
122 return next(e)
123 })
124
125 // /resume으로 다른 세션이 이 프로세스에 오면 그 세션을 골랐는지 다시 찾는다
126 on('session.end', { reason: 'resume' }, async ($, e, next) => {
127 const ended = await next(e)
128 forget()
129 listedId = ''
130 return ended
131 })
132}
133hooks/open-asks.tsx 383 lines1import { atom, read, update } from 'claude-code'
2import type {EngineInterface, On, PluginOptions } from 'claude-code'
3
4import { enter, isKnown, isLive, SESSIONS, settle } from './live'
5import type { Ask, AsksStats as Stats, Book } from '../types'
6
7// Claude가 사용자에게 넘긴 일(물을 것·볼 것·다르게 한 것)을 모아 두고, 입력창 아래 줄 끝에 개수를 보인다. 목록은 옆 패널의 내 차례(side-panel).
8// 턴이 끝날 때마다 작은 모델이 판정한다: 이번 메시지로 답한 질문은 닫고, 이번 답의 새 질문은 더한다.
9
10const BOOK = { plugin: 'baton', key: 'book' } as const
11const TURN = { plugin: 'baton', key: 'turn' } as const
12const LAST = { plugin: 'baton', key: 'lastRequest' } as const
13const HUMAN = { plugin: 'baton', key: 'humanTurn' } as const
14const STATS = { plugin: 'baton', key: 'asksStats' } as const
15const JUDGED = { plugin: 'baton', key: 'judgedAnswer' } as const
16export const EMPTY: Book = { items: [], closed: [], seq: 0 }
17// 이 세션에서 지난 세션 항목을 이미 가져왔는지, 세션이 끝날 때 쓸 작업 폴더
18let importedFor = ''
19let cwdNow = ''
20const book = atom(BOOK, EMPTY)
21const turn = atom(TURN, 0)
22const lastRequest = atom(LAST, '')
23const humanTurn = atom(HUMAN, false)
24const stats = atom(STATS, { judged: 0, failed: 0 })
25
26export type Settings = { model: 'sonnet' | 'haiku' }
27
28export function settingsFrom(o: PluginOptions): Settings {
29 return { model: o['judge_model'] === 'haiku' ? 'haiku' : 'sonnet' }
30}
31
32const CODE = /```[\s\S]*?```/g
33
34// 다른 mod가 사용자의 말로 보낸 글(asUser, 옆 패널에서 모아 보낸 답)도 사람 턴으로 센다
35export function isHuman(origin: { kind?: string; asUser?: boolean } | undefined): boolean {
36 if (!origin || origin.kind === undefined || origin.kind === 'composer' || origin.kind === 'bridge') return true
37 return origin.kind === 'plugin' && origin.asUser === true
38}
39
40// "↩ 항목 → 답" 줄로 답한 항목(옆 패널에서 모아 보낸 답): 판정을 기다리지 않고 바로 닫는다
41export function answeredIds(items: readonly Ask[], text: string): number[] {
42 const ids: number[] = []
43 for (const raw of text.split('\n')) {
44 const l = raw.trim()
45 if (!l.startsWith('↩')) continue
46 const rest = l.slice(1).trim()
47 const hit = items.find(a => rest.startsWith(`${a.text} → `) && rest.slice(a.text.length + 3).trim() !== '')
48 if (hit && !ids.includes(hit.id)) ids.push(hit.id)
49 }
50 return ids
51}
52
53// 열린 항목이 없을 때는 물음표나 "물을 것"이 있는 답만 판정에 보낸다
54export function mayAsk(answer: string): boolean {
55 return /[??]|물을 것/.test(answer.replace(CODE, ' '))
56}
57
58// 항목 글은 300자까지 그대로 둔다: 옆 패널이 줄을 바꿔 다 보여 준다. 띠에 한 줄로 보이던 때의 80자 제한은 2026-10-06 긴 줄을 잘라서 뺐다
59export function shorten(s: string, n = 300): string {
60 const t = s.replace(/\s+/g, ' ').trim()
61 return t.length > n ? `${t.slice(0, n - 1)}…` : t
62}
63
64const sameKey = (s: string) => s.replace(/[\s??.!,·…"'“”]/g, '')
65
66export const JUDGE_SYSTEM =
67 '너는 대화에서 Claude가 사용자에게 넘긴 일(질문, 볼 것, 다르게 한 것) 가운데 아직 끝나지 않은 것을 관리한다. 대화에 없는 내용을 만들지 않는다. JSON 한 줄만 출력한다.'
68
69export function judgePrompt(open: readonly Ask[], request: string | undefined, answer: string, turnNo: number, taken: readonly string[] = []): string {
70 const list = open.length
71 ? open
72 .map((a, i) => `${i + 1}. ${a.text} (${a.skips === 0 ? '직전 답에서 물음' : `${Math.max(1, turnNo - a.turn)}턴 전에 물었고 아직 답 없음`})`)
73 .join('\n')
74 : '(없음)'
75 return [
76 '[아직 끝나지 않은 항목]',
77 list,
78 '',
79 '[이번 답에서 이미 잡은 항목: new에 넣지 않는다]',
80 taken.length ? taken.map(t => `- ${t}`).join('\n') : '(없음)',
81 '',
82 '[이번 사용자 메시지]',
83 request === undefined ? '(없음: 알림 등으로 시작된 턴)' : request.slice(0, 1500),
84 '',
85 '[이번 Claude 답]',
86 answer.replace(CODE, '[코드 블록]').slice(0, 6000),
87 '',
88 '두 가지를 판정하라.',
89 'closed: 위 목록에서 이번 턴으로 끝난 질문의 번호. 사용자가 답했거나 골랐거나 거절·보류한 것, Claude가 이번 답에서 이미 처리한 것, 상황이 바뀌어 더 물을 필요가 없어진 것. "ㅇㅇ" "ㄱㄱ" "해봐"처럼 짧은 답은 직전 답의 질문에 대한 답으로 본다. 사용자가 다른 얘기를 했거나 그 질문에 대해 되묻기만 했으면 닫지 않는다. "볼 것:"이나 "다르게 한 것:"으로 시작하는 항목은 사용자가 이번 메시지를 보냈고 그 항목에 되묻거나 반대하지 않았으면 닫는다.',
90 'new: 이번 Claude 답에서 사용자가 답하거나 골라야 다음으로 넘어가는데 "물을 것:" 줄로 쓰지 않은 질문. "할까요?" "어느 쪽으로 할까요?" 같은 것. 수사적 질문, 이번 메시지에서 이미 답한 것, 목록이나 이미 잡은 항목에 있는 것은 넣지 않는다. 최대 3개. 각각 60자 안에서, 이것만 봐도 무엇에 관한 질문인지 알도록 대상 이름을 넣고, 원문보다 단정하지 않게 쓴다.',
91 '출력 형식: {"closed":[번호],"new":["질문"]}',
92 ].join('\n')
93}
94
95export type Judgment = { closed: number[]; added: string[] }
96
97export function parseJudgment(text: string): Judgment | undefined {
98 const m = text.match(/\{[\s\S]*\}/)
99 if (!m) return undefined
100 try {
101 const j = JSON.parse(m[0]) as { closed?: unknown; new?: unknown }
102 const closed = Array.isArray(j.closed) ? j.closed.map(Number).filter(n => Number.isInteger(n) && n > 0) : []
103 const added = Array.isArray(j.new)
104 ? j.new.filter((x): x is string => typeof x === 'string').map(x => x.trim()).filter(Boolean).slice(0, 3)
105 : []
106 return { closed, added }
107 } catch {
108 return undefined
109 }
110}
111
112// 사용자 차례 줄(물을 것·볼 것·다르게 한 것)은 모델 판정을 기다리지 않고 바로 띠에 올린다
113const TURN_LINE = /^(물을 것|볼 것|다르게 한 것)\s*[::]\s*/
114
115export function labeledAsks(answer: string): string[] {
116 const out: string[] = []
117 for (const raw of answer.replace(CODE, ' ').split('\n')) {
118 const l = raw.replace(/^[\s>*\-•]+/, '').replace(/\*\*/g, '').trim()
119 const m = l.match(TURN_LINE)
120 const rest = m ? l.slice(m[0].length).trim() : ''
121 if (m && rest) out.push(m[1] === '물을 것' ? rest : `${m[1]}: ${rest}`)
122 }
123 return out.slice(0, 5)
124}
125
126// seen: 판정 모델이 본 질문 id. 그중 닫히지 않은 것은 사람 턴이 한 번 지나갈 때마다 skips가 는다
127export function apply(
128 b: Book,
129 seen: readonly number[],
130 closedIds: readonly number[],
131 added: readonly string[],
132 turnNo: number,
133 now: number,
134 countSkips: boolean,
135): Book {
136 const items: Ask[] = []
137 const closed = [...b.closed]
138 for (const a of b.items) {
139 if (closedIds.includes(a.id)) closed.push({ text: a.text, at: now })
140 else items.push(countSkips && seen.includes(a.id) ? { ...a, skips: a.skips + 1 } : a)
141 }
142 let seq = b.seq
143 const have = new Set(items.map(a => sameKey(a.text)))
144 for (const t of added) {
145 const text = shorten(t)
146 if (!text || have.has(sameKey(text))) continue
147 have.add(sameKey(text))
148 items.push({ id: ++seq, text, turn: turnNo, at: now, skips: 0 })
149 }
150 return { items: items.slice(-12), closed: closed.slice(-10), seq }
151}
152
153// 답을 못 한 질문은 세션이 끝날 때 $.store에 작업 폴더별로 남기고, 그 폴더의 다음 세션이 가져간다
154export type Carried = { text: string; at: number }
155export const CARRY_PREFIX = '지난 세션: '
156
157export function carryKey(cwd: string): string {
158 return `carry:${cwd}`
159}
160
161// 넘길 것은 답을 기다리는 질문뿐이다. 볼 것·다르게 한 것은 알림이라 넘기지 않는다
162export function toCarry(b: Book): Carried[] {
163 return b.items
164 .filter(a => !/^(볼 것|다르게 한 것):/.test(a.text))
165 .map(a => ({ text: a.text.startsWith(CARRY_PREFIX) ? a.text : `${CARRY_PREFIX}${a.text}`, at: a.at }))
166}
167
168export function readCarried(raw: unknown): Carried[] {
169 if (!Array.isArray(raw)) return []
170 return raw.filter((c): c is Carried => !!c && typeof c === 'object' && typeof (c as Carried).text === 'string' && typeof (c as Carried).at === 'number')
171}
172
173export function mergeCarried(old: unknown, add: readonly Carried[]): Carried[] {
174 const prev = readCarried(old)
175 const seen = new Set(prev.map(c => sameKey(c.text)))
176 return [...prev, ...add.filter(c => !seen.has(sameKey(c.text)))].slice(-12)
177}
178
179// 세션이 끝날 때 판정을 마친 마지막 답의 지문(answerMark)을 세션 id별로 store에 남긴다. --resume하면 $.state가 비어서다. 최근 50개 세션만 둔다
180export const END_MARKS = 'judgedAtEnd'
181
182function marksOf(raw: unknown): [string, string][] {
183 if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return []
184 return Object.entries(raw as Record<string, unknown>).filter((x): x is [string, string] => typeof x[1] === 'string')
185}
186
187export function withEndMark(raw: unknown, id: string, mark: string): Record<string, string> {
188 return Object.fromEntries([...marksOf(raw).filter(([k]) => k !== id), [id, mark] as [string, string]].slice(-50))
189}
190
191export function endMarkOf(raw: unknown, id: string): string {
192 return marksOf(raw).find(([k]) => k === id)?.[1] ?? ''
193}
194
195async function importCarried($: EngineInterface) {
196 const id = await $.session.id()
197 if (importedFor === id) return
198 importedFor = id
199 cwdNow = cwdNow || (await $.session.cwd())
200 const key = carryKey(cwdNow)
201 const list = readCarried(await $.store.get(key))
202 if (!list.length) return
203 await $.store.delete(key)
204 const { value: turnNo = 0 } = await $.state.get(TURN)
205 await update($, book, (cur: Book): Book => apply(cur, [], [], list.map(c => c.text), turnNo, Date.now(), false))
206}
207
208// 지운 항목을 제자리(들어온 순서인 id 순)에 되살린다: 그사이 같은 항목이 다시 들어왔으면 겹치지 않게 한다
209export function restore(b: Book, back: readonly Ask[]): Book {
210 const have = new Set(b.items.map(a => sameKey(a.text)))
211 return { ...b, items: [...back.filter(a => !have.has(sameKey(a.text))), ...b.items].sort((x, y) => x.id - y.id) }
212}
213
214// "볼 것"과 "다르게 한 것"은 알림이라 다음 답이 나오면 닫는다. 답을 기다리는 건 "물을 것"뿐이다
215export function supersede(b: Book, now: number): Book {
216 const isNotice = (a: Ask) => /^(볼 것|다르게 한 것):/.test(a.text)
217 const gone = b.items.filter(isNotice)
218 if (!gone.length) return b
219 return { ...b, items: b.items.filter(a => !isNotice(a)), closed: [...b.closed, ...gone.map(a => ({ text: a.text, at: now }))].slice(-10) }
220}
221
222// 모델에게 붙이는 목록은 답 없이 한 번 이상 지나간 질문만: 방금 물은 질문은 바로 위 답에 있다
223export function waiting(b: Book): Ask[] {
224 return b.items.filter(a => a.skips >= 1)
225}
226
227// 입력창 아래 줄 끝에 늘 붙는 표시: 켜져 있는지, 방금 넘긴 일이 잡혔는지 바로 보인다
228export function tailText(b: Book): string {
229 return b.items.length ? `↩ 내 차례 ${b.items.length}` : '↩ 내 차례 없음'
230}
231
232export function contextText(list: readonly Ask[]): string {
233 return [
234 '[open-asks] 사용자가 아직 답하지 않은 이전 질문이다. 사용자는 옆 패널의 내 차례에서 이 번호를 본다.',
235 ...list.map((a, i) => `${i + 1}. ${a.text}`),
236 '사용자의 메시지가 이 중 하나에 대한 답이면(번호나 "아까 그거"로 가리켜도) 그 질문에 대한 답으로 처리한다. 관계없는 메시지면 이 목록을 언급하지 않는다.',
237 ].join('\n')
238}
239
240// 대화의 마지막 답과 그 앞 사용자 메시지: 다시 불러온 직후, 불러오기 전에 끝난 턴을 판정할 때 쓴다
241export function lastExchange(msgs: readonly { role: string; text: string }[]): { request: string; answer: string } | undefined {
242 let i = msgs.length - 1
243 while (i >= 0 && !(msgs[i]?.role === 'assistant' && msgs[i]?.text.trim())) i--
244 const answer = msgs[i]?.text
245 if (i < 0 || !answer) return undefined
246 let j = i - 1
247 while (j >= 0 && !(msgs[j]?.role === 'user' && msgs[j]?.text.trim())) j--
248 return { request: msgs[j]?.text ?? '', answer }
249}
250
251// 판정을 마친 답의 지문: 공백을 뺀 글의 길이와 해시. turn.complete의 답과 다시 읽은 대화의 답은 공백·줄바꿈이 다를 수 있다
252export function answerMark(answer: string): string {
253 const t = answer.replace(/\s+/g, '')
254 let h = 5381
255 for (let i = 0; i < t.length; i++) h = (Math.imul(h, 33) ^ t.charCodeAt(i)) >>> 0
256 return `${t.length}:${h.toString(36)}`
257}
258
259// request가 undefined면 사람이 쓴 메시지가 없는 턴이다. countSkips가 false면 닫고 더하기만 한다(같은 턴을 다시 판정할 때)
260async function judge($: EngineInterface, s: Settings, answer: string, request: string | undefined, countSkips: boolean) {
261 await judgeOnce($, s, answer, request, countSkips)
262 // 끝까지 간 판정만 적는다(판정 모델이 답하지 못한 것도 끝난 것이다). 다시 불러올 때 되짚는 건 불러오기에 끊긴 판정뿐이다
263 await $.state.set(JUDGED, answerMark(answer))
264}
265
266async function judgeOnce($: EngineInterface, s: Settings, answer: string, request: string | undefined, countSkips: boolean) {
267 const { value: turnNo = 0 } = await $.state.get(TURN)
268 const taken = labeledAsks(answer)
269 await update($, book, (cur: Book): Book => apply(supersede(cur, Date.now()), [], [], taken, turnNo, Date.now(), false))
270 // 판정은 이번 답 전부터 열려 있던 항목만 본다: 방금 올린 사용자 차례 줄은 이번 메시지로 닫힐 수 없다
271 const takenKeys = new Set(taken.map(t => sameKey(shorten(t))))
272 const { value: b = EMPTY } = await $.state.get(BOOK)
273 const open = b.items.filter(a => !takenKeys.has(sameKey(a.text)))
274 if (!open.length && !mayAsk(answer)) return
275 const r = await $.model.complete({
276 model: s.model,
277 effort: 'low',
278 maxTokens: 400,
279 timeoutMs: 30000,
280 system: JUDGE_SYSTEM,
281 prompt: judgePrompt(open, request, answer, turnNo, taken),
282 })
283 const j = r.isAnswered ? parseJudgment(r.text) : undefined
284 await update($, stats, (x: Stats): Stats => (j ? { ...x, judged: x.judged + 1 } : { ...x, failed: x.failed + 1 }))
285 // 판정이 없으면 닫지도 세지도 않는다
286 const closedIds = j ? j.closed.map(n => open[n - 1]?.id).filter((id): id is number => id !== undefined) : []
287 const added = j ? j.added : []
288 const seen = open.map(a => a.id)
289 await update($, book, (cur: Book): Book => apply(cur, seen, closedIds, added, turnNo, Date.now(), countSkips && request !== undefined && j !== undefined))
290}
291
292// 모듈을 다시 불러오면 그 전에 시작된 판정이 사라질 수 있다. 불러올 때 마지막 턴을 한 번 더 판정한다(같은 질문은 다시 더하지 않는다)
293async function catchUp($: EngineInterface, s: Settings) {
294 const ex = lastExchange(await $.session.messages())
295 if (!ex) return
296 // 판정을 마친 답은 다시 보지 않는다: 다시 판정하면 그사이 [지우기]로 지운 질문이 되살아났다(2026-10-09 /reload-plugins와 --continue로 확인).
297 // /reload-plugins는 $.state가 남고, --resume은 비어서 세션이 끝날 때 store에 남긴 지문을 본다. 끝나지 못하고 죽은 세션은 지문이 없어 다시 판정한다
298 const mark = answerMark(ex.answer)
299 const { value: done = '' } = await $.state.get(JUDGED)
300 const id = await $.session.id().catch(() => '')
301 const ended = id ? endMarkOf(await $.store.get(END_MARKS).catch(() => undefined), id) : ''
302 if (mark === done || mark === ended) return
303 await judge($, s, ex.answer, ex.request || undefined, false)
304}
305
306// 이 세션에서 켜졌는지(live.ts). $를 쓰는 함수라 hook과 같은 파일에 둔다
307async function live($: EngineInterface): Promise<boolean> {
308 if (isKnown()) return isLive()
309 // 읽다가 실패하면 이번만 꺼진 것으로 보고, 정하지 않은 채 두어 다음에 다시 읽는다
310 const id = await $.session.id().catch(() => '')
311 const list = id ? await $.store.get(SESSIONS).catch(() => null) : null
312 return list === null ? false : settle(list, id)
313}
314
315export function registerAsks(on: On, options: PluginOptions): void {
316 const s = settingsFrom(options)
317
318 on('prompt.submit', { text: /[\s\S]/ }, async ($, e, next) => {
319 if (!(await live($))) return next(e)
320 // /clear 뒤처럼 session.start 없이 새 세션이 된 경우에도 지난 세션 항목을 가져온다
321 await importCarried($).catch(() => undefined)
322 if (!isHuman(e.origin as { kind?: string; asUser?: boolean } | undefined)) {
323 await update($, humanTurn, () => false)
324 return next(e)
325 }
326 const turnNo = await update($, turn, (n: number) => n + 1)
327 await update($, lastRequest, () => e.text)
328 await update($, humanTurn, () => true)
329 await update($, book, (cur: Book): Book => {
330 const answered = answeredIds(cur.items, e.text)
331 return answered.length ? apply(cur, [], answered, [], turnNo, Date.now(), false) : cur
332 })
333 const { value: b = EMPTY } = await $.state.get(BOOK)
334 const list = waiting(b)
335 if (!list.length) return next(e)
336 return next({ ...e, context: [...(e.context ?? []), contextText(list)] })
337 })
338
339 on('turn.complete', { reason: 'answer' }, async ($, e, next) => {
340 if (!(await live($))) return next(e)
341 const done = await next(e)
342 if (e.agentId || e.reason !== 'answer' || !e.answer.trim()) return done
343 const { value: request = '' } = await $.state.get(LAST)
344 const { value: human = false } = await $.state.get(HUMAN)
345 void judge($, s, e.answer, human ? request : undefined, true).catch(() => undefined)
346 return done
347 })
348
349 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
350 if (!(await live($))) return next(e)
351 const mine = tailText(await read($, book))
352 return next({ ...e, props: { ...e.props, tail: e.props.tail ? `${e.props.tail} · ${mine}` : mine } })
353 })
354
355 on('session.start', { isInteractive: true }, async ($, e, next) => {
356 if (!enter(e) || !(await live($))) return next(e)
357 const started = await next(e)
358 cwdNow = e.cwd
359 await importCarried($).catch(() => undefined)
360 void catchUp($, s).catch(() => undefined)
361 return started
362 })
363
364 // 세션이 끝나면 답을 못 한 질문을 이 작업 폴더의 다음 세션으로 넘긴다
365 on('session.end', async ($, e, next) => {
366 if (!(await live($))) return next(e)
367 const ended = await next(e)
368 try {
369 const { value: b = EMPTY } = await $.state.get(BOOK)
370 const carry = toCarry(b)
371 if (carry.length && cwdNow) {
372 const key = carryKey(cwdNow)
373 await $.store.set(key, mergeCarried(await $.store.get(key), carry))
374 }
375 const { value: mark = '' } = await $.state.get(JUDGED)
376 if (mark && e.sessionId) await $.store.set(END_MARKS, withEndMark(await $.store.get(END_MARKS), e.sessionId, mark))
377 } catch {
378 // 끝나는 중이라 실패해도 할 수 있는 일이 없다
379 }
380 return ended
381 })
382}
383hooks/side-panel.tsx 629 lines1import { atom, read, update } from 'claude-code'
2import type {EngineInterface, On, PluginOptions } from 'claude-code'
3
4import { enter, isKnown, isLive, OFF_TEXT, SESSIONS, settle } from './live'
5import type { SideStats as Stats } from '../types'
6import type { Ask, Book, SideKeep, SideTurn } from '../types'
7import { paneTitle } from './debate'
8import { answerMark, EMPTY, restore, settingsFrom as asksSettingsFrom } from './open-asks'
9
10// 전체 화면 모드의 옆 패널 하나에 위는 "내 차례"(open-asks가 모은 할 일), 아래는 "곁대화"를 보여 준다.
11// 내 차례는 항목마다 패널 안에서 짧게 답을 써 두었다가, 모은 답을 한 번에 본 대화로 보낸다.
12// 작업과 관계없는 곁질문은 본 대화에 넣지 않고 곁대화로 보낸다: 질문처럼 보이는 메시지만 작은 모델로 가르고,
13// 애매하거나 판정이 늦거나 패널을 둘 자리가 없으면 본 대화로 보낸다. 곁대화의 답은 지금 대화를 이어받은 모델($.model.fork)이 쓴다.
14const ENABLED = { plugin: 'baton', key: 'sideEnabled' } as const
15const LASTSIDE = { plugin: 'baton', key: 'lastSide' } as const
16const STATS = { plugin: 'baton', key: 'sideStats' } as const
17const ASKS = { plugin: 'baton', key: 'book' } as const
18const DEBATE = { plugin: 'baton', key: 'debate' } as const
19const enabled = atom(ENABLED, false)
20const lastSide = atom(LASTSIDE, '')
21const stats = atom(STATS, { checked: 0, side: 0, failed: 0, totalMs: 0 })
22const asks = atom(ASKS, { items: [], closed: [], seq: 0 })
23const ASKS_STATS = { plugin: 'baton', key: 'asksStats' } as const
24const JUDGED = { plugin: 'baton', key: 'judgedAnswer' } as const
25// 패널의 [지우기](또는 /baton-side clear·drop)로 지운 항목: [되돌리기]나 /baton-side undo가 되살린다.
26// 사용자가 다음 메시지를 보내면 비운다: 되돌리기는 지운 직후에만 띄운다(모듈 변수라 다시 불러와도 사라진다)
27let lastRemoved: Ask[] = []
28
29export type Settings = { model: 'haiku' | 'sonnet'; auto: boolean }
30
31export function settingsFrom(o: PluginOptions): Settings {
32 // 곁질문 자동 분리는 옵션이고 기본은 꺼짐이다(2026-10-09 사용자 "자동으로 넘어가는건 옵션 기본은 off"). /baton-side auto on|off는 그 세션만 바꾼다
33 return { model: o['classify_model'] === 'sonnet' ? 'sonnet' : 'haiku', auto: o['side_auto'] === 'on' }
34}
35
36// 곁대화와 써 둔 답은 모듈 변수로 그리고, 바뀔 때마다 state(sideKeep)에도 적는다: 다시 불러오면 session.start에서 되살린다
37export type { SideTurn }
38let chat: SideTurn[] = []
39const KEEP = { plugin: 'baton', key: 'sideKeep' } as const
40const PANE = 'side-chat'
41export const SIDE_PANE = PANE
42const SEND_KEY = 'send-answers'
43// 내 차례 항목 id별로 써 둔 답, 보냈지만 open-asks가 아직 닫지 않은 답(본 대화가 일하는 중이면 끝난 뒤에 들어간다)
44const drafts = new Map<number, string>()
45const sent = new Map<number, string>()
46let editing: number | undefined
47let typed = ''
48
49export function toKeep(d: ReadonlyMap<number, string>, s: ReadonlyMap<number, string>, c: readonly SideTurn[]): SideKeep {
50 return { drafts: Object.fromEntries(d), sent: Object.fromEntries(s), chat: [...c] }
51}
52
53export function fromKeep(k: SideKeep | undefined): { drafts: Map<number, string>; sent: Map<number, string>; chat: SideTurn[] } {
54 const toMap = (r: Record<string, string> | undefined) => new Map(Object.entries(r ?? {}).map(([id, v]) => [Number(id), v] as const))
55 // 답을 기다리던 중에 다시 불러왔으면 그 답은 오지 않는다
56 const chat = (k?.chat ?? []).map(t => (t.a || t.err ? t : { ...t, err: '답을 기다리는 중에 플러그인을 다시 불러와 답을 받지 못했어요.' }))
57 return { drafts: toMap(k?.drafts), sent: toMap(k?.sent), chat }
58}
59
60async function persist($: EngineInterface) {
61 await $.state.set(KEEP, toKeep(drafts, sent, chat))
62}
63
64// 처음 그리거나 처음 쓸 때 한 번 state에서 되살린다: /reload-plugins로 다시 불러왔을 때는 session.start가 오지 않아 거기서만 되살리면 비어 보였다(2026-10-09 tmux로 확인)
65let loaded = false
66async function ensureLoaded($: EngineInterface) {
67 if (loaded) return
68 loaded = true
69 await hydrate($).catch(() => undefined)
70}
71
72async function hydrate($: EngineInterface) {
73 const { value } = await $.state.get(KEEP)
74 const k = fromKeep(value)
75 drafts.clear()
76 sent.clear()
77 for (const [id, v] of k.drafts) drafts.set(id, v)
78 for (const [id, v] of k.sent) sent.set(id, v)
79 chat = k.chat
80}
81
82export function isHuman(origin: { kind?: string } | undefined): boolean {
83 return !origin || origin.kind === undefined || origin.kind === 'composer' || origin.kind === 'bridge'
84}
85
86const INSTRUCTION = /해\s?줘|해\s?봐|하자|ㄱㄱ|ㅇㅇ|고쳐|만들어|적용|돌려|켜\s?줘|꺼\s?줘|지워|추가해|바꿔|진행|계속|부탁/
87const QUESTION_WORD = /뭐|왜|어떻게|어케|언제|어디|누가|누구|얼마|몇|어느|무슨/
88const QUESTION_END = /(인가|일까|나요|까요|냐|니|까)$/
89
90// 판정에 보낼 만한 메시지: 질문처럼 보이고, 짧은 대답·지시·명령이 아닌 것
91export function worthChecking(text: string): boolean {
92 const t = text.trim()
93 if (t.length < 6 || t.startsWith('/') || t.startsWith('↩') || t.includes('\n')) return false
94 if (INSTRUCTION.test(t)) return false
95 return /[??]/.test(t) || QUESTION_WORD.test(t) || QUESTION_END.test(t.replace(/[\s.!~ㅋㅎ]+$/, ''))
96}
97
98export const CLASSIFY_SYSTEM = '너는 사용자의 새 메시지가 지금 하던 작업에 속하는지, 작업과 관계없는 곁질문인지 가른다. 한 단어만 출력한다.'
99
100export function classifyPrompt(request: string, answer: string, message: string): string {
101 return [
102 '[지금 하던 작업]',
103 `사용자 최근 요청: ${request.slice(0, 600)}`,
104 `Claude 최근 답(앞부분): ${answer.slice(0, 600)}`,
105 '',
106 '[새 메시지]',
107 message.slice(0, 800),
108 '',
109 '새 메시지가 지금 작업과 관계없는 곁질문이면 SIDE, 아니면 MAIN.',
110 '- SIDE: 작업과 무관한 일반 지식, 잡담, 다른 주제에 대한 궁금증. 답을 들어도 지금 작업의 다음 행동이 바뀌지 않는다.',
111 '- MAIN: 지금 작업이나 방금 답에 대한 질문·지시·결정·확인, 새 작업 요청, 이전 질문에 대한 답.',
112 '애매하면 MAIN. SIDE 또는 MAIN 한 단어만 출력.',
113 ].join('\n')
114}
115
116export function isSide(reply: string): boolean {
117 const t = reply.trim().toUpperCase()
118 return t.startsWith('SIDE') && !t.includes('MAIN')
119}
120
121// 최근 사용자 요청과 Claude 답: 판정의 "지금 작업"
122export function recentWork(msgs: readonly { role: string; text: string }[]): { request: string; answer: string } {
123 let request = ''
124 let answer = ''
125 for (let i = msgs.length - 1; i >= 0 && !(request && answer); i--) {
126 const m = msgs[i]
127 if (!m?.text.trim()) continue
128 if (m.role === 'assistant' && !answer) answer = m.text
129 if (m.role === 'user' && !request && answer) request = m.text
130 }
131 return { request, answer }
132}
133
134async function classify($: EngineInterface, s: Settings, message: string): Promise<boolean> {
135 const started = Date.now()
136 const work = recentWork(await $.session.messages())
137 if (!work.answer) return false
138 const r = await $.model.complete({
139 model: s.model,
140 effort: 'low',
141 maxTokens: 10,
142 timeoutMs: 4000,
143 system: CLASSIFY_SYSTEM,
144 prompt: classifyPrompt(work.request, work.answer, message),
145 })
146 const side = r.isAnswered && isSide(r.text)
147 await update($, stats, (x: Stats): Stats => ({
148 checked: x.checked + 1,
149 side: x.side + (side ? 1 : 0),
150 failed: x.failed + (r.isAnswered ? 0 : 1),
151 totalMs: x.totalMs + (Date.now() - started),
152 }))
153 return side
154}
155
156// 곁대화의 한 질문: 지금 대화를 이어받은 포크에 앞선 곁대화와 이번 질문을 붙인다
157export function forkPrompt(history: readonly SideTurn[], question: string): string {
158 const past = history
159 .filter(t => t.a)
160 .slice(-4)
161 .map(t => `Q: ${t.q}\nA: ${(t.a ?? '').slice(0, 1500)}`)
162 return [
163 '[곁질문 패널] 사용자가 본 작업과 별개로 묻는 곁질문이다. 이 답은 본 대화에 남지 않는다.',
164 '본 작업을 진행하거나 도구를 쓰려 하지 말고, 지금까지 대화와 아는 범위에서 바로 답하라. 파일이나 명령 확인이 필요하면 여기서는 확인할 수 없다고 말하라.',
165 '답 형식 규칙(세 덩어리)은 따르지 않아도 된다. 짧은 문단으로 답하라.',
166 ...(past.length ? ['', '[앞선 곁대화]', ...past] : []),
167 '',
168 '[이번 질문]',
169 question.trim(),
170 ].join('\n')
171}
172
173// 입력칸을 그리지 못하는 화면에서는 [답하기]가 입력창 앞에 그 항목을 붙인다. 써 둔 글은 뒤에 남긴다
174export function draftWith(q: string, draft: string): string {
175 return `↩ ${q} → ${draft.replace(/^↩ [^\n]*? → /, '').trim()}`
176}
177
178// 모은 답을 본 대화로 보낼 글: 한 줄에 한 항목. open-asks는 "↩ 항목 → 답" 줄을 보고 그 항목을 바로 닫는다
179export function answersText(list: readonly { q: string; a: string }[]): string {
180 return list
181 .filter(x => x.a.trim())
182 .map(x => `↩ ${x.q} → ${x.a.trim()}`)
183 .join('\n')
184}
185
186// 답을 저장한 뒤 키보드가 갈 곳: 아직 답이 없는 다음 항목의 [답하기], 다 썼으면 보내기 버튼
187export function focusAfter(order: readonly number[], from: number, done: ReadonlySet<number>, canSend: boolean): string | undefined {
188 const i = order.indexOf(from)
189 const next = [...order.slice(i + 1), ...order.slice(0, Math.max(0, i))].find(id => !done.has(id))
190 if (next !== undefined) return `todo-btn-${next}`
191 return canSend ? SEND_KEY : undefined
192}
193
194// 닫힌 항목의 답은 버린다
195function prune(open: readonly number[]) {
196 for (const m of [drafts, sent]) for (const id of [...m.keys()]) if (!open.includes(id)) m.delete(id)
197 if (editing !== undefined && !open.includes(editing)) editing = undefined
198}
199
200async function startAnswer($: EngineInterface, id: number) {
201 editing = id
202 typed = drafts.get(id) ?? ''
203 $.ui.invalidate('ui.render')
204 // 버튼을 누른 패널이 키보드를 쥐고 있으면 새 입력칸으로 옮긴다. 못 옮기면 입력칸을 눌러 쓰면 된다
205 await $.ui.focus({ requestId: PANE, key: `answer-${id}` })
206}
207
208function saveAnswer($: EngineInterface, id: number, value: string, order: readonly number[]) {
209 const v = value.trim()
210 if (v) drafts.set(id, v)
211 else drafts.delete(id)
212 editing = undefined
213 typed = ''
214 $.ui.invalidate('ui.render')
215 void persist($).catch(() => undefined)
216 const key = focusAfter(order, id, new Set([...drafts.keys(), ...sent.keys()]), drafts.size > 0)
217 // 입력칸이 사라진 뒤 다시 그려지고 나서 옮긴다
218 if (key) moveRing($, key)
219}
220
221const FOCUS_DELAY_MS = 150
222
223async function sendAnswers($: EngineInterface) {
224 await ensureLoaded($)
225 const { value: b } = await $.state.get(ASKS)
226 const list = (b?.items ?? []).filter(a => drafts.has(a.id))
227 const text = answersText(list.map(a => ({ q: a.text, a: drafts.get(a.id) ?? '' })))
228 if (!text) return
229 for (const a of list) {
230 sent.set(a.id, drafts.get(a.id) ?? '')
231 drafts.delete(a.id)
232 }
233 editing = undefined
234 $.ui.invalidate('ui.render')
235 await persist($).catch(() => undefined)
236 // 사용자가 쓴 답이라 asUser로 보낸다: 본 대화의 모델은 사용자의 말로 읽고, open-asks는 사람 턴으로 센다
237 const r = await $.prompt.submit({ text, asUser: true }).catch((err: unknown) => ({ drop: String(err) }))
238 if (r.drop === undefined) return
239 for (const a of list) {
240 drafts.set(a.id, sent.get(a.id) ?? '')
241 sent.delete(a.id)
242 }
243 $.ui.invalidate('ui.render')
244 await persist($).catch(() => undefined)
245 $.ui.toast(`본 대화로 못 보냈어요: ${r.drop}`, { timeoutMs: 8000 })
246}
247
248async function askSide($: EngineInterface, question: string) {
249 const q = question.trim()
250 if (!q) return
251 await ensureLoaded($)
252 const turn: SideTurn = { q }
253 const history = chat
254 chat = [...chat, turn].slice(-12)
255 $.ui.invalidate('ui.render')
256 await persist($).catch(() => undefined)
257 const r = await $.model.fork({ prompt: forkPrompt(history, q) })
258 if (r.isAnswered) turn.a = r.text
259 else turn.err = `답을 받지 못했어요: ${r.reason}`
260 $.ui.invalidate('ui.render')
261 await persist($).catch(() => undefined)
262}
263
264// Esc는 입력창으로 돌아갈 뿐 패널을 닫지 않는다: 내 차례를 옆에 늘 두기 위해서
265async function openPane($: EngineInterface): Promise<boolean> {
266 const r = await $.ui.open({ id: PANE, title: '내 차례 · 곁대화', focus: true })
267 return r.isPlaced
268}
269
270// 토론(debate.tsx의 패널 'debate')이 있으면 그 패널도 열어 둔다: 패널이 둘이면 엔진이 탭으로 보여 준다(2026-10-09 사용자 "토론을 넣어야 하는거 아님?")
271// 토론을 먼저 열고 이 패널을 나중에 열어, 보이는 탭은 내 차례 · 곁대화가 된다
272async function openDebateTab($: EngineInterface) {
273 const { value: d } = await $.state.get(DEBATE)
274 if (!d) return
275 if ((await $.ui.panes()).some(p => p.id === 'debate')) return
276 await $.ui.open({ id: 'debate', title: paneTitle(d) })
277}
278
279// 패널의 [지우기]: 그 항목만 내 차례에서 뺀다. 2026-10-09 하위 명령(/baton-side drop·undo)을 몰라서 못 쓴다고 해서 버튼으로 옮겼다.
280// 다음 메시지 전까지 지운 것은 쌓아 두어 [되돌리기] 한 번에 모두 살린다
281async function dropTodo($: EngineInterface, a: Ask) {
282 lastRemoved = [...lastRemoved, a]
283 let order: number[] = []
284 await update($, asks, (cur: Book): Book => {
285 order = cur.items.map(x => x.id)
286 return { ...cur, items: cur.items.filter(x => x.id !== a.id) }
287 })
288 $.ui.invalidate('ui.render')
289 // 링이 있던 [지우기]가 버튼과 함께 사라지면 패널은 키를 쥔 채 링이 없어 u·숫자 단축키가 먹지 않았다(2026-10-09 tmux로 확인)
290 moveRing($, ringAfterDrop(order, a.id))
291}
292
293// [지우기] 뒤 링이 갈 곳: 그 자리에 올라온 항목의 [지우기](마지막이었으면 바로 위 항목)라 이어 지울 수 있다. 남은 게 없으면 [되돌리기]
294export function ringAfterDrop(order: readonly number[], dropped: number): string {
295 const at = Math.max(0, order.indexOf(dropped))
296 const rest = order.filter(id => id !== dropped)
297 const next = rest[Math.min(at, rest.length - 1)]
298 return next === undefined ? 'undo' : `todo-del-${next}`
299}
300
301// 지운 항목을 되살린다: 몇 개를 되살렸는지 돌려준다
302async function undoDrop($: EngineInterface): Promise<number> {
303 const back = lastRemoved
304 if (!back.length) return 0
305 lastRemoved = []
306 const ids = new Set(back.map(x => x.id))
307 let first: number | undefined
308 await update($, asks, (cur: Book): Book => {
309 const b = restore(cur, back)
310 first = b.items.find(x => ids.has(x.id))?.id
311 return b
312 })
313 $.ui.invalidate('ui.render')
314 // [되돌리기]에 링이 있었으면 링도 버튼과 함께 사라지니, 되살린 첫 항목의 [지우기]로 옮긴다
315 if (first !== undefined) moveRing($, `todo-del-${first}`)
316 return back.length
317}
318
319// 다시 그려진 뒤에 링을 옮긴다: 바로 옮기면 Enter 처리와 줄 빠짐 뒤에 링이 한 칸 더 밀려 다음 항목을 건너뛰었다(2026-10-08).
320// 패널이 키를 쥐고 있지 않으면(/baton-side 명령으로 불렀을 때 등) 엔진이 거절하고, 그러면 그대로 둔다
321function moveRing($: EngineInterface, key: string) {
322 $.clock.after(FOCUS_DELAY_MS, () => void $.ui.focus({ requestId: PANE, key }).catch(() => undefined))
323}
324
325// 곁대화의 [자동 분리 켜기/끄기]: 이 세션만 바꾼다(/baton-side auto on|off와 같다)
326async function toggleAuto($: EngineInterface, s: Settings) {
327 const { value: isOn = s.auto } = await $.state.get(ENABLED)
328 await update($, enabled, () => !isOn)
329 $.ui.invalidate('ui.render')
330 $.ui.toast(isOn ? '곁질문 자동 분리를 껐어요.' : '작업과 관계없어 보이는 질문을 곁대화로 보내요.', { timeoutMs: 5000 })
331}
332
333async function answerTodo($: EngineInterface, text: string) {
334 const { text: draft } = await $.prompt.read()
335 await $.prompt.fill({ text: draftWith(text, draft), mode: 'replace' })
336}
337
338// 단축키: 입력창에서 ctrl+x tab(엔진 기본)으로 패널에 들어오고, 패널 안에서는 버튼에 링이 있을 때 숫자·글자가 버튼을 누른다
339const KEYS_HINT = 'ctrl+x tab 패널로 · 숫자 답하기 · Enter 저장 · s 보내기 · q 곁질문 · Esc 입력창 · 토론 중이면 위 탭에 토론'
340
341const NO_ROOM = '옆 패널을 둘 자리가 없어요. 전체 화면 모드에서 터미널이 넓어야 옆에 붙어요.'
342
343// 턴이 끝나 내 차례가 생겼는데 패널이 닫혀 있으면 연다. 입력창의 키보드는 뺏지 않는다
344async function openIfTodo($: EngineInterface) {
345 const { value: b } = await $.state.get(ASKS)
346 if (!b?.items.length) return
347 if ((await $.ui.panes()).some(p => p.id === PANE)) return
348 await $.ui.open({ id: PANE, title: '내 차례 · 곁대화' })
349}
350
351// 답이 끝나면 내 차례가 생기는 대로 연다. 판정(open-asks)은 모델을 불러 몇 초 걸려서 1.5초 뒤 한 번만 보면 놓쳤다(2026-10-09 tmux로 확인):
352// 이 답의 판정이 끝났다고 적힐 때까지(judgedAnswer) 1.5초마다 다시 본다(최대 tries번)
353function openWhenJudged($: EngineInterface, mark: string, tries: number) {
354 $.clock.after(1500, () => void (async () => {
355 const { value: b } = await $.state.get(ASKS)
356 if (b?.items.length) return openIfTodo($)
357 const { value: done = '' } = await $.state.get(JUDGED)
358 if (done !== mark && tries > 1) openWhenJudged($, mark, tries - 1)
359 })().catch(() => undefined))
360}
361
362// /baton-side의 목록·지우기·되돌리기: clear, undo, drop N, 그 밖(list)은 목록
363async function asksCommand($: EngineInterface, arg: string, model: string): Promise<{ text: string }> {
364 const { value: b = EMPTY } = await $.state.get(ASKS)
365 if (arg === 'clear') {
366 lastRemoved = [...lastRemoved, ...b.items]
367 await update($, asks, (cur: Book): Book => ({ ...cur, items: [] }))
368 return { text: `내 차례 ${b.items.length}개를 지웠어요. 되돌리려면 패널의 [되돌리기]나 /baton-side undo` }
369 }
370 if (arg === 'undo') {
371 const n = await undoDrop($)
372 return { text: n ? `${n}개를 되돌렸어요.` : '되돌릴 항목이 없어요.' }
373 }
374 const drop = arg.match(/^drop\s+(\d+)$/)
375 if (drop) {
376 const target = b.items[Number(drop[1]) - 1]
377 if (!target) return { text: `${drop[1]}번 항목이 없어요.` }
378 lastRemoved = [...lastRemoved, target]
379 await update($, asks, (cur: Book): Book => ({ ...cur, items: cur.items.filter(a => a.id !== target.id) }))
380 return { text: `지웠어요: ${target.text} · 되돌리려면 패널의 [되돌리기]나 /baton-side undo` }
381 }
382 const { value: n = { judged: 0, failed: 0 } } = await $.state.get(ASKS_STATS)
383 const open = b.items.map((a, i) => `${i + 1}. ${a.text}${a.skips >= 1 ? ` (답 없이 ${a.skips}번 지나감)` : ' (방금 물음)'}`)
384 const closed = b.closed.slice(-5).map(c => `- ${c.text}`)
385 return {
386 text: [
387 open.length ? `내 차례 ${open.length}개\n${open.join('\n')}` : '내 차례 없음',
388 closed.length ? `최근 닫힌 질문\n${closed.join('\n')}` : '',
389 `판정 ${n.judged}번 · 실패 ${n.failed}번 · 모델 ${model}`,
390 ]
391 .filter(Boolean)
392 .join('\n\n'),
393 }
394}
395
396// 하위 명령(main, list, drop N, clear, undo, auto on|off, status)은 그대로 받지만 안내에는 넣지 않는다: 같은 일을 패널 버튼과 /baton이 한다(2026-10-09 사용자 "기능이 너무 많아서 외우기도 벅찬데")
397export const SIDE_COMMAND = {
398 name: 'baton-side',
399 description: '옆 패널 열기(위는 내 차례, 아래는 곁대화). /baton-side 질문: 곁대화에 바로 묻기. 지우기·되돌리기·자동 분리는 패널 버튼으로',
400 argumentHint: '[질문]',
401}
402
403// 이 세션에서 켜졌는지(live.ts). $를 쓰는 함수라 hook과 같은 파일에 둔다
404async function live($: EngineInterface): Promise<boolean> {
405 if (isKnown()) return isLive()
406 // 읽다가 실패하면 이번만 꺼진 것으로 보고, 정하지 않은 채 두어 다음에 다시 읽는다
407 const id = await $.session.id().catch(() => '')
408 const list = id ? await $.store.get(SESSIONS).catch(() => null) : null
409 return list === null ? false : settle(list, id)
410}
411
412export function registerSide(on: On, options: PluginOptions): void {
413 const s = settingsFrom(options)
414 const judgeModel = asksSettingsFrom(options).model
415
416 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
417 const els = $.ui.resolve(e)
418 const { Box, Text, Markdown, Button } = els
419 if (!(await live($))) return <Text dimColor>{OFF_TEXT}</Text>
420 const Input = 'Input' in els ? els.Input : undefined
421 const width = Math.max(20, e.props.bodyColumns - 2)
422 await ensureLoaded($)
423 const todo = (await read($, asks)).items
424 prune(todo.map(a => a.id))
425 const shown = todo.slice(0, 8)
426 const order = shown.map(a => a.id)
427 // 패널이 키보드를 받으면 링은 아직 답 안 한 첫 [답하기]에서 시작한다: 숫자·글자 단축키는 버튼에 링이 있을 때만 먹고, 입력칸에 있으면 글자로 들어가기 때문
428 const firstOpen = editing === undefined ? shown.findIndex(a => !drafts.has(a.id) && !sent.has(a.id)) : -1
429 const rows = shown.flatMap((a, i) => {
430 const draft = drafts.get(a.id)
431 const gone = sent.get(a.id)
432 // 답이 없는 항목은 강조색 [답하기], 써 둔 항목은 보통 [고치기], 보낸 항목은 흐린 [ 보냄 ]: 남은 일이 바로 보이게
433 const head = (
434 <Box key={`todo-${a.id}`} flexDirection="row" gap={1}>
435 {gone !== undefined ? (
436 <Text key={`todo-sent-${a.id}`} dimColor>[ 보냄 ]</Text>
437 ) : (
438 <Button
439 key={`todo-btn-${a.id}`}
440 label={draft === undefined ? '답하기' : '고치기'}
441 hotkey={String(i + 1)}
442 {...(draft === undefined ? { variant: 'primary' as const } : {})}
443 {...(i === firstOpen ? { autoFocus: true as const } : {})}
444 onPress={() => void (Input ? startAnswer($, a.id) : answerTodo($, a.text)).catch(() => undefined)}
445 />
446 )}
447 <Box flexShrink={1}>
448 <Text wrap="wrap">{`${i + 1}. ${a.text}`}</Text>
449 </Box>
450 <Button key={`todo-del-${a.id}`} label="지우기" dimColor onPress={() => void dropTodo($, a).catch(() => undefined)} />
451 </Box>
452 )
453 if (Input && editing === a.id)
454 return [
455 head,
456 <Box key={`answer-box-${a.id}`} marginLeft={4}>
457 <Input
458 key={`answer-${a.id}`}
459 label="→ "
460 placeholder="짧게 답하고 Enter (비우면 지움)"
461 submitLabel="저장"
462 value={typed}
463 autoFocus
464 onInput={v => {
465 typed = v
466 }}
467 onSubmit={v => saveAnswer($, a.id, v, order)}
468 />
469 </Box>,
470 ]
471 const said = gone ?? draft
472 if (said === undefined) return [head]
473 return [
474 head,
475 <Box key={`todo-said-${a.id}`} marginLeft={4}>
476 <Text wrap="wrap" dimColor={gone !== undefined}>{`→ ${said}`}</Text>
477 </Box>,
478 ]
479 })
480 // 방금 지운 항목은 다음 메시지를 보내기 전까지 [되돌리기]로 살린다
481 const removed = lastRemoved
482 const undoRow = removed.length
483 ? [
484 <Box key="undo-row" flexDirection="row" gap={1}>
485 <Button key="undo" label="되돌리기 (u)" hotkey="u" onPress={() => void undoDrop($).catch(() => undefined)} />
486 <Box flexShrink={1}>
487 <Text dimColor wrap="wrap">{removed.length === 1 ? `지운 항목: ${removed[0]?.text ?? ''}` : `지운 항목 ${removed.length}개`}</Text>
488 </Box>
489 </Box>,
490 ]
491 : []
492 const top = [
493 <Text key="todo-h" bold>{`내 차례 ${todo.length}`}</Text>,
494 ...(todo.length ? rows : [<Text key="todo-none" dimColor>지금 답하거나 볼 것이 없어요.</Text>]),
495 ...undoRow,
496 ...(drafts.size
497 ? [<Button key={SEND_KEY} label={`답 ${drafts.size}개 본 대화로 보내기 (s)`} hotkey="s" variant="primary" onPress={() => void sendAnswers($).catch(() => undefined)} />]
498 : []),
499 // 모두 지우기 버튼은 두지 않는다: 2026-10-06 잘못 눌려 다 지워졌다. 항목마다 [지우기]를 두고, 지운 직후에는 [되돌리기]를 띄운다
500 ...(todo.length ? [<Text key="todo-hint" dimColor wrap="wrap">{KEYS_HINT}</Text>] : []),
501 ]
502 const last = chat[chat.length - 1]
503 const { value: isAuto = s.auto } = await $.state.get(ENABLED)
504 const bottom = [
505 <Text key="side-h" bold>곁대화</Text>,
506 ...(chat.length
507 ? chat.slice(-6).flatMap((t, i) => [
508 <Text key={`q-${i}`} bold>{`Q. ${t.q}`}</Text>,
509 t.a ? <Markdown key={`a-${i}`} text={t.a.slice(0, 9500)} /> : <Text key={`a-${i}`} dimColor>{t.err ?? '답 쓰는 중…'}</Text>,
510 ])
511 : [
512 <Text key="side-none" dimColor wrap="wrap">
513 {`작업과 관계없는 질문은 여기서 답해요. 본 대화에는 남지 않아요. ${isAuto ? '자동 분리가 켜져 있어 그런 질문은 저절로 이리 와요.' : '[자동 분리 켜기]를 누르면 그런 질문이 저절로 이리 와요.'}`}
514 </Text>,
515 ]),
516 // 답을 쓰는 중에는 그 입력칸이 키보드를 먼저 받게 곁질문 칸의 autoFocus를 뺀다
517 ...(Input
518 ? [
519 <Input
520 key="ask"
521 placeholder="곁질문 쓰기"
522 submitLabel="보내기"
523 {...(editing === undefined && firstOpen < 0 ? { autoFocus: true as const } : {})}
524 onSubmit={v => void askSide($, v).catch(() => undefined)}
525 />,
526 ]
527 : []),
528 <Box key="side-actions" flexDirection="row" flexWrap="wrap" gap={1}>
529 {[
530 ...(Input ? [<Button key="ask-focus" label="곁질문 (q)" hotkey="q" onPress={() => void $.ui.focus({ requestId: PANE, key: 'ask' }).catch(() => undefined)} />] : []),
531 ...(last ? [<Button key="main" label="본 대화로 보내기 (m)" hotkey="m" variant="primary" onPress={() => void $.prompt.submit({ text: last.q, asUser: true }).catch(() => undefined)} />] : []),
532 <Button key="clear" label="곁대화 비우기" onPress={() => { chat = []; $.ui.invalidate('ui.render'); void persist($).catch(() => undefined) }} />,
533 <Button key="auto" label={isAuto ? '자동 분리 끄기' : '자동 분리 켜기'} dimColor onPress={() => void toggleAuto($, s).catch(() => undefined)} />,
534 ]}
535 </Box>,
536 // 토론은 이 패널 밖(옆 탭)에 있어서, 있는 줄 모르고 지나치지 않게 한 줄로 알린다
537 <Text key="debate-hint" dimColor wrap="wrap">토론: /baton-debate만 치면 지금 대화에서 주제를 골라 두 Claude가 따져요.</Text>,
538 ]
539 return (
540 <Box flexDirection="column">
541 {top}
542 <Text key="rule" dimColor>{'─'.repeat(Math.min(width, 40))}</Text>
543 {bottom}
544 </Box>
545 )
546 })
547
548 // 사용자 차례 줄은 본문에서 빠지고 이 패널에만 뜬다(answer-blocks). open-asks가 항목을 올릴 틈을 두고 연다
549 on('turn.complete', { reason: 'answer' }, async ($, e, next) => {
550 if (!(await live($))) return next(e)
551 const done = await next(e)
552 if (e.agentId || e.reason !== 'answer') return done
553 openWhenJudged($, answerMark(e.answer), 20)
554 return done
555 })
556
557 on('prompt.submit', { text: /[\s\S]/ }, async ($, e, next) => {
558 if (!(await live($))) return next(e)
559 const isPerson = isHuman(e.origin as { kind?: string } | undefined)
560 // 사용자가 다음 메시지를 보내면 방금 지운 항목의 [되돌리기]를 내린다
561 if (isPerson && lastRemoved.length) {
562 lastRemoved = []
563 $.ui.invalidate('ui.render')
564 }
565 if (!isPerson || e.attachments?.length || !worthChecking(e.text)) return next(e)
566 const { value: isOn = s.auto } = await $.state.get(ENABLED)
567 if (!isOn) return next(e)
568 const side = await classify($, s, e.text).catch(() => false)
569 if (!side) return next(e)
570 if (!(await openPane($).catch(() => false))) {
571 $.ui.toast(`${NO_ROOM} 본 대화로 보냈어요.`, { timeoutMs: 8000 })
572 return next(e)
573 }
574 await update($, lastSide, () => e.text)
575 void askSide($, e.text).catch(() => undefined)
576 return { drop: '작업과 관계없는 질문이라 옆 패널의 곁대화에서 답해요. 이어서 물으려면 패널 입력칸에 쓰세요. 본 대화로 보내려면 /baton-side main' }
577 })
578
579 on('session.start', { isInteractive: true }, async ($, e, next) => {
580 if (!enter(e) || !(await live($))) return next(e)
581 const started = await next(e)
582 await ensureLoaded($)
583 await $.command.register(SIDE_COMMAND)
584 return started
585 })
586
587 on('command.run', { command: 'baton-side' }, async ($, e) => {
588 if (!(await live($))) return { text: OFF_TEXT }
589 const arg = e.args.trim()
590 const auto = arg.match(/^(?:auto\s+)?(on|off)$/)
591 if (auto) {
592 await update($, enabled, () => auto[1] === 'on')
593 return { text: auto[1] === 'on' ? '작업과 관계없는 질문을 곁대화로 보내요.' : '곁질문 자동 분리를 껐어요. /baton-side 질문으로는 계속 물을 수 있어요.' }
594 }
595 // 내 차례 정리(예전 /asks)
596 if (arg === 'list' || arg === 'clear' || arg === 'undo' || /^drop\s+\d+$/.test(arg)) return asksCommand($, arg, judgeModel)
597 // 방금 곁대화로 보낸 질문을 본 대화로(예전 /main). 사용자가 쓴 질문이라 asUser로 보낸다
598 if (arg === 'main') {
599 const { value: text = '' } = await $.state.get(LASTSIDE)
600 if (!text) return { text: '본 대화로 보낼 곁질문이 없어요.' }
601 await update($, lastSide, () => '')
602 void $.prompt.submit({ text, asUser: true }).catch(() => undefined)
603 return { text: `본 대화로 보냈어요: ${text}` }
604 }
605 if (arg === 'status') {
606 const { value: n = { checked: 0, side: 0, failed: 0, totalMs: 0 } } = await $.state.get(STATS)
607 const { value: isOn = s.auto } = await $.state.get(ENABLED)
608 const avg = n.checked ? Math.round(n.totalMs / n.checked) : 0
609 return {
610 text:
611 `곁질문 자동 분리 ${isOn ? '켜짐' : '꺼짐'} · 판정 ${n.checked}번 중 곁질문 ${n.side}번 · 실패 ${n.failed}번 · 평균 ${avg}ms · 모델 ${s.model}` +
612 `\n곁대화 ${chat.length}개`,
613 }
614 }
615 // 인자가 없으면 패널만 열고, 그 밖의 글은 질문으로 보고 곁대화에서 답한다. 패널 둘 자리가 없으면 내 차례를 글로 보여 준다
616 const question = arg === 'open' ? '' : arg
617 await openDebateTab($).catch(() => undefined)
618 if (!(await openPane($).catch(() => false))) {
619 if (question) return { text: NO_ROOM }
620 return { text: `${NO_ROOM}\n\n${(await asksCommand($, 'list', judgeModel)).text}` }
621 }
622 if (question) {
623 await update($, lastSide, () => question)
624 void askSide($, question).catch(() => undefined)
625 }
626 return { text: question ? '옆 패널의 곁대화에서 답해요.' : '옆 패널을 열었어요. 위는 내 차례, 아래는 곁대화예요. 숫자로 답하기, s로 보내기, q로 곁질문, Esc로 입력창, ctrl+x tab으로 다시 패널에 와요.' }
627 })
628}
629hooks/live.ts 49 lines1// 사람이 앉아 있고(대화형) 사용자가 /baton on으로 고른 세션에서만 켠다(2026-10-09, 다른 세션에도 퍼져서 바꿈).
2// - interactive: session.start가 알린 값. -p, SDK, 데스크톱 앱은 false다. session.start 전에는 true로 본다(테스트는 session.start 없이 돈다).
3// - chosen: 이 세션을 /baton on으로 골랐는지. 처음 물을 때 store의 목록(SESSIONS)에서 이 세션 id를 찾고, 그 뒤로는 기억한다.
4// /clear는 session.start 없이 새 id로 이어지므로 그대로 둔다(새 id는 entry.tsx가 목록에 더한다). /resume으로 다른 세션이 오면 다시 찾는다.
5// $를 쓰는 함수는 hook과 같은 파일에 두어야 해서, store를 읽는 부분은 각 파일의 live()가 맡고 여기는 값만 쥔다.
6export const SESSIONS = 'sessions'
7const MAX_SESSIONS = 200
8export const OFF_TEXT = '이 세션에서는 baton이 꺼져 있어요. /baton on으로 켜요.'
9
10let interactive = true
11let chosen: boolean | undefined
12
13export function isLive(): boolean {
14 return interactive && chosen === true
15}
16
17export function isKnown(): boolean {
18 return chosen !== undefined
19}
20
21// store에서 읽은 목록에 이 세션 id가 있는지로 정하고, 켜졌는지를 돌려준다
22export function settle(list: unknown, id: string): boolean {
23 chosen = idsFrom(list).includes(id)
24 return isLive()
25}
26
27export function choose(isOn: boolean): void {
28 chosen = isOn
29}
30
31export function forget(): void {
32 chosen = undefined
33}
34
35// session.start hook의 첫 줄: 이 세션이 대화형인지 기록하고 그 값을 돌려준다
36export function enter(e: { isInteractive: boolean }): boolean {
37 interactive = e.isInteractive
38 return interactive
39}
40
41export function idsFrom(v: unknown): string[] {
42 return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []
43}
44
45export function withId(list: string[], id: string, isOn: boolean): string[] {
46 const rest = list.filter(x => x !== id)
47 return isOn ? [...rest, id].slice(-MAX_SESSIONS) : rest
48}
49types/index.d.ts 53 lines1export type Ask = { id: number; text: string; turn: number; at: number; skips: number }
2export type Closed = { text: string; at: number }
3export type Book = { items: Ask[]; closed: Closed[]; seq: number }
4export type AsksStats = { judged: number; failed: number }
5export type SideStats = { checked: number; side: number; failed: number; totalMs: number }
6
7// 곁대화 한 번과, 다시 불러와도 남기는 내 차례 답·곁대화(키는 내 차례 항목 id)
8export type SideTurn = { q: string; a?: string; err?: string }
9export type SideKeep = { drafts: Record<string, string>; sent: Record<string, string>; chat: SideTurn[] }
10
11export type Side = 'pro' | 'con'
12export type Turn = { side: Side; text: string }
13export type Debater = { name: string; id: string | null }
14// 토론 패널 아래 입력칸에서 물은 것
15export type DebateQA = { id: string; q: string; a?: string; err?: string }
16export type Debate = {
17 topic: string
18 brief: string
19 labels: Record<Side, string>
20 maxRounds: number
21 // picking: /baton-debate만 쳐서 지금 대화에서 주제를 고르는 중(topic은 아직 비어 있음)
22 status: 'picking' | 'starting' | 'running' | 'judging' | 'done' | 'failed'
23 pro: Debater
24 con: Debater
25 log: Turn[]
26 verdict: string
27 error: string
28 reportPath: string
29 // 토론 패널에서 물은 질문과 답, 결과를 본 대화로 보냈는지(다시 불러와도 남게 state에 둔다)
30 qa?: DebateQA[]
31 sent?: boolean
32}
33
34declare module 'claude-code' {
35 interface PluginState {
36 'baton': {
37 expanded: string[]
38 book: Book
39 turn: number
40 lastRequest: string
41 humanTurn: boolean
42 // 판정을 마친 마지막 답의 지문(open-asks answerMark): 다시 불러온 뒤 같은 답을 또 판정하지 않는다
43 judgedAnswer: string
44 asksStats: AsksStats
45 sideEnabled: boolean
46 lastSide: string
47 sideStats: SideStats
48 debate: Debate | null
49 sideKeep: SideKeep
50 }
51 }
52}
53