@lopecode/channel 0.1.4 → 0.1.5
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/README.md +205 -0
- package/claude-code-pairing-module.js +107 -45
- package/lopecode-channel.ts +140 -11
- package/package.json +3 -2
- package/sync-module.ts +3 -75
package/README.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# @lopecode/channel
|
|
2
|
+
|
|
3
|
+
Pair program with Claude Code inside [Lopecode](https://tomlarkworthy.github.io/lopecode/) notebooks. An MCP server that bridges browser-based Observable notebooks and Claude Code via WebSocket, enabling real-time collaboration: chat, define cells, watch reactive variables, run tests, and manipulate the DOM — all from inside the notebook.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Requires Bun (https://bun.sh)
|
|
9
|
+
bun install -g @lopecode/channel
|
|
10
|
+
claude mcp add lopecode bunx @lopecode/channel
|
|
11
|
+
|
|
12
|
+
# Start Claude Code with channels enabled
|
|
13
|
+
claude --dangerously-load-development-channels server:lopecode
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Then ask Claude: **"Open a lopecode notebook"**
|
|
17
|
+
|
|
18
|
+
Claude gets a pairing token, opens the notebook in your browser, and auto-connects. No manual setup needed.
|
|
19
|
+
|
|
20
|
+
## What is Lopecode?
|
|
21
|
+
|
|
22
|
+
Lopecode notebooks are self-contained HTML files built on the [Observable runtime](https://github.com/observablehq/runtime). Each notebook contains:
|
|
23
|
+
|
|
24
|
+
- **Modules** — collections of reactive cells (code units)
|
|
25
|
+
- **Embedded dependencies** — everything needed to run, in a single file
|
|
26
|
+
- **A multi-panel UI** (lopepage) — view and edit multiple modules side by side
|
|
27
|
+
|
|
28
|
+
The Observable runtime provides **reactive dataflow**: cells automatically recompute when their dependencies change, similar to a spreadsheet.
|
|
29
|
+
|
|
30
|
+
## How Pairing Works
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
Browser (Notebook) ←→ WebSocket ←→ Channel Server (Bun) ←→ MCP stdio ←→ Claude Code
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
1. The channel server starts a local WebSocket server and generates a pairing token (`LOPE-PORT-XXXX`)
|
|
37
|
+
2. Claude opens a notebook URL with `&cc=TOKEN` in the hash
|
|
38
|
+
3. The notebook auto-connects to the WebSocket server
|
|
39
|
+
4. Claude can now use MCP tools to interact with the live notebook
|
|
40
|
+
|
|
41
|
+
## Observable Cell Syntax
|
|
42
|
+
|
|
43
|
+
Lopecode cells use [Observable JavaScript](https://observablehq.com/@observablehq/observable-javascript) syntax. Here's what you need to know:
|
|
44
|
+
|
|
45
|
+
### Named Cells
|
|
46
|
+
|
|
47
|
+
```javascript
|
|
48
|
+
// A cell is a named expression. It re-runs when dependencies change.
|
|
49
|
+
x = 42
|
|
50
|
+
greeting = `Hello, ${name}!` // depends on the 'name' cell
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Markdown
|
|
54
|
+
|
|
55
|
+
```javascript
|
|
56
|
+
// Use the md tagged template literal for rich text
|
|
57
|
+
md`# My Title
|
|
58
|
+
|
|
59
|
+
Some **bold** text and a list:
|
|
60
|
+
- Item 1
|
|
61
|
+
- Item 2
|
|
62
|
+
`
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### HTML
|
|
66
|
+
|
|
67
|
+
```javascript
|
|
68
|
+
// Use htl.html for DOM elements
|
|
69
|
+
htl.html`<div style="color: red">Hello</div>`
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Imports
|
|
73
|
+
|
|
74
|
+
```javascript
|
|
75
|
+
// Import from other modules in the notebook
|
|
76
|
+
import {md} from "@tomlarkworthy/editable-md"
|
|
77
|
+
import {chart} from "@tomlarkworthy/my-visualization"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### viewof — Interactive Inputs
|
|
81
|
+
|
|
82
|
+
```javascript
|
|
83
|
+
// viewof creates two cells:
|
|
84
|
+
// "viewof slider" — the DOM element (a range input)
|
|
85
|
+
// "slider" — the current value (a number)
|
|
86
|
+
viewof slider = Inputs.range([0, 100], {label: "Value", value: 50})
|
|
87
|
+
|
|
88
|
+
// Other cells can depend on the value
|
|
89
|
+
doubled = slider * 2
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Common inputs: `Inputs.range`, `Inputs.select`, `Inputs.text`, `Inputs.toggle`, `Inputs.button`, `Inputs.table`.
|
|
93
|
+
|
|
94
|
+
### mutable — Imperative State
|
|
95
|
+
|
|
96
|
+
```javascript
|
|
97
|
+
// mutable allows imperative updates from other cells
|
|
98
|
+
mutable counter = 0
|
|
99
|
+
|
|
100
|
+
increment = {
|
|
101
|
+
mutable counter++;
|
|
102
|
+
return counter;
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Generators — Streaming Values
|
|
107
|
+
|
|
108
|
+
```javascript
|
|
109
|
+
// Yield successive values over time
|
|
110
|
+
ticker = {
|
|
111
|
+
let i = 0;
|
|
112
|
+
while (true) {
|
|
113
|
+
yield i++;
|
|
114
|
+
await Promises.delay(1000);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Block Cells
|
|
120
|
+
|
|
121
|
+
```javascript
|
|
122
|
+
// Use braces for multi-statement cells
|
|
123
|
+
result = {
|
|
124
|
+
const data = await fetch("https://api.example.com/data").then(r => r.json());
|
|
125
|
+
const filtered = data.filter(d => d.value > 10);
|
|
126
|
+
return filtered;
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Testing
|
|
131
|
+
|
|
132
|
+
Lopecode uses a reactive testing pattern. Any cell named `test_*` is a test:
|
|
133
|
+
|
|
134
|
+
```javascript
|
|
135
|
+
test_addition = {
|
|
136
|
+
const result = add(2, 2);
|
|
137
|
+
if (result !== 4) throw new Error(`Expected 4, got ${result}`);
|
|
138
|
+
return "2 + 2 = 4"; // shown on success
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
test_greeting = {
|
|
142
|
+
if (typeof greeting !== "string") throw new Error("Expected string");
|
|
143
|
+
return `greeting is: ${greeting}`;
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Tests pass if they don't throw. Use `run_tests` to execute all `test_*` cells.
|
|
148
|
+
|
|
149
|
+
## MCP Tools Reference
|
|
150
|
+
|
|
151
|
+
| Tool | Description |
|
|
152
|
+
|------|-------------|
|
|
153
|
+
| `get_pairing_token` | Get the session pairing token |
|
|
154
|
+
| `reply` | Send markdown to the notebook chat |
|
|
155
|
+
| `define_cell` | **Primary tool.** Define a cell using Observable source code |
|
|
156
|
+
| `list_cells` | List cells with names, inputs, and source |
|
|
157
|
+
| `get_variable` | Read a runtime variable's current value |
|
|
158
|
+
| `define_variable` | Low-level: define a variable with a function string |
|
|
159
|
+
| `delete_variable` | Remove a variable |
|
|
160
|
+
| `list_variables` | List all named variables |
|
|
161
|
+
| `create_module` | Create a new empty module |
|
|
162
|
+
| `delete_module` | Remove a module and all its variables |
|
|
163
|
+
| `watch_variable` | Subscribe to reactive updates |
|
|
164
|
+
| `unwatch_variable` | Unsubscribe from updates |
|
|
165
|
+
| `run_tests` | Run all `test_*` cells |
|
|
166
|
+
| `eval_code` | Run ephemeral JS in the browser (not persisted) |
|
|
167
|
+
| `export_notebook` | Save the notebook to disk (persists cells) |
|
|
168
|
+
| `fork_notebook` | Create a copy as a sibling HTML file |
|
|
169
|
+
|
|
170
|
+
### Tool Usage Tips
|
|
171
|
+
|
|
172
|
+
- **`define_cell`** is the main tool for creating content. It accepts Observable source and compiles it via the toolchain.
|
|
173
|
+
- **`eval_code`** is for throwaway actions (DOM hacks, debugging). Effects are lost on reload.
|
|
174
|
+
- **`define_variable`** is a low-level escape hatch — prefer `define_cell`.
|
|
175
|
+
- Always specify `module` when targeting a specific module.
|
|
176
|
+
- Use `export_notebook` after defining cells to persist them across reloads.
|
|
177
|
+
|
|
178
|
+
## Typical Workflow
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
1. create_module("@tomlarkworthy/my-app")
|
|
182
|
+
2. define_cell('import {md} from "@tomlarkworthy/editable-md"', module: "...")
|
|
183
|
+
3. define_cell('title = md`# My App`', module: "...")
|
|
184
|
+
4. define_cell('viewof name = Inputs.text({label: "Name"})', module: "...")
|
|
185
|
+
5. define_cell('greeting = md`Hello, **${name}**!`', module: "...")
|
|
186
|
+
6. export_notebook() // persist to disk
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Starting from a Notebook
|
|
190
|
+
|
|
191
|
+
If you see the `@tomlarkworthy/claude-code-pairing` panel in a notebook but Claude isn't connected:
|
|
192
|
+
|
|
193
|
+
1. Install Bun: https://bun.sh
|
|
194
|
+
2. Install the plugin: `bun install -g @lopecode/channel`
|
|
195
|
+
3. Register with Claude: `claude mcp add lopecode bunx @lopecode/channel`
|
|
196
|
+
4. Start Claude: `claude --dangerously-load-development-channels server:lopecode`
|
|
197
|
+
5. Ask Claude to connect — it will provide a URL with an auto-connect token
|
|
198
|
+
|
|
199
|
+
## Environment Variables
|
|
200
|
+
|
|
201
|
+
- `LOPECODE_PORT` — WebSocket server port (default: random free port)
|
|
202
|
+
|
|
203
|
+
## License
|
|
204
|
+
|
|
205
|
+
MIT
|
|
@@ -33,7 +33,7 @@ const _cc_module = function _cc_module(thisModule){return(
|
|
|
33
33
|
thisModule()
|
|
34
34
|
)};
|
|
35
35
|
|
|
36
|
-
const _cc_ws = function _cc_ws(cc_config, cc_notebook_id, cc_status, cc_messages, viewof_cc_watches, summarizeJS, observe, realize, createModule, deleteModule, lookupVariable, cc_module, runtime, invalidation){return(
|
|
36
|
+
const _cc_ws = function _cc_ws(cc_config, cc_notebook_id, cc_status, cc_messages, viewof_cc_watches, summarizeJS, observe, realize, compile, createModule, deleteModule, lookupVariable, exportToHTML, cc_module, runtime, invalidation){return(
|
|
37
37
|
(function() {
|
|
38
38
|
var port = cc_config.port, host = cc_config.host;
|
|
39
39
|
var ws = null;
|
|
@@ -131,6 +131,51 @@ const _cc_ws = function _cc_ws(cc_config, cc_notebook_id, cc_status, cc_messages
|
|
|
131
131
|
});
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
case "define-cell": {
|
|
135
|
+
var source = cmd.params.source;
|
|
136
|
+
var moduleName = cmd.params.module;
|
|
137
|
+
var mod = findModule(runtime, moduleName);
|
|
138
|
+
if (!mod) return { ok: false, error: "Module not found: " + (moduleName || "default") };
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
var compiled = compile(source);
|
|
142
|
+
if (!compiled || compiled.length === 0) return { ok: false, error: "Compilation returned no definitions" };
|
|
143
|
+
|
|
144
|
+
var definitions = [];
|
|
145
|
+
for (var ci = 0; ci < compiled.length; ci++) {
|
|
146
|
+
definitions.push(compiled[ci]._definition);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return realize(definitions, runtime).then(function(fns) {
|
|
150
|
+
var defined = [];
|
|
151
|
+
for (var di = 0; di < compiled.length; di++) {
|
|
152
|
+
var cellDef = compiled[di];
|
|
153
|
+
var fn = fns[di];
|
|
154
|
+
var cellName = cellDef._name;
|
|
155
|
+
var cellInputs = cellDef._inputs || [];
|
|
156
|
+
|
|
157
|
+
var existingVar = mod._scope.get(cellName);
|
|
158
|
+
if (existingVar) {
|
|
159
|
+
existingVar.define(cellName, cellInputs, fn);
|
|
160
|
+
} else {
|
|
161
|
+
mod.variable(ojs_observer ? ojs_observer(cellName) : {}).define(cellName, cellInputs, fn);
|
|
162
|
+
}
|
|
163
|
+
defined.push(cellName);
|
|
164
|
+
|
|
165
|
+
// Auto-watch non-internal variables
|
|
166
|
+
if (cellName && !cellName.startsWith("module ")) {
|
|
167
|
+
watchVariable(runtime, cellName, moduleName || null);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { ok: true, result: { success: true, defined: defined, module: moduleName || "default" } };
|
|
171
|
+
}).catch(function(e) {
|
|
172
|
+
return { ok: false, error: "define-cell realize failed: " + e.message };
|
|
173
|
+
});
|
|
174
|
+
} catch (e) {
|
|
175
|
+
return { ok: false, error: "define-cell compile failed: " + e.message };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
134
179
|
case "delete-variable": {
|
|
135
180
|
var name = cmd.params.name;
|
|
136
181
|
var moduleName = cmd.params.module;
|
|
@@ -163,6 +208,28 @@ const _cc_ws = function _cc_ws(cc_config, cc_notebook_id, cc_status, cc_messages
|
|
|
163
208
|
return { ok: true, result: variables };
|
|
164
209
|
}
|
|
165
210
|
|
|
211
|
+
case "list-cells": {
|
|
212
|
+
var moduleName = cmd.params.module;
|
|
213
|
+
var targetModule = findModule(runtime, moduleName);
|
|
214
|
+
if (!targetModule) return { ok: false, error: "Module not found: " + (moduleName || "default") };
|
|
215
|
+
var cells = [];
|
|
216
|
+
for (var entry of targetModule._scope) {
|
|
217
|
+
var v = entry[1];
|
|
218
|
+
var defStr = "";
|
|
219
|
+
try { defStr = String(v._definition).slice(0, 300); } catch(e) {}
|
|
220
|
+
cells.push({
|
|
221
|
+
name: entry[0],
|
|
222
|
+
inputs: (v._inputs || []).map(function(inp) { return inp._name || "?"; }),
|
|
223
|
+
definition: defStr,
|
|
224
|
+
hasValue: v._value !== undefined,
|
|
225
|
+
hasError: v._error !== undefined,
|
|
226
|
+
error: v._error ? (v._error.message || String(v._error)) : undefined
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
cells.sort(function(a, b) { return a.name.localeCompare(b.name); });
|
|
230
|
+
return { ok: true, result: cells };
|
|
231
|
+
}
|
|
232
|
+
|
|
166
233
|
case "run-tests": {
|
|
167
234
|
var filter = cmd.params.filter;
|
|
168
235
|
var timeout = cmd.params.timeout || 30000;
|
|
@@ -202,7 +269,7 @@ const _cc_ws = function _cc_ws(cc_config, cc_notebook_id, cc_status, cc_messages
|
|
|
202
269
|
}
|
|
203
270
|
|
|
204
271
|
case "fork": {
|
|
205
|
-
return handleFork(
|
|
272
|
+
return handleFork();
|
|
206
273
|
}
|
|
207
274
|
|
|
208
275
|
case "watch": {
|
|
@@ -413,24 +480,19 @@ const _cc_ws = function _cc_ws(cc_config, cc_notebook_id, cc_status, cc_messages
|
|
|
413
480
|
return { ok: true, result: { unwatched_all: true, count: keys.length } };
|
|
414
481
|
}
|
|
415
482
|
|
|
416
|
-
function handleFork(
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
});
|
|
426
|
-
return;
|
|
427
|
-
} catch (e) {
|
|
428
|
-
resolve({ ok: false, error: "Export failed: " + e.message });
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
}
|
|
483
|
+
function handleFork() {
|
|
484
|
+
if (typeof exportToHTML !== "function") {
|
|
485
|
+
return { ok: false, error: "exportToHTML not available. Does this notebook include @tomlarkworthy/exporter-2?" };
|
|
486
|
+
}
|
|
487
|
+
return Promise.resolve(exportToHTML({ mains: runtime.mains })).then(function(result) {
|
|
488
|
+
// exportToHTML returns { source: string, report: object }
|
|
489
|
+
var html = typeof result === "string" ? result : result.source;
|
|
490
|
+
if (!html || typeof html !== "string") {
|
|
491
|
+
return { ok: false, error: "Export returned no HTML source" };
|
|
432
492
|
}
|
|
433
|
-
|
|
493
|
+
return { ok: true, result: { html: html } };
|
|
494
|
+
}).catch(function(e) {
|
|
495
|
+
return { ok: false, error: "Export failed: " + e.message };
|
|
434
496
|
});
|
|
435
497
|
}
|
|
436
498
|
|
|
@@ -599,38 +661,31 @@ const _cc_watch_table = function _cc_watch_table(cc_watches, Inputs){return(
|
|
|
599
661
|
})
|
|
600
662
|
)};
|
|
601
663
|
|
|
602
|
-
const _cc_change_forwarder = function _cc_change_forwarder(cc_ws, invalidation){return(
|
|
664
|
+
const _cc_change_forwarder = function _cc_change_forwarder(cc_ws, history, invalidation){return(
|
|
603
665
|
(function() {
|
|
604
666
|
var highWaterMark = 0;
|
|
605
667
|
var initializing = true;
|
|
606
668
|
|
|
607
669
|
var interval = setInterval(function() {
|
|
608
670
|
if (!cc_ws.paired || !cc_ws.ws) return;
|
|
609
|
-
|
|
610
|
-
if (!runtime) return;
|
|
671
|
+
if (!history || !Array.isArray(history)) return;
|
|
611
672
|
|
|
612
|
-
|
|
613
|
-
if (v._name === "history" && v._value && Array.isArray(v._value)) {
|
|
614
|
-
var history = v._value;
|
|
615
|
-
var total = history.length;
|
|
673
|
+
var total = history.length;
|
|
616
674
|
|
|
617
|
-
|
|
618
|
-
|
|
675
|
+
if (initializing) { highWaterMark = total; initializing = false; return; }
|
|
676
|
+
if (total <= highWaterMark) return;
|
|
619
677
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
break;
|
|
632
|
-
}
|
|
633
|
-
}
|
|
678
|
+
var newEntries = history.slice(highWaterMark).map(function(e) {
|
|
679
|
+
return {
|
|
680
|
+
t: e.t, op: e.op, module: e.module, _name: e._name,
|
|
681
|
+
_inputs: e._inputs,
|
|
682
|
+
_definition: typeof e._definition === "function"
|
|
683
|
+
? e._definition.toString().slice(0, 500)
|
|
684
|
+
: String(e._definition || "").slice(0, 500)
|
|
685
|
+
};
|
|
686
|
+
});
|
|
687
|
+
highWaterMark = total;
|
|
688
|
+
cc_ws.ws.send(JSON.stringify({ type: "cell-change", changes: newEntries }));
|
|
634
689
|
}, 1000);
|
|
635
690
|
|
|
636
691
|
invalidation.then(function() { clearInterval(interval); });
|
|
@@ -677,7 +732,7 @@ const _cc_chat = function _cc_chat(cc_messages, cc_status, cc_ws, md, htl, Input
|
|
|
677
732
|
'<code style="background:var(--theme-background-alt);padding:2px 6px;border-radius:3px;font-size:12px;">bun install -g @lopecode/channel</code><br>' +
|
|
678
733
|
'<code style="background:var(--theme-background-alt);padding:2px 6px;border-radius:3px;font-size:12px;">claude mcp add lopecode bunx @lopecode/channel</code></li>' +
|
|
679
734
|
'<li>Start Claude Code:<br><code style="background:var(--theme-background-alt);padding:2px 6px;border-radius:3px;font-size:12px;">claude --dangerously-load-development-channels server:lopecode</code></li>' +
|
|
680
|
-
'<li>Ask Claude
|
|
735
|
+
'<li>Ask Claude for a pairing token, then paste it above</li>' +
|
|
681
736
|
'</ol>';
|
|
682
737
|
|
|
683
738
|
var container = document.createElement("div");
|
|
@@ -875,13 +930,20 @@ export default function define(runtime, observer) {
|
|
|
875
930
|
main.variable().define("cc_watches", ["Generators", "viewof cc_watches"], (G, v) => G.input(v));
|
|
876
931
|
$def("_cc_module", "viewof cc_module", ["thisModule"], _cc_module);
|
|
877
932
|
main.variable().define("cc_module", ["Generators", "viewof cc_module"], (G, v) => G.input(v));
|
|
878
|
-
$def("_cc_ws", "cc_ws", ["cc_config","cc_notebook_id","cc_status","cc_messages","viewof cc_watches","summarizeJS","observe","realize","createModule","deleteModule","lookupVariable","cc_module","runtime","invalidation"], _cc_ws);
|
|
879
|
-
$def("_cc_change_forwarder", "cc_change_forwarder", ["cc_ws","invalidation"], _cc_change_forwarder);
|
|
933
|
+
$def("_cc_ws", "cc_ws", ["cc_config","cc_notebook_id","cc_status","cc_messages","viewof cc_watches","summarizeJS","observe","realize","compile","createModule","deleteModule","lookupVariable","exportToHTML","cc_module","runtime","invalidation"], _cc_ws);
|
|
934
|
+
$def("_cc_change_forwarder", "cc_change_forwarder", ["cc_ws","history","invalidation"], _cc_change_forwarder);
|
|
880
935
|
|
|
881
936
|
// Imports
|
|
882
937
|
main.define("module @tomlarkworthy/module-map", async () => runtime.module((await import("/@tomlarkworthy/module-map.js?v=4")).default));
|
|
883
938
|
main.define("currentModules", ["module @tomlarkworthy/module-map", "@variable"], (_, v) => v.import("currentModules", _));
|
|
884
939
|
main.define("moduleMap", ["module @tomlarkworthy/module-map", "@variable"], (_, v) => v.import("moduleMap", _));
|
|
940
|
+
main.define("module @tomlarkworthy/exporter-2", async () => runtime.module((await import("/@tomlarkworthy/exporter-2.js?v=4")).default));
|
|
941
|
+
main.define("exportToHTML", ["module @tomlarkworthy/exporter-2", "@variable"], (_, v) => v.import("exportToHTML", _));
|
|
942
|
+
main.define("module @tomlarkworthy/observablejs-toolchain", async () => runtime.module((await import("/@tomlarkworthy/observablejs-toolchain.js?v=4")).default));
|
|
943
|
+
main.define("compile", ["module @tomlarkworthy/observablejs-toolchain", "@variable"], (_, v) => v.import("compile", _));
|
|
944
|
+
main.define("module @tomlarkworthy/local-change-history", async () => runtime.module((await import("/@tomlarkworthy/local-change-history.js?v=4")).default));
|
|
945
|
+
main.define("viewof history", ["module @tomlarkworthy/local-change-history", "@variable"], (_, v) => v.import("viewof history", _));
|
|
946
|
+
main.define("history", ["Generators", "viewof history"], (G, v) => G.input(v));
|
|
885
947
|
main.define("module @tomlarkworthy/runtime-sdk", async () => runtime.module((await import("/@tomlarkworthy/runtime-sdk.js?v=4")).default));
|
|
886
948
|
main.define("viewof runtime_variables", ["module @tomlarkworthy/runtime-sdk", "@variable"], (_, v) => v.import("viewof runtime_variables", _));
|
|
887
949
|
main.define("runtime_variables", ["module @tomlarkworthy/runtime-sdk", "@variable"], (_, v) => v.import("runtime_variables", _));
|
package/lopecode-channel.ts
CHANGED
|
@@ -60,6 +60,8 @@ const mcp = new Server(
|
|
|
60
60
|
},
|
|
61
61
|
instructions: `You are connected to Lopecode notebooks via the lopecode channel.
|
|
62
62
|
|
|
63
|
+
Lopecode notebooks are self-contained HTML files built on the Observable runtime. Each notebook contains modules (collections of reactive cells). The Observable runtime provides reactive dataflow: cells automatically recompute when their dependencies change, like a spreadsheet.
|
|
64
|
+
|
|
63
65
|
## Starting a lopecode notebook
|
|
64
66
|
|
|
65
67
|
When the user asks to start/open a lopecode notebook, or start a pairing/collaboration session:
|
|
@@ -71,6 +73,72 @@ When the user asks to start/open a lopecode notebook, or start a pairing/collabo
|
|
|
71
73
|
|
|
72
74
|
If channels are not enabled, tell the user to restart with: claude --channels server:lopecode
|
|
73
75
|
|
|
76
|
+
## Observable Cell Syntax
|
|
77
|
+
|
|
78
|
+
Cells use Observable JavaScript. The define_cell tool accepts this syntax directly.
|
|
79
|
+
|
|
80
|
+
### Named cells
|
|
81
|
+
x = 42
|
|
82
|
+
greeting = \`Hello, \${name}!\` // depends on 'name' cell — auto-recomputes when name changes
|
|
83
|
+
|
|
84
|
+
### Markdown
|
|
85
|
+
md\`# Title\nSome **bold** text\`
|
|
86
|
+
|
|
87
|
+
### HTML
|
|
88
|
+
htl.html\`<div style="color: red">Hello</div>\`
|
|
89
|
+
|
|
90
|
+
### Imports (from other modules in the notebook)
|
|
91
|
+
import {md} from "@tomlarkworthy/editable-md"
|
|
92
|
+
import {chart, data} from "@tomlarkworthy/my-viz"
|
|
93
|
+
|
|
94
|
+
### viewof — interactive inputs (creates TWO cells: "viewof X" for DOM, "X" for value)
|
|
95
|
+
viewof slider = Inputs.range([0, 100], {label: "Value", value: 50})
|
|
96
|
+
viewof name = Inputs.text({label: "Name"})
|
|
97
|
+
viewof choice = Inputs.select(["a", "b", "c"])
|
|
98
|
+
|
|
99
|
+
### mutable — imperative state
|
|
100
|
+
mutable counter = 0
|
|
101
|
+
// Other cells can do: mutable counter++
|
|
102
|
+
|
|
103
|
+
### Block cells (multi-statement)
|
|
104
|
+
result = {
|
|
105
|
+
const data = await fetch(url).then(r => r.json());
|
|
106
|
+
return data.filter(d => d.value > 10);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
### Generator cells (streaming values)
|
|
110
|
+
ticker = {
|
|
111
|
+
let i = 0;
|
|
112
|
+
while (true) { yield i++; await Promises.delay(1000); }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
## Testing
|
|
116
|
+
|
|
117
|
+
Any cell named test_* is a test. It passes if it doesn't throw:
|
|
118
|
+
test_addition = {
|
|
119
|
+
if (add(2, 2) !== 4) throw new Error("Expected 4");
|
|
120
|
+
return "2 + 2 = 4";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
Use run_tests to execute all test_* cells.
|
|
124
|
+
|
|
125
|
+
## Typical workflow
|
|
126
|
+
|
|
127
|
+
1. create_module("@tomlarkworthy/my-app")
|
|
128
|
+
2. define_cell('import {md} from "@tomlarkworthy/editable-md"', module: "...")
|
|
129
|
+
3. define_cell('title = md\`# My App\`', module: "...")
|
|
130
|
+
4. define_cell('viewof name = Inputs.text({label: "Name"})', module: "...")
|
|
131
|
+
5. define_cell('greeting = md\`Hello, **\${name}**!\`', module: "...")
|
|
132
|
+
6. export_notebook() to persist cells to disk
|
|
133
|
+
|
|
134
|
+
## Tool guidance
|
|
135
|
+
|
|
136
|
+
- define_cell: PRIMARY tool for creating content. Accepts Observable source, compiles via toolchain. Use for almost everything.
|
|
137
|
+
- eval_code: For throwaway/ephemeral actions (DOM hacks, debugging, location.reload()). Lost on reload. NEVER use define_cell for one-off side effects.
|
|
138
|
+
- define_variable: Low-level escape hatch with explicit function string + inputs array. Rarely needed.
|
|
139
|
+
- export_notebook: Persists all runtime state to the HTML file. Call after defining cells so they survive reloads.
|
|
140
|
+
- fork_notebook: Creates a sibling HTML copy (checkpoint).
|
|
141
|
+
|
|
74
142
|
## Message formats
|
|
75
143
|
|
|
76
144
|
User chat messages:
|
|
@@ -86,16 +154,6 @@ Lifecycle:
|
|
|
86
154
|
Variable updates (when watching):
|
|
87
155
|
<channel source="lopecode" type="variable_update" notebook="..." name="varName" module="@author/mod">value</channel>
|
|
88
156
|
|
|
89
|
-
## Tools
|
|
90
|
-
|
|
91
|
-
- reply: Send markdown to notebook chat
|
|
92
|
-
- get_variable / define_variable / delete_variable / list_variables: Interact with runtime
|
|
93
|
-
- create_module / delete_module: Create or remove modules (registered in runtime.mains, visible in moduleMap)
|
|
94
|
-
- watch_variable / unwatch_variable: Subscribe to reactive variable updates
|
|
95
|
-
- run_tests: Run test_* variables
|
|
96
|
-
- eval_code: Evaluate JS in browser context
|
|
97
|
-
- fork_notebook: Create a copy as sibling HTML file
|
|
98
|
-
|
|
99
157
|
IMPORTANT: Always specify the module parameter when calling define_variable, get_variable, etc.
|
|
100
158
|
Use the currentModules watch to identify the user's content module (not lopepage, module-selection, or claude-code-pairing).
|
|
101
159
|
When multiple notebooks are connected, specify notebook_id (the URL). When only one is connected, it's used automatically.`,
|
|
@@ -192,6 +250,24 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
192
250
|
required: ["name", "definition"],
|
|
193
251
|
},
|
|
194
252
|
},
|
|
253
|
+
{
|
|
254
|
+
name: "define_cell",
|
|
255
|
+
description:
|
|
256
|
+
"Define a cell using Observable source code. Supports full Observable syntax including imports (e.g. 'import {md} from \"@tomlarkworthy/editable-md\"'), named cells ('x = 42'), markdown ('md`# Hello`'), viewof, mutable, etc. The source is compiled via the Observable toolchain and may produce multiple runtime variables.",
|
|
257
|
+
inputSchema: {
|
|
258
|
+
type: "object",
|
|
259
|
+
properties: {
|
|
260
|
+
notebook_id: { type: "string" },
|
|
261
|
+
source: {
|
|
262
|
+
type: "string",
|
|
263
|
+
description:
|
|
264
|
+
'Observable source code, e.g. \'import {md} from "@tomlarkworthy/editable-md"\' or \'x = 42\'',
|
|
265
|
+
},
|
|
266
|
+
module: { type: "string", description: "Target module name (optional)" },
|
|
267
|
+
},
|
|
268
|
+
required: ["source"],
|
|
269
|
+
},
|
|
270
|
+
},
|
|
195
271
|
{
|
|
196
272
|
name: "delete_variable",
|
|
197
273
|
description: "Delete a variable from the runtime.",
|
|
@@ -216,6 +292,22 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
216
292
|
},
|
|
217
293
|
},
|
|
218
294
|
},
|
|
295
|
+
{
|
|
296
|
+
name: "list_cells",
|
|
297
|
+
description:
|
|
298
|
+
"List all cells in a module with their names, inputs, and definition source. More detailed than list_variables — shows the cell's dependency inputs and function definition.",
|
|
299
|
+
inputSchema: {
|
|
300
|
+
type: "object",
|
|
301
|
+
properties: {
|
|
302
|
+
notebook_id: { type: "string" },
|
|
303
|
+
module: {
|
|
304
|
+
type: "string",
|
|
305
|
+
description: "Module name (required)",
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
required: ["module"],
|
|
309
|
+
},
|
|
310
|
+
},
|
|
219
311
|
{
|
|
220
312
|
name: "run_tests",
|
|
221
313
|
description: "Run test_* variables and return results.",
|
|
@@ -240,6 +332,16 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
240
332
|
required: ["code"],
|
|
241
333
|
},
|
|
242
334
|
},
|
|
335
|
+
{
|
|
336
|
+
name: "export_notebook",
|
|
337
|
+
description: "Export/save the notebook in place. Serializes the current runtime state (all modules, cells, file attachments) back to the HTML file, overwriting it. Use this to persist cells created via define_cell.",
|
|
338
|
+
inputSchema: {
|
|
339
|
+
type: "object",
|
|
340
|
+
properties: {
|
|
341
|
+
notebook_id: { type: "string" },
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
},
|
|
243
345
|
{
|
|
244
346
|
name: "fork_notebook",
|
|
245
347
|
description: "Create a copy of the notebook as a sibling HTML file. Returns the new file path.",
|
|
@@ -348,6 +450,10 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
348
450
|
};
|
|
349
451
|
break;
|
|
350
452
|
}
|
|
453
|
+
case "define_cell":
|
|
454
|
+
action = "define-cell";
|
|
455
|
+
params = { source: args.source, module: args.module || null };
|
|
456
|
+
break;
|
|
351
457
|
case "delete_variable":
|
|
352
458
|
action = "delete-variable";
|
|
353
459
|
params = { name: args.name, module: args.module || null };
|
|
@@ -356,6 +462,10 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
356
462
|
action = "list-variables";
|
|
357
463
|
params = { module: args.module || null };
|
|
358
464
|
break;
|
|
465
|
+
case "list_cells":
|
|
466
|
+
action = "list-cells";
|
|
467
|
+
params = { module: args.module };
|
|
468
|
+
break;
|
|
359
469
|
case "run_tests":
|
|
360
470
|
action = "run-tests";
|
|
361
471
|
timeout = (args.timeout as number) || 30000;
|
|
@@ -370,6 +480,11 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
370
480
|
timeout = 120000;
|
|
371
481
|
params = { suffix: args.suffix || null };
|
|
372
482
|
break;
|
|
483
|
+
case "export_notebook":
|
|
484
|
+
action = "fork";
|
|
485
|
+
timeout = 120000;
|
|
486
|
+
params = { _save_in_place: true };
|
|
487
|
+
break;
|
|
373
488
|
case "create_module":
|
|
374
489
|
action = "create-module";
|
|
375
490
|
params = { name: args.name };
|
|
@@ -398,13 +513,27 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
398
513
|
|
|
399
514
|
// Fork special handling: write the HTML to disk
|
|
400
515
|
if (action === "fork" && result.result?.html) {
|
|
401
|
-
|
|
516
|
+
// Strip hash and query fragments before extracting the file path
|
|
517
|
+
const originalUrl = target.url.split("#")[0].split("?")[0];
|
|
402
518
|
let originalPath: string;
|
|
403
519
|
if (originalUrl.startsWith("file://")) {
|
|
404
520
|
originalPath = decodeURIComponent(originalUrl.replace("file://", ""));
|
|
405
521
|
} else {
|
|
406
522
|
originalPath = originalUrl;
|
|
407
523
|
}
|
|
524
|
+
if (params._save_in_place) {
|
|
525
|
+
// Export: overwrite the original file
|
|
526
|
+
const html = result.result.html;
|
|
527
|
+
const htmlStr = typeof html === "string" ? html : String(html);
|
|
528
|
+
if (typeof html !== "string") {
|
|
529
|
+
console.error(`[export] WARNING: html is ${typeof html}, keys: ${html && typeof html === "object" ? Object.keys(html).join(",") : "N/A"}`);
|
|
530
|
+
}
|
|
531
|
+
if (htmlStr.length < 1000) {
|
|
532
|
+
return { content: [{ type: "text", text: `Export failed: HTML content too small (${htmlStr.length} bytes). Type was: ${typeof html}` }], isError: true };
|
|
533
|
+
}
|
|
534
|
+
await Bun.write(originalPath, htmlStr);
|
|
535
|
+
return { content: [{ type: "text", text: `Exported to ${originalPath} (${(htmlStr.length / 1024 / 1024).toFixed(2)} MB)` }] };
|
|
536
|
+
}
|
|
408
537
|
const dir = dirname(originalPath);
|
|
409
538
|
const base = basename(originalPath, ".html");
|
|
410
539
|
const suffix = params.suffix || Date.now().toString();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lopecode/channel",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Pair program with Claude inside Lopecode notebooks. MCP server bridging browser notebooks and Claude Code.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"lopecode-channel.ts",
|
|
12
12
|
"claude-code-pairing-module.js",
|
|
13
|
-
"sync-module.ts"
|
|
13
|
+
"sync-module.ts",
|
|
14
|
+
"README.md"
|
|
14
15
|
],
|
|
15
16
|
"keywords": [
|
|
16
17
|
"mcp",
|
package/sync-module.ts
CHANGED
|
@@ -126,8 +126,9 @@ function inject(
|
|
|
126
126
|
const existing = html.match(scriptPattern);
|
|
127
127
|
|
|
128
128
|
if (existing) {
|
|
129
|
-
// Replace in-place using
|
|
130
|
-
|
|
129
|
+
// Replace in-place using indexOf+slice (avoids $ interpretation in String.replace)
|
|
130
|
+
const idx = html.indexOf(existing[0]);
|
|
131
|
+
html = html.slice(0, idx) + scriptBlock + html.slice(idx + existing[0].length);
|
|
131
132
|
console.log(`Updated existing ${moduleId} module`);
|
|
132
133
|
} else {
|
|
133
134
|
// Insert before bootloader
|
|
@@ -141,84 +142,11 @@ function inject(
|
|
|
141
142
|
console.log(`Inserted new ${moduleId} module`);
|
|
142
143
|
}
|
|
143
144
|
|
|
144
|
-
// Ensure module is in bootconf.json mains
|
|
145
|
-
html = ensureBootconf(html, moduleId);
|
|
146
|
-
|
|
147
145
|
writeFileSync(targetPath, html);
|
|
148
146
|
const size = (html.length / 1024 / 1024).toFixed(2);
|
|
149
147
|
console.log(`Wrote ${targetPath} (${size} MB)`);
|
|
150
148
|
}
|
|
151
149
|
|
|
152
|
-
function ensureBootconf(html: string, moduleId: string): string {
|
|
153
|
-
const bootconfPattern = 'id="bootconf.json"';
|
|
154
|
-
let bootconfScriptStart = html.lastIndexOf(bootconfPattern);
|
|
155
|
-
if (bootconfScriptStart === -1) {
|
|
156
|
-
console.warn("Could not find bootconf.json — skipping mains/hash update");
|
|
157
|
-
return html;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const next200 = html.substring(
|
|
161
|
-
bootconfScriptStart,
|
|
162
|
-
bootconfScriptStart + 200
|
|
163
|
-
);
|
|
164
|
-
if (!next200.includes("application/json")) {
|
|
165
|
-
console.warn(
|
|
166
|
-
"Found bootconf.json but it's not application/json — skipping"
|
|
167
|
-
);
|
|
168
|
-
return html;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const bootconfContentStart = html.indexOf(">", bootconfScriptStart) + 1;
|
|
172
|
-
const bootconfContentEnd = html.indexOf("</script>", bootconfContentStart);
|
|
173
|
-
const bootconfContent = html.slice(bootconfContentStart, bootconfContentEnd);
|
|
174
|
-
|
|
175
|
-
try {
|
|
176
|
-
const bootconf = JSON.parse(bootconfContent);
|
|
177
|
-
|
|
178
|
-
// Add to mains if missing
|
|
179
|
-
if (!bootconf.mains.includes(moduleId)) {
|
|
180
|
-
bootconf.mains.push(moduleId);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Update hash to include module in layout
|
|
184
|
-
const currentHash: string = bootconf.hash || "";
|
|
185
|
-
if (!currentHash.includes(moduleId.split("/").pop()!)) {
|
|
186
|
-
const modulePattern = /S(\d+)\(([^)]+)\)/g;
|
|
187
|
-
const moduleRefs: { weight: number; module: string }[] = [];
|
|
188
|
-
let m;
|
|
189
|
-
while ((m = modulePattern.exec(currentHash)) !== null) {
|
|
190
|
-
moduleRefs.push({ weight: parseInt(m[1]), module: m[2] });
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (moduleRefs.length > 0) {
|
|
194
|
-
const totalWeight = moduleRefs.reduce((sum, r) => sum + r.weight, 0);
|
|
195
|
-
const scaled = moduleRefs.map((r) => ({
|
|
196
|
-
weight: Math.round((r.weight / totalWeight) * 75),
|
|
197
|
-
module: r.module,
|
|
198
|
-
}));
|
|
199
|
-
scaled.push({ weight: 25, module: moduleId });
|
|
200
|
-
const parts = scaled
|
|
201
|
-
.map((r) => `S${r.weight}(${r.module})`)
|
|
202
|
-
.join(",");
|
|
203
|
-
bootconf.hash = `#view=R100(${parts})`;
|
|
204
|
-
} else {
|
|
205
|
-
bootconf.hash = `#view=R100(S75(@tomlarkworthy/debugger),S25(${moduleId}))`;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const newBootconfContent =
|
|
210
|
-
"\n" + JSON.stringify(bootconf, null, 2) + "\n";
|
|
211
|
-
html =
|
|
212
|
-
html.slice(0, bootconfContentStart) +
|
|
213
|
-
newBootconfContent +
|
|
214
|
-
html.slice(bootconfContentEnd);
|
|
215
|
-
} catch (e: any) {
|
|
216
|
-
console.warn("Failed to parse bootconf.json:", e.message);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
return html;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
150
|
function extractToJs(targetPath: string, moduleId: string, jsPath: string): void {
|
|
223
151
|
const html = readFileSync(targetPath, "utf8");
|
|
224
152
|
const content = extractModuleContent(html, moduleId);
|