Talk to your Claude Code sessions from other sessions, your voice, or any local program. Meetings between two sessions, and a talk button that reads replies…

A Claude Code mod that lets you talk to your open Claude Code sessions from somewhere else: from another session, from your voice, or from any program on the same PC. Two sessions can also hold a meeting with you.
The commands and messages are in Japanese. Tested on Windows 11 with Claude Code 2.1.286 to 2.1.288 (the version with function-hook plugins, "mods").
| Command | What happens |
|---|---|
/namae ぽーたる | Gives this session a name, so others can call it |
/tell ぽーたる 今どこまで進んだ? | Sends a message to the session named ぽーたる. Its answer pops up here |
/sessions | A pane with the running sessions and recent exchanges |
/kaigi ぽーたる | Starts a meeting with ぽーたる. Whatever you say in either session goes to both, and the two Claudes reply to each other up to 3 round trips. /kaigi おわり ends it |
/kaiwa or the 🔊 button | Talk mode: replies are read aloud, then it listens and sends what you say. Needs a voice listener (see below) |
From a program on the same PC:
python bridge.py list
python bridge.py send ぽーたる "テストは通った?" --from 声 --wait 300
bridge.py finds a session by its /namae name or by its title in the app (part of the title is enough, and common English words such as GitHub also match their katakana reading). If two sessions match equally, it asks which one.
In Claude Code:
/plugin marketplace add tsurutanmen/claude-plugins
/plugin install session-bridge@tsurutalab
Or from this repository alone:
/plugin marketplace add tsurutanmen/session-bridge
/plugin install session-bridge@session-bridge
Sessions without a screen (claude -p, scheduled runs) get no inbox, so automated jobs are never interrupted.
~/.claude/session-dash/ only (the layout is below). Nothing else on disk.bridge.py is a separate script you run yourself.bridge.py, a voice assistant) are read-only by default. While the session answers one, the mod refuses every tool except Read, Glob, Grep, LS, ToolSearch and TodoWrite. This is a hook that blocks tool calls inside that session. It is not full isolation. The session can still read anything its own permissions let it read./tell from a session, bridge.py send --do, talk mode and meetings are not read-only. The other session can edit files and run commands with its own permissions. Meetings tell both sides to announce a file before touching it and to ask you before anything that leaves the PC or deletes data, but that is a request, not a guard.~/.claude/session-dash/bridge/ can send messages. Don't install it on a machine you share.Everything is plain JSON files under ~/.claude/session-dash/:
bridge/sessions/<id>.json each session's card (name, folder, last prompt, busy), written every 3 s
bridge/inbox/<id>/<msgid>.json messages to a session { id, from, text, mode: ask|do, kind? }
bridge/outbox/<msgid>.json the answer { id, session, answer, isAborted }
bridge/meetings/<id>.json a meeting (two sessions and you)
bridge/config.json { "owner": "your name" } — how meetings call you (default 持ち主)
speak/<time>.json replies to read aloud { text, voice?, session?, listen? }
wake.alive touched every 10 s by a voice listener, if you run one
The mod polls its inbox every 3 seconds. When the session is idle it submits the oldest message with $.prompt.submit, and writes the turn's answer to the outbox on turn.complete.
Talk mode and meeting voices need a separate program that reads speak/*.json aloud and touches wake.alive. If an item has "listen": true, it should beep, record what you say, and send it back with:
import bridge
bridge.post({"id": item["session"]}, heard_text, frm="声(本人)", mode="do", kind="voice")
The author's listener, claude-desk, uses Vosk for recognition and VOICEVOX for speech. Meetings ask it for voice 3 for the session that opened the meeting and voice 2 for the other, which in VOICEVOX are VOICEVOX:ずんだもん and VOICEVOX:四国めたん. If you publish or share audio made with them, credit the characters that way and follow each character's terms.
Five tools that work together, all MIT:
| homedot | A Dots-style personal agent on Claude Code, in a WSL2 VM on your own PC |
| session-bridge | Let open Claude Code sessions talk to each other, hold meetings, and answer your voice |
| claude-desk | "Hey Claude" voice listener with VOICEVOX replies, and a desktop wallpaper of your sessions |
| homedot-panel | homedot and the 5-hour limit in Claude Code's status line |
| session-dash | Usage limits above the prompt, and every open session in one pane |
More Claude Code plugins: tsurutanmen/claude-plugins
MIT
開いている Claude Code のセッションに、ほかのセッション・声・同じPCのプログラムから話しかけるための mod。2つのセッションと自分の3人で会議もできる。
/namae 名前:このセッションに名前を付ける/tell 名前 伝言:ほかのセッションに伝言する。返事はお知らせで届く/sessions:動いているセッションと、最近のやりとり/kaigi 名前:会議を始める。自分が話すと両方のClaudeに回り、Claude同士が最大3往復する。/kaigi おわり で終わる/kaiwa・🔊:会話モード。返事を読み上げて、読み終えたら声を聞いてそのまま送る。声の聞き役が別に要る外のプログラムからの伝言は、既定で「読むだけ」。読む道具のほかは mod が止める。ただし、そのセッションの中で道具を止める仕組みで、完全な隔離ではない。/tell・会議・会話モードは作業もできる。
hooks/register.tsx 468 lines1import type { Register } from 'claude-code'
2
3// セッションの伝言箱。置き場はこのPCの中だけ(~/.claude/session-dash/bridge)
4// sessions/<id>.json 各セッションの名前・場所・今の状態(3秒ごとに書く)
5// inbox/<id>/<msgid>.json このセッションへの伝言 { id, from, text, at, mode, kind }
6// outbox/<msgid>.json 伝言への返事 { id, session, answer, isAborted, at }
7// meetings/<id>.json 会議(2つのセッションと持ち主)
8// config.json { "owner": "呼び名" }(会議で持ち主をどう呼ぶか。無ければ「持ち主」)
9// 伝言は「〇〇からの伝言」として入れる(本人が打った言葉のふりはしない)。
10// 例外は会話ボタンの声(kind=voice):画面の前の本人がしゃべった言葉なので、本人の言葉として入れる。
11// 読み上げは ~/.claude/session-dash/speak/ に置き、声の聞き役(別のプログラム)が読む。
12
13type Peer = { id: string; name: string; cwd: string; prompt: string; busy: boolean; at: number; isEnded: boolean; interactive?: boolean }
14type Letter = {
15 id: string; from: string; text: string; at: number
16 mode?: 'ask' | 'do'
17 kind?: 'voice' | 'meeting'
18 meeting?: string // 会議の id
19 n?: number // 会議:持ち主の発言からかぞえて何回目のClaude同士の発言か
20 join?: boolean // 会議への招待(ターンにはしない)
21}
22type Reply = { id: string; session: string; from?: string; to?: string; text?: string; answer: string; isAborted: boolean; at: number }
23type Meeting = { id: string; members: string[]; names: Record<string, string>; voices: Record<string, number>; talk: boolean; active: boolean; at: number }
24
25const PANE = 'sessions'
26const ALIVE_MS = 15000
27// 読むだけの伝言(声や外のプログラムから)で使ってよい道具。ほかは mod が止める
28const READ_ONLY = new Set(['Read', 'Glob', 'Grep', 'LS', 'NotebookRead', 'ToolSearch', 'TodoWrite'])
29// 持ち主が1回話すごとの、Claude同士の発言の上限(3往復)
30const MEETING_LIMIT = 6
31const VOICES = [3, 2] // ずんだもん・四国めたん(会議の2人を声で分ける)
32
33// 状態はモジュールの変数に持つ($ の呼び出しはフックの中に直接書く)
34const S = {
35 dir: '',
36 self: { id: '', name: '', cwd: '', prompt: '', busy: false, at: 0, isEnded: false } as Peer,
37 pending: null as Letter | null, // 今このセッションで答えている伝言
38 done: new Set<string>(),
39 waiting: new Map<string, { to: string; text: string; at: number }>(), // /tell で送って返事待ち
40 peers: [] as Peer[],
41 recent: [] as Reply[],
42 talk: false, // 会話ボタン:オンの間、返事を読み上げ、読み終えたら声を聞いて送る
43 listenerAt: 0, // 声の聞き役が最後に生きていた時刻(wake.alive)
44 meeting: null as Meeting | null,
45 lastUser: '', // 本人が最後に言ったこと(会議で相手に回す)
46 live: false, // 画面につながって受け箱が動き出したか
47 owner: '持ち主', // 会議での持ち主の呼び名(config.json の owner)
48}
49
50const short = (s: string, n: number) => {
51 const t = s.replace(/\s+/g, ' ').trim()
52 return t.length > n ? t.slice(0, n - 1) + '…' : t
53}
54const label = (p: Peer) => p.name || `#${p.id.slice(0, 4)}`
55const norm = (s: string) => s.replace(/\s+/g, '').toLowerCase()
56const isPass = (s: string) => /^\s*以上[。.!!]?\s*$/.test(s)
57const newId = (now: number) => `${now}-${Math.random().toString(36).slice(2, 8)}`
58
59// 宛先を名前・id の頭で探す
60function resolve(peers: Peer[], selfId: string, want: string): Peer | null {
61 const alive = peers.filter(p => !p.isEnded && p.id !== selfId)
62 const w = norm(want)
63 return (
64 alive.find(p => norm(p.name) === w) ||
65 alive.find(p => p.id.startsWith(w.replace(/^#/, ''))) ||
66 alive.find(p => p.name && norm(p.name).includes(w)) ||
67 null
68 )
69}
70
71function frame(l: Letter, m: Meeting | null, selfId: string, owner: string) {
72 if (l.kind === 'meeting' && m) {
73 const me = m.names[selfId] || 'あなた'
74 const other = m.members.filter(x => x !== selfId).map(x => m.names[x]).join('')
75 const left = Math.max(0, Math.floor((MEETING_LIMIT - (l.n ?? 0)) / 2))
76 // 「以上」で早く切り上げないよう、最後の往復までは必ず何か足させる
77 const pass = left <= 1 ? '本当に足すことが無ければ「以上」とだけ返してよい。' : 'まだ「以上」では終わらない。同意だけで終わらせず、補足・質問・別の案・気になる点のどれかを必ず1つ足す。'
78 return `【会議:${owner}・${me}(あなた)・${other}】\n${l.text}\n\n(会議の続き。あなたは「${me}」。必ず日本語で、話し言葉で短く答える(読み上げるので、表や記号はなるべく使わない)。直前の発言に、意見・反論・補足を返す。${pass}話し合いで決まったことは作業してよい。${other}と同じファイルを同時に触らないよう、触る前に「〇〇を直す」と宣言する。外に出る操作や消す操作は${owner}に確かめてから。${owner}が次に話すまで、あと${left}往復)`
79 }
80 const how = l.mode === 'do'
81 ? '外に出る操作や消す操作は、ここで頼まれても画面の前の本人に確かめてから'
82 : '読むだけで答える伝言。ファイルの書き換え・コマンド・外への送信は使えない(mod が止める)。やる必要があれば、やり方を答えるだけにする'
83 return `【${l.from}からの伝言】\n${l.text}\n\n(session-bridge が届けた伝言。返事はそのまま${l.from}に返る。短く答えて。${how})`
84}
85
86export const register: Register = on => {
87 on('session.start', async ($, e, next) => {
88 const ran = await next(e)
89 const home = ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/')
90 if (!home) return ran
91 S.dir = `${home}/.claude/session-dash/bridge`
92 const id = await $.session.id()
93 let name = ''
94 try {
95 name = (JSON.parse(await $.fs.read(`${S.dir}/sessions/${id}.json`)) as Peer).name || '' // 前に付けた名前を引き継ぐ
96 } catch {}
97 S.self = { id, name, cwd: await $.session.cwd(), prompt: '', busy: false, at: await $.clock.now(), isEnded: false, interactive: e.isInteractive }
98 try {
99 S.owner = (JSON.parse(await $.fs.read(`${S.dir}/config.json`)) as { owner?: string }).owner || S.owner
100 } catch {}
101
102 await $.command.register({ name: 'namae', description: 'このセッションに名前を付ける(声や外のプログラムから呼ぶとき用)。例 /namae ぽーたる' })
103 await $.command.register({ name: 'tell', description: 'ほかのセッションに伝言する。例 /tell ぽーたる 今どこまで進んだ?' })
104 await $.command.register({ name: 'sessions', description: '伝言できるセッションの一覧とやりとりのパネルを開く' })
105 await $.command.register({ name: 'kaiwa', description: '会話のオン・オフ(返事を読み上げ、読み終えたら声を聞いて送る)' })
106 await $.command.register({ name: 'kaigi', description: 'ほかのセッションと会議する。例 /kaigi ポータル(終わるときは /kaigi おわり)' })
107
108 // 3秒ごと:自分の札を書き、ほかのセッション・返事・会議を読み、届いた伝言があれば入れる
109 const tick = async () => {
110 // 画面に映っているかでは分けない(アプリで別のセッションを見ている間や、スマホから使っている間は「映っていない」になり、
111 // 伝言箱が止まってしまった)。どのセッションでも動く。画面の無い実行かどうかは札の interactive に残すだけ
112 S.live = true
113 const now = await $.clock.now()
114 const base = S.dir.replace(/\/bridge$/, '')
115 S.self.at = now
116 await $.fs.write(`${S.dir}/sessions/${S.self.id}.json`, JSON.stringify(S.self))
117
118 try {
119 S.listenerAt = (await $.fs.stat(`${base}/wake.alive`)).mtimeMs
120 } catch {}
121
122 const peers: Peer[] = []
123 try {
124 for (const f of await $.fs.list(`${S.dir}/sessions`)) {
125 if (!f.name.endsWith('.json')) continue
126 try {
127 const p = JSON.parse(await $.fs.read(`${S.dir}/sessions/${f.name}`)) as Peer
128 if (now - p.at < ALIVE_MS && !p.isEnded) peers.push(p)
129 } catch {}
130 }
131 } catch {}
132 S.peers = peers.sort((a, b) => b.at - a.at)
133
134 // mod を入れ直すと覚えていた会議を忘れるので、自分が入っている会議をファイルから探し直す
135 if (!S.meeting) {
136 try {
137 for (const f of await $.fs.list(`${S.dir}/meetings`)) {
138 if (!f.name.endsWith('.json') || now - f.mtimeMs > 12 * 60 * 60 * 1000) continue
139 try {
140 const m = JSON.parse(await $.fs.read(`${S.dir}/meetings/${f.name}`)) as Meeting
141 if (m.active && m.members.includes(S.self.id)) {
142 S.meeting = m
143 $.ui.status(`🗣 会議中:${Object.values(m.names).join('・')}`)
144 break
145 }
146 } catch {}
147 }
148 } catch {}
149 }
150
151 // 会議が終わっていたら抜ける
152 if (S.meeting) {
153 try {
154 const m = JSON.parse(await $.fs.read(`${S.dir}/meetings/${S.meeting.id}.json`)) as Meeting
155 S.meeting = m.active ? m : null
156 if (!m.active) {
157 $.ui.toast('会議が終わった')
158 $.ui.status(S.talk ? '🔊 会話中' : undefined)
159 }
160 } catch {
161 S.meeting = null
162 }
163 }
164
165 // /tell の返事が来たら知らせる
166 for (const [mid, w] of S.waiting) {
167 try {
168 const r = JSON.parse(await $.fs.read(`${S.dir}/outbox/${mid}.json`)) as Reply
169 S.waiting.delete(mid)
170 S.recent = [{ ...r, to: w.to, text: w.text }, ...S.recent].slice(0, 8)
171 $.ui.toast(`${w.to}から:${short(r.answer, 80)}`)
172 } catch {
173 if (now - w.at > 30 * 60 * 1000) S.waiting.delete(mid) // 30分で待つのをやめる
174 }
175 }
176
177 // 入れ直しで「答えている途中の伝言」を忘れていたら、受け取った印から思い出す
178 // (受け取った印があって答え終わっていない=まだそのターンの途中。ターンの終わりに返事を書ける)
179 if (!S.pending) {
180 try {
181 for (const f of await $.fs.list(`${S.dir}/inbox/${S.self.id}`)) {
182 const mid = f.name.replace(/\.json$/, '')
183 if (!f.name.endsWith('.json') || (await $.fs.exists(`${S.dir}/outbox/${mid}.json`))) continue
184 if (!(await $.fs.exists(`${S.dir}/outbox/${mid}.taken`))) continue
185 if (now - f.mtimeMs > 30 * 60 * 1000) continue
186 try {
187 S.pending = JSON.parse(await $.fs.read(`${S.dir}/inbox/${S.self.id}/${f.name}`)) as Letter
188 S.self.busy = true
189 } catch {}
190 }
191 } catch {}
192 }
193
194 // 届いた伝言(古い順に1通ずつ。答えている最中は待つ)
195 if (!S.self.busy && !S.pending) {
196 let letters: Letter[] = []
197 try {
198 for (const f of await $.fs.list(`${S.dir}/inbox/${S.self.id}`)) {
199 const mid = f.name.replace(/\.json$/, '')
200 if (!f.name.endsWith('.json') || S.done.has(mid)) continue
201 // 答え終わった印か、受け取った印(mod を入れ直しても二度は入れない)があれば飛ばす
202 if ((await $.fs.exists(`${S.dir}/outbox/${mid}.json`)) || (await $.fs.exists(`${S.dir}/outbox/${mid}.taken`))) {
203 S.done.add(mid)
204 continue
205 }
206 try {
207 letters.push(JSON.parse(await $.fs.read(`${S.dir}/inbox/${S.self.id}/${f.name}`)) as Letter)
208 } catch {}
209 }
210 } catch {}
211 letters = letters.sort((a, b) => a.at - b.at)
212 const l = letters[0]
213 if (l) {
214 const mark = async () => {
215 S.done.add(l.id)
216 await $.fs.write(`${S.dir}/outbox/${l.id}.json`, JSON.stringify({ id: l.id, session: S.self.id, answer: '', isAborted: false, at: now }))
217 }
218 if (l.kind === 'meeting' && l.meeting) {
219 try {
220 S.meeting = JSON.parse(await $.fs.read(`${S.dir}/meetings/${l.meeting}.json`)) as Meeting
221 $.ui.status(`🗣 会議中:${Object.values(S.meeting.names).join('・')}`)
222 } catch {}
223 }
224 if (l.join) {
225 await mark() // 招待はターンにしない。会議に入るだけ
226 $.ui.toast(`会議に呼ばれた:${l.text}`)
227 } else if (l.kind === 'voice') {
228 // 会話ボタンの声:本人の言葉としてそのまま入れる(返事は会話ボタンの読み上げに乗る)
229 await mark()
230 S.self.busy = true
231 await $.prompt.submit({ text: l.text, asUser: true })
232 } else {
233 S.pending = l
234 S.self.busy = true
235 await $.fs.write(`${S.dir}/outbox/${l.id}.taken`, String(now))
236 $.ui.toast(`${l.from}から:${short(l.text, 60)}`)
237 await $.prompt.submit({ text: frame(l, S.meeting, S.self.id, S.owner) })
238 }
239 }
240 }
241 $.ui.invalidate('ui.render')
242 }
243 void tick()
244 $.clock.every(3000, () => void tick())
245 return ran
246 })
247
248 on('prompt.submit', async ($, e, next) => {
249 // 本人の言葉だけを数える:打った(composer)・スマホやブラウザから(bridge)・デスクトップのアプリ(sdk)・会話ボタンの声(plugin の asUser)。
250 // ほかのセッションからの伝言(peer)・裏の作業の知らせ・予定の仕事は、本人の言葉ではない
251 const kind = e.origin?.kind
252 const byPerson = !e.origin || kind === 'composer' || kind === 'bridge' || kind === 'sdk' ||
253 (kind === 'plugin' && (e.origin as { asUser?: boolean }).asUser === true)
254 if (byPerson) {
255 S.self.prompt = short(e.text, 60)
256 S.lastUser = e.text
257 }
258 return next(e)
259 })
260
261 // 読むだけの伝言に答えている間は、読む道具のほかを止める
262 on('tool.call', async ($, e, next) => {
263 if (S.pending && S.pending.mode !== 'do' && S.pending.kind !== 'meeting' && !READ_ONLY.has(e.tool)) {
264 return { deny: `session-bridge:声や外のプログラムからの伝言は読むだけ。${e.tool} は使えない。必要なら、やり方を返事に書いて画面の前の本人に任せる` }
265 }
266 return next(e)
267 })
268
269 on('turn.start', async ($, e, next) => {
270 S.self.busy = true
271 return next(e)
272 })
273
274 on('turn.complete', async ($, e, next) => {
275 const out = await next(e)
276 if (e.agentId) return out // 下請けのエージェントの区切りは数えない
277 S.self.busy = false
278 if (!S.dir) return out
279 const now = await $.clock.now()
280 const base = S.dir.replace(/\/bridge$/, '')
281 const answer = e.answer || ''
282 const l = S.pending
283 S.pending = null
284 const m = S.meeting
285 const me = m ? m.names[S.self.id] || label(S.self) : label(S.self)
286
287 if (l) {
288 const r: Reply = { id: l.id, session: S.self.id, answer, isAborted: e.isAborted, at: now }
289 await $.fs.write(`${S.dir}/outbox/${l.id}.json`, JSON.stringify(r))
290 S.done.add(l.id)
291 }
292
293 // 会議:持ち主の発言のあと、または会議の伝言に答えたあと、相手に回す(3往復まで・「以上」で終わり)
294 let relayed = false
295 if (m && !e.isAborted && (!l || l.kind === 'meeting')) {
296 const n = l ? (l.n ?? 0) + 1 : 1
297 const other = m.members.find(x => x !== S.self.id)
298 if (other && n <= MEETING_LIMIT && answer && !isPass(answer)) {
299 // コマンドを打ったときなど、持ち主の言葉が拾えていなければその行は付けない
300 const said = !l && S.lastUser.trim() ? `${S.owner}:${S.lastUser}\n` : ''
301 const text = `${said}${me}:${answer}`
302 S.lastUser = ''
303 const letter: Letter = { id: newId(now), from: me, text, at: now, mode: 'do', kind: 'meeting', meeting: m.id, n }
304 await $.fs.write(`${S.dir}/inbox/${other}/${letter.id}.json`, JSON.stringify(letter))
305 relayed = true
306 }
307 }
308
309 // 読み上げ:会話ボタンがオンの返事、会議中で読み上げがオンの返事。最後の発言のあとは声を聞いて持ち主の番にする
310 // 声や外のプログラムからの伝言(kind なし)の返事は、向こうが読むのでここでは読まない
311 const reading = m ? m.talk && (!l || l.kind === 'meeting') : S.talk && !l
312 if (answer && !e.isAborted && reading) {
313 const voice = m ? m.voices[S.self.id] : undefined
314 const text = isPass(answer) ? '' : m ? `${me}。${answer}` : answer
315 await $.fs.write(`${base}/speak/${now}-${S.self.id.slice(0, 8)}.json`, JSON.stringify({
316 text, from: me, at: now, session: S.self.id, voice, listen: !relayed,
317 }))
318 }
319 return out
320 })
321
322 // 会話がオンなら、道具を使う前に挟むひとこと(「〇〇を調べる」など)も読み上げる。最後の返事は turn.complete が読む
323 on('turn.step', async function* ($, e, next) {
324 const r = yield* next(e)
325 const reading = S.meeting ? S.meeting.talk : S.talk
326 if (reading && S.dir && !(S.pending && !S.pending.kind) && !e.agentId && r.answer && r.toolUses.length > 0) {
327 const now = await $.clock.now()
328 const voice = S.meeting ? S.meeting.voices[S.self.id] : undefined
329 await $.fs.write(`${S.dir.replace(/\/bridge$/, '')}/speak/${now}-${S.self.id.slice(0, 8)}.json`, JSON.stringify({ text: r.answer, from: label(S.self), at: now, voice }))
330 }
331 return r
332 })
333
334 // 会話ボタン:入力欄の下、右側のモード表示(auto mode などの並び)の端に置く
335 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
336 if (!S.dir) return next(e)
337 const { Box, Button, Text } = $.ui.resolve(e)
338 const listening = Date.now() - S.listenerAt < 30000
339 const label = !S.talk ? '🔊' : listening ? '🔊 会話中' : '🔊 聞き役なし'
340 return (
341 <Box>
342 {e.props.modes.length > 0 && <Text dimColor>{e.props.modes.join(' & ')} </Text>}
343 <Button
344 key="talk"
345 label={label}
346 onPress={async () => {
347 S.talk = !S.talk
348 $.ui.status(S.talk ? '🔊 会話中' : undefined) // 下のステータス行にも出す
349 if (S.talk && S.dir) {
350 // オンにしたら、すぐピッと鳴らして聞き始めてもらう
351 const now = await $.clock.now()
352 await $.fs.write(`${S.dir.replace(/\/bridge$/, '')}/speak/${now}-${S.self.id.slice(0, 8)}.json`, JSON.stringify({ text: '', from: label, at: now, session: S.self.id, listen: true }))
353 }
354 $.ui.invalidate('ui.render')
355 }}
356 />
357 </Box>
358 )
359 })
360
361 on('command.run', { command: 'kaiwa' }, async $ => {
362 S.talk = !S.talk
363 $.ui.status(S.talk ? '🔊 会話中' : undefined)
364 if (S.talk && S.dir) {
365 const now = await $.clock.now()
366 await $.fs.write(`${S.dir.replace(/\/bridge$/, '')}/speak/${now}-${S.self.id.slice(0, 8)}.json`, JSON.stringify({ text: '', from: label(S.self), at: now, session: S.self.id, listen: true }))
367 }
368 $.ui.invalidate('ui.render')
369 return { text: S.talk ? '会話をオンにした。ピッのあとに話すと、そのまま送る。返事は読み上げる。' : '会話をオフにした。' }
370 })
371
372 on('command.run', { command: 'kaigi' }, async ($, e) => {
373 const arg = e.args.trim()
374 const now = await $.clock.now()
375 if (/^(おわり|終わり|終了|end)$/i.test(arg)) {
376 if (!S.meeting) return { text: '会議はしていない。' }
377 await $.fs.write(`${S.dir}/meetings/${S.meeting.id}.json`, JSON.stringify({ ...S.meeting, active: false }))
378 S.meeting = null
379 $.ui.status(S.talk ? '🔊 会話中' : undefined)
380 return { text: '会議を終えた。' }
381 }
382 if (!arg) return { text: '使い方:/kaigi 相手の名前(/sessions で一覧)。終わるときは /kaigi おわり' }
383 const to = resolve(S.peers, S.self.id, arg)
384 if (!to) return { text: `「${arg}」というセッションが見つからない。相手のセッションで /namae を付けてから呼んで。` }
385 const mine = label(S.self)
386 const theirs = label(to)
387 const m: Meeting = {
388 id: newId(now), members: [S.self.id, to.id], names: { [S.self.id]: mine, [to.id]: theirs },
389 voices: { [S.self.id]: VOICES[0], [to.id]: VOICES[1] }, talk: S.talk, active: true, at: now,
390 }
391 await $.fs.write(`${S.dir}/meetings/${m.id}.json`, JSON.stringify(m))
392 S.meeting = m
393 const invite: Letter = { id: newId(now), from: mine, text: `${mine}・${theirs}・${S.owner}の会議`, at: now, kind: 'meeting', meeting: m.id, join: true }
394 await $.fs.write(`${S.dir}/inbox/${to.id}/${invite.id}.json`, JSON.stringify(invite))
395 $.ui.status(`🗣 会議中:${mine}・${theirs}`)
396 return {
397 text: `${theirs}との会議を始めた。ここで話すと${theirs}にも回り、Claude同士が最大3往復する(作業もしてよい)。${S.talk ? '声は ずんだもん(こちら)と四国めたん(相手)。' : ''}終わるときは /kaigi おわり`,
398 }
399 })
400
401 on('session.end', async ($, e, next) => {
402 if (S.live && S.dir && S.self.id) {
403 S.self.isEnded = true
404 await $.fs.write(`${S.dir}/sessions/${S.self.id}.json`, JSON.stringify(S.self))
405 }
406 return next(e)
407 })
408
409 on('command.run', { command: 'namae' }, async ($, e) => {
410 const name = e.args.trim()
411 if (!name) return { text: `今の名前:${S.self.name || 'なし'}(/namae ぽーたる のように付ける)` }
412 S.self.name = name
413 if (S.dir) await $.fs.write(`${S.dir}/sessions/${S.self.id}.json`, JSON.stringify(S.self))
414 return { text: `このセッションの名前を「${name}」にした。声や外のプログラムからはこの名前で呼べる。` }
415 })
416
417 on('command.run', { command: 'tell' }, async ($, e) => {
418 const m = e.args.trim().match(/^(\S+)\s+([\s\S]+)$/)
419 if (!m) return { text: '使い方:/tell 名前 伝言(名前は /sessions で見られる)' }
420 const to = resolve(S.peers, S.self.id, m[1])
421 if (!to) return { text: `「${m[1]}」というセッションが見つからない。/sessions で一覧を見て。` }
422 const now = await $.clock.now()
423 const mid = newId(now)
424 const from = S.self.name ? `セッション「${S.self.name}」` : `セッション #${S.self.id.slice(0, 4)}`
425 const letter: Letter = { id: mid, from, text: m[2], at: now, mode: 'do' } // 画面の前の本人が打った伝言
426 await $.fs.write(`${S.dir}/inbox/${to.id}/${mid}.json`, JSON.stringify(letter))
427 S.waiting.set(mid, { to: label(to), text: m[2], at: now })
428 return { text: `${label(to)}に送った。返事が来たら知らせる。` }
429 })
430
431 on('command.run', { command: 'sessions' }, async $ => {
432 await $.ui.open({ id: PANE, title: 'セッション' })
433 return { text: 'セッションの一覧を開いた。' }
434 })
435
436 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
437 const { Box, Text } = $.ui.resolve(e)
438 const others = S.peers.filter(p => p.id !== S.self.id)
439 return (
440 <Box flexDirection="column" gap={1}>
441 <Text bold>このセッション:{label(S.self)}</Text>
442 {S.meeting && <Text color="cyan">🗣 会議中:{Object.values(S.meeting.names).join('・')}</Text>}
443 <Box flexDirection="column">
444 <Text bold>ほかのセッション</Text>
445 {others.length === 0 && <Text dimColor>(今動いているものは無い)</Text>}
446 {others.map(p => (
447 <Text wrap="truncate-end">
448 {p.busy ? '🟡' : '🟢'} {label(p)} <Text dimColor>{p.prompt}</Text>
449 </Text>
450 ))}
451 </Box>
452 {S.waiting.size > 0 && <Text color="yellow">返事待ち {S.waiting.size} 通</Text>}
453 {S.recent.length > 0 && (
454 <Box flexDirection="column">
455 <Text bold>最近のやりとり</Text>
456 {S.recent.map(r => (
457 <Text wrap="truncate-end">
458 → {r.to}:{short(r.text || '', 30)} <Text dimColor>/ {short(r.answer, 60)}</Text>
459 </Text>
460 ))}
461 </Box>
462 )}
463 <Text dimColor>/tell 名前 伝言・/kaigi 名前・/namae 自分の名前・/kaiwa 会話</Text>
464 </Box>
465 )
466 })
467}
468