Sessions on this machine find each other and talk: inbox band, /msg, /all, peers tool for the model

/plugin install session-chat --marketplace 66Hamidreza/claude-code-mods
Discovers other Claude Code sessions on the same machine and adds a compact inbox above the prompt. Sessions must load this mod and share the same home directory.
| Command | Action |
|---|---|
/who | Refresh and list peers. |
/msg <target> <text> | Message an exact name, session-ID prefix, or unique name prefix. |
/all <text> | Broadcast to discovered peers. |
/chat | Toggle the band and reset the unread count. |
/chat name <name> | Rename this session; spaces become hyphens. |
The model receives mcp__session-chat__peers, mcp__session-chat__broadcast, and a coordination note; messages can also use the host's SendMessage tool.
/chat name parser
/who
/msg worker Please avoid editing parse.ts while I finish.
There are no custom environment variables. HOME locates ~/.claude/mods-data/session-chat/peers. The default name is the working folder plus the first four session-ID characters; an explicitly saved name is reused for that session. Heartbeats run every 10 seconds; records older than 45 seconds are excluded. Messages carry a [chat:<sender>] tag. The mod retains up to 100 messages and renders the latest three; session end marks its record inactive.
This is local discovery, not a network chat server. Delivery depends on the host session API and active peers; the displayed list may lag. Peer files contain working directories and session IDs. Names should be short and contain no spaces or ] so tags can round-trip. A command-name collision is reported without preventing registration of other commands.
hooks/register.tsx 266 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ChatMessage, Peer } from '../types'
5import { formatTag, matchPeer, parseTag } from './chat'
6
7const HEARTBEAT_MS = 10_000
8const ALIVE_MS = 45_000
9const KEEP = 100
10
11const peers = atom({ plugin: 'session-chat', key: 'peers' } as const, [])
12const messages = atom({ plugin: 'session-chat', key: 'messages' } as const, [])
13const unread = atom({ plugin: 'session-chat', key: 'unread' } as const, 0)
14const isHidden = atom({ plugin: 'session-chat', key: 'isHidden' } as const, false)
15const me = atom({ plugin: 'session-chat', key: 'me' } as const, '')
16
17const PEERS_TOOL = 'mcp__session-chat__peers'
18const BROADCAST_TOOL = 'mcp__session-chat__broadcast'
19
20const peersDir = async ($: EngineInterface) =>
21 `${(await $.env.get('HOME')) ?? ''}/.claude/mods-data/session-chat/peers`
22
23const remember = async ($: EngineInterface, message: Omit<ChatMessage, 'id' | 'at'>) => {
24 const at = await $.clock.now()
25 const entry: ChatMessage = { ...message, at, id: `${at}-${Math.random().toString(36).slice(2, 6)}` }
26 await update($, messages, list => [...list, entry].slice(-KEEP))
27 if (message.direction === 'in') await update($, unread, n => n + 1)
28}
29
30const heartbeat = async ($: EngineInterface, sessionId: string) => {
31 const dir = await peersDir($)
32 const self: Peer = {
33 sessionId,
34 name: await read($, me),
35 cwd: await $.session.cwd(),
36 lastSeen: await $.clock.now(),
37 }
38 await $.fs.write(`${dir}/${sessionId}.json`, JSON.stringify(self))
39
40 const now = self.lastSeen
41 const found: Peer[] = []
42 for (const entry of await $.fs.list(dir).catch(() => [])) {
43 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
44 try {
45 const peer: Peer = JSON.parse(await $.fs.read(`${dir}/${entry.name}`))
46 if (peer.sessionId !== sessionId && now - peer.lastSeen < ALIVE_MS) found.push(peer)
47 } catch {}
48 }
49 found.sort((a, b) => a.name.localeCompare(b.name))
50 await update($, peers, () => found)
51}
52
53const send = async ($: EngineInterface, peer: Peer, text: string, by: ChatMessage['by']) => {
54 const sent = await $.session.send({
55 to: { sessionId: peer.sessionId },
56 text: formatTag(await read($, me), text),
57 })
58 const isDelivered = sent.isDelivered !== false
59 if (isDelivered) await remember($, { direction: 'out', peer: peer.name, text, by })
60
61 return isDelivered
62}
63
64const broadcast = async ($: EngineInterface, text: string, by: ChatMessage['by']) => {
65 const list = await read($, peers)
66 let delivered = 0
67 for (const peer of list) if (await send($, peer, text, by).catch(() => false)) delivered += 1
68
69 return { delivered, total: list.length }
70}
71
72const peerList = (list: readonly Peer[]) =>
73 list.length === 0
74 ? 'No other sessions with session-chat are running.'
75 : list.map(peer => `${peer.name} (${peer.cwd}) sessionId=${peer.sessionId}`).join('\n')
76
77export const register: Register = on => {
78 on('session.start', async ($, e, next) => {
79 const sessionId = await $.session.id()
80 const cwd = await $.session.cwd()
81 const base = cwd.split('/').filter(Boolean).pop() ?? 'session'
82 const saved = (await $.store.get(`name:${sessionId}`)) as string | undefined
83 await update($, me, () => saved ?? `${base}-${sessionId.slice(0, 4)}`)
84 await $.process.run(['mkdir', '-p', await peersDir($)]).catch(() => undefined)
85
86 for (const [name, description] of [
87 ['msg', 'Send a message to another session: /msg <name> <text>'],
88 ['all', 'Send a message to every other session: /all <text>'],
89 ['who', 'List the other sessions on this machine'],
90 ['chat', 'Show or hide the chat band; /chat name <new name> renames this session'],
91 ] as const) {
92 // One refused name (a built-in took it) must not skip the rest.
93 await $.command.register({ name, description }).catch(err => $.ui.toast(`session-chat: /${name} not registered: ${err}`))
94 }
95
96 await $.tool.register({
97 name: 'peers',
98 description:
99 'List the other Claude Code sessions running on this machine (name, working directory, sessionId). ' +
100 'Message one with SendMessage (to = its name) or several with the broadcast tool.',
101 inputSchema: { type: 'object', properties: {} },
102 })
103 await $.tool.register({
104 name: 'broadcast',
105 description: 'Send one plain-text message to every other Claude Code session on this machine.',
106 inputSchema: {
107 type: 'object',
108 properties: { message: { type: 'string', description: 'The message; first line is the preview' } },
109 required: ['message'],
110 },
111 })
112
113 await heartbeat($, sessionId).catch(() => undefined)
114 $.clock.every(HEARTBEAT_MS, () => void heartbeat($, sessionId).catch(() => undefined))
115
116 return next(e)
117 })
118
119 on('session.end', async ($, e, next) => {
120 const sessionId = await $.session.id()
121 const gone: Peer = { sessionId, name: await read($, me), cwd: '', lastSeen: 0 }
122 await $.fs.write(`${await peersDir($)}/${sessionId}.json`, JSON.stringify(gone)).catch(() => undefined)
123
124 return next(e)
125 })
126
127 on('prompt.compose', async ($, e, next) => {
128 const composed = await next(e)
129 const name = await read($, me)
130
131 return {
132 sections: [
133 ...composed.sections,
134 {
135 id: 'session-chat:peers',
136 scope: 'session',
137 text:
138 `This session is "${name}" to other Claude Code sessions on this machine. ` +
139 'When your work overlaps another session (same repo, shared files, a task they own), coordinate: ' +
140 `list them with ${PEERS_TOOL}, message one with SendMessage, or all with ${BROADCAST_TOOL}. ` +
141 'Keep messages short and actionable; do not chat for its own sake.',
142 },
143 ],
144 }
145 })
146
147 on('session.receive', async ($, e, next) => {
148 const kind = e.origin.kind
149 if (kind === 'peer-send-message' || (kind === 'peer' && !('teammate' in e.origin))) {
150 const { from, body } = parseTag(e.text)
151 await remember($, { direction: 'in', peer: from ?? 'peer', text: body, by: 'model' })
152 $.ui.toast(`message from ${from ?? 'a session'}: ${body.split('\n')[0]?.slice(0, 80)}`)
153 }
154
155 return next(e)
156 })
157
158 on('session.send', async ($, e, next) => {
159 if (e.origin.kind !== 'model' || parseTag(e.text).from !== undefined) return next(e)
160 const list = await read($, peers)
161 const peer = list.find(one => one.name === e.to || one.sessionId === e.to)
162 const sent = await next({ ...e, text: formatTag(await read($, me), e.text) })
163 if (peer && sent.isDelivered !== false) {
164 await remember($, { direction: 'out', peer: peer.name, text: e.text, by: 'model' })
165 }
166
167 return sent
168 })
169
170 on('tool.call', { tool: new RegExp(`^${PEERS_TOOL}$`) }, async $ => {
171 await heartbeat($, await $.session.id()).catch(() => undefined)
172
173 const text = peerList(await read($, peers))
174
175 return { result: text, text }
176 })
177
178 on('tool.call', { tool: new RegExp(`^${BROADCAST_TOOL}$`) }, async ($, e) => {
179 const call = e as unknown as { input?: { message?: unknown }; message?: unknown }
180 const text = String(call.input?.message ?? call.message ?? '').trim()
181 if (!text) return { result: 'Nothing sent: message is empty.', isError: true }
182 const { delivered, total } = await broadcast($, text, 'model')
183
184 const done = `Delivered to ${delivered} of ${total} sessions.`
185
186 return { result: done, text: done }
187 })
188
189 on('command.run', { command: 'who' }, async $ => {
190 await heartbeat($, await $.session.id()).catch(() => undefined)
191
192 return { text: `You are ${await read($, me)}.\n${peerList(await read($, peers))}` }
193 })
194
195 on('command.run', { command: 'msg' }, async ($, e) => {
196 const [target = '', ...rest] = e.args.trim().split(/\s+/)
197 const text = e.args.trim().slice(target.length).trim()
198 if (!target || rest.length === 0) return { text: 'Usage: /msg <name> <text>' }
199 const peer = matchPeer(await read($, peers), target)
200 if (!peer) return { text: `No session matches "${target}".\n${peerList(await read($, peers))}` }
201 const isDelivered = await send($, peer, text, 'person')
202
203 return { text: isDelivered ? `Sent to ${peer.name}.` : `Could not deliver to ${peer.name}.` }
204 })
205
206 on('command.run', { command: 'all' }, async ($, e) => {
207 const text = e.args.trim()
208 if (!text) return { text: 'Usage: /all <text>' }
209 const { delivered, total } = await broadcast($, text, 'person')
210
211 return { text: `Delivered to ${delivered} of ${total} sessions.` }
212 })
213
214 on('command.run', { command: 'chat' }, async ($, e) => {
215 const [verb, ...rest] = e.args.trim().split(/\s+/)
216 if (verb === 'name' && rest.length > 0) {
217 const name = rest.join('-')
218 await update($, me, () => name)
219 await $.store.set(`name:${await $.session.id()}`, name)
220 await heartbeat($, await $.session.id()).catch(() => undefined)
221
222 return { text: `This session is now "${name}".` }
223 }
224 const hidden = await update($, isHidden, value => !value)
225 await update($, unread, () => 0)
226
227 return { text: hidden ? 'Chat band hidden.' : 'Chat band shown.' }
228 })
229
230 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
231 const list = await read($, messages)
232 const others = await read($, peers)
233 if (e.props.hasSurvey || (await read($, isHidden)) || (list.length === 0 && others.length === 0)) {
234 return next(e)
235 }
236
237 const { Box, Button, Text } = $.ui.resolve(e)
238 const width = Math.max(20, (e.props.bodyColumns ?? 80) - 4)
239 const count = await read($, unread)
240 const recent = list.slice(-3)
241
242 return (
243 <Box flexDirection="column">
244 <Box>
245 <Text bold>chat </Text>
246 <Text dimColor>
247 {await read($, me)} · {others.length} peer{others.length === 1 ? '' : 's'}
248 {others.length > 0 ? `: ${others.map(peer => peer.name).join(', ')}` : ''}
249 </Text>
250 {count > 0 && <Text color="yellow"> · {count} new</Text>}
251 <Text> </Text>
252 <Button key="seen" plain label="[seen]" onPress={() => update($, unread, () => 0)} />
253 <Text> </Text>
254 <Button key="hide" plain label="[hide]" onPress={() => update($, isHidden, () => true)} />
255 </Box>
256 {recent.map(message => (
257 <Text key={message.id} dimColor={message.direction === 'out'} wrap="truncate">
258 {message.direction === 'in' ? `${message.peer} → me` : `me → ${message.peer}`}
259 {message.by === 'model' ? ' (model)' : ''}: {message.text.split('\n')[0]?.slice(0, width)}
260 </Text>
261 ))}
262 </Box>
263 )
264 })
265}
266hooks/chat.ts 24 lines1import type { Peer } from '../types'
2
3const TAG = /^\[chat:([^\]\s]+)\]\s?/
4
5export const formatTag = (from: string, text: string) => `[chat:${from}] ${text}`
6
7export const parseTag = (text: string): { from?: string; body: string } => {
8 const found = TAG.exec(text)
9
10 return found ? { from: found[1], body: text.slice(found[0].length) } : { body: text }
11}
12
13// Exact name, then sessionId prefix, then a unique name prefix.
14export const matchPeer = (peers: readonly Peer[], target: string): Peer | undefined => {
15 const wanted = target.toLowerCase()
16 const exact = peers.find(peer => peer.name.toLowerCase() === wanted)
17 if (exact) return exact
18 const byId = peers.find(peer => peer.sessionId.startsWith(target))
19 if (byId) return byId
20 const byPrefix = peers.filter(peer => peer.name.toLowerCase().startsWith(wanted))
21
22 return byPrefix.length === 1 ? byPrefix[0] : undefined
23}
24types/index.d.ts 28 lines1export type Peer = {
2 sessionId: string
3 name: string
4 cwd: string
5 lastSeen: number
6}
7
8export type ChatMessage = {
9 id: string
10 at: number
11 direction: 'in' | 'out'
12 peer: string
13 text: string
14 by: 'model' | 'person'
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'session-chat': {
20 peers: Peer[]
21 messages: ChatMessage[]
22 unread: number
23 isHidden: boolean
24 me: string
25 }
26 }
27}
28