jupyter-link 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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -0
  3. package/bin/run +20 -0
  4. package/oclif.manifest.json +299 -0
  5. package/package.json +55 -0
  6. package/scripts/check_env.mjs +18 -0
  7. package/scripts/close_channels.mjs +16 -0
  8. package/scripts/collect_outputs.mjs +18 -0
  9. package/scripts/daemon.mjs +133 -0
  10. package/scripts/exec.mjs +116 -0
  11. package/scripts/execute_code.mjs +16 -0
  12. package/scripts/insert_cell.mjs +28 -0
  13. package/scripts/ipc_client.mjs +2 -0
  14. package/scripts/jupyter_proto.mjs +54 -0
  15. package/scripts/list_sessions.mjs +22 -0
  16. package/scripts/noop_collect_outputs.mjs +8 -0
  17. package/scripts/noop_open_channels.mjs +9 -0
  18. package/scripts/open_kernel_channels.mjs +31 -0
  19. package/scripts/read_cell.mjs +80 -0
  20. package/scripts/read_notebook.mjs +15 -0
  21. package/scripts/save_notebook.mjs +16 -0
  22. package/scripts/test_api.mjs +56 -0
  23. package/scripts/update_cell.mjs +43 -0
  24. package/scripts/util.mjs +12 -0
  25. package/scripts/write_notebook.mjs +17 -0
  26. package/src/commands/cell/insert.mjs +27 -0
  27. package/src/commands/cell/read.mjs +102 -0
  28. package/src/commands/cell/update.mjs +31 -0
  29. package/src/commands/check/env.mjs +14 -0
  30. package/src/commands/close/channels.mjs +18 -0
  31. package/src/commands/collect/outputs.mjs +20 -0
  32. package/src/commands/config/get.mjs +17 -0
  33. package/src/commands/config/set.mjs +16 -0
  34. package/src/commands/contents/read.mjs +17 -0
  35. package/src/commands/contents/write.mjs +18 -0
  36. package/src/commands/execute/code.mjs +18 -0
  37. package/src/commands/list/sessions.mjs +23 -0
  38. package/src/commands/open/kernel-channels.mjs +28 -0
  39. package/src/commands/save/notebook.mjs +17 -0
  40. package/src/lib/common.mjs +79 -0
  41. package/src/lib/daemonClient.mjs +30 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Roberto Arce
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # jupyter-link
2
+
3
+ CLI and AgentSkill to execute code in running Jupyter kernels and persist outputs back to `.ipynb` notebooks.
4
+
5
+ ## Features
6
+
7
+ - Discover running sessions and match by notebook path or name
8
+ - Read/write notebooks via Jupyter Contents API (nbformat v4)
9
+ - Insert or update code cells with agent metadata (`metadata.agent`)
10
+ - Open persistent kernel WebSocket channels for execution
11
+ - Send `execute_request` and collect iopub/shell outputs
12
+ - Map outputs to nbformat v4 format (stream, execute_result, display_data, error)
13
+ - Persistent config — set URL and token once, use everywhere
14
+
15
+ ## Requirements
16
+
17
+ - Node.js 20+
18
+ - A running Jupyter Server (JupyterLab/Notebook)
19
+
20
+ ## Installation
21
+
22
+ ### As a global CLI
23
+ ```bash
24
+ npm install -g jupyter-link
25
+ ```
26
+
27
+ ### Via npx (no install)
28
+ ```bash
29
+ echo '{}' | npx jupyter-link@0.1.0 check:env
30
+ ```
31
+
32
+ ### As an AgentSkill
33
+ ```bash
34
+ npx skills add <owner/repo>
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ ```bash
40
+ # 1. Configure connection (saved to ~/.config/jupyter-link/config.json)
41
+ echo '{"url":"http://localhost:8888","token":"your-token"}' | npx jupyter-link@0.1.0 config:set
42
+
43
+ # 2. Verify connectivity
44
+ echo '{}' | npx jupyter-link@0.1.0 check:env
45
+
46
+ # 3. List running sessions
47
+ echo '{}' | npx jupyter-link@0.1.0 list:sessions
48
+
49
+ # 4. Read cell outputs
50
+ echo '{"path":"notebook.ipynb","cells":[0,1,2]}' | npx jupyter-link@0.1.0 cell:read
51
+ ```
52
+
53
+ ## Commands
54
+
55
+ All commands read JSON from stdin and write JSON to stdout.
56
+
57
+ | Command | Description |
58
+ |---------|-------------|
59
+ | `config:set` | Save connection settings (url, token, port) |
60
+ | `config:get` | Show effective config with source per field |
61
+ | `check:env` | Verify Jupyter Server connectivity |
62
+ | `list:sessions` | List sessions, filter by path or name |
63
+ | `cell:read` | Read specific cells with outputs (preferred) |
64
+ | `cell:insert` | Insert a code cell with agent metadata |
65
+ | `cell:update` | Update cell source, outputs, execution_count |
66
+ | `contents:read` | Read full notebook JSON |
67
+ | `contents:write` | Write notebook JSON |
68
+ | `open:kernel-channels` | Open persistent WebSocket to kernel |
69
+ | `execute:code` | Send execute_request, get parent_msg_id |
70
+ | `collect:outputs` | Wait for outputs/reply/idle |
71
+ | `close:channels` | Close a channel |
72
+ | `save:notebook` | Save notebook (round-trip PUT) |
73
+
74
+ ## Configuration
75
+
76
+ Priority: environment variables > config file > defaults.
77
+
78
+ | Source | URL | Token | Daemon Port |
79
+ |--------|-----|-------|-------------|
80
+ | Env var | `JUPYTER_URL` | `JUPYTER_TOKEN` | `JUPYTER_LINK_PORT` |
81
+ | Config file | `url` | `token` | `port` |
82
+ | Default | `http://127.0.0.1:8888` | — | `32123` |
83
+
84
+ ## Tests
85
+
86
+ ```bash
87
+ npm test # Run all tests
88
+ npm run test:coverage # With coverage report
89
+ ```
90
+
91
+ ## License
92
+
93
+ MIT
package/bin/run ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '@oclif/core';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, resolve } from 'node:path';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+
8
+ run(process.argv.slice(2), { root: resolve(__dirname, '..') }).then(() => {
9
+ // ok
10
+ }).catch((err) => {
11
+ // Print minimal error and exit non-zero
12
+ const msg = (err && err.message) || String(err);
13
+ process.stderr.write(msg + '\n');
14
+ try {
15
+ // emit JSON error for AgentSkills
16
+ process.stdout.write(JSON.stringify({ error: msg, stack: err && err.stack }) + '\n');
17
+ } catch {}
18
+ process.exit(1);
19
+ });
20
+
@@ -0,0 +1,299 @@
1
+ {
2
+ "commands": {
3
+ "cell:insert": {
4
+ "aliases": [],
5
+ "args": {},
6
+ "description": "Insert a code cell with agent metadata",
7
+ "flags": {},
8
+ "hasDynamicHelp": false,
9
+ "hiddenAliases": [],
10
+ "id": "cell:insert",
11
+ "pluginAlias": "jupyter-link",
12
+ "pluginName": "jupyter-link",
13
+ "pluginType": "core",
14
+ "strict": true,
15
+ "enableJsonFlag": false,
16
+ "isESM": true,
17
+ "relativePath": [
18
+ "src",
19
+ "commands",
20
+ "cell",
21
+ "insert.mjs"
22
+ ]
23
+ },
24
+ "cell:read": {
25
+ "aliases": [],
26
+ "args": {},
27
+ "description": "Read specific cells from a notebook with their outputs, source, and metadata",
28
+ "flags": {},
29
+ "hasDynamicHelp": false,
30
+ "hiddenAliases": [],
31
+ "id": "cell:read",
32
+ "pluginAlias": "jupyter-link",
33
+ "pluginName": "jupyter-link",
34
+ "pluginType": "core",
35
+ "strict": true,
36
+ "enableJsonFlag": false,
37
+ "isESM": true,
38
+ "relativePath": [
39
+ "src",
40
+ "commands",
41
+ "cell",
42
+ "read.mjs"
43
+ ]
44
+ },
45
+ "cell:update": {
46
+ "aliases": [],
47
+ "args": {},
48
+ "description": "Update a code cell (source/outputs/execution_count/metadata)",
49
+ "flags": {},
50
+ "hasDynamicHelp": false,
51
+ "hiddenAliases": [],
52
+ "id": "cell:update",
53
+ "pluginAlias": "jupyter-link",
54
+ "pluginName": "jupyter-link",
55
+ "pluginType": "core",
56
+ "strict": true,
57
+ "enableJsonFlag": false,
58
+ "isESM": true,
59
+ "relativePath": [
60
+ "src",
61
+ "commands",
62
+ "cell",
63
+ "update.mjs"
64
+ ]
65
+ },
66
+ "check:env": {
67
+ "aliases": [],
68
+ "args": {},
69
+ "description": "Verify connectivity and basic Jupyter Server compatibility",
70
+ "flags": {},
71
+ "hasDynamicHelp": false,
72
+ "hiddenAliases": [],
73
+ "id": "check:env",
74
+ "pluginAlias": "jupyter-link",
75
+ "pluginName": "jupyter-link",
76
+ "pluginType": "core",
77
+ "strict": true,
78
+ "enableJsonFlag": false,
79
+ "isESM": true,
80
+ "relativePath": [
81
+ "src",
82
+ "commands",
83
+ "check",
84
+ "env.mjs"
85
+ ]
86
+ },
87
+ "close:channels": {
88
+ "aliases": [],
89
+ "args": {},
90
+ "description": "Close a previously opened channel_ref",
91
+ "flags": {},
92
+ "hasDynamicHelp": false,
93
+ "hiddenAliases": [],
94
+ "id": "close:channels",
95
+ "pluginAlias": "jupyter-link",
96
+ "pluginName": "jupyter-link",
97
+ "pluginType": "core",
98
+ "strict": true,
99
+ "enableJsonFlag": false,
100
+ "isESM": true,
101
+ "relativePath": [
102
+ "src",
103
+ "commands",
104
+ "close",
105
+ "channels.mjs"
106
+ ]
107
+ },
108
+ "collect:outputs": {
109
+ "aliases": [],
110
+ "args": {},
111
+ "description": "Wait for outputs/reply/idle for a parent_msg_id on a channel",
112
+ "flags": {},
113
+ "hasDynamicHelp": false,
114
+ "hiddenAliases": [],
115
+ "id": "collect:outputs",
116
+ "pluginAlias": "jupyter-link",
117
+ "pluginName": "jupyter-link",
118
+ "pluginType": "core",
119
+ "strict": true,
120
+ "enableJsonFlag": false,
121
+ "isESM": true,
122
+ "relativePath": [
123
+ "src",
124
+ "commands",
125
+ "collect",
126
+ "outputs.mjs"
127
+ ]
128
+ },
129
+ "contents:read": {
130
+ "aliases": [],
131
+ "args": {},
132
+ "description": "Read a notebook JSON via Contents API",
133
+ "flags": {},
134
+ "hasDynamicHelp": false,
135
+ "hiddenAliases": [],
136
+ "id": "contents:read",
137
+ "pluginAlias": "jupyter-link",
138
+ "pluginName": "jupyter-link",
139
+ "pluginType": "core",
140
+ "strict": true,
141
+ "enableJsonFlag": false,
142
+ "isESM": true,
143
+ "relativePath": [
144
+ "src",
145
+ "commands",
146
+ "contents",
147
+ "read.mjs"
148
+ ]
149
+ },
150
+ "contents:write": {
151
+ "aliases": [],
152
+ "args": {},
153
+ "description": "Write notebook JSON via Contents API",
154
+ "flags": {},
155
+ "hasDynamicHelp": false,
156
+ "hiddenAliases": [],
157
+ "id": "contents:write",
158
+ "pluginAlias": "jupyter-link",
159
+ "pluginName": "jupyter-link",
160
+ "pluginType": "core",
161
+ "strict": true,
162
+ "enableJsonFlag": false,
163
+ "isESM": true,
164
+ "relativePath": [
165
+ "src",
166
+ "commands",
167
+ "contents",
168
+ "write.mjs"
169
+ ]
170
+ },
171
+ "execute:code": {
172
+ "aliases": [],
173
+ "args": {},
174
+ "description": "Send execute_request on an open channel and return parent_msg_id",
175
+ "flags": {},
176
+ "hasDynamicHelp": false,
177
+ "hiddenAliases": [],
178
+ "id": "execute:code",
179
+ "pluginAlias": "jupyter-link",
180
+ "pluginName": "jupyter-link",
181
+ "pluginType": "core",
182
+ "strict": true,
183
+ "enableJsonFlag": false,
184
+ "isESM": true,
185
+ "relativePath": [
186
+ "src",
187
+ "commands",
188
+ "execute",
189
+ "code.mjs"
190
+ ]
191
+ },
192
+ "list:sessions": {
193
+ "aliases": [],
194
+ "args": {},
195
+ "description": "List sessions and optionally filter by path or name",
196
+ "flags": {},
197
+ "hasDynamicHelp": false,
198
+ "hiddenAliases": [],
199
+ "id": "list:sessions",
200
+ "pluginAlias": "jupyter-link",
201
+ "pluginName": "jupyter-link",
202
+ "pluginType": "core",
203
+ "strict": true,
204
+ "enableJsonFlag": false,
205
+ "isESM": true,
206
+ "relativePath": [
207
+ "src",
208
+ "commands",
209
+ "list",
210
+ "sessions.mjs"
211
+ ]
212
+ },
213
+ "config:get": {
214
+ "aliases": [],
215
+ "args": {},
216
+ "description": "Show effective configuration (env vars > config file > defaults)",
217
+ "flags": {},
218
+ "hasDynamicHelp": false,
219
+ "hiddenAliases": [],
220
+ "id": "config:get",
221
+ "pluginAlias": "jupyter-link",
222
+ "pluginName": "jupyter-link",
223
+ "pluginType": "core",
224
+ "strict": true,
225
+ "enableJsonFlag": false,
226
+ "isESM": true,
227
+ "relativePath": [
228
+ "src",
229
+ "commands",
230
+ "config",
231
+ "get.mjs"
232
+ ]
233
+ },
234
+ "config:set": {
235
+ "aliases": [],
236
+ "args": {},
237
+ "description": "Save Jupyter connection settings to ~/.config/jupyter-link/config.json",
238
+ "flags": {},
239
+ "hasDynamicHelp": false,
240
+ "hiddenAliases": [],
241
+ "id": "config:set",
242
+ "pluginAlias": "jupyter-link",
243
+ "pluginName": "jupyter-link",
244
+ "pluginType": "core",
245
+ "strict": true,
246
+ "enableJsonFlag": false,
247
+ "isESM": true,
248
+ "relativePath": [
249
+ "src",
250
+ "commands",
251
+ "config",
252
+ "set.mjs"
253
+ ]
254
+ },
255
+ "open:kernel-channels": {
256
+ "aliases": [],
257
+ "args": {},
258
+ "description": "Open persistent kernel WS channels and return a channel_ref",
259
+ "flags": {},
260
+ "hasDynamicHelp": false,
261
+ "hiddenAliases": [],
262
+ "id": "open:kernel-channels",
263
+ "pluginAlias": "jupyter-link",
264
+ "pluginName": "jupyter-link",
265
+ "pluginType": "core",
266
+ "strict": true,
267
+ "enableJsonFlag": false,
268
+ "isESM": true,
269
+ "relativePath": [
270
+ "src",
271
+ "commands",
272
+ "open",
273
+ "kernel-channels.mjs"
274
+ ]
275
+ },
276
+ "save:notebook": {
277
+ "aliases": [],
278
+ "args": {},
279
+ "description": "Save notebook (round-trip PUT)",
280
+ "flags": {},
281
+ "hasDynamicHelp": false,
282
+ "hiddenAliases": [],
283
+ "id": "save:notebook",
284
+ "pluginAlias": "jupyter-link",
285
+ "pluginName": "jupyter-link",
286
+ "pluginType": "core",
287
+ "strict": true,
288
+ "enableJsonFlag": false,
289
+ "isESM": true,
290
+ "relativePath": [
291
+ "src",
292
+ "commands",
293
+ "save",
294
+ "notebook.mjs"
295
+ ]
296
+ }
297
+ },
298
+ "version": "0.1.0"
299
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "jupyter-link",
3
+ "version": "0.1.0",
4
+ "description": "CLI and AgentSkill to execute code in Jupyter kernels and persist outputs to notebooks",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Roberto Arce",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/rarce/jupyter-link.git"
11
+ },
12
+ "homepage": "https://github.com/rarce/jupyter-link",
13
+ "keywords": [
14
+ "jupyter",
15
+ "notebook",
16
+ "kernel",
17
+ "agentskills",
18
+ "cli",
19
+ "execute",
20
+ "ipynb",
21
+ "nbformat"
22
+ ],
23
+ "engines": {
24
+ "node": ">=20"
25
+ },
26
+ "files": [
27
+ "bin",
28
+ "src",
29
+ "scripts",
30
+ "oclif.manifest.json"
31
+ ],
32
+ "dependencies": {
33
+ "@oclif/core": "^3.26.3"
34
+ },
35
+ "devDependencies": {
36
+ "@vitest/coverage-v8": "^1.6.1",
37
+ "vitest": "^1.5.0",
38
+ "yaml": "^2.4.1"
39
+ },
40
+ "scripts": {
41
+ "build": "npx oclif manifest",
42
+ "test": "vitest run --reporter=dot",
43
+ "test:watch": "vitest",
44
+ "test:coverage": "vitest run --coverage",
45
+ "prepublishOnly": "npm run build"
46
+ },
47
+ "bin": {
48
+ "jupyter-link": "bin/run"
49
+ },
50
+ "oclif": {
51
+ "bin": "jupyter-link",
52
+ "commands": "./src/commands",
53
+ "plugins": []
54
+ }
55
+ }
@@ -0,0 +1,18 @@
1
+ import { getConfig, httpJson, ok, fail, assertNodeVersion } from './util.mjs';
2
+
3
+ async function main() {
4
+ assertNodeVersion();
5
+ const { baseUrl, token } = getConfig();
6
+ // Try sessions and contents root
7
+ const sessions = await httpJson('GET', `${baseUrl}/api/sessions`, token).catch(e => ({ error: e.message }));
8
+ const contents = await httpJson('GET', `${baseUrl}/api/contents`, token).catch(e => ({ error: e.message }));
9
+ ok({
10
+ ok: !sessions.error && !contents.error,
11
+ sessions_ok: !sessions.error,
12
+ contents_ok: !contents.error,
13
+ details: { sessions, contents }
14
+ });
15
+ }
16
+
17
+ main().catch(fail);
18
+
@@ -0,0 +1,16 @@
1
+ import { readStdinJson, ok, fail, assertNodeVersion } from './util.mjs';
2
+ import { ensureDaemon, request } from './ipc_client.mjs';
3
+
4
+ async function main() {
5
+ assertNodeVersion();
6
+ const input = await readStdinJson();
7
+ const ref = input.channel_ref ?? input.ref;
8
+ if (!ref) throw new Error('channel_ref is required');
9
+ await ensureDaemon();
10
+ const out = await request('close', { channel_ref: ref });
11
+ if (out.error) throw new Error(out.error);
12
+ ok(out);
13
+ }
14
+
15
+ main().catch(fail);
16
+
@@ -0,0 +1,18 @@
1
+ import { readStdinJson, ok, fail, assertNodeVersion } from './util.mjs';
2
+ import { ensureDaemon, request } from './ipc_client.mjs';
3
+
4
+ async function main() {
5
+ assertNodeVersion();
6
+ const input = await readStdinJson();
7
+ const ref = input.channel_ref ?? input.ref;
8
+ const parent = input.parent_msg_id ?? input.parent_id;
9
+ if (!ref) throw new Error('channel_ref is required');
10
+ if (!parent) throw new Error('parent_msg_id is required');
11
+ await ensureDaemon();
12
+ const out = await request('collect', { channel_ref: ref, parent_msg_id: parent, timeout_s: input.timeout_s || 60 });
13
+ if (out.error) throw new Error(out.error);
14
+ ok(out);
15
+ }
16
+
17
+ main().catch(fail);
18
+
@@ -0,0 +1,133 @@
1
+ import net from 'node:net';
2
+ import crypto from 'node:crypto';
3
+ import { URL } from 'node:url';
4
+ import { mapIopubToOutput, isStatusIdle, isParent, makeExecuteRequest } from './jupyter_proto.mjs';
5
+ import { nowIso, newSessionId, getConfig } from '../src/lib/common.mjs';
6
+
7
+ const PORT = getConfig().port;
8
+ // helper functions imported from jupyter_proto.mjs
9
+
10
+ // State
11
+ const channels = new Map(); // ref -> { ws, sessionId, kernelId, url, outputsByParent }
12
+
13
+ export function wsUrlFor(baseUrl, token, kernelId, sessionId) {
14
+ const url = new URL(baseUrl);
15
+ const wsScheme = url.protocol === 'https:' ? 'wss:' : 'ws:';
16
+ const query = new URLSearchParams();
17
+ if (token) query.set('token', token);
18
+ query.set('session_id', sessionId);
19
+ const u = `${wsScheme}//${url.host}${url.pathname.replace(/\/$/, '')}/api/kernels/${kernelId}/channels?${query.toString()}`;
20
+ return u;
21
+ }
22
+
23
+ function handleOpen({ baseUrl, token, kernelId }) {
24
+ if (!baseUrl || !kernelId) throw new Error('baseUrl and kernelId are required');
25
+ const sessionId = newSessionId();
26
+ const url = wsUrlFor(baseUrl, token, kernelId, sessionId);
27
+ const WS = globalThis.WebSocket;
28
+ if (!WS) throw new Error('WebSocket API not available in Node. Use Node >=20 or enable experimental WebSocket');
29
+ const ws = new WS(url);
30
+ const ref = 'ch-' + crypto.randomBytes(6).toString('hex');
31
+ const state = { ws, sessionId, kernelId, url, outputsByParent: new Map(), ready: false };
32
+ channels.set(ref, state);
33
+ ws.on('open', () => { state.ready = true; });
34
+ ws.on('message', (data) => {
35
+ let msg; try { msg = JSON.parse(data.toString()); } catch { return; }
36
+ const parentId = msg.parent_header && msg.parent_header.msg_id;
37
+ if (!parentId) return;
38
+ let agg = state.outputsByParent.get(parentId);
39
+ if (!agg) return;
40
+ const channel = msg.channel;
41
+ const msgType = msg.header && msg.header.msg_type;
42
+ if (channel === 'iopub' && isParent(msg, parentId)) {
43
+ const out = mapIopubToOutput(msg);
44
+ if (out) agg.outputs.push(out);
45
+ if (isStatusIdle(msg)) agg.gotIdle = true;
46
+ }
47
+ if (channel === 'shell' && msgType === 'execute_reply' && isParent(msg, parentId)) {
48
+ agg.gotReply = true;
49
+ agg.status = (msg.content && msg.content.status) || agg.status;
50
+ agg.execution_count = (msg.content && msg.content.execution_count) || agg.execution_count;
51
+ }
52
+ if (agg.gotReply && agg.gotIdle && !agg.resolved) {
53
+ agg.resolved = true;
54
+ if (agg.resolve) agg.resolve();
55
+ }
56
+ });
57
+ ws.on('close', () => { state.ready = false; state.dead = true; });
58
+ ws.on('error', () => { state.ready = false; state.dead = true; });
59
+ return { channel_ref: ref, session_id: sessionId };
60
+ }
61
+
62
+ function handleExec({ channel_ref, code, allow_stdin = false, stop_on_error = true }) {
63
+ const ch = channels.get(channel_ref);
64
+ if (!ch) throw new Error('unknown channel_ref');
65
+ if (ch.dead) throw new Error('channel is closed or errored');
66
+ if (!ch.ready) throw new Error('channel not ready');
67
+ const msg = makeExecuteRequest(code, ch.sessionId, allow_stdin, stop_on_error);
68
+ const parentId = msg.header.msg_id;
69
+ ch.outputsByParent.set(parentId, { outputs: [], execution_count: null, status: 'unknown', gotReply: false, gotIdle: false, resolved: false });
70
+ ch.ws.send(JSON.stringify(msg));
71
+ return { parent_msg_id: parentId };
72
+ }
73
+
74
+ async function handleCollect({ channel_ref, parent_msg_id, timeout_s = 60 }) {
75
+ const ch = channels.get(channel_ref);
76
+ if (!ch) throw new Error('unknown channel_ref');
77
+ const agg = ch.outputsByParent.get(parent_msg_id);
78
+ if (!agg) throw new Error('unknown parent_msg_id');
79
+ let timedOut = false;
80
+ if (!(agg.gotReply && agg.gotIdle)) {
81
+ await new Promise((resolve, reject) => {
82
+ const timer = setTimeout(() => { if (agg.resolve === resolve) agg.resolve = undefined; timedOut = true; resolve(); }, timeout_s * 1000);
83
+ agg.resolve = () => { clearTimeout(timer); resolve(); };
84
+ });
85
+ }
86
+ const status = timedOut ? 'timeout' : (agg.status || (agg.gotReply ? 'ok' : 'unknown'));
87
+ // Clean up to prevent memory leak
88
+ ch.outputsByParent.delete(parent_msg_id);
89
+ return { outputs: agg.outputs, execution_count: agg.execution_count, status };
90
+ }
91
+
92
+ function handleClose({ channel_ref }) {
93
+ const ch = channels.get(channel_ref);
94
+ if (!ch) return { ok: true };
95
+ try { ch.ws.close(); } catch {}
96
+ channels.delete(channel_ref);
97
+ return { ok: true };
98
+ }
99
+
100
+ function handleList() {
101
+ const arr = [];
102
+ for (const [ref, st] of channels.entries()) arr.push({ channel_ref: ref, kernel_id: st.kernelId, url: st.url, ready: st.ready });
103
+ return { channels: arr };
104
+ }
105
+
106
+ const server = net.createServer((socket) => {
107
+ let buf = '';
108
+ socket.on('data', (chunk) => {
109
+ buf += chunk.toString('utf8');
110
+ let idx;
111
+ while ((idx = buf.indexOf('\n')) >= 0) {
112
+ const line = buf.slice(0, idx); buf = buf.slice(idx + 1);
113
+ let req; try { req = JSON.parse(line); } catch { socket.write(JSON.stringify({ error: 'bad json' }) + '\n'); continue; }
114
+ try {
115
+ let res;
116
+ switch (req.op) {
117
+ case 'ping': res = { ok: true }; break;
118
+ case 'open': res = handleOpen(req.args || {}); break;
119
+ case 'exec': res = handleExec(req.args || {}); break;
120
+ case 'collect': res = handleCollect(req.args || {}); break;
121
+ case 'close': res = handleClose(req.args || {}); break;
122
+ case 'list': res = handleList(); break;
123
+ default: res = { error: 'unknown op' };
124
+ }
125
+ Promise.resolve(res).then((out) => socket.write(JSON.stringify(out) + '\n')).catch((e) => { try { socket.write(JSON.stringify({ error: e.message || String(e) }) + '\n'); } catch {} });
126
+ } catch (e) {
127
+ socket.write(JSON.stringify({ error: e.message || String(e) }) + '\n');
128
+ }
129
+ }
130
+ });
131
+ });
132
+
133
+ server.listen(PORT, '127.0.0.1');