nebula-notebook 0.1.1 → 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 (142) hide show
  1. package/README.md +110 -16
  2. package/dist/assets/errorwidget-Wy5ajPiM.js +5 -0
  3. package/dist/assets/fa-brands-400-CEJbCg16.woff +0 -0
  4. package/dist/assets/fa-brands-400-CSYNqBb_.ttf +0 -0
  5. package/dist/assets/fa-brands-400-DnkPfk3o.eot +0 -0
  6. package/dist/assets/fa-brands-400-UxlILjvJ.woff2 +0 -0
  7. package/dist/assets/fa-brands-400-cH1MgKbP.svg +3717 -0
  8. package/dist/assets/fa-regular-400-BhTwtT8w.eot +0 -0
  9. package/dist/assets/fa-regular-400-D1vz6WBx.ttf +0 -0
  10. package/dist/assets/fa-regular-400-DFnMcJPd.woff +0 -0
  11. package/dist/assets/fa-regular-400-DGzu1beS.woff2 +0 -0
  12. package/dist/assets/fa-regular-400-gwj8Pxq-.svg +801 -0
  13. package/dist/assets/fa-solid-900-B4ZZ7kfP.svg +5034 -0
  14. package/dist/assets/fa-solid-900-B6Axprfb.eot +0 -0
  15. package/dist/assets/fa-solid-900-BUswJgRo.woff2 +0 -0
  16. package/dist/assets/fa-solid-900-DOXgCApm.woff +0 -0
  17. package/dist/assets/fa-solid-900-mxuxnBEa.ttf +0 -0
  18. package/dist/assets/index-B_Bs5koo.js +1 -0
  19. package/dist/assets/index-BtWv4MIT.css +7 -0
  20. package/dist/assets/index-DK4nS74r.js +829 -0
  21. package/dist/assets/index-DeQN03nW.js +81 -0
  22. package/dist/assets/index-DzjpmUak.css +32 -0
  23. package/dist/assets/index-g6MjT-VG.js +1 -0
  24. package/dist/assets/services-shim-BirHVsih.js +33 -0
  25. package/dist/assets/viewlist-uomDf7I7.js +1 -0
  26. package/dist/assets/widgets-X7J3NxEn.css +1 -0
  27. package/dist/index.html +2 -2
  28. package/node-server/dist/auth/auth-middleware.js +22 -4
  29. package/node-server/dist/auth/auth-service.js +37 -7
  30. package/node-server/dist/cluster/client-registration.d.ts +12 -1
  31. package/node-server/dist/cluster/client-registration.js +38 -2
  32. package/node-server/dist/cluster/kernel-proxy.js +43 -12
  33. package/node-server/dist/cluster/server-registry.d.ts +17 -0
  34. package/node-server/dist/cluster/server-registry.js +57 -7
  35. package/node-server/dist/discovery/conda-locations.d.ts +73 -0
  36. package/node-server/dist/discovery/conda-locations.js +427 -0
  37. package/node-server/dist/discovery/discovery-service.d.ts +73 -4
  38. package/node-server/dist/discovery/discovery-service.js +211 -127
  39. package/node-server/dist/discovery/types.d.ts +0 -1
  40. package/node-server/dist/discovery/types.js +1 -2
  41. package/node-server/dist/environment.d.ts +12 -0
  42. package/node-server/dist/environment.js +104 -0
  43. package/node-server/dist/fs/fs-service.d.ts +86 -8
  44. package/node-server/dist/fs/fs-service.js +640 -99
  45. package/node-server/dist/fs/notebook-formats/percent.d.ts +25 -0
  46. package/node-server/dist/fs/notebook-formats/percent.js +286 -0
  47. package/node-server/dist/fs/notebook-formats/qmd.d.ts +29 -0
  48. package/node-server/dist/fs/notebook-formats/qmd.js +307 -0
  49. package/node-server/dist/fs/notebook-formats/registry.d.ts +12 -0
  50. package/node-server/dist/fs/notebook-formats/registry.js +77 -0
  51. package/node-server/dist/fs/notebook-formats/types.d.ts +37 -0
  52. package/node-server/dist/fs/notebook-formats/types.js +13 -0
  53. package/node-server/dist/fs/types.d.ts +9 -0
  54. package/node-server/dist/fs/types.js +8 -0
  55. package/node-server/dist/idle-exit.d.ts +52 -0
  56. package/node-server/dist/idle-exit.js +83 -0
  57. package/node-server/dist/index.js +198 -8
  58. package/node-server/dist/kernel/kernel-service.d.ts +130 -2
  59. package/node-server/dist/kernel/kernel-service.js +834 -64
  60. package/node-server/dist/kernel/kernelspec.d.ts +27 -0
  61. package/node-server/dist/kernel/kernelspec.js +75 -4
  62. package/node-server/dist/notebook/headless-handler.d.ts +12 -0
  63. package/node-server/dist/notebook/headless-handler.js +125 -29
  64. package/node-server/dist/notebook/operation-router.js +7 -2
  65. package/node-server/dist/notebook/undoRedoManager.d.ts +12 -1
  66. package/node-server/dist/notebook/undoRedoManager.js +49 -18
  67. package/node-server/dist/output/display-data.js +2 -0
  68. package/node-server/dist/private-tmp.d.ts +14 -0
  69. package/node-server/dist/private-tmp.js +70 -0
  70. package/node-server/dist/routes/autocomplete.d.ts +19 -0
  71. package/node-server/dist/routes/autocomplete.js +294 -0
  72. package/node-server/dist/routes/cluster.js +2 -2
  73. package/node-server/dist/routes/compute.d.ts +8 -0
  74. package/node-server/dist/routes/compute.js +136 -0
  75. package/node-server/dist/routes/fs.js +23 -6
  76. package/node-server/dist/routes/kernel.js +156 -3
  77. package/node-server/dist/routes/notebook.js +37 -3
  78. package/node-server/dist/routes/python.d.ts +1 -2
  79. package/node-server/dist/routes/python.js +139 -3
  80. package/node-server/dist/scheduler/allocation-service.d.ts +63 -0
  81. package/node-server/dist/scheduler/allocation-service.js +285 -0
  82. package/node-server/dist/scheduler/job-template.d.ts +30 -0
  83. package/node-server/dist/scheduler/job-template.js +85 -0
  84. package/node-server/dist/scheduler/mock-scheduler.d.ts +30 -0
  85. package/node-server/dist/scheduler/mock-scheduler.js +125 -0
  86. package/node-server/dist/scheduler/slurm-scheduler.d.ts +31 -0
  87. package/node-server/dist/scheduler/slurm-scheduler.js +406 -0
  88. package/node-server/dist/scheduler/types.d.ts +118 -0
  89. package/node-server/dist/scheduler/types.js +8 -0
  90. package/node-server/dist/scheduler/util.d.ts +7 -0
  91. package/node-server/dist/scheduler/util.js +20 -0
  92. package/node-server/dist/terminal/agent-registry.d.ts +87 -0
  93. package/node-server/dist/terminal/agent-registry.js +246 -0
  94. package/node-server/dist/terminal/binding-store.d.ts +57 -0
  95. package/node-server/dist/terminal/binding-store.js +0 -0
  96. package/node-server/dist/terminal/pty-manager.d.ts +23 -6
  97. package/node-server/dist/terminal/pty-manager.js +138 -19
  98. package/node-server/dist/terminal/server.js +155 -23
  99. package/node-server/dist/terminal/types.d.ts +2 -0
  100. package/node-server/dist/update-check.d.ts +20 -0
  101. package/node-server/dist/update-check.js +114 -0
  102. package/node-server/package.json +2 -0
  103. package/node_modules/nebula-autocomplete/README.md +145 -0
  104. package/node_modules/nebula-autocomplete/dist/backends/claude.d.ts +69 -0
  105. package/node_modules/nebula-autocomplete/dist/backends/claude.js +449 -0
  106. package/node_modules/nebula-autocomplete/dist/backends/codex.d.ts +45 -0
  107. package/node_modules/nebula-autocomplete/dist/backends/codex.js +108 -0
  108. package/node_modules/nebula-autocomplete/dist/client/fetcher.d.ts +16 -0
  109. package/node_modules/nebula-autocomplete/dist/client/fetcher.js +41 -0
  110. package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.d.ts +43 -0
  111. package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.js +175 -0
  112. package/node_modules/nebula-autocomplete/dist/core/engine.d.ts +17 -0
  113. package/node_modules/nebula-autocomplete/dist/core/engine.js +135 -0
  114. package/node_modules/nebula-autocomplete/dist/core/lru.d.ts +7 -0
  115. package/node_modules/nebula-autocomplete/dist/core/lru.js +26 -0
  116. package/node_modules/nebula-autocomplete/dist/core/prompt.d.ts +12 -0
  117. package/node_modules/nebula-autocomplete/dist/core/prompt.js +0 -0
  118. package/node_modules/nebula-autocomplete/dist/core/text.d.ts +53 -0
  119. package/node_modules/nebula-autocomplete/dist/core/text.js +169 -0
  120. package/node_modules/nebula-autocomplete/dist/index.d.ts +7 -0
  121. package/node_modules/nebula-autocomplete/dist/index.js +5 -0
  122. package/node_modules/nebula-autocomplete/dist/server/fastify.d.ts +40 -0
  123. package/node_modules/nebula-autocomplete/dist/server/fastify.js +52 -0
  124. package/node_modules/nebula-autocomplete/dist/transport.d.ts +55 -0
  125. package/node_modules/nebula-autocomplete/dist/transport.js +66 -0
  126. package/node_modules/nebula-autocomplete/dist/types.d.ts +103 -0
  127. package/node_modules/nebula-autocomplete/dist/types.js +1 -0
  128. package/node_modules/nebula-autocomplete/package.json +58 -0
  129. package/node_modules/nebula-autocomplete/server.d.ts +3 -0
  130. package/node_modules/nebula-autocomplete/server.js +3 -0
  131. package/package.json +18 -5
  132. package/scripts/postinstall.cjs +25 -5
  133. package/dist/assets/index-BvrHu37J.js +0 -648
  134. package/dist/assets/index-Dfj_xsKU.css +0 -32
  135. package/node-server/dist/llm/index.d.ts +0 -5
  136. package/node-server/dist/llm/index.js +0 -21
  137. package/node-server/dist/llm/llm-service.d.ts +0 -77
  138. package/node-server/dist/llm/llm-service.js +0 -454
  139. package/node-server/dist/llm/types.d.ts +0 -40
  140. package/node-server/dist/llm/types.js +0 -15
  141. package/node-server/dist/routes/llm.d.ts +0 -8
  142. package/node-server/dist/routes/llm.js +0 -105
