nebula-notebook 0.2.0 → 0.2.1

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 (100) hide show
  1. package/README.md +35 -20
  2. package/dist/assets/{errorwidget-C4r2j2DQ.js → errorwidget-Wy5ajPiM.js} +1 -1
  3. package/dist/assets/{index-Day3QcNs.js → index-B_Bs5koo.js} +1 -1
  4. package/dist/assets/index-DK4nS74r.js +829 -0
  5. package/dist/assets/{index-D5w21_Z8.js → index-DeQN03nW.js} +1 -1
  6. package/dist/assets/index-DzjpmUak.css +32 -0
  7. package/dist/assets/{index-CsHoPQy-.js → index-g6MjT-VG.js} +1 -1
  8. package/dist/assets/{services-shim-D6p_A67v.js → services-shim-BirHVsih.js} +1 -1
  9. package/dist/index.html +2 -2
  10. package/node-server/dist/auth/auth-middleware.js +22 -4
  11. package/node-server/dist/auth/auth-service.js +37 -7
  12. package/node-server/dist/cluster/client-registration.d.ts +12 -1
  13. package/node-server/dist/cluster/client-registration.js +35 -2
  14. package/node-server/dist/cluster/kernel-proxy.js +19 -3
  15. package/node-server/dist/cluster/server-registry.d.ts +9 -0
  16. package/node-server/dist/cluster/server-registry.js +26 -0
  17. package/node-server/dist/discovery/conda-locations.d.ts +73 -0
  18. package/node-server/dist/discovery/conda-locations.js +427 -0
  19. package/node-server/dist/discovery/discovery-service.d.ts +73 -4
  20. package/node-server/dist/discovery/discovery-service.js +211 -127
  21. package/node-server/dist/discovery/types.d.ts +0 -1
  22. package/node-server/dist/discovery/types.js +1 -2
  23. package/node-server/dist/environment.d.ts +12 -0
  24. package/node-server/dist/environment.js +104 -0
  25. package/node-server/dist/fs/fs-service.d.ts +31 -1
  26. package/node-server/dist/fs/fs-service.js +160 -28
  27. package/node-server/dist/fs/types.d.ts +9 -0
  28. package/node-server/dist/fs/types.js +8 -0
  29. package/node-server/dist/index.js +71 -4
  30. package/node-server/dist/kernel/kernel-service.d.ts +17 -0
  31. package/node-server/dist/kernel/kernel-service.js +72 -4
  32. package/node-server/dist/kernel/kernelspec.d.ts +27 -0
  33. package/node-server/dist/kernel/kernelspec.js +75 -4
  34. package/node-server/dist/notebook/headless-handler.d.ts +10 -0
  35. package/node-server/dist/notebook/headless-handler.js +55 -5
  36. package/node-server/dist/notebook/undoRedoManager.d.ts +8 -0
  37. package/node-server/dist/notebook/undoRedoManager.js +39 -16
  38. package/node-server/dist/private-tmp.d.ts +14 -0
  39. package/node-server/dist/private-tmp.js +70 -0
  40. package/node-server/dist/routes/autocomplete.d.ts +19 -0
  41. package/node-server/dist/routes/autocomplete.js +294 -0
  42. package/node-server/dist/routes/fs.js +21 -4
  43. package/node-server/dist/routes/kernel.js +45 -2
  44. package/node-server/dist/routes/notebook.js +8 -2
  45. package/node-server/dist/routes/python.d.ts +1 -2
  46. package/node-server/dist/routes/python.js +139 -3
  47. package/node-server/dist/scheduler/allocation-service.d.ts +20 -0
  48. package/node-server/dist/scheduler/allocation-service.js +124 -8
  49. package/node-server/dist/scheduler/mock-scheduler.js +7 -3
  50. package/node-server/dist/scheduler/slurm-scheduler.js +21 -8
  51. package/node-server/dist/scheduler/types.d.ts +3 -2
  52. package/node-server/dist/terminal/agent-registry.d.ts +87 -0
  53. package/node-server/dist/terminal/agent-registry.js +246 -0
  54. package/node-server/dist/terminal/binding-store.d.ts +57 -0
  55. package/node-server/dist/terminal/binding-store.js +0 -0
  56. package/node-server/dist/terminal/pty-manager.d.ts +23 -6
  57. package/node-server/dist/terminal/pty-manager.js +130 -19
  58. package/node-server/dist/terminal/server.js +112 -21
  59. package/node-server/dist/terminal/types.d.ts +2 -0
  60. package/node-server/package.json +1 -0
  61. package/node_modules/nebula-autocomplete/README.md +145 -0
  62. package/node_modules/nebula-autocomplete/dist/backends/claude.d.ts +69 -0
  63. package/node_modules/nebula-autocomplete/dist/backends/claude.js +449 -0
  64. package/node_modules/nebula-autocomplete/dist/backends/codex.d.ts +45 -0
  65. package/node_modules/nebula-autocomplete/dist/backends/codex.js +108 -0
  66. package/node_modules/nebula-autocomplete/dist/client/fetcher.d.ts +16 -0
  67. package/node_modules/nebula-autocomplete/dist/client/fetcher.js +41 -0
  68. package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.d.ts +43 -0
  69. package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.js +175 -0
  70. package/node_modules/nebula-autocomplete/dist/core/engine.d.ts +17 -0
  71. package/node_modules/nebula-autocomplete/dist/core/engine.js +135 -0
  72. package/node_modules/nebula-autocomplete/dist/core/lru.d.ts +7 -0
  73. package/node_modules/nebula-autocomplete/dist/core/lru.js +26 -0
  74. package/node_modules/nebula-autocomplete/dist/core/prompt.d.ts +12 -0
  75. package/node_modules/nebula-autocomplete/dist/core/prompt.js +0 -0
  76. package/node_modules/nebula-autocomplete/dist/core/text.d.ts +53 -0
  77. package/node_modules/nebula-autocomplete/dist/core/text.js +169 -0
  78. package/node_modules/nebula-autocomplete/dist/index.d.ts +7 -0
  79. package/node_modules/nebula-autocomplete/dist/index.js +5 -0
  80. package/node_modules/nebula-autocomplete/dist/server/fastify.d.ts +40 -0
  81. package/node_modules/nebula-autocomplete/dist/server/fastify.js +52 -0
  82. package/node_modules/nebula-autocomplete/dist/transport.d.ts +55 -0
  83. package/node_modules/nebula-autocomplete/dist/transport.js +66 -0
  84. package/node_modules/nebula-autocomplete/dist/types.d.ts +103 -0
  85. package/node_modules/nebula-autocomplete/dist/types.js +1 -0
  86. package/node_modules/nebula-autocomplete/package.json +58 -0
  87. package/node_modules/nebula-autocomplete/server.d.ts +3 -0
  88. package/node_modules/nebula-autocomplete/server.js +3 -0
  89. package/package.json +17 -5
  90. package/scripts/postinstall.cjs +25 -5
  91. package/dist/assets/index-7-YBurka.js +0 -716
  92. package/dist/assets/index-CFBUnxSZ.css +0 -32
  93. package/node-server/dist/llm/index.d.ts +0 -5
  94. package/node-server/dist/llm/index.js +0 -21
  95. package/node-server/dist/llm/llm-service.d.ts +0 -77
  96. package/node-server/dist/llm/llm-service.js +0 -454
  97. package/node-server/dist/llm/types.d.ts +0 -40
  98. package/node-server/dist/llm/types.js +0 -15
  99. package/node-server/dist/routes/llm.d.ts +0 -8
  100. package/node-server/dist/routes/llm.js +0 -105
