@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 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(runtime);
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(runtime) {
417
- return new Promise(function(resolve) {
418
- for (var v of runtime._variables) {
419
- if ((v._name === "_exportToHTML" || v._name === "exportToHTML") && typeof v._value === "function") {
420
- try {
421
- Promise.resolve(v._value()).then(function(html) {
422
- resolve({ ok: true, result: { html: html } });
423
- }).catch(function(e) {
424
- resolve({ ok: false, error: "Export failed: " + e.message });
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
- resolve({ ok: false, error: "_exportToHTML not found. Does this notebook include @tomlarkworthy/exporter-2?" });
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
- var runtime = window.__ojs_runtime;
610
- if (!runtime) return;
671
+ if (!history || !Array.isArray(history)) return;
611
672
 
612
- for (var v of runtime._variables) {
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
- if (initializing) { highWaterMark = total; initializing = false; return; }
618
- if (total <= highWaterMark) return;
675
+ if (initializing) { highWaterMark = total; initializing = false; return; }
676
+ if (total <= highWaterMark) return;
619
677
 
620
- var newEntries = history.slice(highWaterMark).map(function(e) {
621
- return {
622
- t: e.t, op: e.op, module: e.module, _name: e._name,
623
- _inputs: e._inputs,
624
- _definition: typeof e._definition === "function"
625
- ? e._definition.toString().slice(0, 500)
626
- : String(e._definition || "").slice(0, 500)
627
- };
628
- });
629
- highWaterMark = total;
630
- cc_ws.ws.send(JSON.stringify({ type: "cell-change", changes: newEntries }));
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 to connect, or paste a pairing token above</li>' +
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", _));
@@ -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
- const originalUrl = target.url;
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.4",
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 string replacement (preserves backslashes)
130
- html = html.replace(existing[0], scriptBlock);
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);