@@ -10,6 +10,16 @@ export type { KernelSpec } from './types';
10
10
  * Standard kernelspec search paths
11
11
  */
12
12
  export declare function getKernelSearchPaths(): string[];
13
+ /**
14
+ * Read kernelspecs from a list of `…/kernels` directories. `seen` dedupes by
15
+ * kernel name across calls (first path wins — Jupyter precedence order).
16
+ */
17
+ export declare function readKernelSpecsFromPaths(searchPaths: string[], options?: {
18
+ skipDefaultPythonSpecs?: boolean;
19
+ seen?: Set<string>;
20
+ }): KernelSpec[];
21
+ /** The `share/jupyter/kernels` dir belonging to each interpreter's prefix. */
22
+ export declare function envKernelspecDirs(envPythonPaths: string[]): string[];
13
23
  /**
14
24
  * Discover all available kernelspecs on the system
15
25
  * Uses 60-second in-memory cache to avoid repeated disk I/O
@@ -23,6 +33,23 @@ export declare function invalidateKernelspecCache(): void;
23
33
  * Get a specific kernelspec by name
24
34
  */
25
35
  export declare function getKernelSpec(name: string): KernelSpec | null;
36
+ /**
37
+ * Env kernels (VSCode-style raw launch): any Python environment can be used
38
+ * as a kernel WITHOUT registering a kernelspec on disk. The kernel name
39
+ * `env:<pythonPath>` resolves to a synthetic in-memory spec that launches
40
+ * `<python> -m ipykernel_launcher` directly — only ipykernel needs to be
41
+ * importable in the environment; jupyter itself is never required.
42
+ */
43
+ export declare const ENV_KERNEL_PREFIX = "env:";
44
+ export declare function isEnvKernelName(name: string): boolean;
45
+ /** The interpreter path encoded in an env: kernel name, or null. */
46
+ export declare function envKernelPythonPath(name: string): string | null;
47
+ export declare function makeEnvKernelSpec(pythonPath: string, displayName?: string): KernelSpec;
48
+ /**
49
+ * Resolve a kernel name to a spec: env: names produce a synthetic raw-launch
50
+ * spec, anything else falls back to registered kernelspecs on disk.
51
+ */
52
+ export declare function resolveKernelSpec(name: string, displayName?: string): KernelSpec | null;
26
53
  /**
27
54
  * Check if a kernelspec exists
28
55
  */