@@ -0,0 +1,294 @@
1
+ "use strict";
2
+ /**
3
+ * AI autocomplete API — inline code completions for notebook cells.
4
+ *
5
+ * Routes live under /api and are auth-protected like the rest of the API.
6
+ * Backed by the nebula-autocomplete engine, which drives a Claude Code / Codex
7
+ * CLI using the user's own subscription. The CLI can run in one of two places:
8
+ *
9
+ * - on THIS server (default) — when the Nebula host itself has claude/codex
10
+ * (a local install, or a login node where the CLIs are available);
11
+ * - on the USER'S machine over the reverse SSH tunnel ("run on my machine") —
12
+ * for remote Nebula where the user would rather use their own laptop's CLI +
13
+ * subscription. See the `transport` field on the completion request.
14
+ *
15
+ * Engines are created lazily and cached per (backend, transport) so a warm pool
16
+ * is reused across turns; disposed on server shutdown.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.disposeAutocompleteEngines = disposeAutocompleteEngines;
20
+ exports.default = autocompleteRoutes;
21
+ const child_process_1 = require("child_process");
22
+ const environment_1 = require("../environment");
23
+ const nebula_autocomplete_1 = require("nebula-autocomplete");
24
+ const server_1 = require("nebula-autocomplete/server");
25
+ const engines = new Map();
26
+ /**
27
+ * Async command runner. NEVER use spawnSync here: these probes run ssh calls
28
+ * that can take 10+ seconds, and spawnSync freezes Node's event loop — which
29
+ * stalls EVERYTHING the server does (terminal WS input, API responses), felt
30
+ * as "the whole app is unresponsive for ~10s after a refresh".
31
+ */
32
+ function runCmd(cmd, args, timeoutMs) {
33
+ return new Promise((resolve) => {
34
+ let out = '';
35
+ let done = false;
36
+ const finish = (status) => { if (!done) {
37
+ done = true;
38
+ resolve({ status, stdout: out });
39
+ } };
40
+ let proc;
41
+ try {
42
+ proc = (0, child_process_1.spawn)(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] });
43
+ }
44
+ catch {
45
+ return finish(null);
46
+ }
47
+ const timer = setTimeout(() => { try {
48
+ proc.kill('SIGKILL');
49
+ }
50
+ catch { /* */ } finish(null); }, timeoutMs);
51
+ proc.stdout?.on('data', (d) => (out += d.toString()));
52
+ proc.on('error', () => { clearTimeout(timer); finish(null); });
53
+ proc.on('exit', (code) => { clearTimeout(timer); finish(code); });
54
+ });
55
+ }
56
+ async function binaryAvailable(name) {
57
+ return (await runCmd('which', [name], 3000)).status === 0;
58
+ }
59
+ /** Parse + validate a client-supplied transport (untrusted input). */
60
+ function parseTransport(raw) {
61
+ if (!raw || typeof raw !== 'object')
62
+ return null;
63
+ const t = raw;
64
+ const port = Number(t.port);
65
+ const user = typeof t.user === 'string' ? t.user.trim() : '';
66
+ const bin = typeof t.bin === 'string' ? t.bin.trim() : '';
67
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
68
+ return null;
69
+ if (!user || !/^[A-Za-z0-9._-]+$/.test(user))
70
+ return null;
71
+ if (!bin.startsWith('/'))
72
+ return null; // must be an absolute path
73
+ const host = typeof t.host === 'string' && t.host.trim() ? t.host.trim() : 'localhost';
74
+ return { port, user, bin, host };
75
+ }
76
+ function toSsh(t) {
77
+ return { kind: 'ssh', host: t.host ?? 'localhost', port: t.port, user: t.user, remoteBin: t.bin };
78
+ }
79
+ function sanitizeTuning(name, raw) {
80
+ // Experimental tuning surface: any shell-safe model id is allowed (it's the
81
+ // user's own CLI/subscription; a bad id just fails the worker's warmup
82
+ // visibly). Pattern-gated so nothing hostile reaches the spawn argv.
83
+ let model = typeof raw.model === 'string' ? raw.model.trim().toLowerCase() : '';
84
+ if (name === 'claude') {
85
+ // Default sonnet (was haiku): BENCHMARKED (eval/bench.mjs, 54 samples/config,
86
+ // interleaved) — sonnet ttfb p50 1086ms vs haiku 987ms (+10%, fixed API
87
+ // overhead dominates short completions) while fixing haiku's context
88
+ // failures (100% vs 94% ctx-accuracy). Thinking: on haiku it engages
89
+ // every turn (probed: ~1-1.5k chars) = 3-8s of non-streaming dead air; on
90
+ // sonnet MAX_THINKING_TOKENS never engages at all for completion-sized
91
+ // prompts (probed: 0 thinking chars, 4/4), so it can't help there either.
92
+ model = /^[a-z0-9][a-z0-9._:-]{0,63}$/.test(model) ? model : 'sonnet';
93
+ }
94
+ else
95
+ model = ''; // codex: fixed model
96
+ const rawTokens = Number(raw.thinkingTokens);
97
+ const thinkingTokens = Number.isFinite(rawTokens)
98
+ ? Math.min(16_000, Math.max(0, Math.round(rawTokens)))
99
+ : raw.thinking === true ? 4096 : 0;
100
+ return { model, thinkingTokens };
101
+ }
102
+ function engineKey(name, t, tune) {
103
+ const base = t ? `${name}:ssh:${t.user}@${t.host ?? 'localhost'}:${t.port}:${t.bin}` : name;
104
+ return `${base}:${tune.model}:${tune.thinkingTokens}`;
105
+ }
106
+ function getEngine(name, t, tune) {
107
+ const key = engineKey(name, t, tune);
108
+ let engine = engines.get(key);
109
+ if (!engine) {
110
+ const backend = name === 'codex'
111
+ ? new nebula_autocomplete_1.CodexBackend({
112
+ codexHome: t ? undefined : process.env.NEBULA_AUTOCOMPLETE_CODEX_HOME,
113
+ transport: t ? toSsh(t) : { kind: 'local' },
114
+ })
115
+ : new nebula_autocomplete_1.ClaudeBackend({
116
+ transport: t ? toSsh(t) : { kind: 'local' },
117
+ ...(tune.model ? { model: tune.model } : {}),
118
+ maxThinkingTokens: tune.thinkingTokens,
119
+ });
120
+ // contextBudget 20000: field testing showed 20k chars of context leaves
121
+ // latency essentially unchanged (worker history is prefix-cached; only the
122
+ // new message is uncached) — an earlier small A/B suggesting otherwise was
123
+ // cache/variance artifact. 20k covers the whole notebook in most cases;
124
+ // per-request overrides (Advanced settings) are clamped inside the engine.
125
+ engine = new nebula_autocomplete_1.AutocompleteEngine({ backend, contextBudget: 20_000 });
126
+ engines.set(key, engine);
127
+ }
128
+ return engine;
129
+ }
130
+ function disposeAutocompleteEngines() {
131
+ for (const engine of engines.values())
132
+ engine.dispose();
133
+ engines.clear();
134
+ }
135
+ /**
136
+ * Discover the absolute path of a provider CLI on the user's machine over the
137
+ * reverse tunnel (PATH isn't set for a non-interactive ssh, so running the CLI
138
+ * needs an absolute path). Uses a login shell + a sentinel so rc-file noise
139
+ * doesn't corrupt the result. Best-effort; returns null on any failure.
140
+ */
141
+ async function discoverRemoteBin(port, user, provider, host = 'localhost') {
142
+ if (!Number.isInteger(port) || !/^[A-Za-z0-9._-]+$/.test(user))
143
+ return null;
144
+ // `whence -p` FIRST: on the user's machine `claude`/`codex` is typically a zsh
145
+ // FUNCTION, so `command -v` returns the function name (not a path) and would
146
+ // short-circuit. `whence -p` (zsh) resolves the actual binary; `command -v` is
147
+ // the fallback for non-zsh login shells (where `whence` doesn't exist).
148
+ const remote = `$SHELL -lic 'printf "NB_BIN=%s\\n" "$(whence -p ${provider} 2>/dev/null || command -v ${provider} 2>/dev/null)"' 2>/dev/null`;
149
+ const res = await runCmd('ssh', ['-p', String(port), '-o', 'ProxyCommand=none', '-o', 'StrictHostKeyChecking=accept-new',
150
+ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', `${user}@${host}`, remote], 12000);
151
+ if (res.status !== 0 || !res.stdout)
152
+ return null;
153
+ const line = res.stdout.split('\n').find((l) => l.startsWith('NB_BIN='));
154
+ const bin = line ? line.slice('NB_BIN='.length).trim() : '';
155
+ return bin.startsWith('/') ? bin : null;
156
+ }
157
+ /**
158
+ * Actually RUN a provider CLI on THIS server with a trivial prompt to tell
159
+ * "installed" from "usable" (logged in). Bounded + best-effort. Returns a status
160
+ * the diagnostics UI can render.
161
+ */
162
+ async function checkCliUsable(name) {
163
+ if (!(await binaryAvailable(name)))
164
+ return { installed: false, usable: false, detail: 'not installed on this server' };
165
+ return new Promise((resolve) => {
166
+ const cfg = `/tmp/nebula-diag-${name}-${process.pid}-${Math.round(process.hrtime()[1])}`;
167
+ const args = name === 'claude'
168
+ ? ['-p', 'reply with just: ok', '--model', 'haiku']
169
+ : ['exec', '--json', '--skip-git-repo-check', '--sandbox', 'read-only', '--color', 'never', '--ephemeral', '-m', 'gpt-5.6-luna', 'reply ok'];
170
+ const env = { ...process.env, CLAUDE_CODE_MAX_OUTPUT_TOKENS: '64', CLAUDE_CONFIG_DIR: cfg };
171
+ let out = '', err = '', done = false;
172
+ const finish = (r) => { if (!done) {
173
+ done = true;
174
+ resolve(r);
175
+ } };
176
+ let proc;
177
+ try {
178
+ proc = (0, child_process_1.spawn)(name, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
179
+ }
180
+ catch {
181
+ return finish({ installed: true, usable: false, detail: 'failed to launch' });
182
+ }
183
+ const timer = setTimeout(() => { try {
184
+ proc.kill('SIGKILL');
185
+ }
186
+ catch { /* */ } finish({ installed: true, usable: false, detail: 'timed out (no response in 25s)' }); }, 25000);
187
+ proc.stdout?.on('data', (d) => (out += d.toString()));
188
+ proc.stderr?.on('data', (d) => (err += d.toString()));
189
+ proc.on('error', () => { clearTimeout(timer); finish({ installed: true, usable: false, detail: 'failed to launch' }); });
190
+ proc.on('exit', (code) => {
191
+ clearTimeout(timer);
192
+ const blob = (out + '\n' + err).toLowerCase();
193
+ const authIssue = /log ?in|logged in|authenticat|unauthor|oauth|api key|credit balance|subscription|setup-token|not authenticated/.test(blob);
194
+ if (authIssue)
195
+ return finish({ installed: true, usable: false, detail: 'not logged in on this server' });
196
+ if (code === 0 && out.trim())
197
+ return finish({ installed: true, usable: true, detail: 'logged in' });
198
+ return finish({ installed: true, usable: false, detail: err.trim().slice(-160) || `exited ${code}` });
199
+ });
200
+ });
201
+ }
202
+ async function autocompleteRoutes(fastify) {
203
+ /**
204
+ * Which CLI backends are usable on THIS server (for the settings UI and the
205
+ * editor's ghost-text gate). "Usable" means an actual trivial turn succeeds
206
+ * (logged in), not merely that the binary exists — a binary without
207
+ * credentials previously reported true here, so the UI enabled ghost text
208
+ * that could only ever produce "Not logged in" errors. The real probe costs
209
+ * a CLI round trip, so results are cached with a TTL.
210
+ */
211
+ let statusCache = null;
212
+ let statusProbe = null;
213
+ const STATUS_TTL_MS = 5 * 60_000;
214
+ fastify.get('/autocomplete/status', async (_req, reply) => {
215
+ if (!statusCache || Date.now() - statusCache.at > STATUS_TTL_MS) {
216
+ if (!statusProbe) {
217
+ statusProbe = Promise.all([checkCliUsable('claude'), checkCliUsable('codex')])
218
+ .then(([claude, codex]) => {
219
+ const backends = { claude: claude.usable, codex: codex.usable };
220
+ statusCache = { at: Date.now(), backends };
221
+ return backends;
222
+ })
223
+ .finally(() => { statusProbe = null; });
224
+ }
225
+ await statusProbe;
226
+ }
227
+ return reply.send({
228
+ enabled: true,
229
+ backends: statusCache.backends,
230
+ });
231
+ });
232
+ /**
233
+ * Resolve the user's local CLI paths over the reverse tunnel, so the client
234
+ * can enable "run on my machine". Body: { port, user, host? }.
235
+ * Returns { ok, reachable, claude, codex }.
236
+ */
237
+ fastify.post('/autocomplete/probe-remote', async (req, reply) => {
238
+ const body = (req.body ?? {});
239
+ const port = Number(body.port);
240
+ const user = typeof body.user === 'string' ? body.user.trim() : '';
241
+ const host = typeof body.host === 'string' && body.host.trim() ? body.host.trim() : 'localhost';
242
+ if (!Number.isInteger(port) || port <= 0 || !user) {
243
+ return reply.code(400).send({ ok: false, error: 'port and user required' });
244
+ }
245
+ const [claude, codex] = await Promise.all([
246
+ discoverRemoteBin(port, user, 'claude', host),
247
+ discoverRemoteBin(port, user, 'codex', host),
248
+ ]);
249
+ return reply.send({ ok: true, reachable: claude !== null || codex !== null, claude, codex });
250
+ });
251
+ /**
252
+ * Comprehensive diagnostics for the settings "AI" tab. Body: { port?, user? }.
253
+ * Reports the environment, whether this server's CLIs are usable (logged in),
254
+ * and — if tunnel params are given — whether the user's machine is reachable
255
+ * and which CLIs resolve there.
256
+ */
257
+ fastify.post('/autocomplete/diagnose', async (req, reply) => {
258
+ const body = (req.body ?? {});
259
+ const port = Number(body.port);
260
+ const user = typeof body.user === 'string' ? body.user.trim() : '';
261
+ const host = typeof body.host === 'string' && body.host.trim() ? body.host.trim() : 'localhost';
262
+ const hasTunnel = Number.isInteger(port) && port > 0 && !!user;
263
+ const [claude, codex] = await Promise.all([checkCliUsable('claude'), checkCliUsable('codex')]);
264
+ let tunnel = null;
265
+ if (hasTunnel) {
266
+ const [rc, rx] = await Promise.all([
267
+ discoverRemoteBin(port, user, 'claude', host),
268
+ discoverRemoteBin(port, user, 'codex', host),
269
+ ]);
270
+ tunnel = { configured: true, reachable: rc !== null || rx !== null, claude: rc, codex: rx };
271
+ }
272
+ return reply.send({ environment: (0, environment_1.getEnvironment)(), server: { claude, codex }, tunnel });
273
+ });
274
+ /** POST /autocomplete — SSE stream. Body may include `transport` to run the
275
+ * CLI on the user's machine instead of this server. */
276
+ (0, server_1.registerAutocompleteRoute)(fastify, (req) => {
277
+ const raw = req;
278
+ const name = req.backend === 'codex' ? 'codex' : 'claude';
279
+ const t = parseTransport(raw.transport);
280
+ return getEngine(name, t, sanitizeTuning(name, raw));
281
+ });
282
+ // Pre-warm the default local engine at server start: the first completion
283
+ // after a server (re)start otherwise pays engine creation + pool spawn +
284
+ // warmup turns on the user's keystroke (measured ~19s cold). Booting it
285
+ // here moves that entirely off the request path. Best-effort; claude only
286
+ // (the default backend — codex spawns per-request by design).
287
+ void binaryAvailable('claude').then((ok) => {
288
+ if (ok)
289
+ getEngine('claude', null, { model: 'sonnet', thinkingTokens: 0 });
290
+ }).catch(() => { });
291
+ fastify.addHook('onClose', async () => {
292
+ disposeAutocompleteEngines();
293
+ });
294
+ }
@@ -41,7 +41,7 @@ exports.default = fsRoutes;
41
41
  const fs_service_1 = require("../fs/fs-service");
42
42
  Object.defineProperty(exports, "fsService", { enumerable: true, get: function () { return fs_service_1.fsService; } });
43
43
  const path = __importStar(require("path"));
44
- const os = __importStar(require("os"));
44
+ const private_tmp_1 = require("../private-tmp");
45
45
  const nodeFs = __importStar(require("fs"));
46
46
  async function fsRoutes(fastify) {
47
47
  /**
@@ -50,7 +50,7 @@ async function fsRoutes(fastify) {
50
50
  fastify.get('/fs/list', async (request, reply) => {
51
51
  try {
52
52
  const dirPath = request.query.path || '~';
53
- const result = fs_service_1.fsService.listDirectory(dirPath);
53
+ const result = await fs_service_1.fsService.listDirectory(dirPath);
54
54
  return reply.send(result);
55
55
  }
56
56
  catch (err) {
@@ -319,10 +319,27 @@ async function fsRoutes(fastify) {
319
319
  '.zip': 'application/zip',
320
320
  };
321
321
  const contentType = mimeTypes[extension] || 'application/octet-stream';
322
+ // Inline viewing (?inline=1): hand the file to the browser's own viewer
323
+ // in a new tab instead of forcing a download. Restricted to an allowlist
324
+ // of types the browser renders SAFELY as data (PDF/images/media/text).
325
+ // HTML is intentionally excluded — served inline same-origin, its JS
326
+ // could read the Nebula auth token; it goes through the trust-gated
327
+ // /?html= view instead. nosniff stops the browser from re-interpreting a
328
+ // mislabeled file as something executable.
329
+ const wantInline = request.query.inline === '1' || request.query.inline === 'true';
330
+ const INLINE_OK = new Set([
331
+ '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico', '.avif',
332
+ '.mp4', '.webm', '.mov', '.m4v', '.ogv',
333
+ '.mp3', '.wav', '.ogg', '.oga', '.flac', '.m4a', '.aac',
334
+ '.txt', '.csv', '.tsv', '.log', '.json', '.md',
335
+ ]);
336
+ const inline = wantInline && INLINE_OK.has(extension);
337
+ const disposition = inline ? 'inline' : 'attachment';
322
338
  const stream = nodeFs.createReadStream(normalizedPath);
323
339
  return reply
324
340
  .header('Content-Type', contentType)
325
- .header('Content-Disposition', `attachment; filename="${filename}"`)
341
+ .header('X-Content-Type-Options', 'nosniff')
342
+ .header('Content-Disposition', `${disposition}; filename="${filename}"`)
326
343
  .header('Content-Length', stat.size)
327
344
  .send(stream);
328
345
  }
@@ -357,7 +374,7 @@ async function fsRoutes(fastify) {
357
374
  return reply.code(400).send({ detail: 'path is required' });
358
375
  }
359
376
  // Save the uploaded file to a temp location first
360
- const tmpPath = path.join(os.tmpdir(), `upload-${Date.now()}-${data.filename}`);
377
+ const tmpPath = path.join((0, private_tmp_1.privateTmpDir)('uploads'), `upload-${Date.now()}-${path.basename(data.filename || 'file')}`);
361
378
  const writeStream = nodeFs.createWriteStream(tmpPath);
362
379
  await new Promise((resolve, reject) => {
363
380
  data.file.pipe(writeStream);
@@ -45,6 +45,8 @@ const path = __importStar(require("path"));
45
45
  const os = __importStar(require("os"));
46
46
  const kernel_service_1 = require("../kernel/kernel-service");
47
47
  const kernelspec_1 = require("../kernel/kernelspec");
48
+ const discovery_service_1 = require("../discovery/discovery-service");
49
+ const types_1 = require("../discovery/types");
48
50
  const fs_service_1 = require("../fs/fs-service");
49
51
  const operation_router_1 = require("../notebook/operation-router");
50
52
  const kernel_proxy_1 = require("../cluster/kernel-proxy");
@@ -109,7 +111,11 @@ kernelService.initialize().catch(err => {
109
111
  console.error('Failed to initialize kernel service:', err);
110
112
  });
111
113
  function buildKernelMetadata(kernelName) {
112
- const spec = (0, kernelspec_1.getKernelSpec)(kernelName);
114
+ // env: kernels have no on-disk spec synthesize one, preferring the
115
+ // discovery cache's label ("Python 3.10 (conda: hypir)") over the raw path.
116
+ const envPython = (0, kernelspec_1.envKernelPythonPath)(kernelName);
117
+ const cachedLabel = envPython ? discovery_service_1.pythonDiscovery.getFromCache()?.[envPython]?.displayName : undefined;
118
+ const spec = (0, kernelspec_1.resolveKernelSpec)(kernelName, cachedLabel);
113
119
  return {
114
120
  kernelspec: {
115
121
  name: kernelName,
@@ -118,6 +124,20 @@ function buildKernelMetadata(kernelName) {
118
124
  },
119
125
  };
120
126
  }
127
+ /**
128
+ * Map provisioning failures to structured responses (matching routes/python.ts)
129
+ * so the UI can branch on `code` — e.g. open the ipykernel install prompt —
130
+ * instead of parsing error prose. Returns true if the error was handled.
131
+ */
132
+ function sendProvisionError(reply, err) {
133
+ if (!(err instanceof types_1.KernelProvisionError))
134
+ return false;
135
+ const status = err.code === 'python_not_found' ? 404
136
+ : err.code === 'externally_managed' || err.code === 'needs_ipykernel' ? 422
137
+ : 500;
138
+ reply.code(status).send({ detail: err.message, code: err.code, install_hint: err.installHint });
139
+ return true;
140
+ }
121
141
  async function persistNotebookKernelMetadata(filePath, kernelName) {
122
142
  // Best-effort: persisting the kernel choice into the notebook's metadata is a
123
143
  // secondary side-effect. If it fails (e.g. the notebook file doesn't exist yet,
@@ -498,6 +518,8 @@ async function kernelRoutes(fastify) {
498
518
  return reply.send({ session_id: sessionId, kernel_name, server_id: localServerId, mtime: notebookMtime });
499
519
  }
500
520
  catch (err) {
521
+ if (sendProvisionError(reply, err))
522
+ return reply;
501
523
  const message = err instanceof Error ? err.message : 'Unknown error';
502
524
  return reply.code(500).send({ detail: message });
503
525
  }
@@ -526,7 +548,7 @@ async function kernelRoutes(fastify) {
526
548
  */
527
549
  fastify.post('/kernels/for-file', async (request, reply) => {
528
550
  try {
529
- const { file_path, kernel_name = 'python3', server_id, client_origin } = request.body;
551
+ const { file_path, kernel_name, server_id, client_origin } = request.body;
530
552
  if (!file_path) {
531
553
  return reply.code(400).send({ detail: 'file_path is required' });
532
554
  }
@@ -548,6 +570,25 @@ async function kernelRoutes(fastify) {
548
570
  }
549
571
  }
550
572
  }
573
+ // No explicit kernel and no saved preference: resolve from the
574
+ // notebook's own kernelspec metadata (headless callers — CLI/MCP —
575
+ // don't resolve it client-side the way the UI does). Only then fall
576
+ // back to python3.
577
+ if (!effectiveKernelName) {
578
+ try {
579
+ const metadata = await fs_service_1.fsService.getNotebookMetadataAsync(normalizedFilePath);
580
+ const specName = metadata.kernelspec?.name;
581
+ if (specName) {
582
+ effectiveKernelName = specName;
583
+ }
584
+ }
585
+ catch {
586
+ // fall through to the default
587
+ }
588
+ }
589
+ if (!effectiveKernelName) {
590
+ effectiveKernelName = 'python3';
591
+ }
551
592
  // Check if we should start on a remote server
552
593
  if (effectiveServerId && effectiveServerId !== localServerId && effectiveServerId !== 'local') {
553
594
  // Start on remote server
@@ -594,6 +635,8 @@ async function kernelRoutes(fastify) {
594
635
  });
595
636
  }
596
637
  catch (err) {
638
+ if (sendProvisionError(reply, err))
639
+ return reply;
597
640
  const message = err instanceof Error ? err.message : 'Unknown error';
598
641
  return reply.code(500).send({ detail: message });
599
642
  }
@@ -87,6 +87,12 @@ async function notebookRoutes(fastify) {
87
87
  return reply.code(400).send({ detail: 'cells is required' });
88
88
  }
89
89
  const result = await fs_service_1.fsService.saveNotebookBundle(filePath, cells, kernel_name, history);
90
+ // Outputs-unchanged sentinel couldn't be resolved against the file on
91
+ // disk (first save, changed ids, external rewrite…) — tell the client
92
+ // to resend the full payload.
93
+ if (!result.success && result.needsFull) {
94
+ return reply.code(409).send({ detail: 'unchanged-outputs sentinel unresolved; resend full payload', code: 'needs_full' });
95
+ }
90
96
  // The UI just wrote newer content than whatever the headless handler may
91
97
  // have cached. Drop the cache so headless ops (an agent working after the
92
98
  // user closes the tab) reload from disk instead of serving — or worse,
@@ -185,7 +191,7 @@ async function notebookRoutes(fastify) {
185
191
  status: 'ok',
186
192
  notebook_path,
187
193
  mtime: result.mtime,
188
- ...(result.status || fs_service_1.fsService.getAgentPermissionStatus(notebook_path)),
194
+ ...(result.status || await fs_service_1.fsService.getAgentPermissionStatusAsync(notebook_path)),
189
195
  });
190
196
  }
191
197
  catch (err) {
@@ -202,7 +208,7 @@ async function notebookRoutes(fastify) {
202
208
  if (!filePath) {
203
209
  return reply.code(400).send({ detail: 'path query parameter is required' });
204
210
  }
205
- const status = fs_service_1.fsService.getAgentPermissionStatus(filePath);
211
+ const status = await fs_service_1.fsService.getAgentPermissionStatusAsync(filePath);
206
212
  return reply.send({
207
213
  notebook_path: filePath,
208
214
  ...status,
@@ -2,7 +2,6 @@
2
2
  * Python Discovery API Routes
3
3
  */
4
4
  import { FastifyInstance } from 'fastify';
5
- import { PythonDiscoveryService } from '../discovery/discovery-service';
6
- declare const discoveryService: PythonDiscoveryService;
5
+ declare const discoveryService: import("../discovery/discovery-service").PythonDiscoveryService;
7
6
  export default function pythonRoutes(fastify: FastifyInstance): Promise<void>;
8
7
  export { discoveryService };
@@ -11,7 +11,10 @@ const kernelspec_1 = require("../kernel/kernelspec");
11
11
  const default_kernel_1 = require("../kernel/default-kernel");
12
12
  const server_registry_1 = require("../cluster/server-registry");
13
13
  const kernel_1 = require("./kernel");
14
- const discoveryService = new discovery_service_1.PythonDiscoveryService();
14
+ // The shared singleton — kernel-service preflight/labels and per-env
15
+ // kernelspec scanning read its in-memory cache, so routes must write through
16
+ // the SAME instance (a second instance would leave the singleton stale).
17
+ const discoveryService = discovery_service_1.pythonDiscovery;
15
18
  exports.discoveryService = discoveryService;
16
19
  async function pythonRoutes(fastify) {
17
20
  /**
@@ -41,6 +44,9 @@ async function pythonRoutes(fastify) {
41
44
  const data = await response.json();
42
45
  return reply.send(data);
43
46
  }
47
+ // Discover Python environments FIRST: kernelspec discovery also scans
48
+ // each known env's share/jupyter/kernels, so it must see the fresh list.
49
+ const environments = await discoveryService.discover({ forceRefresh: refresh });
44
50
  // Get Jupyter kernelspecs and transform to snake_case
45
51
  const kernelspecs = (0, kernelspec_1.discoverKernelSpecs)(refresh).map(k => ({
46
52
  name: k.name,
@@ -50,8 +56,6 @@ async function pythonRoutes(fastify) {
50
56
  python_path: k.argv?.[0] || null, // First element of argv is typically the Python executable
51
57
  }));
52
58
  const kernelspecNames = new Set(kernelspecs.map(k => k.name));
53
- // Get discovered Python environments
54
- const environments = await discoveryService.discover({ forceRefresh: refresh });
55
59
  // Convert to snake_case and match with kernelspecs
56
60
  const envObjects = environments.map(env => ({
57
61
  path: env.path,
@@ -75,6 +79,138 @@ async function pythonRoutes(fastify) {
75
79
  return reply.code(500).send({ detail: message });
76
80
  }
77
81
  });
82
+ /**
83
+ * Install ipykernel into an environment (no kernelspec registration — env
84
+ * kernels raw-launch). One installer chosen up front (conda → uv → pip).
85
+ *
86
+ * Responds with an NDJSON stream so the UI can show installer output LIVE:
87
+ * {"type":"output","data":"…"} — stdout/stderr chunks as they arrive
88
+ * {"type":"done","installer":…} — success terminator
89
+ * {"type":"error","detail":…,"code":…,"install_hint":…} — failure terminator
90
+ * Pre-stream validation failures are plain JSON error responses.
91
+ */
92
+ fastify.post('/python/install-ipykernel', async (request, reply) => {
93
+ const { python_path, server_id } = request.body;
94
+ if (!python_path) {
95
+ return reply.code(400).send({ detail: 'python_path is required' });
96
+ }
97
+ const localServerId = server_registry_1.serverRegistry.getLocalServerId();
98
+ if (server_id && server_id !== localServerId && server_id !== 'local') {
99
+ const server = server_registry_1.serverRegistry.getServer(server_id);
100
+ if (!server) {
101
+ return reply.code(404).send({ detail: `Server not found: ${server_id}` });
102
+ }
103
+ if (server.status !== 'online') {
104
+ return reply.code(503).send({ detail: `Server is offline: ${server_id}` });
105
+ }
106
+ const response = await fetch(`${server.url}/api/python/install-ipykernel`, {
107
+ method: 'POST',
108
+ headers: {
109
+ 'Content-Type': 'application/json',
110
+ ...(process.env.NEBULA_CLUSTER_SECRET ? { 'X-Nebula-Cluster-Secret': process.env.NEBULA_CLUSTER_SECRET } : {}),
111
+ },
112
+ body: JSON.stringify({ python_path }),
113
+ });
114
+ if (!response.ok || !response.body) {
115
+ const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
116
+ return reply.code(response.status).send({
117
+ detail: error.detail || 'Failed to install ipykernel',
118
+ code: error.code,
119
+ install_hint: error.install_hint,
120
+ });
121
+ }
122
+ // Pipe the remote NDJSON stream through untouched.
123
+ reply.hijack();
124
+ reply.raw.writeHead(200, { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-cache' });
125
+ try {
126
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
127
+ for await (const chunk of response.body) {
128
+ reply.raw.write(chunk);
129
+ }
130
+ }
131
+ catch { /* upstream died — terminate what we have */ }
132
+ reply.raw.end();
133
+ return reply;
134
+ }
135
+ // Local install: stream output as it happens, then a terminator event.
136
+ reply.hijack();
137
+ reply.raw.writeHead(200, { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-cache' });
138
+ const send = (obj) => reply.raw.write(`${JSON.stringify(obj)}\n`);
139
+ try {
140
+ const result = await discoveryService.installIpykernel(python_path, (chunk) => send({ type: 'output', data: chunk }));
141
+ send({ type: 'done', installer: result.installer, message: result.message, python_path });
142
+ }
143
+ catch (err) {
144
+ if (err instanceof types_1.KernelProvisionError) {
145
+ send({ type: 'error', detail: err.message, code: err.code, install_hint: err.installHint });
146
+ }
147
+ else {
148
+ send({ type: 'error', detail: err instanceof Error ? err.message : 'Unknown error' });
149
+ }
150
+ }
151
+ reply.raw.end();
152
+ return reply;
153
+ });
154
+ /**
155
+ * Probe a manually-entered interpreter path ("Enter interpreter path…"):
156
+ * validate it runs, classify + enrich it, persist it into the discovery
157
+ * cache, and return it in the same snake_case shape as /python/environments.
158
+ */
159
+ fastify.post('/python/probe', async (request, reply) => {
160
+ try {
161
+ const { python_path, server_id } = request.body;
162
+ if (!python_path) {
163
+ return reply.code(400).send({ detail: 'python_path is required' });
164
+ }
165
+ const localServerId = server_registry_1.serverRegistry.getLocalServerId();
166
+ if (server_id && server_id !== localServerId && server_id !== 'local') {
167
+ const server = server_registry_1.serverRegistry.getServer(server_id);
168
+ if (!server) {
169
+ return reply.code(404).send({ detail: `Server not found: ${server_id}` });
170
+ }
171
+ if (server.status !== 'online') {
172
+ return reply.code(503).send({ detail: `Server is offline: ${server_id}` });
173
+ }
174
+ const response = await fetch(`${server.url}/api/python/probe`, {
175
+ method: 'POST',
176
+ headers: {
177
+ 'Content-Type': 'application/json',
178
+ ...(process.env.NEBULA_CLUSTER_SECRET ? { 'X-Nebula-Cluster-Secret': process.env.NEBULA_CLUSTER_SECRET } : {}),
179
+ },
180
+ body: JSON.stringify({ python_path }),
181
+ });
182
+ if (!response.ok) {
183
+ const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
184
+ return reply.code(response.status).send({
185
+ detail: error.detail || 'Failed to probe interpreter',
186
+ code: error.code,
187
+ install_hint: error.install_hint,
188
+ });
189
+ }
190
+ return reply.send(await response.json());
191
+ }
192
+ const env = await discoveryService.probeAndRemember(python_path);
193
+ return reply.send({
194
+ path: env.path,
195
+ version: env.version,
196
+ display_name: env.displayName,
197
+ env_type: env.envType,
198
+ env_name: env.envName,
199
+ has_ipykernel: env.hasIpykernel,
200
+ externally_managed: env.externallyManaged,
201
+ install_hint: env.installHint,
202
+ kernel_name: env.kernelName,
203
+ });
204
+ }
205
+ catch (err) {
206
+ if (err instanceof types_1.KernelProvisionError) {
207
+ const status = err.code === 'python_not_found' ? 404 : 500;
208
+ return reply.code(status).send({ detail: err.message, code: err.code, install_hint: err.installHint });
209
+ }
210
+ const message = err instanceof Error ? err.message : 'Unknown error';
211
+ return reply.code(500).send({ detail: message });
212
+ }
213
+ });
78
214
  /**
79
215
  * Install ipykernel and register a Python environment as a Jupyter kernel
80
216
  */