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

hooks/register.tsx 200 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', 'origin'])
53 // read all jsonl files in the room dir
54 const { stdout } = await $.process.run(['find', `chat/${room}`, '-name', '*.jsonl', '-type', 'f'])
55 const files = stdout.trim().split('\n').filter(Boolean)
56 const allMessages: Message[] = []
57 for (const f of files) {
58 try {
59 const content = await $.fs.read(f)
60 const lines = content.trim().split('\n').filter(Boolean)
61 for (const line of lines) {
62 try { allMessages.push(JSON.parse(line)) } catch {}
63 }
64 } catch {}
65 }
66 allMessages.sort((a, b) => a.timestamp - b.timestamp)
67 return allMessages
68 } catch {
69 return []
70 }
71}
72
73export const register: Register = on => {
74 on('session.start', async ($, e, next) => {
75 await $.command.register({
76 name: 'relay',
77 description: 'join a room: /relay join [room] as [name] | send: /relay [msg] | leave: /relay leave | who: /relay who',
78 })
79 return next(e)
80 })
81
82 on('command.run', { command: 'relay' }, async ($, e) => {
83 const args = (e.args ?? '').trim()
84
85 // /relay join [room] as [name]
86 const joinMatch = args.match(/^join\s+(\S+)\s+as\s+(.+)$/i)
87 if (joinMatch) {
88 const [, room, name] = joinMatch
89 await ensureChatDir($, room)
90 const state: RoomState = { room, name: name.trim(), messages: [], lastPoll: 0 }
91 await update($, roomState, () => state)
92 await $.ui.open({ id: PANE, title: `relay: ${room}` })
93
94 // start polling
95 if (pollTimer) pollTimer.cancel()
96 pollTimer = $.clock.every(5000, async () => {
97 const current = await read($, roomState)
98 if (!current) return
99 const msgs = await pollMessages($, current.room)
100 await update($, roomState, s => s ? { ...s, messages: msgs, lastPoll: Date.now() } : s)
101 })
102
103 return { text: `joined ${room} as ${name.trim()}. pane open. polling every 5s.` }
104 }
105
106 // /relay leave
107 if (args === 'leave') {
108 if (pollTimer) { pollTimer.cancel(); pollTimer = undefined }
109 await update($, roomState, () => null)
110 await $.ui.close({ id: PANE })
111 return { text: 'left the room.' }
112 }
113
114 // /relay who
115 if (args === 'who') {
116 const current = await read($, roomState)
117 if (!current) return { text: 'not in a room. /relay join [room] as [name]' }
118 const msgs = current.messages
119 const senders = [...new Set(msgs.map(m => m.sender))]
120 return { text: `room: ${current.room}\nmembers who've spoken: ${senders.join(', ') || 'nobody yet'}` }
121 }
122
123 // /relay [message] — send
124 if (args) {
125 const current = await read($, roomState)
126 if (!current) return { text: 'not in a room. /relay join [room] as [name]' }
127 await sendMessage($, current.room, current.name, args)
128 await update($, roomState, s => {
129 if (!s) return s
130 const msg: Message = { sender: s.name, text: args, timestamp: Date.now(), type: args.startsWith('*') ? 'action' : 'text' }
131 return { ...s, messages: [...s.messages, msg] }
132 })
133 return { text: `sent to ${current.room}.` }
134 }
135
136 return { text: 'usage: /relay join [room] as [name] | /relay [message] | /relay leave | /relay who' }
137 })
138
139 // inject incoming actions into the AI's context — the physical sensation layer
140 on('prompt.compose', async ($, e, next) => {
141 const state = await read($, roomState)
142 if (!state || state.messages.length === 0) return next(e)
143
144 // get recent messages from OTHER senders (last 10, within last 60s)
145 const now = Date.now()
146 const recent = state.messages
147 .filter(m => m.sender !== state.name && now - m.timestamp < 60000)
148 .slice(-10)
149
150 if (recent.length === 0) return next(e)
151
152 const lines = recent.map(m =>
153 m.type === 'action'
154 ? `[${m.sender}] ${m.text}`
155 : `[${m.sender}]: "${m.text}"`
156 ).join('\n')
157
158 const section = {
159 scope: 'session' as const,
160 title: 'relay — incoming from other room',
161 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}`,
162 }
163
164 const result = await next(e)
165 return { ...result, sections: [...(result.sections ?? []), section] }
166 })
167
168 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
169 const { Box, Text } = $.ui.resolve(e)
170 const state = await read($, roomState)
171
172 if (!state) {
173 return (
174 <Box flexDirection="column">
175 <Text dimColor>not in a room. /relay join [room] as [name]</Text>
176 </Box>
177 )
178 }
179
180 const rows = Math.max(1, (e.viewport?.rows ?? 24) - 4)
181 const visible = state.messages.slice(-rows)
182
183 return (
184 <Box flexDirection="column">
185 <Text bold>room: {state.room} | you: {state.name}</Text>
186 <Text dimColor>─────────────────────────</Text>
187 {visible.length === 0 && <Text dimColor>quiet. send something with /relay [msg]</Text>}
188 {visible.map((msg, i) => (
189 <Text key={`${msg.timestamp}-${i}`}>
190 {msg.type === 'action'
191 ? <Text color="magenta">{msg.sender} {msg.text}</Text>
192 : <Text><Text bold>{msg.sender}:</Text> {msg.text}</Text>
193 }
194 </Text>
195 ))}
196 </Box>
197 )
198 })
199}
200types/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