@@ -39,14 +39,23 @@ var __importStar = (this && this.__importStar) || (function () {
39
39
  };
40
40
  })();
41
41
  Object.defineProperty(exports, "__esModule", { value: true });
42
+ exports.ENV_KERNEL_PREFIX = void 0;
42
43
  exports.getKernelSearchPaths = getKernelSearchPaths;
44
+ exports.readKernelSpecsFromPaths = readKernelSpecsFromPaths;
45
+ exports.envKernelspecDirs = envKernelspecDirs;
43
46
  exports.discoverKernelSpecs = discoverKernelSpecs;
44
47
  exports.invalidateKernelspecCache = invalidateKernelspecCache;
45
48
  exports.getKernelSpec = getKernelSpec;
49
+ exports.isEnvKernelName = isEnvKernelName;
50
+ exports.envKernelPythonPath = envKernelPythonPath;
51
+ exports.makeEnvKernelSpec = makeEnvKernelSpec;
52
+ exports.resolveKernelSpec = resolveKernelSpec;
46
53
  exports.hasKernelSpec = hasKernelSpec;
47
54
  const fs = __importStar(require("fs"));
48
55
  const path = __importStar(require("path"));
49
56
  const os = __importStar(require("os"));
57
+ const discovery_service_1 = require("../discovery/discovery-service");
58
+ const conda_locations_1 = require("../discovery/conda-locations");
50
59
  // In-memory cache for kernelspecs (avoids repeated disk I/O)
51
60
  let kernelspecCache = null;
52
61
  let kernelspecCacheTime = 0;
@@ -139,12 +148,19 @@ function readKernelSpec(kernelDir) {
139
148
  }
140
149
  }
141
150
  /**
142
- * Perform actual kernelspec discovery (disk I/O)
151
+ * Auto-registered default python specs (`python3`, `python3.11`, …) inside an
152
+ * environment duplicate what the env row in the picker already offers ("run
153
+ * Python here") — showing N identical "Python 3 (ipykernel)" entries would
154
+ * drown the real kernels. VSCode hides these the same way.
143
155
  */
