A private text chat with another human while Claude works, with random matching, interests, next, block, report, and automatic task completion handling.

Meet another human while Claude is thinking.
A native Claude Code mod and terminal client for text-only chat roulette. Match with another person waiting on Claude, swap ideas, and move on whenever you like.
Visit the lounge · Claude integration

git clone https://github.com/tldev/claude-roulette.git
cd claude-roulette
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir "$PWD/plugin"
Run /roulette, accept the community rules, and start a Claude task. Matching starts while the task runs. The pane includes interests, language preferences, next, pause, typing indicators, block/report, and an option to keep a conversation going after Claude finishes.
The mod connects to the hosted lounge automatically. No server configuration or npm install is needed for the native mod.
Claude Mods is experimental. The plugin was validated and rendered in Claude Code 2.1.270 and typechecked against the published 2.1.271 API. See the integration guide for the exact verified interfaces and layout requirements.
Requires Node 24.8 or later and npm (Bun also works).
npm ci
npm run demo
npm run chat -- --interests typescript,coffee
The offline demo is clearly labeled and uses a scripted partner. The real client connects to the hosted lounge. The standalone client uses /done and /working to update task status; the native mod detects it automatically.
Terminal colors adapt to light or dark backgrounds when the terminal reports its theme. Use --theme light when launching, or /theme light inside the lounge, to choose the light palette explicitly. Your choice is saved. /theme dark and /theme auto are also available.
| Command | Action |
|---|---|
/next | Find another person |
/leave, /join | Pause or resume matching |
/interests rust,music | Choose up to eight interests |
/mode random, /mode interests | Change matching preference |
/language en | Match within a language |
/block, /report spam | Leave and block or report a partner |
/stay | Keep this conversation when the task finishes |
/done, /working | Update standalone task status |
/help, /quit | Help or exit |
/theme light, /theme dark, /theme auto | Choose terminal colors |
Report reasons: spam, harassment, sexual, hate, or other. Use Page Up/Down for scrollback, Tab for command completion, Ctrl+N for next, and Ctrl+L to leave.
Chat messages never enter Claude's prompts or tools. The client does not share your code, files, paths, task names, tool output, or transcripts. It sends your chosen interests/language, chat text, and a boolean task-availability signal. Messages pass through the relay and are not saved as conversations. Moderation records contain identities, report reasons, and timestamps.
Anonymous identity tokens are stored locally. The native mod uses Claude's plugin store; the terminal client uses an owner-only file under ~/.config/claude-roulette/. Anyone you chat with can copy messages, so keep sensitive information out of the lounge. The community is for adults 18 and older.
npm run check
npm audit
This public repository contains only the mod, terminal client, shared wire contract, and their tests. The hosted backend, website, and operations dashboard are maintained separately in a private repository.
To test the native client against a compatible local server, set ROULETTE_TEST_SERVER_URL=http://127.0.0.1:8787 before npm test. The optional integration test creates two synthetic clients using the zz language. Run it on an empty development server.
This is an independent community project, not an official Anthropic product. Inspired by the Claude Mods proposal.
hooks/register.ts 68 lines1import type { On } from 'claude-code';
2import { RouletteController, serverUrl } from './controller';
3import { paneView } from './view';
4
5const PANE = 'claude-roulette';
6
7export function register(on: On): void {
8 let controller: RouletteController | undefined;
9
10 on('session.start', async ($, e, next) => {
11 if (e.isInteractive && (e.surface === 'terminal' || e.surface === 'desktop')) {
12 try {
13 const url = serverUrl(await $.env.get('ROULETTE_URL'));
14 const created = new RouletteController({
15 fetch: (address, init) => $.http.fetch(address, init),
16 storeGet: key => $.store.get(key),
17 storeSet: (key, value) => $.store.set(key, value),
18 now: () => $.clock.now(),
19 every: (ms, fn) => $.clock.every(ms, fn),
20 invalidate: () => $.ui.invalidate('ui.render'),
21 status: text => $.ui.status(text),
22 toast: text => $.ui.toast(text),
23 open: focus => $.ui.open({ id: PANE, title: 'Roulette', rows: 25, ...(focus ? { focus: true } : {}) }),
24 close: () => $.ui.close({ id: PANE }),
25 }, url);
26 await created.load();
27 controller = created;
28 await $.command.register({ name: 'roulette', description: 'Meet another human while Claude works', immediate: true });
29 } catch {
30 $.ui.log('Roulette could not initialize. Check that your Claude Code version supports Mods.');
31 }
32 }
33 return next(e);
34 });
35
36 on('command.run', { command: 'roulette' }, async ($, e, next) => {
37 if (!controller) return next(e);
38 await controller.open();
39 return {};
40 });
41
42 on('turn.start', ($, e, next) => {
43 controller?.setBusy(e.turnId);
44 return next(e);
45 });
46
47 on('turn.complete', ($, e, next) => {
48 controller?.complete(e.turnId, e.agentId);
49 return next(e);
50 });
51
52 on('ui.render', { component: 'Pane' }, ($, e, next) => {
53 if (e.requestId !== PANE || !controller || (e.surface !== 'terminal' && e.surface !== 'desktop')) return next(e);
54 const ui = $.ui.resolve(e);
55 return paneView(ui, controller, e.props.bodyColumns, e.props.scroll.bodyRows);
56 });
57
58 on('ui.close', { id: PANE }, ($, e, next) => {
59 controller?.closed();
60 return next(e);
61 });
62
63 on('classic.SessionEnd', ($, e, next) => {
64 void controller?.stop();
65 return next(e);
66 });
67}
68hooks/controller.ts 449 lines1import type { ClientEvent, Peer, Profile, ReportReason, ServerEvent, Stats } from './protocol';
2import { cleanText, decodeServerEvent, profileInterests } from './protocol';
3
4export interface Host {
5 fetch(url: string, init?: { method?: string; headers?: Record<string, string>; body?: string }): Promise<{ ok: boolean; status: number; text: string }>;
6 storeGet(key: string): Promise<unknown>;
7 storeSet(key: string, value: unknown): Promise<void>;
8 now(): Promise<number>;
9 every(ms: number, fn: () => void): { cancel(): void };
10 invalidate(): void;
11 status(text: string | undefined): void;
12 toast(text: string): void;
13 open(focus: boolean): Promise<void>;
14 close(): Promise<void>;
15}
16
17export type Phase = 'off' | 'ready' | 'connecting' | 'queued' | 'chatting' | 'paused' | 'reconnecting';
18export type ChatLine = { id: string; text: string; who: 'you' | 'peer' | 'system'; at: number };
19export type Model = {
20 enabled: boolean; busy: boolean; phase: Phase; alias: string; peer: Peer | null; roomId: string | null;
21 stats: Stats; profile: Profile; lines: ChatLine[]; sharedInterests: string[];
22 icebreaker: string; draft: string; notice: string; error: string; keepChat: boolean;
23 peerTyping: boolean; showSettings: boolean; showReport: boolean; scroll: number;
24 now: number; queuedAt: number; startedAt: number; frame: number; confirmedRules: boolean;
25};
26
27const INITIAL_PROFILE: Profile = { interests: [], language: 'en', mode: 'interests' };
28
29export class RouletteController {
30 readonly model: Model = {
31 enabled: false, busy: false, phase: 'off', alias: 'You', peer: null, roomId: null,
32 stats: { online: 0, waiting: 0, chatting: 0 }, profile: { ...INITIAL_PROFILE },
33 lines: [], sharedInterests: [], icebreaker: '', draft: '', notice: '', error: '',
34 keepChat: false, peerTyping: false, showSettings: false, showReport: false,
35 scroll: 0, now: 0, queuedAt: 0, startedAt: 0, frame: 0, confirmedRules: false,
36 };
37 private token = '';
38 private cursor = 0;
39 private sessionId = '';
40 private session = false;
41 private fetching = false;
42 private connecting = false;
43 private timer: { cancel(): void } | undefined;
44 private generation = 0;
45 private retryAt = 0;
46 private retryDelay = 1000;
47 private typingAt = 0;
48 private busyTurn: string | null = null;
49 private pausedByUser = false;
50 private paneOpen = false;
51 private tokenKey: string;
52 private sendChain: Promise<void> = Promise.resolve();
53
54 constructor(private readonly host: Host, private readonly server: string) {
55 this.tokenKey = `roulette.identity.${server}`;
56 }
57
58 async load(): Promise<void> {
59 const [token, profile] = await Promise.all([
60 this.host.storeGet(this.tokenKey).catch(() => undefined),
61 this.host.storeGet('roulette.profile').catch(() => undefined),
62 ]);
63 if (typeof token === 'string' && /^[a-f0-9]{64}$/.test(token)) this.token = token;
64 if (profile && typeof profile === 'object') {
65 const p = profile as Partial<Profile>;
66 this.model.profile = {
67 interests: Array.isArray(p.interests) ? profileInterests(p.interests.filter(v => typeof v === 'string').join(',')) : [],
68 language: typeof p.language === 'string' && /^[a-z]{2}$/.test(p.language) ? p.language : 'en',
69 mode: p.mode === 'random' ? 'random' : 'interests',
70 };
71 }
72 }
73
74 async open(): Promise<void> {
75 this.paneOpen = true;
76 await this.host.open(true);
77 this.redraw();
78 }
79
80 closed(): void { this.paneOpen = false; }
81
82 activate(): void {
83 this.model.enabled = true;
84 this.model.confirmedRules = true;
85 this.pausedByUser = false;
86 this.model.error = '';
87 this.model.phase = this.model.busy ? 'connecting' : 'ready';
88 this.model.notice = this.model.busy ? 'Finding another human...' : 'Ready for your next Claude task.';
89 this.startTimer();
90 if (this.model.busy) void this.connect();
91 this.redraw();
92 }
93
94 setBusy(turnId: string): void {
95 this.busyTurn = turnId;
96 this.model.busy = true;
97 this.model.notice = '';
98 if (!this.model.enabled || this.pausedByUser) { this.redraw(); return; }
99 if (this.session) this.enqueue({ type: 'availability', available: true, keepChat: this.model.keepChat });
100 else void this.connect();
101 if (!this.paneOpen) {
102 this.paneOpen = true;
103 void this.host.open(false).catch(() => { this.paneOpen = false; });
104 }
105 this.redraw();
106 }
107
108 complete(turnId: string, agentId?: string): void {
109 if (agentId || this.busyTurn !== turnId) return;
110 this.busyTurn = null;
111 this.model.busy = false;
112 if (this.session) this.enqueue({ type: 'availability', available: false, keepChat: this.model.keepChat });
113 if (!this.model.keepChat || this.model.phase !== 'chatting') {
114 this.model.phase = this.model.enabled ? 'ready' : 'off';
115 this.model.peerTyping = false;
116 this.model.roomId = null;
117 }
118 this.model.notice = this.model.keepChat && this.model.peer ? 'Claude finished. Your conversation can continue.' : 'Claude finished. Matching resumes with your next task.';
119 if (this.model.enabled) this.host.toast('Claude finished. Your task is ready.');
120 this.redraw();
121 }
122
123 next(): void {
124 if (!this.model.busy) { this.model.error = 'Start another Claude task to find a new person.'; this.redraw(); return; }
125 this.model.showReport = false;
126 this.model.roomId = null;
127 this.model.draft = '';
128 this.model.scroll = 0;
129 this.pausedByUser = false;
130 if (this.session) this.enqueue({ type: 'next', profile: this.model.profile });
131 else this.activate();
132 this.redraw();
133 }
134
135 pause(): void {
136 this.pausedByUser = true;
137 this.model.roomId = null;
138 this.model.phase = 'paused';
139 this.model.peerTyping = false;
140 if (this.session) this.enqueue({ type: 'leave' });
141 this.model.notice = 'Taking a breather. Join when you are ready.';
142 this.redraw();
143 }
144
145 join(): void {
146 if (!this.model.enabled) { this.activate(); return; }
147 if (!this.model.busy) { this.model.notice = 'Matching starts when Claude starts your next task.'; this.redraw(); return; }
148 this.pausedByUser = false;
149 if (this.session) this.enqueue({ type: 'join', profile: this.model.profile });
150 else void this.connect();
151 }
152
153 send(value: string): void {
154 const text = cleanText(value).trim();
155 if (!text || this.model.phase !== 'chatting') return;
156 this.model.draft = '';
157 this.model.scroll = 0;
158 this.enqueue({ type: 'message', id: crypto.randomUUID(), text });
159 this.enqueue({ type: 'typing', active: false });
160 this.redraw();
161 }
162
163 draft(value: string): void {
164 this.model.draft = cleanText(value);
165 if (this.model.now - this.typingAt > 2000 && this.model.phase === 'chatting') {
166 this.typingAt = this.model.now;
167 this.enqueue({ type: 'typing', active: true });
168 }
169 }
170
171 report(reason: ReportReason): void {
172 this.model.showReport = false;
173 this.enqueue({ type: 'report', reason });
174 this.redraw();
175 }
176
177 block(): void {
178 this.model.showReport = false;
179 this.enqueue({ type: 'block' });
180 this.redraw();
181 }
182
183 toggleKeepChat(): void {
184 this.model.keepChat = !this.model.keepChat;
185 if (!this.model.busy && !this.model.keepChat && this.session) {
186 this.enqueue({ type: 'availability', available: false, keepChat: false });
187 }
188 this.redraw();
189 }
190
191 settings(): void { this.model.showSettings = !this.model.showSettings; this.redraw(); }
192 reports(): void { this.model.showReport = !this.model.showReport; this.redraw(); }
193 scroll(by: number): void { this.model.scroll = Math.max(0, Math.min(this.model.lines.length - 1, this.model.scroll + by)); this.redraw(); }
194
195 setInterests(value: string): void {
196 this.model.profile = { ...this.model.profile, interests: profileInterests(value) };
197 this.profileChanged();
198 }
199
200 setLanguage(value: string): void {
201 const language = value.trim().toLowerCase();
202 if (!/^[a-z]{2}$/.test(language)) { this.model.error = 'Use a two-letter language code, such as en, es, or fr.'; this.redraw(); return; }
203 this.model.profile = { ...this.model.profile, language };
204 this.profileChanged();
205 }
206
207 toggleMode(): void {
208 this.model.profile = { ...this.model.profile, mode: this.model.profile.mode === 'random' ? 'interests' : 'random' };
209 this.profileChanged();
210 }
211
212 private profileChanged(): void {
213 this.model.error = '';
214 this.model.notice = 'Preferences saved. They apply to your next match.';
215 void this.host.storeSet('roulette.profile', this.model.profile).catch(() => undefined);
216 if (this.model.phase === 'queued') this.enqueue({ type: 'join', profile: this.model.profile });
217 this.redraw();
218 }
219
220 async stop(): Promise<void> {
221 this.generation += 1;
222 this.timer?.cancel();
223 this.timer = undefined;
224 this.model.enabled = false;
225 this.model.phase = 'off';
226 this.model.roomId = null;
227 this.session = false;
228 this.host.status(undefined);
229 if (this.token) await this.request('/api/session', 'DELETE').catch(() => undefined);
230 this.redraw();
231 }
232
233 async hide(): Promise<void> { this.paneOpen = false; await this.host.close(); }
234
235 private startTimer(): void {
236 this.timer ??= this.host.every(1000, () => { void this.tick(); });
237 }
238
239 private async tick(): Promise<void> {
240 if (!this.model.enabled) return;
241 this.model.now = await this.host.now();
242 this.model.frame += 1;
243 if (!this.session && !this.connecting && this.model.now >= this.retryAt && this.model.busy && !this.pausedByUser) void this.connect();
244 if (this.session) void this.poll();
245 this.redraw();
246 }
247
248 private async connect(): Promise<void> {
249 if (this.connecting || this.session || !this.model.enabled) return;
250 this.connecting = true;
251 const generation = this.generation;
252 this.model.phase = this.retryAt ? 'reconnecting' : 'connecting';
253 this.redraw();
254 try {
255 const value = await this.request('/api/session', 'POST', {
256 ...(this.token ? { token: this.token } : {}), profile: this.model.profile,
257 available: this.model.busy && !this.pausedByUser,
258 }) as { token?: unknown; sessionId?: unknown };
259 if (generation !== this.generation) {
260 if (typeof value.token === 'string' && /^[a-f0-9]{64}$/.test(value.token)) {
261 await this.host.fetch(`${this.server}/api/session`, { method: 'DELETE', headers: { Authorization: `Bearer ${value.token}`, ...(typeof value.sessionId === 'string' ? { 'X-Roulette-Session': value.sessionId } : {}) } }).catch(() => undefined);
262 }
263 return;
264 }
265 if (typeof value.token !== 'string' || !/^[a-f0-9]{64}$/.test(value.token)) throw new Error('Server returned an invalid session.');
266 if (typeof value.sessionId !== 'string' || value.sessionId.length > 128) throw new Error('Server returned an invalid session ID.');
267 this.sessionId = value.sessionId;
268 this.token = value.token;
269 this.session = true;
270 this.cursor = 0;
271 this.retryAt = 0;
272 this.retryDelay = 1000;
273 this.model.error = '';
274 await this.host.storeSet(this.tokenKey, this.token).catch(() => undefined);
275 await this.poll();
276 if (!this.model.busy || this.pausedByUser) this.enqueue({ type: 'availability', available: false, keepChat: this.model.keepChat });
277 } catch (error) {
278 if (generation === this.generation) this.connectionFailed(error);
279 } finally { this.connecting = false; }
280 }
281
282 private connectionFailed(error: unknown): void {
283 this.session = false;
284 this.model.roomId = null;
285 if (!this.model.enabled) return;
286 this.model.phase = 'reconnecting';
287 this.model.error = `${cleanText(error instanceof Error ? error.message : 'Connection failed.', 180)} Retrying...`;
288 this.retryAt = this.model.now + this.retryDelay;
289 this.retryDelay = Math.min(30000, this.retryDelay * 2);
290 this.redraw();
291 }
292
293 private async request(path: string, method = 'GET', body?: unknown): Promise<unknown> {
294 const generation = this.generation;
295 const sessionId = this.sessionId;
296 const response = await this.host.fetch(`${this.server}${path}`, {
297 method,
298 headers: { 'Content-Type': 'application/json', ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), ...(this.sessionId && !(path === '/api/session' && method === 'POST') ? { 'X-Roulette-Session': this.sessionId } : {}) },
299 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
300 });
301 if (response.text.length > 2_000_000) throw new Error('Server response exceeded the size limit.');
302 let value: unknown;
303 try { value = response.text ? JSON.parse(response.text) : {}; } catch { throw new Error('The lounge returned an unexpected response.'); }
304 if (!response.ok) {
305 const detail = value as { error?: { code?: string; message?: string } };
306 if (generation === this.generation && sessionId === this.sessionId) {
307 if ([401, 404, 409].includes(response.status)) this.session = false;
308 if (detail.error?.code === 'duplicate_session') this.replaced();
309 if (detail.error?.code === 'banned') this.suspended();
310 if (detail.error?.code === 'operator_disconnected' || detail.error?.code === 'maintenance') {
311 this.operatorStopped(detail.error.code, typeof detail.error.message === 'string' ? detail.error.message : 'The server ended this session.');
312 }
313 }
314 throw new Error(typeof detail.error?.message === 'string' ? detail.error.message : `Server returned HTTP ${response.status}.`);
315 }
316 return value;
317 }
318
319 private enqueue(event: ClientEvent): void {
320 if (event.type === 'message' || event.type === 'typing' || event.type === 'block' || event.type === 'report') {
321 if (!this.model.roomId) return;
322 event = { ...event, roomId: this.model.roomId };
323 }
324 const generation = this.generation;
325 const sessionId = this.sessionId;
326 this.sendChain = this.sendChain.then(async () => {
327 if (!this.session || generation !== this.generation || sessionId !== this.sessionId) return;
328 try { await this.request('/api/action', 'POST', event); await this.poll(); }
329 catch (error) {
330 if (generation !== this.generation || sessionId !== this.sessionId) return;
331 this.model.error = cleanText(error instanceof Error ? error.message : 'Unable to send.', 180);
332 if (!this.session) this.connectionFailed(error);
333 this.redraw();
334 }
335 });
336 }
337
338 private async poll(): Promise<void> {
339 if (this.fetching || !this.session) return;
340 this.fetching = true;
341 const generation = this.generation;
342 const sessionId = this.sessionId;
343 try {
344 const reply = await this.request(`/api/events?after=${this.cursor}`) as { events?: unknown; cursor?: unknown };
345 if (generation !== this.generation || sessionId !== this.sessionId) return;
346 if (!Array.isArray(reply.events) || reply.events.length > 1024 || typeof reply.cursor !== 'number' || !Number.isSafeInteger(reply.cursor) || reply.cursor < this.cursor) throw new Error('Invalid event response.');
347 for (const event of reply.events) {
348 if (generation !== this.generation || sessionId !== this.sessionId || !this.session) break;
349 this.receive(decodeServerEvent(event));
350 }
351 if (generation === this.generation && sessionId === this.sessionId) this.cursor = reply.cursor;
352 this.redraw();
353 } catch (error) { if (generation === this.generation && sessionId === this.sessionId) this.connectionFailed(error); }
354 finally { this.fetching = false; }
355 }
356
357 private line(text: string): void {
358 this.model.lines.push({ id: crypto.randomUUID(), text: cleanText(text), who: 'system', at: this.model.now });
359 this.model.lines = this.model.lines.slice(-120);
360 }
361
362 private receive(event: ServerEvent): void {
363 if (!event || typeof event !== 'object') return;
364 switch (event.type) {
365 case 'welcome': this.model.alias = cleanText(event.alias, 40); this.model.stats = event.stats; break;
366 case 'stats': this.model.stats = event.stats; break;
367 case 'queued':
368 this.model.phase = 'queued'; this.model.queuedAt = event.since; this.model.peer = null; this.model.roomId = null;
369 this.model.peerTyping = false; this.model.notice = ''; this.model.error = ''; break;
370 case 'matched':
371 this.model.phase = 'chatting'; this.model.peer = event.peer; this.model.roomId = event.roomId; this.model.sharedInterests = event.sharedInterests;
372 this.model.icebreaker = cleanText(event.icebreaker, 180); this.model.startedAt = event.startedAt;
373 this.model.peerTyping = false; this.model.lines = []; this.model.scroll = 0;
374 this.model.notice = 'Connected. Say hello!'; this.model.error = ''; break;
375 case 'message':
376 if (!this.model.lines.some(line => line.id === event.id)) {
377 this.model.lines.push({ id: event.id, text: cleanText(event.text), who: event.from, at: event.at });
378 this.model.lines = this.model.lines.slice(-120);
379 }
380 if (event.from === 'peer') this.model.peerTyping = false;
381 break;
382 case 'typing': this.model.peerTyping = event.active; break;
383 case 'peer_left':
384 this.pausedByUser = true;
385 this.model.peerTyping = false; this.model.roomId = null; this.model.phase = this.model.busy ? 'paused' : 'ready';
386 this.line(event.reason === 'task_done' ? 'Their Claude task finished. Time well spent.' : 'Your partner left the room. Next finds someone new.');
387 break;
388 case 'paused': this.model.roomId = null; this.model.phase = this.model.busy ? 'paused' : 'ready'; this.model.peerTyping = false; break;
389 case 'task_status': break;
390 case 'reported': this.pausedByUser = true; this.model.notice = 'Report received. This person is blocked from future matches.'; break;
391 case 'blocked': this.pausedByUser = true; this.model.notice = 'Blocked. You will not be matched with this person again.'; break;
392 case 'error':
393 if (event.code === 'duplicate_session') this.replaced();
394 else if (event.code === 'banned') this.suspended();
395 else if (event.code === 'operator_disconnected' || event.code === 'maintenance') this.operatorStopped(event.code, event.message);
396 else this.model.error = cleanText(event.message, 180);
397 break;
398 case 'pong': break;
399 }
400 }
401
402 private suspended(): void {
403 this.stopRetrying('This identity is suspended. Contact the server operator.');
404 }
405
406 private replaced(): void {
407 this.stopRetrying('This identity connected in another Claude session. Automatic reconnect is stopped.');
408 }
409
410 private operatorStopped(code: 'operator_disconnected' | 'maintenance', message: string): void {
411 const guidance = code === 'maintenance' ? 'Turn on again after the lounge reopens.' : 'Turn on again when you are ready.';
412 this.stopRetrying(`${cleanText(message, 180)} ${guidance}`);
413 }
414
415 private stopRetrying(message: string): void {
416 this.generation += 1;
417 this.session = false;
418 this.sessionId = '';
419 this.pausedByUser = true;
420 this.model.enabled = false;
421 this.model.phase = 'off';
422 this.model.roomId = null;
423 this.model.peer = null;
424 this.model.peerTyping = false;
425 this.timer?.cancel();
426 this.timer = undefined;
427 this.model.error = message;
428 this.host.status(undefined);
429 this.host.toast(this.model.error);
430 this.redraw();
431 }
432
433 private redraw(): void {
434 this.host.invalidate();
435 if (!this.model.enabled) return;
436 this.host.status(this.model.phase === 'chatting'
437 ? `Roulette: ${this.model.peer ? cleanText(this.model.peer.alias, 32) : 'connected'}${this.model.busy ? ' | Claude working' : ' | task finished'}`
438 : this.model.phase === 'queued' ? 'Roulette: finding another human | /roulette'
439 : this.model.busy ? 'Roulette: /roulette to open chat' : 'Roulette ready for your next task | /roulette');
440 }
441}
442
443export function serverUrl(value: unknown): string {
444 const url = new URL(typeof value === 'string' && value ? value : 'https://clauderoulette.thelazydeveloper.com');
445 if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('ROULETTE_URL must be an http(s) server URL without credentials, query, or fragment.');
446 if (url.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) throw new Error('Remote roulette servers must use HTTPS.');
447 return url.toString().replace(/\/$/, '');
448}
449hooks/view.ts 136 lines1import type { Elements, RenderElement } from 'claude-code';
2import type { RouletteController } from './controller';
3import { cleanText } from './protocol';
4
5const ACCENT = '#bf8cff';
6const MINT = '#76ead7';
7const GOLD = '#f5cd79';
8const SPIN = ['◐', '◓', '◑', '◒'];
9
10export function paneView(ui: Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Input'>, controller: RouletteController, columns: number, rows: number): RenderElement {
11 const { Box, Text, Button, Input } = ui;
12 const m = controller.model;
13 const width = Math.max(20, Math.min(columns - 2, 88));
14 const text = (value: string, color?: string, bold = false) => Text({ children: cleanText(value), ...(color ? { color } : {}), ...(bold ? { bold } : {}) });
15 const dim = (value: string) => Text({ children: cleanText(value), dimColor: true });
16 const button = (key: string, label: string, onPress: () => void) => Button({ key, label, onPress, ...(key === 'accept' ? { autoFocus: true } : {}) });
17 const column = (children: RenderElement[]) => Box({ flexDirection: 'column', children });
18 const row = (children: RenderElement[]) => Box({ flexDirection: 'row', gap: 1, children });
19 const body: RenderElement[] = [];
20 const busy = m.busy ? `${SPIN[m.frame % 4]} Claude is working` : '✓ Claude is ready';
21 body.push(text('◌ C L A U D E R O U L E T T E', ACCENT, true));
22 body.push(row([text(busy, m.busy ? GOLD : MINT), dim(` · ${m.stats.online} online · ${m.stats.waiting} waiting`)]));
23 body.push(dim('Human company while your agent does the work.'));
24
25 if (!m.confirmedRules) {
26 body.push(Box({ flexDirection: 'column', borderStyle: 'round', borderColor: ACCENT, paddingX: 1, marginTop: 1, children: [
27 text('A little serendipity between prompts.', MINT, true),
28 text('Meet one random person. Talk code, swap ideas, or just say hi.'),
29 text('Your chat never goes into Claude. No prompts, files, or task names are shared.'),
30 dim('18+ only. Be kind. Do not share secrets. Block or report anyone.'),
31 dim('The server relays chat. Operators receive report metadata.'),
32 button('accept', 'I am 18+, agree, and want to join', () => controller.activate()),
33 button('preferences', 'Set interests first', () => controller.settings()),
34 ] }));
35 } else {
36 const status = m.phase === 'chatting' ? `● CONNECTED TO ${m.peer ? cleanText(m.peer.alias, 40) : 'A HUMAN'}`
37 : m.phase === 'queued' ? `${SPIN[m.frame % 4]} FINDING YOUR PERSON`
38 : m.phase === 'reconnecting' ? '◌ RECONNECTING'
39 : m.phase === 'connecting' ? '◌ CONNECTING'
40 : m.phase === 'paused' ? 'Ⅱ TAKING A BREATHER' : '○ ON DECK';
41 body.push(Box({ marginTop: 1, children: text(status, m.phase === 'chatting' ? MINT : ACCENT, true) }));
42 if (m.phase === 'chatting') {
43 const tags = m.sharedInterests.length ? `Common ground: ${m.sharedInterests.map(v => `#${cleanText(v, 24)}`).join(' ')}` : 'A random connection. Start somewhere unexpected.';
44 body.push(dim(tags));
45 if (m.lines.length === 0) {
46 body.push(Box({ borderStyle: 'round', borderColor: 'gray', paddingX: 1, children: column([
47 text('BREAK THE ICE', GOLD, true), text(m.icebreaker || 'What is the most surprising thing you have built lately?'),
48 button('icebreaker', 'Send this icebreaker', () => controller.send(m.icebreaker || 'What are you building lately?')),
49 ]) }));
50 }
51 } else if (m.phase === 'queued' || m.phase === 'connecting' || m.phase === 'reconnecting') {
52 const seconds = Math.max(0, Math.floor((m.now - m.queuedAt) / 1000));
53 body.push(Box({ borderStyle: 'round', borderColor: ACCENT, paddingX: 1, children: column([
54 text(`${'░'.repeat(m.frame % 8)}◉${'░'.repeat(7 - m.frame % 8)}`, ACCENT),
55 text(m.phase === 'queued' ? `Looking for another person${seconds ? ` · ${seconds}s` : ''}` : 'Opening a connection...'),
56 dim(m.profile.mode === 'interests' ? 'Shared interests first. Random matches follow.' : 'Surprise me. Every connection is random.'),
57 dim(`Language ${m.profile.language} · ${m.profile.interests.length ? m.profile.interests.join(', ') : 'all interests welcome'}`),
58 ]) }));
59 } else if (m.lines.length === 0) {
60 body.push(Box({ borderStyle: 'round', borderColor: 'gray', paddingX: 1, children: column([
61 text(m.busy ? 'Your next conversation is one click away.' : 'You are on the guest list.', MINT, true),
62 dim(m.busy ? 'Join the queue whenever you are ready.' : 'Start a Claude task. We will find you company while it runs.'),
63 ]) }));
64 }
65
66 if (m.lines.length) {
67 const count = Math.max(2, Math.min(12, Math.floor((rows - 13) / 2)));
68 const end = Math.max(1, m.lines.length - m.scroll);
69 const lines = m.lines.slice(Math.max(0, end - count), end);
70 const conversation = lines.flatMap(line => {
71 if (line.who === 'system') return [dim(`· ${line.text}`)];
72 const name = line.who === 'you' ? 'YOU' : m.peer ? cleanText(m.peer.alias, 28).toUpperCase() : 'STRANGER';
73 const color = line.who === 'you' ? ACCENT : MINT;
74 return [text(name, color, true), text(line.text)];
75 });
76 body.push(Box({ flexDirection: 'column', borderStyle: 'round', borderColor: 'gray', paddingX: 1, width, children: conversation }));
77 if (m.lines.length > count || m.scroll) body.push(row([
78 button('older', '↑ Older', () => controller.scroll(count)),
79 button('newer', '↓ Newer', () => controller.scroll(-count)),
80 dim(m.scroll ? `${m.scroll} newer messages` : 'Latest messages'),
81 ]));
82 }
83 if (m.peerTyping) body.push(text('··· your partner is typing', MINT));
84 if (m.phase === 'chatting') body.push(Input({
85 key: 'message', label: '› ', placeholder: 'Say something human...',
86 value: m.draft, submitLabel: 'send', autoFocus: true,
87 onInput: value => controller.draft(value), onSubmit: value => controller.send(value),
88 }));
89 if (m.notice) body.push(dim(m.notice));
90 if (m.error) body.push(text(m.error, '#ff9a9e'));
91 const actions: RenderElement[] = [];
92 if (!m.enabled) actions.push(button('enable', 'Turn on', () => controller.activate()));
93 if (m.busy) actions.push(button('next', m.phase === 'chatting' ? 'Next person →' : 'Join queue →', () => m.phase === 'chatting' ? controller.next() : controller.join()));
94 if (['queued', 'chatting', 'connecting', 'reconnecting'].includes(m.phase)) actions.push(button('pause', 'Pause', () => controller.pause()));
95 actions.push(button('settings', 'Preferences', () => controller.settings()));
96 if (m.roomId) actions.push(button('report', 'Report / block', () => controller.reports()));
97 body.push(row(actions));
98 body.push(row([
99 button('keep', `${m.keepChat ? '☑' : '☐'} Keep chat when Claude finishes`, () => controller.toggleKeepChat()),
100 ]));
101 body.push(row([
102 button('hide', 'Hide pane', () => { void controller.hide(); }),
103 button('stop', 'Turn off', () => { void controller.stop(); }),
104 ]));
105 }
106 if (m.showSettings) {
107 body.push(Box({ borderStyle: 'round', borderColor: ACCENT, paddingX: 1, flexDirection: 'column', children: [
108 text('YOUR ORBIT', ACCENT, true),
109 Input({ key: 'interests', label: 'Interests', value: m.profile.interests.join(', '), placeholder: 'typescript, music, startups', submitLabel: 'save', onSubmit: value => controller.setInterests(value) }),
110 dim('Up to 8 comma-separated interests. Press Enter to save each field.'),
111 Input({ key: 'language', label: 'Language', value: m.profile.language, submitLabel: 'save', onSubmit: value => controller.setLanguage(value) }),
112 button('mode', `Matching: ${m.profile.mode === 'interests' ? 'shared interests' : 'pure random'}`, () => controller.toggleMode()),
113 button('close-settings', 'Done', () => controller.settings()),
114 ] }));
115 }
116 if (m.showReport) {
117 body.push(Box({ borderStyle: 'round', borderColor: '#ff9a9e', paddingX: 1, flexDirection: 'column', children: [
118 text('YOU CONTROL THIS ROOM', '#ff9a9e', true),
119 dim('Reports share room metadata with the operator for review.'),
120 row([
121 button('report-spam', 'Spam', () => controller.report('spam')),
122 button('report-harassment', 'Harassment', () => controller.report('harassment')),
123 button('report-sexual', 'Sexual content', () => controller.report('sexual')),
124 ]),
125 row([
126 button('report-hate', 'Hate', () => controller.report('hate')),
127 button('report-other', 'Other', () => controller.report('other')),
128 button('block', 'Block and leave', () => controller.block()),
129 button('cancel-report', 'Cancel', () => controller.reports()),
130 ]),
131 ] }));
132 }
133 body.push(dim('Private from Claude · Esc returns to your task · /roulette reopens'));
134 return Box({ flexDirection: 'column', paddingX: 1, children: body });
135}
136hooks/protocol.ts 97 lines1// Dependency-free wire types for the isolated Claude Mods runtime.
2// A compile-time test checks these against src/shared/protocol.ts.
3export type Profile = {
4 interests: string[];
5 language: string;
6 mode: 'interests' | 'random';
7};
8export type ReportReason = 'spam' | 'harassment' | 'sexual' | 'hate' | 'other';
9export type ClientEvent =
10 | { type: 'hello'; protocol: 1; token: string; profile: Profile; available: boolean }
11 | { type: 'join'; profile?: Profile }
12 | { type: 'leave' }
13 | { type: 'next'; profile?: Profile }
14 | { type: 'message'; id: string; text: string; roomId?: string }
15 | { type: 'typing'; active: boolean; roomId?: string }
16 | { type: 'availability'; available: boolean; keepChat: boolean }
17 | { type: 'block'; roomId?: string }
18 | { type: 'report'; reason: ReportReason; roomId?: string }
19 | { type: 'ping' };
20export type Stats = { online: number; waiting: number; chatting: number };
21export type Peer = { alias: string; interests: string[] };
22export type ServerEvent =
23 | { type: 'welcome'; sessionId: string; alias: string; stats: Stats }
24 | { type: 'queued'; since: number; position: number }
25 | { type: 'matched'; roomId: string; peer: Peer; sharedInterests: string[]; icebreaker: string; startedAt: number }
26 | { type: 'message'; id: string; text: string; from: 'you' | 'peer'; at: number }
27 | { type: 'typing'; active: boolean }
28 | { type: 'peer_left'; reason: 'next' | 'left' | 'disconnected' | 'task_done' | 'blocked' }
29 | { type: 'paused'; reason: string }
30 | { type: 'task_status'; done: boolean }
31 | { type: 'reported' }
32 | { type: 'blocked' }
33 | { type: 'stats'; stats: Stats }
34 | { type: 'error'; code: string; message: string; retryAfterMs?: number }
35 | { type: 'pong' };
36
37export function cleanText(value: string, max = 2000): string {
38 return value.replace(/\u001b(?:\][^\u0007]*(?:\u0007|\u001b\\)|\[[0-?]*[ -/]*[@-~])/g, '')
39 .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g, '')
40 .replace(/\r/g, '').slice(0, max);
41}
42
43export function profileInterests(value: string): string[] {
44 return [...new Set(value.toLowerCase().split(',').map(tag => tag.trim())
45 .filter(tag => /^[a-z0-9+#. -]{1,24}$/.test(tag)))].slice(0, 8);
46}
47
48function record(value: unknown): Record<string, unknown> {
49 if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid server event.');
50 return value as Record<string, unknown>;
51}
52function string(value: unknown, limit = 2000): string {
53 if (typeof value !== 'string' || value.length > limit) throw new Error('Invalid server text.');
54 return cleanText(value, limit);
55}
56function number(value: unknown): number {
57 if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) throw new Error('Invalid server number.');
58 return value;
59}
60function boolean(value: unknown): boolean {
61 if (typeof value !== 'boolean') throw new Error('Invalid server flag.');
62 return value;
63}
64function strings(value: unknown): string[] {
65 if (!Array.isArray(value) || value.length > 8) throw new Error('Invalid server interests.');
66 return value.map(v => string(v, 24));
67}
68function stats(value: unknown): Stats {
69 const v = record(value);
70 return { online: number(v.online), waiting: number(v.waiting), chatting: number(v.chatting) };
71}
72function oneOf<T extends string>(value: unknown, options: readonly T[]): T {
73 if (typeof value !== 'string' || !options.includes(value as T)) throw new Error('Invalid server event value.');
74 return value as T;
75}
76
77export function decodeServerEvent(value: unknown): ServerEvent {
78 const e = record(value);
79 switch (e.type) {
80 case 'welcome': return { type: e.type, sessionId: string(e.sessionId, 128), alias: string(e.alias, 40), stats: stats(e.stats) };
81 case 'stats': return { type: e.type, stats: stats(e.stats) };
82 case 'queued': return { type: e.type, since: number(e.since), position: number(e.position) };
83 case 'matched': {
84 const peer = record(e.peer);
85 return { type: e.type, roomId: string(e.roomId, 128), peer: { alias: string(peer.alias, 40), interests: strings(peer.interests) }, sharedInterests: strings(e.sharedInterests), icebreaker: string(e.icebreaker, 2000), startedAt: number(e.startedAt) };
86 }
87 case 'message': return { type: e.type, id: string(e.id, 128), text: string(e.text), from: oneOf(e.from, ['you', 'peer']), at: number(e.at) };
88 case 'typing': return { type: e.type, active: boolean(e.active) };
89 case 'peer_left': return { type: e.type, reason: oneOf(e.reason, ['next', 'left', 'disconnected', 'task_done', 'blocked']) };
90 case 'paused': return { type: e.type, reason: string(e.reason, 1000) };
91 case 'task_status': return { type: e.type, done: boolean(e.done) };
92 case 'error': return { type: e.type, code: string(e.code, 100), message: string(e.message, 2000), ...(e.retryAfterMs === undefined ? {} : { retryAfterMs: number(e.retryAfterMs) }) };
93 case 'reported': case 'blocked': case 'pong': return { type: e.type };
94 default: throw new Error('Unsupported server event. Check that the client and server versions match.');
95 }
96}
97