SLOPSHOPPER

baton

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

newpanespinnerrowsguardcommand
v0.7.0no licenseupdated 2026-10-09chenjingdev/chenjing-plugins/plugins/baton
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · baton
│ ┃ side-chat ✕ › fix the failing auth test and add an audit log call │ ┃ 이 세션에서는 baton이 꺼져 있어요. /baton │ ┃ on으로 켜요. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /baton │ ⎿ baton: 이 세션에서 꺼짐 · 켜 둔 세션 0개 │ ⎿ baton: /baton on으로 켜면 이렇게 돼요. │ ⎿ baton: - 답의 설명이 접혀요. 펼치기는 버튼이나 /baton-e │ ⎿ baton: - 옆 패널에 내 차례(답이 남긴 물을 것·볼 것)와 곁대화가 생겨요. 열기는 /baton-side │ ⎿ baton: - /baton-debate만 치면 지금 대화에서 주제를 골라 두 Claude가 토론해요 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · side-chat
이 세션에서는 baton이 꺼져 있어요. /baton on으로 켜요.
Pane · debate
이 세션에서는 baton이 꺼져 있어요. /baton on으로 켜요.
README

baton

Claude Code 터미널 세션에 붙이는 mod 네 개를 묶은 플러그인. /baton on으로 고른 대화형 세션에서만 켜진다. 고르지 않은 세션에는 /baton 명령 하나만 보이고, 나머지 hook은 아무것도 하지 않는다. 외울 것은 /baton 하나다: 인자 없이 치면(/baton 도움말처럼 on·off가 아닌 말을 붙여도 같다) 상태와 쓸 수 있는 것을 화면별로 보여 준다. 다른 명령도 모두 /baton으로 시작해서, 켠 세션에서 입력창에 /baton까지 치면 넷이 목록에 함께 뜬다.

명령은 훅 모듈이 $.command.register로 등록한다. 이 방법은 플러그인 이름을 붙이지 않고(/플러그인:이름은 마크다운 명령·스킬에만 붙는다) 이름에 콜론을 받지 않아서, 플러그인 이름을 하이픈 머리말로 붙인다.

  • 답 형식: 답을 첫 문단(물은 것에 대한 답), 화면에서 접히는 "설명:", 사용자 차례 줄로 나눠 쓰게 한다. 접힌 설명 위에는 설명 목차: … 줄이 붙고, 버튼이나 /baton-e로 펼친다.
  • 내 차례: 답에 남은 물을 것·볼 것을 모아 입력창 아래 줄 끝에 ↩ 내 차례 N으로 보여 준다. 답할 때마다 판정 모델(judge_model)을 한 번 부른다.
  • 옆 패널: 전체 화면 모드(settings tui: fullscreen)의 옆 패널에 내 차례와 곁대화를 둔다. 내 차례 항목마다 [답하기]·[지우기]가 있고, 지운 직후에는 [되돌리기]가 뜬다. 곁대화는 도구 없이 답하고, 곁질문 자동 분리는 패널의 [자동 분리 켜기]로 켠다.
  • 토론: sonnet 세션 두 개를 띄워 주제를 놓고 토론시키고, 토론 패널로 중계한다. /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_modelsonnet내 차례를 뽑고 닫는 판정 모델
classify_modelhaiku곁질문인지 가르는 모델
side_autooff작업과 관계없어 보이는 질문을 곁대화로 자동으로 보낼지
debate_dir~/.claude/baton/debates토론 기록(.md)을 쓰는 폴더
debate_work_dir비움토론자 세션이 도는 폴더. 비우면 토론을 연 세션의 작업 폴더. 신뢰한 폴더여야 하고, 토론자는 그 폴더의 CLAUDE.md를 읽는다

알려진 문제

  • Claude Code 2.1.295에서는 답 형식 규칙을 넣는 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가 돈다.
Source 8 files
hooks/register.tsx 20 lines
1import 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}
20
hooks/answer-blocks.tsx 185 lines
1import { 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}
185
hooks/debate.tsx 629 lines
1import { 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 [라운드] &lt;주제&gt;</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}
629
hooks/entry.tsx 133 lines
1import 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}
133
hooks/open-asks.tsx 383 lines
1import { 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}
383
hooks/side-panel.tsx 629 lines
1import { 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}
629
hooks/live.ts 49 lines
1// 사람이 앉아 있고(대화형) 사용자가 /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}
49
types/index.d.ts 53 lines
1export 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