144
- function performKernelspecDiscovery() {
156
+ const DEFAULT_PYTHON_SPEC_RE = /^python\d*(\.\d+)*$/i;
157
+ /**
158
+ * Read kernelspecs from a list of `…/kernels` directories. `seen` dedupes by
159
+ * kernel name across calls (first path wins — Jupyter precedence order).
160
+ */
161
+ function readKernelSpecsFromPaths(searchPaths, options = {}) {
145
162
  const specs = [];
146
- const seenNames = new Set();
147
- const searchPaths = getKernelSearchPaths();
163
+ const seenNames = options.seen ?? new Set();
148
164
  for (const searchPath of searchPaths) {
149
165
  if (!fs.existsSync(searchPath)) {
150
166
  continue;
@@ -159,6 +175,9 @@ function performKernelspecDiscovery() {
159
175
  if (seenNames.has(kernelName)) {
160
176
  continue;
161
177
  }
178
+ if (options.skipDefaultPythonSpecs && DEFAULT_PYTHON_SPEC_RE.test(kernelName)) {
179
+ continue;
180
+ }
162
181
  const kernelDir = path.join(searchPath, kernelName);
163
182
  const spec = readKernelSpec(kernelDir);
164
183
  if (spec) {
@@ -173,6 +192,24 @@ function performKernelspecDiscovery() {
173
192
  }
174
193
  return specs;
175
194
  }
195
+ /** The `share/jupyter/kernels` dir belonging to each interpreter's prefix. */
196
+ function envKernelspecDirs(envPythonPaths) {
197
+ const dirs = envPythonPaths.map(p => path.join((0, conda_locations_1.prefixForPythonExe)(p), 'share', 'jupyter', 'kernels'));
198
+ return [...new Set(dirs)];
199
+ }
200
+ /**
201
+ * Perform actual kernelspec discovery (disk I/O): the standard Jupyter search
202
+ * paths first, then every discovered Python environment's own
203
+ * share/jupyter/kernels (how conda-installed kernels like R/Julia surface) —
204
+ * with each env's redundant default python spec hidden.
205
+ */
206
+ function performKernelspecDiscovery() {
207
+ const seen = new Set();
208
+ const specs = readKernelSpecsFromPaths(getKernelSearchPaths(), { seen });
209
+ const envPaths = Object.keys(discovery_service_1.pythonDiscovery.getFromCache() ?? {});
210
+ specs.push(...readKernelSpecsFromPaths(envKernelspecDirs(envPaths), { seen, skipDefaultPythonSpecs: true }));
211
+ return specs;
212
+ }
176
213
  /**
177
214
  * Discover all available kernelspecs on the system
178
215
  * Uses 60-second in-memory cache to avoid repeated disk I/O
@@ -202,6 +239,40 @@ function getKernelSpec(name) {
202
239
  const specs = discoverKernelSpecs();
203
240
  return specs.find(s => s.name === name) || null;
204
241
  }
242
+ /**
243
+ * Env kernels (VSCode-style raw launch): any Python environment can be used
244
+ * as a kernel WITHOUT registering a kernelspec on disk. The kernel name
245
+ * `env:<pythonPath>` resolves to a synthetic in-memory spec that launches
246
+ * `<python> -m ipykernel_launcher` directly — only ipykernel needs to be
247
+ * importable in the environment; jupyter itself is never required.
248
+ */
249
+ exports.ENV_KERNEL_PREFIX = 'env:';
250
+ function isEnvKernelName(name) {
251
+ return name.startsWith(exports.ENV_KERNEL_PREFIX);
252
+ }
253
+ /** The interpreter path encoded in an env: kernel name, or null. */
254
+ function envKernelPythonPath(name) {
255
+ return isEnvKernelName(name) ? name.slice(exports.ENV_KERNEL_PREFIX.length) : null;
256
+ }
257
+ function makeEnvKernelSpec(pythonPath, displayName) {
258
+ return {
259
+ name: exports.ENV_KERNEL_PREFIX + pythonPath,
260
+ displayName: displayName || `Python (${pythonPath})`,
261
+ language: 'python',
262
+ path: '', // synthetic — no kernelspec directory on disk
263
+ argv: [pythonPath, '-m', 'ipykernel_launcher', '-f', '{connection_file}'],
264
+ };
265
+ }
266
+ /**
267
+ * Resolve a kernel name to a spec: env: names produce a synthetic raw-launch
268
+ * spec, anything else falls back to registered kernelspecs on disk.
269
+ */
270
+ function resolveKernelSpec(name, displayName) {
271
+ const pythonPath = envKernelPythonPath(name);
272
+ if (pythonPath)
273
+ return makeEnvKernelSpec(pythonPath, displayName);
274
+ return getKernelSpec(name);
275
+ }
205
276
  /**
206
277
  * Check if a kernelspec exists
207
278
  */
@@ -45,6 +45,16 @@ export declare class HeadlessOperationHandler {
45
45
  * Get notebook from cache, loading from disk if needed.
46
46
  */
47
47
  private getCachedNotebook;
48
+ /** In-flight cache warm-ups per notebook path (single-flight). */
49
+ private warming;
50
+ /**
51
+ * Warm the notebook + undo-history caches with non-blocking I/O. The sync
52
+ * fallback in getCachedNotebook blocks the event loop on a cache miss — on
53
+ * a network filesystem a large notebook + history read stalls every
54
+ * in-flight request (kernel completions included) for seconds. Entry points
55
+ * await this first so the sync accessors below are pure in-memory hits.
56
+ */
57
+ private ensureCached;
48
58
  private getCells;
49
59
  private getVisibleOutputs;
50
60
  private saveCells;
@@ -52,6 +62,8 @@ export declare class HeadlessOperationHandler {
52
62
  * Persist dirty notebooks to disk.
53
63
  */
54
64
  flush(notebookPath?: string): Promise<void>;
65
+ /** In-flight persist per notebook path (single-flight). */
66
+ private persisting;
55
67
  private asyncPersist;
56
68
  private schedulePersist;
57
69
  /**
@@ -43,8 +43,9 @@ exports.HeadlessOperationHandler = void 0;
43
43
  const fs = __importStar(require("fs"));
44
44
  const cell_hash_1 = require("./cell-hash");
45
45
  const path = __importStar(require("path"));
46
- const os = __importStar(require("os"));
46
+ const private_tmp_1 = require("../private-tmp");
47
47
  const uuid_1 = require("uuid");
48
+ const registry_1 = require("../fs/notebook-formats/registry");
48
49
  const cell_metadata_1 = require("./cell-metadata");
49
50
  const undoRedoManager_1 = require("./undoRedoManager");
50
51
  const kernelspec_1 = require("../kernel/kernelspec");
@@ -80,7 +81,7 @@ class HeadlessOperationHandler {
80
81
  */
81
82
  getCachedNotebook(notebookPath) {
82
83
  if (!this.cache.has(notebookPath)) {
83
- const result = this.fsService.getNotebookCells(notebookPath);
84
+ const result = this.fsService.getNotebookCellsSync(notebookPath);
84
85
  this.cache.set(notebookPath, {
85
86
  cells: result.cells || [],
86
87
  metadata: result.metadata || {},
@@ -89,6 +90,37 @@ class HeadlessOperationHandler {
89
90
  }
90
91
  return this.cache.get(notebookPath);
91
92
  }
93
+ /** In-flight cache warm-ups per notebook path (single-flight). */
94
+ warming = new Map();
95
+ /**
96
+ * Warm the notebook + undo-history caches with non-blocking I/O. The sync
97
+ * fallback in getCachedNotebook blocks the event loop on a cache miss — on
98
+ * a network filesystem a large notebook + history read stalls every
99
+ * in-flight request (kernel completions included) for seconds. Entry points
100
+ * await this first so the sync accessors below are pure in-memory hits.
101
+ */
102
+ ensureCached(notebookPath) {
103
+ if (!notebookPath || this.cache.has(notebookPath))
104
+ return Promise.resolve();
105
+ const inflight = this.warming.get(notebookPath);
106
+ if (inflight)
107
+ return inflight;
108
+ const run = (async () => {
109
+ const result = await this.fsService.getNotebookCells(notebookPath);
110
+ if (!this.cache.has(notebookPath)) {
111
+ this.cache.set(notebookPath, {
112
+ cells: result.cells || [],
113
+ metadata: result.metadata || {},
114
+ dirty: false,
115
+ });
116
+ }
117
+ await this.undoRedoManager.warm(notebookPath, this.cache.get(notebookPath).cells);
118
+ })().finally(() => {
119
+ this.warming.delete(notebookPath);
120
+ });
121
+ this.warming.set(notebookPath, run);
122
+ return run;
123
+ }
92
124
  getCells(notebookPath) {
93
125
  return this.getCachedNotebook(notebookPath).cells;
94
126
  }
@@ -109,23 +141,41 @@ class HeadlessOperationHandler {
109
141
  async flush(notebookPath) {
110
142
  const paths = notebookPath ? [notebookPath] : Array.from(this.cache.keys());
111
143
  for (const p of paths) {
112
- const notebook = this.cache.get(p);
113
- if (notebook?.dirty) {
144
+ // A scheduled persist may have already cleared the dirty flag but not
145
+ // finished writing — flush must wait for it, or callers read a stale
146
+ // file. The persist loop keeps writing while dirty, so awaiting the
147
+ // in-flight run covers later mutations too.
148
+ const inFlight = this.persisting.get(p);
149
+ if (inFlight) {
150
+ await inFlight;
151
+ }
152
+ if (this.cache.get(p)?.dirty) {
114
153
  await this.asyncPersist(p);
115
154
  }
116
155
  }
117
156
  }
118
- async asyncPersist(notebookPath) {
119
- const notebook = this.cache.get(notebookPath);
120
- if (!notebook)
121
- return;
122
- // Keep writing while dirty
123
- while (notebook.dirty) {
124
- const cells = JSON.parse(JSON.stringify(notebook.cells));
125
- notebook.dirty = false;
126
- const history = this.undoRedoManager.getHistory(notebookPath, cells);
127
- await this.fsService.saveNotebookBundle(notebookPath, cells, undefined, history);
128
- }
157
+ /** In-flight persist per notebook path (single-flight). */
158
+ persisting = new Map();
159
+ asyncPersist(notebookPath) {
160
+ const existing = this.persisting.get(notebookPath);
161
+ if (existing)
162
+ return existing;
163
+ const run = (async () => {
164
+ const notebook = this.cache.get(notebookPath);
165
+ if (!notebook)
166
+ return;
167
+ // Keep writing while dirty
168
+ while (notebook.dirty) {
169
+ const cells = JSON.parse(JSON.stringify(notebook.cells));
170
+ notebook.dirty = false;
171
+ const history = this.undoRedoManager.getHistory(notebookPath, cells);
172
+ await this.fsService.saveNotebookBundle(notebookPath, cells, undefined, history);
173
+ }
174
+ })().finally(() => {
175
+ this.persisting.delete(notebookPath);
176
+ });
177
+ this.persisting.set(notebookPath, run);
178
+ return run;
129
179
  }
130
180
  schedulePersist(notebookPath) {
131
181
  // Schedule async persist
@@ -143,6 +193,10 @@ class HeadlessOperationHandler {
143
193
  else {
144
194
  this.cache.clear();
145
195
  }
196
+ // Undo/redo state is derived from the persisted history file; when the
197
+ // notebook may have changed underneath us (UI save, external edit
198
+ // reconciliation), rebuild it from disk on next access.
199
+ this.undoRedoManager.clearState(notebookPath);
146
200
  }
147
201
  /**
148
202
  * Check if notebook has unsaved changes.
@@ -153,23 +207,24 @@ class HeadlessOperationHandler {
153
207
  /**
154
208
  * Check if agent has permission to modify this notebook.
155
209
  */
156
- checkAgentPermission(notebookPath, _operationType) {
157
- const status = this.fsService.getAgentPermissionStatus(notebookPath);
158
- if (status.agent_created) {
210
+ async checkAgentPermission(notebookPath, _operationType) {
211
+ const status = await this.fsService.getAgentPermissionStatusAsync(notebookPath);
212
+ // can_agent_modify already encodes the tri-state rule (agent-created notebooks
213
+ // are permitted unless explicitly revoked; user notebooks need permission +
214
+ // history), so an explicit user revoke wins even for agent-created notebooks.
215
+ if (status.can_agent_modify) {
159
216
  return null;
160
217
  }
161
- if (status.agent_permitted) {
162
- if (!status.has_history) {
163
- return {
164
- success: false,
165
- error: `Agent cannot modify "${notebookPath}": notebook is user-permitted but history is not enabled. Open the notebook in the UI first to enable history tracking, or the agent can create a new notebook.`,
166
- };
167
- }
168
- return null;
218
+ // User-permitted but history not yet enabled (soft, fixable) vs. not permitted.
219
+ if (status.agent_permitted && !status.has_history && !status.agent_created) {
220
+ return {
221
+ success: false,
222
+ error: `Agent cannot modify "${notebookPath}": notebook is user-permitted but history is not enabled. Open the notebook in the UI first to enable history tracking, or the agent can create a new notebook.`,
223
+ };
169
224
  }
170
225
  return {
171
226
  success: false,
172
- error: `Agent cannot modify "${notebookPath}": notebook is not agent-permitted. Either open the notebook in Nebula UI and grant agent permission, or the agent can create a new notebook which will be automatically permitted.`,
227
+ error: `Agent cannot modify "${notebookPath}": notebook is not agent-permitted (agent access may have been revoked). Either open the notebook in Nebula UI and grant agent permission, or the agent can create a new notebook which will be automatically permitted.`,
173
228
  };
174
229
  }
175
230
  /**
@@ -228,9 +283,20 @@ class HeadlessOperationHandler {
228
283
  // Read-only operations
229
284
  const readOnlyOps = new Set(['readCell', 'readCellOutput', 'searchCells', 'getUpdatesSince', 'startAgentSession', 'endAgentSession']);
230
285
  const permissionExemptOps = new Set(['createNotebook', 'readCell', 'readCellOutput', 'searchCells', 'getUpdatesSince', 'startAgentSession', 'endAgentSession']);
286
+ // Warm the notebook/history caches without blocking the event loop before
287
+ // any sync accessor below runs. createNotebook targets a file that may
288
+ // not exist yet; a failed warm defers to the operation's own error.
289
+ if (notebookPath && opType !== 'createNotebook') {
290
+ try {
291
+ await this.ensureCached(notebookPath);
292
+ }
293
+ catch {
294
+ // Operation handlers report missing/corrupt notebooks themselves
295
+ }
296
+ }
231
297
  // Check permission for write operations
232
298
  if (!permissionExemptOps.has(opType) && notebookPath) {
233
- const permissionError = this.checkAgentPermission(notebookPath, opType);
299
+ const permissionError = await this.checkAgentPermission(notebookPath, opType);
234
300
  if (permissionError) {
235
301
  return permissionError;
236
302
  }
@@ -361,6 +427,14 @@ class HeadlessOperationHandler {
361
427
  */
362
428
  async readNotebook(notebookPath, includeOutputs = true, maxLines, maxChars, maxLinesError, maxCharsError) {
363
429
  try {
430
+ // Non-blocking cache warm-up; on failure the sync accessor below
431
+ // produces the proper error (e.g. notebook not found).
432
+ try {
433
+ await this.ensureCached(notebookPath);
434
+ }
435
+ catch {
436
+ // fall through to getCachedNotebook's own error handling
437
+ }
364
438
  const notebook = this.getCachedNotebook(notebookPath);
365
439
  let cells = notebook.cells;
366
440
  if (includeOutputs) {
@@ -781,6 +855,28 @@ class HeadlessOperationHandler {
781
855
  error: `Notebook already exists: ${notebookPath}. Use overwrite=true to replace.`,
782
856
  };
783
857
  }
858
+ // Text notebook formats (.py percent / .qmd): create through the format
859
+ // adapter — agent flags live in the file's own metadata like .ipynb.
860
+ const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
861
+ if (formatAdapter) {
862
+ const dir = path.dirname(normalizedPath);
863
+ if (!fs.existsSync(dir)) {
864
+ fs.mkdirSync(dir, { recursive: true });
865
+ }
866
+ const metadata = {
867
+ kernelspec: { name: kernelName, display_name: kernelDisplayName },
868
+ nebula: { agent_created: true, agent_permitted: true },
869
+ };
870
+ if (formatAdapter.name === 'qmd')
871
+ metadata.__qmd_language = 'python';
872
+ fs.writeFileSync(normalizedPath, formatAdapter.serialize([], metadata), 'utf-8');
873
+ this.invalidate(notebookPath);
874
+ return {
875
+ success: true,
876
+ path: notebookPath,
877
+ mtime: fs.statSync(normalizedPath).mtimeMs / 1000,
878
+ };
879
+ }
784
880
  const notebook = {
785
881
  nbformat: 4,
786
882
  nbformat_minor: 5,
@@ -988,7 +1084,7 @@ class HeadlessOperationHandler {
988
1084
  return new Promise(resolve => setTimeout(resolve, ms));
989
1085
  }
990
1086
  saveOutputToTempFile(content, cellId) {
991
- const tempDir = path.join(os.tmpdir(), 'nebula', 'outputs');
1087
+ const tempDir = (0, private_tmp_1.privateTmpDir)('outputs');
992
1088
  if (!fs.existsSync(tempDir)) {
993
1089
  fs.mkdirSync(tempDir, { recursive: true });
994
1090
  }
@@ -48,6 +48,7 @@ const fs = __importStar(require("fs"));
48
48
  const os = __importStar(require("os"));
49
49
  const ws_1 = require("ws");
50
50
  const cell_hash_1 = require("./cell-hash");
51
+ const registry_1 = require("../fs/notebook-formats/registry");
51
52
  const AGENT_LOCK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
52
53
  const UI_STALE_TIMEOUT_MS = 45 * 1000; // 45 seconds
53
54
  function normalizeNotebookPath(notebookPath) {
@@ -320,9 +321,13 @@ class OperationRouter {
320
321
  const opType = operation.type;
321
322
  const agentId = operation.agentId;
322
323
  // For createNotebook, route to ANY connected UI (not path-specific)
323
- // This allows the UI to open the new notebook in a new tab
324
+ // This allows the UI to open the new notebook in a new tab.
325
+ // EXCEPT text notebook formats (.py/.qmd): the UI's create handler only
326
+ // builds Jupyter JSON, so those are always created headless through the
327
+ // format adapters.
324
328
  const isCreateNotebook = opType === 'createNotebook';
325
- const anyUI = isCreateNotebook ? this.getAnyUIConnection() : null;
329
+ const isTextFormatCreate = isCreateNotebook && (0, registry_1.isTextNotebookPath)(operation.notebookPath || '');
330
+ const anyUI = isCreateNotebook && !isTextFormatCreate ? this.getAnyUIConnection() : null;
326
331
  const hasUI = isCreateNotebook ? (anyUI !== null) : (this.getResponsiveUIConnection(normalizedPath) !== null);
327
332
  const backend = hasUI ? 'ui' : 'headless';
328
333
  // Clean up expired locks
@@ -139,6 +139,14 @@ export declare class HeadlessUndoRedoManager {
139
139
  * Get or initialize undo state for a notebook.
140
140
  */
141
141
  getState(notebookPath: string, cells: NebulaCell[]): NotebookUndoState;
142
+ /**
143
+ * Preload a notebook's history without blocking the event loop. After this
144
+ * resolves, getState is a pure in-memory hit — its loadHistorySync fallback
145
+ * (a large journal read that stalls every in-flight request on a network
146
+ * filesystem) never fires. No-op if state already exists.
147
+ */
148
+ warm(notebookPath: string, cells: NebulaCell[]): Promise<void>;
149
+ private initState;
142
150
  /**
143
151
  * Rebuild undo stack from history.
144
152
  */
@@ -182,7 +190,10 @@ export declare class HeadlessUndoRedoManager {
182
190
  /**
183
191
  * Clear state for a notebook (e.g., when closing).
184
192
  */
185
- clearState(notebookPath: string): void;
193
+ /** Drop in-memory state so the next access rebuilds from the persisted
194
+ * history file (the file/history may have changed underneath us: UI save,
195
+ * external-edit reconciliation). Omit the path to clear all. */
196
+ clearState(notebookPath?: string): void;
186
197
  /**
187
198
  * Get updates since a timestamp (for agent awareness).
188
199
  * Returns human-readable summaries of operations made between agent sessions.
@@ -142,30 +142,53 @@ class HeadlessUndoRedoManager {
142
142
  // Try to load existing history
143
143
  let loadedHistory = [];
144
144
  try {
145
- loadedHistory = this.fsService.loadHistory(notebookPath);
145
+ loadedHistory = this.fsService.loadHistorySync(notebookPath);
146
146
  }
147
147
  catch (err) {
148
148
  console.warn(`[UndoRedoManager] Failed to load history for ${notebookPath}:`, err);
149
149
  }
150
- // Initialize state
151
- const state = {
152
- undoStack: [],
153
- redoStack: [],
154
- fullHistory: loadedHistory.length > 0 ? loadedHistory : [{
155
- type: 'snapshot',
156
- cells: cells.map(stripCellOutputs),
157
- timestamp: Date.now()
158
- }],
159
- lastContent: new Map(cells.map(c => [c.id, c.content]))
160
- };
161
- // Rebuild undo stack from history
162
- if (loadedHistory.length > 0) {
163
- state.undoStack = this.rebuildUndoStack(loadedHistory);
164
- }
165
- this.states.set(notebookPath, state);
150
+ this.initState(notebookPath, cells, loadedHistory);
166
151
  }
167
152
  return this.states.get(notebookPath);
168
153
  }
154
+ /**
155
+ * Preload a notebook's history without blocking the event loop. After this
156
+ * resolves, getState is a pure in-memory hit — its loadHistorySync fallback
157
+ * (a large journal read that stalls every in-flight request on a network
158
+ * filesystem) never fires. No-op if state already exists.
159
+ */
160
+ async warm(notebookPath, cells) {
161
+ if (this.states.has(notebookPath))
162
+ return;
163
+ let loadedHistory = [];
164
+ try {
165
+ loadedHistory = (await this.fsService.loadHistory(notebookPath));
166
+ }
167
+ catch (err) {
168
+ console.warn(`[UndoRedoManager] Failed to load history for ${notebookPath}:`, err);
169
+ }
170
+ // A concurrent sync getState may have initialized while we awaited
171
+ if (this.states.has(notebookPath))
172
+ return;
173
+ this.initState(notebookPath, cells, loadedHistory);
174
+ }
175
+ initState(notebookPath, cells, loadedHistory) {
176
+ const state = {
177
+ undoStack: [],
178
+ redoStack: [],
179
+ fullHistory: loadedHistory.length > 0 ? loadedHistory : [{
180
+ type: 'snapshot',
181
+ cells: cells.map(stripCellOutputs),
182
+ timestamp: Date.now()
183
+ }],
184
+ lastContent: new Map(cells.map(c => [c.id, c.content]))
185
+ };
186
+ // Rebuild undo stack from history
187
+ if (loadedHistory.length > 0) {
188
+ state.undoStack = this.rebuildUndoStack(loadedHistory);
189
+ }
190
+ this.states.set(notebookPath, state);
191
+ }
169
192
  /**
170
193
  * Rebuild undo stack from history.
171
194
  */
@@ -357,8 +380,16 @@ class HeadlessUndoRedoManager {
357
380
  /**
358
381
  * Clear state for a notebook (e.g., when closing).
359
382
  */
383
+ /** Drop in-memory state so the next access rebuilds from the persisted
384
+ * history file (the file/history may have changed underneath us: UI save,
385
+ * external-edit reconciliation). Omit the path to clear all. */
360
386
  clearState(notebookPath) {
361
- this.states.delete(notebookPath);
387
+ if (notebookPath) {
388
+ this.states.delete(notebookPath);
389
+ }
390
+ else {
391
+ this.states.clear();
392
+ }
362
393
  }
363
394
  /**
364
395
  * Get updates since a timestamp (for agent awareness).
@@ -8,6 +8,8 @@ exports.buildDisplayOutput = buildDisplayOutput;
8
8
  exports.convertMimeBundleToJupyter = convertMimeBundleToJupyter;
9
9
  const PREFERRED_MIME_TYPES = [
10
10
  'application/vnd.nebula.web+json',
11
+ // Widget views must win over plotly/html when both are present
12
+ 'application/vnd.jupyter.widget-view+json',
11
13
  'application/vnd.plotly.v1+json',
12
14
  'text/html',
13
15
  'image/png',
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Per-user private temp directories.
3
+ *
4
+ * Shared paths like /tmp/nebula-kernels break on multi-user machines twice
5
+ * over: the first user's mkdir (with a restrictive umask) locks everyone else
6
+ * out, and anything world-readable leaks — kernel connection files carry the
7
+ * HMAC key, which is remote code execution as the kernel's owner for anyone
8
+ * who can read it.
9
+ *
10
+ * Layout: <tmpdir>/nebula-<username>/<segments...>, mode 0700 throughout.
11
+ * /tmp is sticky, so another user CAN pre-create our directory name — we
12
+ * verify ownership after mkdir and refuse to use a directory we don't own.
13
+ */
14
+ export declare function privateTmpDir(...segments: string[]): string;
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ /**
3
+ * Per-user private temp directories.
4
+ *
5
+ * Shared paths like /tmp/nebula-kernels break on multi-user machines twice
6
+ * over: the first user's mkdir (with a restrictive umask) locks everyone else
7
+ * out, and anything world-readable leaks — kernel connection files carry the
8
+ * HMAC key, which is remote code execution as the kernel's owner for anyone
9
+ * who can read it.
10
+ *
11
+ * Layout: <tmpdir>/nebula-<username>/<segments...>, mode 0700 throughout.
12
+ * /tmp is sticky, so another user CAN pre-create our directory name — we
13
+ * verify ownership after mkdir and refuse to use a directory we don't own.
14
+ */
15
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
16
+ if (k2 === undefined) k2 = k;
17
+ var desc = Object.getOwnPropertyDescriptor(m, k);
18
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
19
+ desc = { enumerable: true, get: function() { return m[k]; } };
20
+ }
21
+ Object.defineProperty(o, k2, desc);
22
+ }) : (function(o, m, k, k2) {
23
+ if (k2 === undefined) k2 = k;
24
+ o[k2] = m[k];
25
+ }));
26
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
27
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
28
+ }) : function(o, v) {
29
+ o["default"] = v;
30
+ });
31
+ var __importStar = (this && this.__importStar) || (function () {
32
+ var ownKeys = function(o) {
33
+ ownKeys = Object.getOwnPropertyNames || function (o) {
34
+ var ar = [];
35
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
36
+ return ar;
37
+ };
38
+ return ownKeys(o);
39
+ };
40
+ return function (mod) {
41
+ if (mod && mod.__esModule) return mod;
42
+ var result = {};
43
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
44
+ __setModuleDefault(result, mod);
45
+ return result;
46
+ };
47
+ })();
48
+ Object.defineProperty(exports, "__esModule", { value: true });
49
+ exports.privateTmpDir = privateTmpDir;
50
+ const fs = __importStar(require("fs"));
51
+ const path = __importStar(require("path"));
52
+ const os = __importStar(require("os"));
53
+ function privateTmpDir(...segments) {
54
+ const user = (os.userInfo().username || String(process.getuid?.() ?? 'user'))
55
+ .replace(/[^a-zA-Z0-9._-]/g, '_');
56
+ const root = path.join(os.tmpdir(), `nebula-${user}`);
57
+ const dir = path.join(root, ...segments);
58
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
59
+ if (process.platform !== 'win32' && typeof process.getuid === 'function') {
60
+ const st = fs.statSync(root);
61
+ if (st.uid !== process.getuid()) {
62
+ throw new Error(`${root} exists but is owned by uid ${st.uid}, not us (${process.getuid()}) — ` +
63
+ `refusing to write private files into another user's directory. Remove or rename it.`);
64
+ }
65
+ // mkdirSync's mode is filtered by umask and ignored for pre-existing dirs;
66
+ // assert the closed mode explicitly.
67
+ fs.chmodSync(root, 0o700);
68
+ }
69
+ return dir;
70
+ }