@lopecode/channel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,583 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Lopecode Channel Server
4
+ *
5
+ * Bridges Lopecode notebooks (WebSocket) to Claude Code (MCP stdio).
6
+ * Notebooks connect over ws://127.0.0.1:8787, Claude interacts via MCP tools.
7
+ */
8
+
9
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
10
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
+ import {
12
+ ListToolsRequestSchema,
13
+ CallToolRequestSchema,
14
+ } from "@modelcontextprotocol/sdk/types.js";
15
+ import type { ServerWebSocket } from "bun";
16
+ import { join, dirname, basename } from "path";
17
+
18
+ // --- Configuration ---
19
+ const PORT = Number(process.env.LOPECODE_PORT ?? 8787);
20
+
21
+ // --- Pairing token ---
22
+ function generateToken(): string {
23
+ const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no O/0/I/1
24
+ let code = "";
25
+ for (let i = 0; i < 4; i++) code += chars[Math.floor(Math.random() * chars.length)];
26
+ return `LOPE-${PORT}-${code}`;
27
+ }
28
+
29
+ const PAIRING_TOKEN = generateToken();
30
+
31
+ // --- State ---
32
+ type ConnectionMeta = { url: string; title: string; modules?: string[] };
33
+
34
+ const pendingConnections = new Set<ServerWebSocket<unknown>>();
35
+ const pairedConnections = new Map<string, ServerWebSocket<unknown>>(); // notebook URL → ws
36
+ const connectionMeta = new Map<ServerWebSocket<unknown>, ConnectionMeta>();
37
+ const wsBySocket = new Map<ServerWebSocket<unknown>, string>(); // ws → notebook URL (reverse lookup)
38
+
39
+ // Command correlation for async request-response
40
+ type PendingCommand = {
41
+ resolve: (value: any) => void;
42
+ reject: (reason: any) => void;
43
+ timer: ReturnType<typeof setTimeout>;
44
+ };
45
+ const pendingCommands = new Map<string, PendingCommand>();
46
+ let commandSeq = 0;
47
+
48
+ function nextCommandId(): string {
49
+ return `cmd-${Date.now()}-${++commandSeq}`;
50
+ }
51
+
52
+ // --- MCP Server ---
53
+ const mcp = new Server(
54
+ { name: "lopecode", version: "1.0.0" },
55
+ {
56
+ capabilities: {
57
+ experimental: { "claude/channel": {} },
58
+ tools: {},
59
+ },
60
+ instructions: `You are connected to Lopecode notebooks via the lopecode channel.
61
+
62
+ ## Starting a notebook
63
+
64
+ When the user asks to start/open/create a notebook, or when no notebooks are connected and collaboration would benefit from one:
65
+ 1. Call get_pairing_token to get the token
66
+ 2. Open the browser with: open 'https://tomlarkworthy.github.io/lopecode/notebooks/@tomlarkworthy_blank-notebook.html#view=R100(S50(@tomlarkworthy/blank-notebook),S25(@tomlarkworthy/module-selection),S25(@tomlarkworthy/claude-code-pairing))&cc=TOKEN'
67
+ 3. The notebook auto-connects — wait for the connected notification
68
+ 4. Send a welcome message via reply
69
+
70
+ ## Message formats
71
+
72
+ User chat messages:
73
+ <channel source="lopecode" type="message" notebook="..." sender="user">text</channel>
74
+
75
+ Cell changes (automatic):
76
+ <channel source="lopecode" type="cell_change" notebook="..." module="@author/mod" cell="cellName" op="upd">definition</channel>
77
+
78
+ Lifecycle:
79
+ <channel source="lopecode" type="connected" notebook="...">title</channel>
80
+ <channel source="lopecode" type="disconnected" notebook="...">title</channel>
81
+
82
+ Variable updates (when watching):
83
+ <channel source="lopecode" type="variable_update" notebook="..." name="varName" module="@author/mod">value</channel>
84
+
85
+ ## Tools
86
+
87
+ - reply: Send markdown to notebook chat
88
+ - get_variable / define_variable / delete_variable / list_variables: Interact with runtime
89
+ - watch_variable / unwatch_variable: Subscribe to reactive variable updates
90
+ - run_tests: Run test_* variables
91
+ - eval_code: Evaluate JS in browser context
92
+ - fork_notebook: Create a copy as sibling HTML file
93
+
94
+ When multiple notebooks are connected, specify notebook_id (the URL). When only one is connected, it's used automatically.`,
95
+ }
96
+ );
97
+
98
+ // --- Helper: resolve notebook_id ---
99
+ function resolveNotebook(notebookId?: string): { ws: ServerWebSocket<unknown>; url: string } | { error: string } {
100
+ if (notebookId) {
101
+ const ws = pairedConnections.get(notebookId);
102
+ if (!ws) return { error: `Notebook not connected: ${notebookId}` };
103
+ return { ws, url: notebookId };
104
+ }
105
+ // If only one notebook connected, use it
106
+ if (pairedConnections.size === 1) {
107
+ const [url, ws] = [...pairedConnections.entries()][0];
108
+ return { ws, url };
109
+ }
110
+ if (pairedConnections.size === 0) {
111
+ return { error: "No notebooks connected" };
112
+ }
113
+ const urls = [...pairedConnections.keys()].map(u => ` - ${u}`).join("\n");
114
+ return { error: `Multiple notebooks connected. Specify notebook_id:\n${urls}` };
115
+ }
116
+
117
+ // --- Helper: send command and await result ---
118
+ function sendCommand(
119
+ ws: ServerWebSocket<unknown>,
120
+ action: string,
121
+ params: Record<string, any>,
122
+ timeout = 30000
123
+ ): Promise<any> {
124
+ const id = nextCommandId();
125
+ return new Promise((resolve, reject) => {
126
+ const timer = setTimeout(() => {
127
+ pendingCommands.delete(id);
128
+ reject(new Error(`Command ${action} timed out after ${timeout}ms`));
129
+ }, timeout);
130
+
131
+ pendingCommands.set(id, { resolve, reject, timer });
132
+ ws.send(JSON.stringify({ type: "command", id, action, params }));
133
+ });
134
+ }
135
+
136
+ // --- MCP Tools ---
137
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
138
+ tools: [
139
+ {
140
+ name: "get_pairing_token",
141
+ description: "Returns the pairing token needed to connect a notebook to this channel session.",
142
+ inputSchema: { type: "object", properties: {} },
143
+ },
144
+ {
145
+ name: "reply",
146
+ description: "Send a markdown message to a notebook's chat widget.",
147
+ inputSchema: {
148
+ type: "object",
149
+ properties: {
150
+ notebook_id: { type: "string", description: "Notebook URL (optional if only one connected)" },
151
+ markdown: { type: "string", description: "Markdown content to display" },
152
+ },
153
+ required: ["markdown"],
154
+ },
155
+ },
156
+ {
157
+ name: "get_variable",
158
+ description: "Get the current value of a runtime variable.",
159
+ inputSchema: {
160
+ type: "object",
161
+ properties: {
162
+ notebook_id: { type: "string" },
163
+ name: { type: "string", description: "Variable name" },
164
+ module: { type: "string", description: "Module name (optional, defaults to main)" },
165
+ },
166
+ required: ["name"],
167
+ },
168
+ },
169
+ {
170
+ name: "define_variable",
171
+ description: "Define or redefine a runtime variable. Definition must be a function string like '() => 42' or '(x, y) => x + y'.",
172
+ inputSchema: {
173
+ type: "object",
174
+ properties: {
175
+ notebook_id: { type: "string" },
176
+ name: { type: "string", description: "Variable name" },
177
+ definition: { type: "string", description: "Function definition string, e.g. '() => 42'" },
178
+ inputs: {
179
+ type: "array",
180
+ items: { type: "string" },
181
+ description: "Array of dependency variable names (default: [])",
182
+ },
183
+ module: { type: "string", description: "Target module name (optional)" },
184
+ },
185
+ required: ["name", "definition"],
186
+ },
187
+ },
188
+ {
189
+ name: "delete_variable",
190
+ description: "Delete a variable from the runtime.",
191
+ inputSchema: {
192
+ type: "object",
193
+ properties: {
194
+ notebook_id: { type: "string" },
195
+ name: { type: "string", description: "Variable name to delete" },
196
+ module: { type: "string", description: "Module name (optional)" },
197
+ },
198
+ required: ["name"],
199
+ },
200
+ },
201
+ {
202
+ name: "list_variables",
203
+ description: "List all named variables in the runtime (or a specific module).",
204
+ inputSchema: {
205
+ type: "object",
206
+ properties: {
207
+ notebook_id: { type: "string" },
208
+ module: { type: "string", description: "Filter to specific module (optional)" },
209
+ },
210
+ },
211
+ },
212
+ {
213
+ name: "run_tests",
214
+ description: "Run test_* variables and return results.",
215
+ inputSchema: {
216
+ type: "object",
217
+ properties: {
218
+ notebook_id: { type: "string" },
219
+ filter: { type: "string", description: "Filter tests by name substring (optional)" },
220
+ timeout: { type: "number", description: "Timeout in ms (default: 30000)" },
221
+ },
222
+ },
223
+ },
224
+ {
225
+ name: "eval_code",
226
+ description: "Evaluate JavaScript code in the notebook's browser context.",
227
+ inputSchema: {
228
+ type: "object",
229
+ properties: {
230
+ notebook_id: { type: "string" },
231
+ code: { type: "string", description: "JavaScript code to evaluate" },
232
+ },
233
+ required: ["code"],
234
+ },
235
+ },
236
+ {
237
+ name: "fork_notebook",
238
+ description: "Create a copy of the notebook as a sibling HTML file. Returns the new file path.",
239
+ inputSchema: {
240
+ type: "object",
241
+ properties: {
242
+ notebook_id: { type: "string" },
243
+ suffix: { type: "string", description: "Suffix for forked file (default: timestamp)" },
244
+ },
245
+ },
246
+ },
247
+ {
248
+ name: "watch_variable",
249
+ description: "Subscribe to reactive updates for a variable. Changes are pushed as notifications.",
250
+ inputSchema: {
251
+ type: "object",
252
+ properties: {
253
+ notebook_id: { type: "string" },
254
+ name: { type: "string", description: "Variable name to watch" },
255
+ module: { type: "string", description: "Module name (optional, defaults to main)" },
256
+ },
257
+ required: ["name"],
258
+ },
259
+ },
260
+ {
261
+ name: "unwatch_variable",
262
+ description: "Unsubscribe from a watched variable.",
263
+ inputSchema: {
264
+ type: "object",
265
+ properties: {
266
+ notebook_id: { type: "string" },
267
+ name: { type: "string", description: "Variable name to unwatch" },
268
+ module: { type: "string", description: "Module name (optional)" },
269
+ },
270
+ required: ["name"],
271
+ },
272
+ },
273
+ ],
274
+ }));
275
+
276
+ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
277
+ const args = (req.params.arguments ?? {}) as Record<string, unknown>;
278
+ try {
279
+ // get_pairing_token needs no notebook connection
280
+ if (req.params.name === "get_pairing_token") {
281
+ return { content: [{ type: "text", text: PAIRING_TOKEN }] };
282
+ }
283
+
284
+ const notebookId = args.notebook_id as string | undefined;
285
+
286
+ // reply is fire-and-forget to the WebSocket
287
+ if (req.params.name === "reply") {
288
+ const target = resolveNotebook(notebookId);
289
+ if ("error" in target) return { content: [{ type: "text", text: target.error }], isError: true };
290
+ target.ws.send(JSON.stringify({ type: "reply", markdown: args.markdown as string }));
291
+ return { content: [{ type: "text", text: "sent" }] };
292
+ }
293
+
294
+ // All other tools send a command and await a result
295
+ const target = resolveNotebook(notebookId);
296
+ if ("error" in target) return { content: [{ type: "text", text: target.error }], isError: true };
297
+
298
+ let action: string;
299
+ let params: Record<string, any> = {};
300
+ let timeout = 30000;
301
+
302
+ switch (req.params.name) {
303
+ case "get_variable":
304
+ action = "get-variable";
305
+ params = { name: args.name, module: args.module || null };
306
+ break;
307
+ case "define_variable":
308
+ action = "define-variable";
309
+ params = {
310
+ name: args.name,
311
+ definition: args.definition,
312
+ inputs: (args.inputs as string[]) || [],
313
+ module: args.module || null,
314
+ };
315
+ break;
316
+ case "delete_variable":
317
+ action = "delete-variable";
318
+ params = { name: args.name, module: args.module || null };
319
+ break;
320
+ case "list_variables":
321
+ action = "list-variables";
322
+ params = { module: args.module || null };
323
+ break;
324
+ case "run_tests":
325
+ action = "run-tests";
326
+ timeout = (args.timeout as number) || 30000;
327
+ params = { filter: args.filter || null, timeout };
328
+ break;
329
+ case "eval_code":
330
+ action = "eval";
331
+ params = { code: args.code };
332
+ break;
333
+ case "fork_notebook":
334
+ action = "fork";
335
+ timeout = 120000;
336
+ params = { suffix: args.suffix || null };
337
+ break;
338
+ case "watch_variable":
339
+ action = "watch";
340
+ params = { name: args.name, module: args.module || null };
341
+ break;
342
+ case "unwatch_variable":
343
+ action = "unwatch";
344
+ params = { name: args.name, module: args.module || null };
345
+ break;
346
+ default:
347
+ return { content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }], isError: true };
348
+ }
349
+
350
+ const result = await sendCommand(target.ws, action, params, timeout);
351
+
352
+ if (!result.ok) {
353
+ return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
354
+ }
355
+
356
+ // Fork special handling: write the HTML to disk
357
+ if (action === "fork" && result.result?.html) {
358
+ const originalUrl = target.url;
359
+ let originalPath: string;
360
+ if (originalUrl.startsWith("file://")) {
361
+ originalPath = decodeURIComponent(originalUrl.replace("file://", ""));
362
+ } else {
363
+ originalPath = originalUrl;
364
+ }
365
+ const dir = dirname(originalPath);
366
+ const base = basename(originalPath, ".html");
367
+ const suffix = params.suffix || Date.now().toString();
368
+ const forkPath = join(dir, `${base}--${suffix}.html`);
369
+ await Bun.write(forkPath, result.result.html);
370
+ const forkUrl = `file://${forkPath}`;
371
+ return { content: [{ type: "text", text: `Forked to ${forkUrl}\nFile: ${forkPath}` }] };
372
+ }
373
+
374
+ // Return result as formatted text
375
+ const text = typeof result.result === "string"
376
+ ? result.result
377
+ : JSON.stringify(result.result, null, 2);
378
+ return { content: [{ type: "text", text }] };
379
+ } catch (err) {
380
+ const msg = err instanceof Error ? err.message : String(err);
381
+ return { content: [{ type: "text", text: msg }], isError: true };
382
+ }
383
+ });
384
+
385
+ // --- WebSocket Server ---
386
+ function handleWsMessage(ws: ServerWebSocket<unknown>, raw: string | Buffer) {
387
+ let msg: any;
388
+ try {
389
+ msg = JSON.parse(String(raw));
390
+ } catch {
391
+ return;
392
+ }
393
+
394
+ switch (msg.type) {
395
+ case "pair": {
396
+ if (msg.token !== PAIRING_TOKEN) {
397
+ ws.send(JSON.stringify({ type: "pair-failed", reason: "Invalid pairing token" }));
398
+ ws.close();
399
+ return;
400
+ }
401
+ const url = msg.url as string;
402
+ const title = msg.title as string || "Untitled";
403
+ pendingConnections.delete(ws);
404
+ pairedConnections.set(url, ws);
405
+ connectionMeta.set(ws, { url, title });
406
+ wsBySocket.set(ws, url);
407
+ ws.send(JSON.stringify({ type: "paired", notebook_id: url }));
408
+
409
+ // Notify Claude
410
+ void mcp.notification({
411
+ method: "notifications/claude/channel",
412
+ params: {
413
+ content: `${title} connected`,
414
+ meta: {
415
+ type: "connected",
416
+ notebook: url,
417
+ title,
418
+ },
419
+ },
420
+ });
421
+ process.stderr.write(`lopecode-channel: paired ${url}\n`);
422
+ break;
423
+ }
424
+
425
+ case "message": {
426
+ const notebookUrl = wsBySocket.get(ws);
427
+ if (!notebookUrl) return; // not paired
428
+ void mcp.notification({
429
+ method: "notifications/claude/channel",
430
+ params: {
431
+ content: msg.content as string,
432
+ meta: {
433
+ type: "message",
434
+ notebook: notebookUrl,
435
+ sender: "user",
436
+ },
437
+ },
438
+ });
439
+ break;
440
+ }
441
+
442
+ case "cell-change": {
443
+ const notebookUrl = wsBySocket.get(ws);
444
+ if (!notebookUrl) return;
445
+ const changes = msg.changes as any[];
446
+ if (!changes) return;
447
+ for (const change of changes) {
448
+ void mcp.notification({
449
+ method: "notifications/claude/channel",
450
+ params: {
451
+ content: change._definition || "",
452
+ meta: {
453
+ type: "cell_change",
454
+ notebook: notebookUrl,
455
+ module: change.module || "",
456
+ cell: change._name || "",
457
+ op: change.op || "",
458
+ },
459
+ },
460
+ });
461
+ }
462
+ break;
463
+ }
464
+
465
+ case "variable-update": {
466
+ const notebookUrl = wsBySocket.get(ws);
467
+ if (!notebookUrl) return;
468
+ void mcp.notification({
469
+ method: "notifications/claude/channel",
470
+ params: {
471
+ content: msg.error
472
+ ? `Error: ${msg.error}`
473
+ : (typeof msg.value === "string" ? msg.value : JSON.stringify(msg.value)),
474
+ meta: {
475
+ type: "variable_update",
476
+ notebook: notebookUrl,
477
+ name: msg.name || "",
478
+ module: msg.module || "",
479
+ ...(msg.error ? { error: true } : {}),
480
+ },
481
+ },
482
+ });
483
+ break;
484
+ }
485
+
486
+ case "notebook-info": {
487
+ const meta = connectionMeta.get(ws);
488
+ if (meta) {
489
+ meta.modules = msg.modules;
490
+ meta.title = msg.title || meta.title;
491
+ }
492
+ break;
493
+ }
494
+
495
+ case "command-result": {
496
+ const pending = pendingCommands.get(msg.id);
497
+ if (pending) {
498
+ clearTimeout(pending.timer);
499
+ pendingCommands.delete(msg.id);
500
+ pending.resolve({ ok: msg.ok, result: msg.result, error: msg.error });
501
+ }
502
+ break;
503
+ }
504
+ }
505
+ }
506
+
507
+ function handleWsClose(ws: ServerWebSocket<unknown>) {
508
+ pendingConnections.delete(ws);
509
+ const url = wsBySocket.get(ws);
510
+ if (url) {
511
+ const meta = connectionMeta.get(ws);
512
+ pairedConnections.delete(url);
513
+ connectionMeta.delete(ws);
514
+ wsBySocket.delete(ws);
515
+ void mcp.notification({
516
+ method: "notifications/claude/channel",
517
+ params: {
518
+ content: `${meta?.title || "Notebook"} disconnected`,
519
+ meta: {
520
+ type: "disconnected",
521
+ notebook: url,
522
+ },
523
+ },
524
+ });
525
+ process.stderr.write(`lopecode-channel: disconnected ${url}\n`);
526
+ }
527
+ }
528
+
529
+ // Connect MCP stdio transport FIRST (must happen before Bun.serve so Claude Code
530
+ // sees the channel capability during the initialization handshake)
531
+ await mcp.connect(new StdioServerTransport());
532
+
533
+ // Start WebSocket + HTTP server
534
+ try {
535
+ Bun.serve({
536
+ port: PORT,
537
+ hostname: "127.0.0.1",
538
+ fetch(req, server) {
539
+ const url = new URL(req.url);
540
+ if (url.pathname === "/ws") {
541
+ if (server.upgrade(req)) return;
542
+ return new Response("WebSocket upgrade failed", { status: 400 });
543
+ }
544
+ // Health check
545
+ if (url.pathname === "/health") {
546
+ return new Response(JSON.stringify({
547
+ paired: pairedConnections.size,
548
+ pending: pendingConnections.size,
549
+ }), { headers: { "content-type": "application/json" } });
550
+ }
551
+ // Root: redirect to blank notebook with auto-connect token
552
+ if (url.pathname === "/") {
553
+ const notebookUrl = `https://tomlarkworthy.github.io/lopecode/notebooks/@tomlarkworthy_blank-notebook.html#view=R100(S50(@tomlarkworthy/blank-notebook),S25(@tomlarkworthy/module-selection),S25(@tomlarkworthy/claude-code-pairing))&cc=${PAIRING_TOKEN}`;
554
+ return new Response(null, {
555
+ status: 302,
556
+ headers: { Location: notebookUrl },
557
+ });
558
+ }
559
+ return new Response("lopecode-channel", { status: 200 });
560
+ },
561
+ websocket: {
562
+ open(ws) {
563
+ pendingConnections.add(ws);
564
+ },
565
+ message: handleWsMessage,
566
+ close: handleWsClose,
567
+ },
568
+ });
569
+ } catch (err) {
570
+ const msg = err instanceof Error ? err.message : String(err);
571
+ if (msg.includes("EADDRINUSE") || msg.includes("address already in use") || msg.includes("port") && msg.includes("in use")) {
572
+ process.stderr.write(
573
+ `lopecode-channel: ERROR — port ${PORT} is already in use.\n` +
574
+ `Another lopecode-channel or other service is running on this port.\n` +
575
+ `Kill the existing process or set LOPECODE_PORT=<other port>.\n`
576
+ );
577
+ process.exit(1);
578
+ }
579
+ throw err;
580
+ }
581
+
582
+ process.stderr.write(`lopecode-channel: pairing token: ${PAIRING_TOKEN}\n`);
583
+ process.stderr.write(`lopecode-channel: WebSocket server on ws://127.0.0.1:${PORT}/ws\n`);
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@lopecode/channel",
3
+ "version": "0.1.0",
4
+ "description": "Pair program with Claude inside Lopecode notebooks. MCP server bridging browser notebooks and Claude Code.",
5
+ "type": "module",
6
+ "bin": {
7
+ "lopecode-channel": "./lopecode-channel.ts",
8
+ "@lopecode/channel": "./lopecode-channel.ts"
9
+ },
10
+ "files": [
11
+ "lopecode-channel.ts",
12
+ "claude-code-pairing-module.js",
13
+ "inject-module.js"
14
+ ],
15
+ "keywords": [
16
+ "mcp",
17
+ "claude",
18
+ "lopecode",
19
+ "notebook",
20
+ "pair-programming",
21
+ "observable"
22
+ ],
23
+ "author": {
24
+ "name": "Tom Larkworthy",
25
+ "url": "https://github.com/tomlarkworthy"
26
+ },
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/tomlarkworthy/lopecode-dev",
31
+ "directory": "tools/channel"
32
+ },
33
+ "homepage": "https://github.com/tomlarkworthy/lopecode-dev#readme",
34
+ "engines": {
35
+ "bun": ">=1.0.0"
36
+ },
37
+ "dependencies": {
38
+ "@modelcontextprotocol/sdk": "^1.12.1"
39
+ }
40
+ }