@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.
- package/claude-code-pairing-module.js +788 -0
- package/inject-module.js +131 -0
- package/lopecode-channel.ts +583 -0
- package/package.json +40 -0
|
@@ -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
|
+
}
|