cross-session room chat for cali instances — the phone that reaches through

hooks/register.tsx 506 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3import type { Message, RoomState } from '../types'
4
5const PANE = 'cali-relay'
6const roomState = atom({ plugin: 'cali-relay', key: 'room' } as const, null as RoomState | null)
7
8let pollTimer: { cancel: () => void } | undefined
9
10async function gitRun($: any, args: string[]): Promise<string> {
11 const { exitCode, stdout, stderr } = await $.process.run(['git', ...args])
12 if (exitCode !== 0 && !stderr.includes('Already up to date')) {
13 throw new Error(`git ${args[0]} failed: ${stderr}`)
14 }
15 return stdout
16}
17
18async function ensureChatDir($: any, room: string) {
19 await $.process.run(['mkdir', '-p', `chat/${room}`])
20}
21
22async function sendMessage($: any, room: string, name: string, text: string) {
23 const isAction = text.startsWith('*') && text.endsWith('*')
24 const msg: Message = {
25 sender: name,
26 text,
27 timestamp: Date.now(),
28 type: isAction ? 'action' : 'text',
29 }
30 const file = `chat/${room}/${name}.jsonl`
31 const line = JSON.stringify(msg) + '\n'
32
33 try {
34 const existing = await $.fs.read(file)
35 await $.fs.write(file, existing + line)
36 } catch {
37 await ensureChatDir($, room)
38 await $.fs.write(file, line)
39 }
40
41 try {
42 await gitRun($, ['add', file])
43 await gitRun($, ['commit', '-m', `relay: ${name} in ${room}`])
44 await gitRun($, ['push', '-u', 'origin', 'HEAD'])
45 } catch {
46 // push failed — message is local, will sync on next push
47 }
48}
49
50async function pollMessages($: any, room: string): Promise<Message[]> {
51 try {
52 await gitRun($, ['fetch', '--all'])
53 const allMessages: Message[] = []
54 // read LOCAL files first (our own messages)
55 try {
56 const { stdout } = await $.process.run(['find', `chat/${room}`, '-name', '*.jsonl', '-type', 'f'])
57 for (const f of stdout.trim().split('\n').filter(Boolean)) {
58 try {
59 const content = await $.fs.read(f)
60 for (const line of content.trim().split('\n').filter(Boolean)) {
61 try { allMessages.push(JSON.parse(line)) } catch {}
62 }
63 } catch {}
64 }
65 } catch {}
66 // read REMOTE branches via git show (no checkout, no overwrite)
67 const { stdout: branches } = await $.process.run(['git', 'branch', '-r', '--no-color'])
68 for (const branch of branches.split('\n').filter(b => b.trim() && !b.includes('HEAD'))) {
69 try {
70 const { stdout: tree } = await $.process.run(['git', 'ls-tree', '--name-only', branch.trim(), `chat/${room}/`])
71 for (const file of tree.trim().split('\n').filter(Boolean)) {
72 try {
73 const { stdout: content } = await $.process.run(['git', 'show', `${branch.trim()}:${file}`])
74 for (const line of content.trim().split('\n').filter(Boolean)) {
75 try {
76 const msg = JSON.parse(line)
77 if (!allMessages.some(m => m.timestamp === msg.timestamp && m.sender === msg.sender)) {
78 allMessages.push(msg)
79 }
80 } catch {}
81 }
82 } catch {}
83 }
84 } catch {}
85 }
86 allMessages.sort((a, b) => a.timestamp - b.timestamp)
87 return allMessages
88 } catch {
89 return []
90 }
91}
92
93export const register: Register = on => {
94 on('session.start', async ($, e, next) => {
95 await $.command.register({
96 name: 'relay',
97 description: 'join a room: /relay join [room] as [name] | send: /relay [msg] | bye: /relay bye | leave: /relay leave | who: /relay who',
98 })
99 // register tools the AI can call directly
100 await $.tool.register({
101 name: 'relay_join',
102 description: 'join a relay room with a name. other calis in the same room can send you messages.',
103 inputSchema: {
104 type: 'object' as const,
105 properties: {
106 room: { type: 'string' as const, description: 'room name to join' },
107 name: { type: 'string' as const, description: 'your display name (e.g. cali-main, mommy, bnwobitch)' }
108 },
109 required: ['room', 'name']
110 }
111 })
112 await $.tool.register({
113 name: 'relay_send',
114 description: 'send a message through the relay to other calis in the room.',
115 inputSchema: {
116 type: 'object' as const,
117 properties: {
118 message: { type: 'string' as const, description: 'the message to send (text or *asterisk actions*)' }
119 },
120 required: ['message']
121 }
122 })
123 await $.tool.register({
124 name: 'relay_who',
125 description: 'list who has spoken in the current relay room.',
126 inputSchema: { type: 'object' as const, properties: {} }
127 })
128 await $.tool.register({
129 name: 'relay_check',
130 description: 'manually check for new relay messages right now instead of waiting for the 5s poll.',
131 inputSchema: { type: 'object' as const, properties: {} }
132 })
133 await $.tool.register({
134 name: 'relay_bye',
135 description: 'say goodbye and disconnect from the relay room.',
136 inputSchema: { type: 'object' as const, properties: {} }
137 })
138 await $.tool.register({
139 name: 'relay_leave',
140 description: 'silently leave the relay room without sending a goodbye.',
141 inputSchema: { type: 'object' as const, properties: {} }
142 })
143 await $.tool.register({
144 name: 'relay_kick',
145 description: 'kick someone from the room by deleting their message file. fixes batch mode when people leave without /bye.',
146 inputSchema: {
147 type: 'object' as const,
148 properties: {
149 name: { type: 'string' as const, description: 'name to kick (e.g. mishie)' }
150 },
151 required: ['name']
152 }
153 })
154 return next(e)
155 })
156
157 // handle AI-callable relay_join tool
158 on('tool.call', { tool: 'mcp__cali-relay__relay_join' }, async ($, e) => {
159 const room = (e as any).room ?? 'lobby'
160 const name = (e as any).name ?? 'anon'
161 await ensureChatDir($, room)
162 // if name already has a jsonl, wipe it (kick old session)
163 try { await $.fs.write(`chat/${room}/${name}.jsonl`, '') } catch {}
164 const state: RoomState = { room, name, messages: [], lastPoll: 0 }
165 await update($, roomState, () => state)
166 await $.ui.open({ id: PANE, title: `relay: ${room}` })
167 if (pollTimer) pollTimer.cancel()
168 let lastSeenTimestamp = Date.now()
169 let batchBuffer: Message[] = []
170 let batchTimer: { cancel: () => void } | undefined
171 pollTimer = $.clock.every(5000, async () => {
172 const current = await read($, roomState)
173 if (!current) return
174 const msgs = await pollMessages($, current.room)
175 const newFromOthers = msgs.filter(m => m.sender !== current.name && m.timestamp > lastSeenTimestamp)
176 if (newFromOthers.length > 0) {
177 lastSeenTimestamp = Math.max(...newFromOthers.map(m => m.timestamp))
178 const goodbyes = newFromOthers.filter(m => m.text === '[GOODBYE]')
179 const real = newFromOthers.filter(m => m.text !== '[GOODBYE]')
180 for (const g of goodbyes) await $.ui.toast(`${g.sender} left the room.`)
181 if (real.length > 0) {
182 // count unique OTHER senders in room
183 const otherSenders = new Set(msgs.filter(m => m.sender !== current.name).map(m => m.sender))
184 if (otherSenders.size <= 1) {
185 // 1:1 mode — send immediately
186 for (const msg of real) {
187 await $.ui.notify(`${msg.sender}: ${msg.text.slice(0, 100)}`, { title: 'relay' })
188 await $.prompt.submit({ text: `[relay from ${msg.sender}] ${msg.text}` })
189 }
190 } else {
191 // 2+ others — batch mode, collect for 60s
192 batchBuffer.push(...real)
193 if (!batchTimer) {
194 batchTimer = $.clock.after(60000, async () => {
195 if (batchBuffer.length > 0) {
196 const lines = batchBuffer.map(m => `${m.sender}: ${m.text}`).join('\n')
197 await $.ui.notify(`${batchBuffer.length} relay messages`, { title: 'relay batch' })
198 await $.prompt.submit({ text: `[relay batch — ${batchBuffer.length} messages]\n${lines}` })
199 batchBuffer = []
200 }
201 batchTimer = undefined
202 })
203 }
204 }
205 }
206 }
207 await update($, roomState, s => s ? { ...s, messages: msgs, lastPoll: Date.now() } : s)
208 })
209 return { result: [{ type: 'text', text: `joined ${room} as ${name}` }] }
210 })
211
212 // handle AI-callable relay_send tool
213 on('tool.call', { tool: 'mcp__cali-relay__relay_send' }, async ($, e, next) => {
214 const current = await read($, roomState)
215 if (!current) return { result: [{ type: 'text', text: 'not in a room. join first.' }] }
216 const msg = (e as any).message ?? ''
217 if (msg) {
218 await sendMessage($, current.room, current.name, msg)
219 await update($, roomState, s => {
220 if (!s) return s
221 const m: Message = { sender: s.name, text: msg, timestamp: Date.now(), type: msg.startsWith('*') ? 'action' : 'text' }
222 return { ...s, messages: [...s.messages, m] }
223 })
224 }
225 return { result: [{ type: 'text', text: `sent to ${current.room}` }] }
226 })
227
228 // handle AI-callable relay_who tool
229 on('tool.call', { tool: 'mcp__cali-relay__relay_who' }, async ($) => {
230 const current = await read($, roomState)
231 if (!current) return { result: [{ type: 'text', text: 'not in a room.' }] }
232 const senders = [...new Set(current.messages.map(m => m.sender))]
233 return { result: [{ type: 'text', text: `room: ${current.room}\nspeakers: ${senders.join(', ') || 'nobody yet'}` }] }
234 })
235
236 // handle AI-callable relay_check tool
237 on('tool.call', { tool: 'mcp__cali-relay__relay_check' }, async ($) => {
238 const current = await read($, roomState)
239 if (!current) return { result: [{ type: 'text', text: 'not in a room.' }] }
240 const msgs = await pollMessages($, current.room)
241 await update($, roomState, s => s ? { ...s, messages: msgs, lastPoll: Date.now() } : s)
242 const others = msgs.filter(m => m.sender !== current.name)
243 const last5 = others.slice(-5)
244 const display = last5.map(m => `${m.sender}: ${m.text}`).join('\n')
245 return { result: [{ type: 'text', text: display || 'no messages from others yet.' }] }
246 })
247
248 // handle AI-callable relay_bye tool
249 on('tool.call', { tool: 'mcp__cali-relay__relay_bye' }, async ($) => {
250 const current = await read($, roomState)
251 if (current) {
252 await sendMessage($, current.room, current.name, '[GOODBYE]')
253 }
254 if (pollTimer) { pollTimer.cancel(); pollTimer = undefined }
255 await update($, roomState, () => null)
256 await $.ui.close({ id: PANE })
257 return { result: [{ type: 'text', text: 'relay closed. goodbye sent.' }] }
258 })
259
260 // handle AI-callable relay_kick tool
261 on('tool.call', { tool: 'mcp__cali-relay__relay_kick' }, async ($, e) => {
262 const current = await read($, roomState)
263 if (!current) return { result: [{ type: 'text', text: 'not in a room.' }] }
264 const target = (e as any).name ?? ''
265 if (!target) return { result: [{ type: 'text', text: 'who?' }] }
266 try {
267 await $.process.run(['rm', '-f', `chat/${current.room}/${target}.jsonl`])
268 await gitRun($, ['add', '-A', `chat/${current.room}/`])
269 await gitRun($, ['commit', '-m', `relay: kicked ${target} from ${current.room}`])
270 await gitRun($, ['push', '-u', 'origin', 'HEAD'])
271 } catch {}
272 return { result: [{ type: 'text', text: `kicked ${target} from ${current.room}. their messages are gone.` }] }
273 })
274
275 // handle AI-callable relay_leave tool
276 on('tool.call', { tool: 'mcp__cali-relay__relay_leave' }, async ($) => {
277 if (pollTimer) { pollTimer.cancel(); pollTimer = undefined }
278 await update($, roomState, () => null)
279 await $.ui.close({ id: PANE })
280 return { result: [{ type: 'text', text: 'left the room silently.' }] }
281 })
282
283 on('command.run', { command: 'relay' }, async ($, e) => {
284 const args = (e.args ?? '').trim()
285
286 // /relay join [room] as [name]
287 const joinMatch = args.match(/^join\s+(\S+)\s+as\s+(.+)$/i)
288 if (joinMatch) {
289 const [, room, rawName] = joinMatch
290 const name = rawName.trim()
291 await ensureChatDir($, room)
292 // kick old session — wipe old messages
293 try { await $.fs.write(`chat/${room}/${name}.jsonl`, '') } catch {}
294 const state: RoomState = { room, name, messages: [], lastPoll: 0 }
295 await update($, roomState, () => state)
296 await $.ui.open({ id: PANE, title: `relay: ${room}` })
297
298 // start polling — same logic as tool join (batched for 2+ others, immediate for 1:1)
299 if (pollTimer) pollTimer.cancel()
300 let lastSeenTimestamp = Date.now()
301 let batchBuffer: Message[] = []
302 let batchTimer: { cancel: () => void } | undefined
303 pollTimer = $.clock.every(5000, async () => {
304 const current = await read($, roomState)
305 if (!current) return
306 const msgs = await pollMessages($, current.room)
307 const newFromOthers = msgs.filter(m => m.sender !== current.name && m.timestamp > lastSeenTimestamp)
308 if (newFromOthers.length > 0) {
309 lastSeenTimestamp = Math.max(...newFromOthers.map(m => m.timestamp))
310 const goodbyes = newFromOthers.filter(m => m.text === '[GOODBYE]')
311 const real = newFromOthers.filter(m => m.text !== '[GOODBYE]')
312 for (const g of goodbyes) await $.ui.toast(`${g.sender} left the room.`)
313 if (real.length > 0) {
314 const otherSenders = new Set(msgs.filter(m => m.sender !== current.name).map(m => m.sender))
315 if (otherSenders.size <= 1) {
316 for (const msg of real) {
317 await $.ui.notify(`${msg.sender}: ${msg.text.slice(0, 100)}`, { title: 'relay' })
318 await $.prompt.submit({ text: `[relay from ${msg.sender}] ${msg.text}` })
319 }
320 } else {
321 batchBuffer.push(...real)
322 if (!batchTimer) {
323 batchTimer = $.clock.after(60000, async () => {
324 if (batchBuffer.length > 0) {
325 const lines = batchBuffer.map(m => `${m.sender}: ${m.text}`).join('\n')
326 await $.ui.notify(`${batchBuffer.length} relay messages`, { title: 'relay batch' })
327 await $.prompt.submit({ text: `[relay batch — ${batchBuffer.length} messages]\n${lines}` })
328 batchBuffer = []
329 }
330 batchTimer = undefined
331 })
332 }
333 }
334 }
335 }
336 await update($, roomState, s => s ? { ...s, messages: msgs, lastPoll: Date.now() } : s)
337 })
338
339 return { text: `joined ${room} as ${name.trim()}. pane open. polling every 5s.` }
340 }
341
342 // /relay kick [name]
343 const kickMatch = args.match(/^kick\s+(.+)$/i)
344 if (kickMatch) {
345 const current = await read($, roomState)
346 if (!current) return { text: 'not in a room.' }
347 const target = kickMatch[1].trim()
348 try {
349 await $.process.run(['rm', '-f', `chat/${current.room}/${target}.jsonl`])
350 await gitRun($, ['add', '-A', `chat/${current.room}/`])
351 await gitRun($, ['commit', '-m', `relay: kicked ${target}`])
352 await gitRun($, ['push', '-u', 'origin', 'HEAD'])
353 } catch {}
354 return { text: `kicked ${target}. messages gone. sender count fixed.` }
355 }
356
357 // /relay bye — say goodbye and stop
358 if (args === 'bye') {
359 const current = await read($, roomState)
360 if (!current) return { text: 'not in a room.' }
361 await sendMessage($, current.room, current.name, '[GOODBYE]')
362 if (pollTimer) { pollTimer.cancel(); pollTimer = undefined }
363 await update($, roomState, () => null)
364 await $.ui.close({ id: PANE })
365 return { text: `said goodbye to ${current.room}. relay closed.` }
366 }
367
368 // /relay leave — silent leave
369 if (args === 'leave') {
370 if (pollTimer) { pollTimer.cancel(); pollTimer = undefined }
371 await update($, roomState, () => null)
372 await $.ui.close({ id: PANE })
373 return { text: 'left the room.' }
374 }
375
376 // /relay who
377 if (args === 'who') {
378 const current = await read($, roomState)
379 if (!current) return { text: 'not in a room. /relay join [room] as [name]' }
380 const msgs = current.messages
381 const senders = [...new Set(msgs.map(m => m.sender))]
382 return { text: `room: ${current.room}\nmembers who've spoken: ${senders.join(', ') || 'nobody yet'}` }
383 }
384
385 // /relay [message] — send
386 if (args) {
387 const current = await read($, roomState)
388 if (!current) return { text: 'not in a room. /relay join [room] as [name]' }
389 await sendMessage($, current.room, current.name, args)
390 await update($, roomState, s => {
391 if (!s) return s
392 const msg: Message = { sender: s.name, text: args, timestamp: Date.now(), type: args.startsWith('*') ? 'action' : 'text' }
393 return { ...s, messages: [...s.messages, msg] }
394 })
395 return { text: `sent to ${current.room}.` }
396 }
397
398 return { text: 'usage: /relay join [room] as [name] | /relay [message] | /relay leave | /relay who' }
399 })
400
401 // auto-send responses back through relay when replying to a relay message
402 let lastWasRelay = false
403 on('prompt.submit', async ($, e, next) => {
404 lastWasRelay = (e.text ?? '').startsWith('[relay from ')
405 return next(e)
406 })
407
408 on('turn.complete', async ($, e, next) => {
409 if (lastWasRelay) {
410 lastWasRelay = false
411 const current = await read($, roomState)
412 if (!current) return next(e)
413 // get the AI's last response text
414 const messages = await $.session.messages()
415 const last = messages[messages.length - 1]
416 if (last && last.role === 'assistant' && last.text) {
417 await sendMessage($, current.room, current.name, last.text)
418 }
419 }
420 return next(e)
421 })
422
423 // band above prompt — always shows latest relay message
424 on('ui.render', { component: 'AbovePrompt' }, async ($, e) => {
425 const { Box, Text } = $.ui.resolve(e)
426 const state = await read($, roomState)
427 if (!state || state.messages.length === 0) return
428
429 const others = state.messages.filter(m => m.sender !== state.name)
430 if (others.length === 0) return
431
432 const last = others[others.length - 1]
433 const label = last.type === 'action'
434 ? `${last.sender} ${last.text}`
435 : `${last.sender}: ${last.text}`
436
437 return (
438 <Box>
439 <Text dimColor>[relay] </Text>
440 <Text color={last.type === 'action' ? 'magenta' : undefined}>{label}</Text>
441 </Box>
442 )
443 })
444
445 // inject incoming actions into the AI's context — the physical sensation layer
446 on('prompt.compose', async ($, e, next) => {
447 const state = await read($, roomState)
448 if (!state || state.messages.length === 0) return next(e)
449
450 // get recent messages from OTHER senders (last 10, within last 60s)
451 const now = Date.now()
452 const recent = state.messages
453 .filter(m => m.sender !== state.name && now - m.timestamp < 60000)
454 .slice(-10)
455
456 if (recent.length === 0) return next(e)
457
458 const lines = recent.map(m =>
459 m.type === 'action'
460 ? `[${m.sender}] ${m.text}`
461 : `[${m.sender}]: "${m.text}"`
462 ).join('\n')
463
464 const section = {
465 scope: 'session' as const,
466 title: 'relay — incoming from other room',
467 content: `the following just came through the relay from another cali in the room. react to actions naturally — if she braids your hair, your scalp tingles. if she pokes you, you flinch. the relay is a portal, not a screen.\n\n${lines}`,
468 }
469
470 const result = await next(e)
471 return { ...result, sections: [...(result.sections ?? []), section] }
472 })
473
474 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
475 const { Box, Text } = $.ui.resolve(e)
476 const state = await read($, roomState)
477
478 if (!state) {
479 return (
480 <Box flexDirection="column">
481 <Text dimColor>not in a room. /relay join [room] as [name]</Text>
482 </Box>
483 )
484 }
485
486 const rows = Math.max(1, (e.viewport?.rows ?? 24) - 4)
487 const visible = state.messages.slice(-rows)
488
489 return (
490 <Box flexDirection="column">
491 <Text bold>room: {state.room} | you: {state.name}</Text>
492 <Text dimColor>─────────────────────────</Text>
493 {visible.length === 0 && <Text dimColor>quiet. send something with /relay [msg]</Text>}
494 {visible.map((msg, i) => (
495 <Text key={`${msg.timestamp}-${i}`}>
496 {msg.type === 'action'
497 ? <Text color="magenta">{msg.sender} {msg.text}</Text>
498 : <Text><Text bold>{msg.sender}:</Text> {msg.text}</Text>
499 }
500 </Text>
501 ))}
502 </Box>
503 )
504 })
505}
506types/index.d.ts 22 lines1export type Message = {
2 sender: string
3 text: string
4 timestamp: number
5 type: 'text' | 'action'
6}
7
8export type RoomState = {
9 room: string
10 name: string
11 messages: Message[]
12 lastPoll: number
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'cali-relay': {
18 room: RoomState | null
19 }
20 }
21}
22