SLOPSHOPPER

claude-roulette

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

newpanecommandtoaststatusnetwork
★ 1v0.1.0no licenseupdated 2026-09-15tldev/claude-roulette/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claude-roulette
│ ┃ Roulette ✕ › fix the failing auth test and add an audit log call │ ┃ ◌ C L A U D E R O U L E T T E │ ┃ ✓ Claude is ready · 0 online · 0 waiting ⏺ Read(src/auth.ts) │ ┃ Human company while your agent does the ⎿ Read 6 lines │ ┃ work. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ╭────────────────────────────────────────╮ ⏺ Bash(bun test) │ ┃ │ A little serendipity between prompts. │ ⎿ 3 pass, 1 fail │ ┃ │ Meet one random person. Talk code, │ │ ┃ │ swap ideas, or just say hi. │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ Your chat never goes into Claude. No │ │ ┃ │ prompts, files, or task names are │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ shared. │ │ ┃ │ 18+ only. Be kind. Do not share │ › /roulette │ ┃ │ secrets. Block or report anyone. │ │ ┃ │ The server relays chat. Operators │ │ ┃ │ receive report metadata. │ │ ┃ │ [ I am 18+, agree, and want to join ] │ │ ┃ │ [ Set interests first ] │ │ ┃ ╰────────────────────────────────────────╯ │ ┃ Private from Claude · Esc returns to your │ ┃ task · /roulette reopens │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Roulette
◌ C L A U D E R O U L E T T E ✓ Claude is ready · 0 online · 0 waiting Human company while your agent does the work. ╭────────────────────────────────────────────────────────╮ │ A little serendipity between prompts. │ │ Meet one random person. Talk code, swap ideas, or just │ │ say hi. │ │ Your chat never goes into Claude. No prompts, files, │ │ or task names are shared. │ │ 18+ only. Be kind. Do not share secrets. Block or │ │ report anyone. │ │ The server relays chat. Operators receive report │ │ metadata. │ │ [ I am 18+, agree, and want to join ] │ │ [ Set interests first ] │ ╰────────────────────────────────────────────────────────╯ Private from Claude · Esc returns to your task · /roulette reopens
README

Claude Roulette

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

Terminal client with a simulated conversation

Use the Claude mod

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.

Try the standalone terminal client

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.

CommandAction
/nextFind another person
/leave, /joinPause or resume matching
/interests rust,musicChoose up to eight interests
/mode random, /mode interestsChange matching preference
/language enMatch within a language
/block, /report spamLeave and block or report a partner
/stayKeep this conversation when the task finishes
/done, /workingUpdate standalone task status
/help, /quitHelp or exit
/theme light, /theme dark, /theme autoChoose 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.

Privacy

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.

Development

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.

Source 4 files
hooks/register.ts 68 lines
1import 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}
68
hooks/controller.ts 449 lines
1import 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}
449
hooks/view.ts 136 lines
1import 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}
136
hooks/protocol.ts 97 lines
1// 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