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
@@ -40,11 +40,15 @@ var __importStar = (this && this.__importStar) || (function () {
40
40
  Object.defineProperty(exports, "__esModule", { value: true });
41
41
  exports.fsService = exports.FilesystemService = void 0;
42
42
  const fs = __importStar(require("fs"));
43
+ const fs_1 = require("fs");
43
44
  const path = __importStar(require("path"));
44
45
  const os = __importStar(require("os"));
45
46
  const crypto = __importStar(require("crypto"));
46
47
  const util_1 = require("util");
48
+ const types_1 = require("./types");
47
49
  const default_kernel_1 = require("../kernel/default-kernel");
50
+ const registry_1 = require("./notebook-formats/registry");
51
+ const percent_1 = require("./notebook-formats/percent");
48
52
  const display_data_1 = require("../output/display-data");
49
53
  const NEBULA_DIR = path.join(os.homedir(), '.nebula');
50
54
  const USER_CONFIG_PATH = path.join(NEBULA_DIR, 'config.json');
@@ -345,6 +349,52 @@ class FilesystemService {
345
349
  writeJsonAtomicSync(targetPath, payload) {
346
350
  this.atomicWriteFileSync(targetPath, JSON.stringify(payload, null, 2));
347
351
  }
352
+ /**
353
+ * Async variant of atomicWriteFileSync (write temp, fsync, rename, fsync dir).
354
+ * Used on hot notebook-save paths so large writes never block the event loop.
355
+ *
356
+ * `durable: false` skips both fsyncs while keeping the tmp+rename atomicity.
357
+ * Use it for auxiliary files (history/session/journal/lastsave): on network
358
+ * filesystems (GPFS) each fsync can take seconds, and a save doing ~10 of
359
+ * them was observed at 20-27s. A crash can lose the last few seconds of an
360
+ * auxiliary file, but never corrupt it — the rename is still atomic. The
361
+ * notebook file itself stays fully durable.
362
+ */
363
+ async atomicWriteFile(targetPath, data, opts = {}) {
364
+ const durable = opts.durable !== false;
365
+ const dir = path.dirname(targetPath);
366
+ await fs_1.promises.mkdir(dir, { recursive: true });
367
+ const tmpName = `.${path.basename(targetPath)}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
368
+ const tmpPath = path.join(dir, tmpName);
369
+ const handle = await fs_1.promises.open(tmpPath, 'w', 0o600);
370
+ try {
371
+ await handle.writeFile(data, 'utf-8');
372
+ if (durable)
373
+ await handle.sync();
374
+ }
375
+ finally {
376
+ await handle.close();
377
+ }
378
+ await fs_1.promises.rename(tmpPath, targetPath);
379
+ if (!durable)
380
+ return;
381
+ // Best-effort directory fsync
382
+ try {
383
+ const dirHandle = await fs_1.promises.open(dir, 'r');
384
+ try {
385
+ await dirHandle.sync();
386
+ }
387
+ finally {
388
+ await dirHandle.close();
389
+ }
390
+ }
391
+ catch {
392
+ // Ignore fsync errors on directory handles
393
+ }
394
+ }
395
+ async writeJsonAtomic(targetPath, payload, opts = {}) {
396
+ await this.atomicWriteFile(targetPath, JSON.stringify(payload, null, 2), opts);
397
+ }
348
398
  /**
349
399
  * Format file size for display
350
400
  */
@@ -367,10 +417,10 @@ class FilesystemService {
367
417
  */
368
418
  getFileType(extension) {
369
419
  const ext = extension.toLowerCase();
370
- if (ext === '.ipynb') {
420
+ if (ext === '.ipynb' || ext === '.qmd') {
371
421
  return 'notebook';
372
422
  }
373
- else if (['.py', '.js', '.ts', '.tsx', '.jsx', '.json', '.yaml', '.yml', '.toml', '.md', '.txt'].includes(ext)) {
423
+ else if (['.py', '.r', '.jl', '.js', '.ts', '.tsx', '.jsx', '.json', '.yaml', '.yml', '.toml', '.md', '.txt'].includes(ext)) {
374
424
  return 'code';
375
425
  }
376
426
  else if (['.csv', '.tsv', '.xlsx', '.xls'].includes(ext)) {
@@ -400,6 +450,21 @@ class FilesystemService {
400
450
  extension: isDir ? '' : path.extname(name),
401
451
  };
402
452
  }
453
+ /** Async, non-blocking variant of getFileInfo (one `fsp.stat`). Used by
454
+ * listDirectory so a folder's entries stat in parallel off the event loop. */
455
+ async getFileInfoAsync(filePath) {
456
+ const stat = await fs_1.promises.stat(filePath);
457
+ const name = path.basename(filePath);
458
+ const isDir = stat.isDirectory();
459
+ return {
460
+ name,
461
+ path: filePath,
462
+ isDirectory: isDir,
463
+ size: isDir ? 0 : stat.size,
464
+ modified: stat.mtimeMs / 1000,
465
+ extension: isDir ? '' : path.extname(name),
466
+ };
467
+ }
403
468
  /**
404
469
  * Convert FileInfo to FileInfoResponse for API
405
470
  */
@@ -436,12 +501,16 @@ class FilesystemService {
436
501
  /**
437
502
  * Get file modification time
438
503
  */
439
- getFileMtime(filePath) {
504
+ async getFileMtime(filePath) {
440
505
  const normalizedPath = this.normalizePath(filePath);
441
- if (!fs.existsSync(normalizedPath)) {
506
+ let stat;
507
+ try {
508
+ stat = await fs_1.promises.stat(normalizedPath);
509
+ }
510
+ catch {
511
+ // Matches the sync existsSync() behavior: any stat failure reads as missing
442
512
  throw new Error(`File not found: ${normalizedPath}`);
443
513
  }
444
- const stat = fs.statSync(normalizedPath);
445
514
  return {
446
515
  path: normalizedPath,
447
516
  mtime: stat.mtimeMs / 1000,
@@ -450,32 +519,35 @@ class FilesystemService {
450
519
  /**
451
520
  * List contents of a directory
452
521
  */
453
- listDirectory(dirPath) {
522
+ async listDirectory(dirPath) {
454
523
  const normalizedPath = this.normalizePath(dirPath);
455
- if (!fs.existsSync(normalizedPath)) {
524
+ let stat;
525
+ try {
526
+ stat = await fs_1.promises.stat(normalizedPath);
527
+ }
528
+ catch {
456
529
  throw new Error(`Path not found: ${normalizedPath}`);
457
530
  }
458
- const stat = fs.statSync(normalizedPath);
459
531
  if (!stat.isDirectory()) {
460
532
  throw new Error(`Not a directory: ${normalizedPath}`);
461
533
  }
462
- const items = [];
463
- const entries = fs.readdirSync(normalizedPath);
464
- for (const name of entries) {
465
- // Skip hidden files
466
- if (name.startsWith('.')) {
467
- continue;
468
- }
469
- const fullPath = path.join(normalizedPath, name);
534
+ const entries = await fs_1.promises.readdir(normalizedPath);
535
+ // Stat every visible entry in PARALLEL rather than one blocking statSync at a
536
+ // time. On a network filesystem a serial loop stalls the single event
537
+ // loop for N × stat-latency, delaying every other in-flight request (kernel
538
+ // starts, terminal creation, saves) — the source of the "everything hangs
539
+ // while a folder lists" latency.
540
+ const settled = await Promise.all(entries
541
+ .filter((name) => !name.startsWith('.')) // skip hidden files
542
+ .map(async (name) => {
470
543
  try {
471
- const info = this.getFileInfo(fullPath);
472
- items.push(this.toFileInfoResponse(info));
544
+ return this.toFileInfoResponse(await this.getFileInfoAsync(path.join(normalizedPath, name)));
473
545
  }
474
546
  catch {
475
- // Skip files we can't access
476
- continue;
547
+ return null; // skip entries we can't access
477
548
  }
478
- }
549
+ }));
550
+ const items = settled.filter((x) => x !== null);
479
551
  // Sort: directories first, then by name
480
552
  items.sort((a, b) => {
481
553
  if (a.isDirectory !== b.isDirectory) {
@@ -493,18 +565,22 @@ class FilesystemService {
493
565
  /**
494
566
  * Read a file's contents
495
567
  */
496
- readFile(filePath) {
568
+ async readFile(filePath) {
497
569
  const normalizedPath = this.normalizePath(filePath);
498
- if (!fs.existsSync(normalizedPath)) {
570
+ let stat;
571
+ try {
572
+ stat = await fs_1.promises.stat(normalizedPath);
573
+ }
574
+ catch {
575
+ // Matches the sync existsSync() behavior: any stat failure reads as missing
499
576
  throw new Error(`File not found: ${normalizedPath}`);
500
577
  }
501
- const stat = fs.statSync(normalizedPath);
502
578
  if (stat.isDirectory()) {
503
579
  throw new Error(`Path is a directory: ${normalizedPath}`);
504
580
  }
505
581
  const extension = path.extname(normalizedPath).toLowerCase();
506
582
  if (extension === '.ipynb') {
507
- const content = JSON.parse(fs.readFileSync(normalizedPath, 'utf-8'));
583
+ const content = JSON.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8'));
508
584
  return {
509
585
  path: normalizedPath,
510
586
  type: 'notebook',
@@ -513,7 +589,7 @@ class FilesystemService {
513
589
  }
514
590
  // Try to read as text
515
591
  try {
516
- const content = fs.readFileSync(normalizedPath, 'utf-8');
592
+ const content = await fs_1.promises.readFile(normalizedPath, 'utf-8');
517
593
  return {
518
594
  path: normalizedPath,
519
595
  type: 'text',
@@ -582,6 +658,16 @@ class FilesystemService {
582
658
  };
583
659
  this.writeJsonAtomicSync(normalizedPath, notebook);
584
660
  }
661
+ else if (extension === '.qmd') {
662
+ // Text-format notebook template (NOT .py: creating a .py must stay a
663
+ // plain empty script — percent parsing only applies when one is
664
+ // opened as a notebook)
665
+ const adapter = (0, registry_1.getFormatAdapter)(normalizedPath);
666
+ this.atomicWriteFileSync(normalizedPath, adapter.serialize([], {
667
+ kernelspec: { display_name: 'Python 3', language: 'python', name: 'python3' },
668
+ __qmd_language: 'python',
669
+ }));
670
+ }
585
671
  else {
586
672
  // Create empty file
587
673
  fs.writeFileSync(normalizedPath, '', 'utf-8');
@@ -597,6 +683,31 @@ class FilesystemService {
597
683
  const { nebulaDir, nameWithoutExt } = this.getNebulaPaths(notebookPath);
598
684
  return path.join(nebulaDir, `${nameWithoutExt}.history.json`);
599
685
  }
686
+ /**
687
+ * Last-Nebula-save record for text-format notebooks: the canonical cell
688
+ * state (id/type/content) as of the last save THROUGH Nebula. At history
689
+ * load, divergence between this record and the file means the notebook was
690
+ * edited externally (vim, git checkout, ...) — the difference is
691
+ * reconciled into history as a synthesized external-edit operation so
692
+ * undo/redo stays infinite across external edits.
693
+ */
694
+ getLastSavePath(notebookPath) {
695
+ const { nebulaDir, nameWithoutExt } = this.getNebulaPaths(notebookPath);
696
+ return path.join(nebulaDir, `${nameWithoutExt}.lastsave.json`);
697
+ }
698
+ async writeTextNotebookLastSave(notebookPath, serializedText, adapter) {
699
+ try {
700
+ // Record the POST-round-trip identity (parse of what was written), so
701
+ // positional ids (qmd markdown cells) never read as false divergence.
702
+ const canonical = adapter.parse(serializedText).cells;
703
+ await this.writeJsonAtomic(this.getLastSavePath(notebookPath), {
704
+ cells: canonical.map((c) => ({ id: c.id, type: c.type, content: c.content })),
705
+ }, { durable: false });
706
+ }
707
+ catch (e) {
708
+ console.warn('[FilesystemService] Failed to write lastsave record:', e);
709
+ }
710
+ }
600
711
  /**
601
712
  * Get the session state file path for a notebook
602
713
  */
@@ -632,7 +743,13 @@ class FilesystemService {
632
743
  const normalizedPath = this.normalizePath(notebookPath);
633
744
  const parentDir = path.dirname(normalizedPath);
634
745
  const notebookName = path.basename(normalizedPath);
635
- const nameWithoutExt = path.basename(notebookName, path.extname(notebookName));
746
+ // .ipynb sidecar names are extension-stripped (unchanged, pre-existing
747
+ // files keep working). Text-format notebooks keep their full basename so
748
+ // foo.py and foo.ipynb in one directory never share sidecars.
749
+ const ext = path.extname(notebookName).toLowerCase();
750
+ const nameWithoutExt = ext === '.ipynb'
751
+ ? path.basename(notebookName, path.extname(notebookName))
752
+ : notebookName;
636
753
  const nebulaDir = path.join(parentDir, '.nebula');
637
754
  return { nebulaDir, nameWithoutExt };
638
755
  }
@@ -643,6 +760,10 @@ class FilesystemService {
643
760
  const historyPath = this.getHistoryPath(notebookPath);
644
761
  const sessionPath = this.getSessionPath(notebookPath);
645
762
  const journalPath = this.getJournalPath(notebookPath);
763
+ const lastSavePath = this.getLastSavePath(notebookPath);
764
+ if (fs.existsSync(lastSavePath)) {
765
+ fs.unlinkSync(lastSavePath);
766
+ }
646
767
  if (fs.existsSync(historyPath)) {
647
768
  fs.unlinkSync(historyPath);
648
769
  }
@@ -662,9 +783,8 @@ class FilesystemService {
662
783
  throw new Error(`Path not found: ${normalizedPath}`);
663
784
  }
664
785
  // For notebooks, also delete history and session files
665
- const ext = path.extname(normalizedPath).toLowerCase();
666
786
  const stat = fs.statSync(normalizedPath);
667
- if (ext === '.ipynb' && !stat.isDirectory()) {
787
+ if ((0, registry_1.isNotebookPath)(normalizedPath) && !stat.isDirectory()) {
668
788
  this.deleteNotebookMetadata(normalizedPath);
669
789
  }
670
790
  if (stat.isDirectory()) {
@@ -699,6 +819,10 @@ class FilesystemService {
699
819
  // Journal files are ephemeral; remove any stale commit record on rename
700
820
  fs.unlinkSync(oldJournal);
701
821
  }
822
+ const oldLastSave = this.getLastSavePath(oldPath);
823
+ if (fs.existsSync(oldLastSave)) {
824
+ fs.renameSync(oldLastSave, this.getLastSavePath(newPath));
825
+ }
702
826
  }
703
827
  /**
704
828
  * Rename/move a file or directory
@@ -713,8 +837,7 @@ class FilesystemService {
713
837
  throw new Error(`Destination already exists: ${normalizedNew}`);
714
838
  }
715
839
  // For notebooks, also rename history and session files
716
- const ext = path.extname(normalizedOld).toLowerCase();
717
- if (ext === '.ipynb') {
840
+ if ((0, registry_1.isNotebookPath)(normalizedOld)) {
718
841
  this.renameNotebookMetadata(normalizedOld, normalizedNew);
719
842
  }
720
843
  fs.renameSync(normalizedOld, normalizedNew);
@@ -728,6 +851,14 @@ class FilesystemService {
728
851
  const destHistory = this.getHistoryPath(destPath);
729
852
  const srcSession = this.getSessionPath(srcPath);
730
853
  const destSession = this.getSessionPath(destPath);
854
+ const srcLastSave = this.getLastSavePath(srcPath);
855
+ if (fs.existsSync(srcLastSave)) {
856
+ const destNebulaDirForLastSave = path.dirname(destHistory);
857
+ if (!fs.existsSync(destNebulaDirForLastSave)) {
858
+ fs.mkdirSync(destNebulaDirForLastSave, { recursive: true });
859
+ }
860
+ fs.copyFileSync(srcLastSave, this.getLastSavePath(destPath));
861
+ }
731
862
  // Create destination .nebula directory if needed
732
863
  const destNebulaDir = path.dirname(destHistory);
733
864
  if (!fs.existsSync(destNebulaDir)) {
@@ -758,7 +889,7 @@ class FilesystemService {
758
889
  }
759
890
  fs.copyFileSync(srcPath, destPath);
760
891
  // For notebooks, also duplicate history and session files
761
- if (entry.name.toLowerCase().endsWith('.ipynb')) {
892
+ if ((0, registry_1.isNotebookPath)(entry.name)) {
762
893
  this.duplicateNotebookMetadata(srcPath, destPath);
763
894
  }
764
895
  }
@@ -794,7 +925,7 @@ class FilesystemService {
794
925
  // Copy the file
795
926
  fs.copyFileSync(normalizedPath, newPath);
796
927
  // For notebooks, also duplicate history and session files
797
- if (ext.toLowerCase() === '.ipynb') {
928
+ if ((0, registry_1.isNotebookPath)(normalizedPath)) {
798
929
  this.duplicateNotebookMetadata(normalizedPath, newPath);
799
930
  }
800
931
  }
@@ -959,14 +1090,58 @@ class FilesystemService {
959
1090
  return result;
960
1091
  }
961
1092
  /**
962
- * Read a notebook and convert to internal cell format
1093
+ * Read a notebook and convert to internal cell format (async; hot path for
1094
+ * notebook load — large .ipynb reads must not block the event loop)
1095
+ */
1096
+ async getNotebookCells(notebookPath) {
1097
+ const normalizedPath = this.normalizePath(notebookPath);
1098
+ let raw;
1099
+ let stat;
1100
+ try {
1101
+ [raw, stat] = await Promise.all([
1102
+ fs_1.promises.readFile(normalizedPath, 'utf-8'),
1103
+ fs_1.promises.stat(normalizedPath),
1104
+ ]);
1105
+ }
1106
+ catch (err) {
1107
+ if (err?.code === 'ENOENT') {
1108
+ throw new Error(`Notebook not found: ${normalizedPath}`);
1109
+ }
1110
+ throw err;
1111
+ }
1112
+ return this.buildNotebookCellsResponse(normalizedPath, raw, stat.mtimeMs);
1113
+ }
1114
+ /**
1115
+ * Sync variant of getNotebookCells. Kept for cold callers that need a
1116
+ * synchronous read (headless cache warm-up, initial-history bootstrap).
963
1117
  */
964
- getNotebookCells(notebookPath) {
1118
+ getNotebookCellsSync(notebookPath) {
965
1119
  const normalizedPath = this.normalizePath(notebookPath);
966
1120
  if (!fs.existsSync(normalizedPath)) {
967
1121
  throw new Error(`Notebook not found: ${normalizedPath}`);
968
1122
  }
969
- const notebook = JSON.parse(fs.readFileSync(normalizedPath, 'utf-8'));
1123
+ const raw = fs.readFileSync(normalizedPath, 'utf-8');
1124
+ const stat = fs.statSync(normalizedPath);
1125
+ return this.buildNotebookCellsResponse(normalizedPath, raw, stat.mtimeMs);
1126
+ }
1127
+ /**
1128
+ * Convert raw notebook file content to the internal cell format (CPU only).
1129
+ */
1130
+ buildNotebookCellsResponse(normalizedPath, raw, mtimeMs) {
1131
+ // Text notebook formats (.py percent / .qmd): adapter path. The .ipynb
1132
+ // body below is untouched.
1133
+ const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
1134
+ if (formatAdapter) {
1135
+ const parsed = formatAdapter.parse(raw);
1136
+ return {
1137
+ cells: parsed.cells,
1138
+ metadata: parsed.metadata,
1139
+ kernelspec: parsed.kernelspecName || (0, registry_1.defaultKernelForPath)(normalizedPath),
1140
+ kernelspecSource: parsed.kernelspecName ? 'metadata' : 'default',
1141
+ mtime: mtimeMs / 1000,
1142
+ };
1143
+ }
1144
+ const notebook = JSON.parse(raw);
970
1145
  const metadataKernel = notebook.metadata?.kernelspec?.name;
971
1146
  const kernelspec = metadataKernel || 'python3';
972
1147
  const cells = notebook.cells.map((nbCell, i) => {
@@ -1001,20 +1176,19 @@ class FilesystemService {
1001
1176
  }
1002
1177
  return cell;
1003
1178
  });
1004
- const stat = fs.statSync(normalizedPath);
1005
1179
  return {
1006
1180
  cells,
1007
1181
  metadata: notebook.metadata || {},
1008
1182
  kernelspec,
1009
1183
  kernelspecSource: metadataKernel ? 'metadata' : 'default',
1010
- mtime: stat.mtimeMs / 1000,
1184
+ mtime: mtimeMs / 1000,
1011
1185
  };
1012
1186
  }
1013
1187
  /**
1014
1188
  * Read a notebook and convert to internal cell format, resolving default kernel if needed
1015
1189
  */
1016
1190
  async getNotebookCellsWithKernel(notebookPath) {
1017
- const result = this.getNotebookCells(notebookPath);
1191
+ const result = await this.getNotebookCells(notebookPath);
1018
1192
  if (result.kernelspecSource === 'metadata') {
1019
1193
  return result;
1020
1194
  }
@@ -1036,36 +1210,66 @@ class FilesystemService {
1036
1210
  /**
1037
1211
  * Save cells to a notebook file
1038
1212
  */
1039
- saveNotebookCells(notebookPath, cells, kernelName, notebookMetadata) {
1213
+ async saveNotebookCells(notebookPath, cells, kernelName, notebookMetadata) {
1040
1214
  const normalizedPath = this.normalizePath(notebookPath);
1215
+ const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
1216
+ if (formatAdapter) {
1217
+ return await this.saveTextNotebookCells(formatAdapter, normalizedPath, cells, kernelName, notebookMetadata);
1218
+ }
1041
1219
  // Load existing notebook metadata if file exists.
1042
1220
  // Prefer a fast scan for the top-level metadata object so we avoid
1043
1221
  // parsing the full notebook on every save. Fall back to full JSON
1044
1222
  // parsing only for smaller notebooks when the fast path cannot find it.
1045
1223
  let existingMetadata = {};
1046
- if (fs.existsSync(normalizedPath)) {
1224
+ try {
1225
+ const stat = await fs_1.promises.stat(normalizedPath);
1226
+ const handle = await fs_1.promises.open(normalizedPath, 'r');
1227
+ let head;
1047
1228
  try {
1048
- const stat = fs.statSync(normalizedPath);
1049
- const fd = fs.openSync(normalizedPath, 'r');
1050
1229
  const buf = Buffer.alloc(NOTEBOOK_METADATA_FAST_PATH_BYTES);
1051
- const bytesRead = fs.readSync(fd, buf, 0, NOTEBOOK_METADATA_FAST_PATH_BYTES, 0);
1052
- fs.closeSync(fd);
1053
- const head = buf.toString('utf-8', 0, bytesRead);
1054
- const extractedMetadata = extractTopLevelObjectField(head, 'metadata');
1055
- if (extractedMetadata) {
1056
- existingMetadata = extractedMetadata;
1057
- }
1058
- else if (stat.size <= NOTEBOOK_METADATA_FALLBACK_PARSE_BYTES) {
1059
- const notebook = JSON.parse(fs.readFileSync(normalizedPath, 'utf-8'));
1060
- existingMetadata = notebook.metadata || {};
1061
- }
1230
+ const { bytesRead } = await handle.read(buf, 0, NOTEBOOK_METADATA_FAST_PATH_BYTES, 0);
1231
+ head = buf.toString('utf-8', 0, bytesRead);
1062
1232
  }
1063
- catch {
1064
- // Start fresh — metadata extraction failed
1233
+ finally {
1234
+ await handle.close();
1235
+ }
1236
+ const extractedMetadata = extractTopLevelObjectField(head, 'metadata');
1237
+ if (extractedMetadata) {
1238
+ existingMetadata = extractedMetadata;
1065
1239
  }
1240
+ else if (stat.size <= NOTEBOOK_METADATA_FALLBACK_PARSE_BYTES) {
1241
+ const notebook = JSON.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8'));
1242
+ existingMetadata = notebook.metadata || {};
1243
+ }
1244
+ }
1245
+ catch {
1246
+ // Start fresh — file missing or metadata extraction failed
1066
1247
  }
1067
1248
  kernelName = kernelName || 'python3';
1068
1249
  const displayName = kernelName === 'python3' ? 'Python 3' : kernelName.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
1250
+ // Resolve outputs-unchanged sentinels from the file on disk: the client
1251
+ // elides unchanged outputs (often MBs of base64 images) from autosaves so
1252
+ // slow uplinks aren't saturated. If anything can't be resolved, ask the
1253
+ // client to retry with a full payload rather than guessing.
1254
+ const sentinelCells = cells.filter((c) => c.outputs === types_1.OUTPUTS_UNCHANGED_SENTINEL);
1255
+ const priorOutputs = new Map();
1256
+ if (sentinelCells.length > 0) {
1257
+ try {
1258
+ const existing = JSON.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8'));
1259
+ for (const jc of existing.cells || []) {
1260
+ const nid = jc.metadata?.nebula_id;
1261
+ if (nid)
1262
+ priorOutputs.set(String(nid), { outputs: jc.outputs || [], execution_count: jc.execution_count ?? null });
1263
+ }
1264
+ }
1265
+ catch {
1266
+ return { success: false, mtime: 0, needsFull: true };
1267
+ }
1268
+ for (const c of sentinelCells) {
1269
+ if (!priorOutputs.has(c.id))
1270
+ return { success: false, mtime: 0, needsFull: true };
1271
+ }
1272
+ }
1069
1273
  const nbCells = cells.map((cell) => {
1070
1274
  const preservedMetadata = cell._metadata || {};
1071
1275
  const cellMetadata = {
@@ -1084,8 +1288,15 @@ class FilesystemService {
1084
1288
  metadata: cellMetadata,
1085
1289
  };
1086
1290
  if (cell.type === 'code') {
1087
- nbCell.outputs = this.convertOutputsToJupyter(cell.outputs);
1088
- nbCell.execution_count = cell.executionCount;
1291
+ if (cell.outputs === types_1.OUTPUTS_UNCHANGED_SENTINEL) {
1292
+ const prior = priorOutputs.get(cell.id);
1293
+ nbCell.outputs = prior.outputs;
1294
+ nbCell.execution_count = cell.executionCount ?? prior.execution_count;
1295
+ }
1296
+ else {
1297
+ nbCell.outputs = this.convertOutputsToJupyter(cell.outputs);
1298
+ nbCell.execution_count = cell.executionCount;
1299
+ }
1089
1300
  }
1090
1301
  return nbCell;
1091
1302
  });
@@ -1122,11 +1333,9 @@ class FilesystemService {
1122
1333
  };
1123
1334
  // Create parent directory if needed
1124
1335
  const parentDir = path.dirname(normalizedPath);
1125
- if (!fs.existsSync(parentDir)) {
1126
- fs.mkdirSync(parentDir, { recursive: true });
1127
- }
1128
- this.writeJsonAtomicSync(normalizedPath, notebook);
1129
- const stat = fs.statSync(normalizedPath);
1336
+ await fs_1.promises.mkdir(parentDir, { recursive: true });
1337
+ await this.writeJsonAtomic(normalizedPath, notebook);
1338
+ const stat = await fs_1.promises.stat(normalizedPath);
1130
1339
  return {
1131
1340
  success: true,
1132
1341
  mtime: stat.mtimeMs / 1000,
@@ -1157,25 +1366,25 @@ class FilesystemService {
1157
1366
  };
1158
1367
  // Ensure .nebula directory exists for journal/history/session
1159
1368
  const { nebulaDir } = this.getNebulaPaths(notebookPath);
1160
- if (!fs.existsSync(nebulaDir)) {
1161
- fs.mkdirSync(nebulaDir, { recursive: true });
1162
- }
1163
- this.writeJsonAtomicSync(journalPath, journal);
1369
+ await fs_1.promises.mkdir(nebulaDir, { recursive: true });
1370
+ // Auxiliary files: atomic but non-durable (no fsync) — see atomicWriteFile.
1371
+ // Only the notebook itself (inside saveNotebookCells) pays for fsync.
1372
+ await this.writeJsonAtomic(journalPath, journal, { durable: false });
1164
1373
  if (historyPath) {
1165
- this.writeJsonAtomicSync(historyPath, history || []);
1374
+ await this.writeJsonAtomic(historyPath, history || [], { durable: false });
1166
1375
  }
1167
1376
  if (sessionPath) {
1168
- this.writeJsonAtomicSync(sessionPath, session || {});
1377
+ await this.writeJsonAtomic(sessionPath, session || {}, { durable: false });
1169
1378
  }
1170
- const result = this.saveNotebookCells(notebookPath, cells, kernelName, notebookMetadata);
1379
+ const result = await this.saveNotebookCells(notebookPath, cells, kernelName, notebookMetadata);
1171
1380
  const committedJournal = {
1172
1381
  ...journal,
1173
1382
  status: 'commit',
1174
1383
  committedAt: Date.now(),
1175
1384
  };
1176
- this.writeJsonAtomicSync(journalPath, committedJournal);
1385
+ await this.writeJsonAtomic(journalPath, committedJournal, { durable: false });
1177
1386
  try {
1178
- fs.unlinkSync(journalPath);
1387
+ await fs_1.promises.unlink(journalPath);
1179
1388
  }
1180
1389
  catch {
1181
1390
  // Ignore cleanup errors; journal can be inspected if needed
@@ -1183,6 +1392,38 @@ class FilesystemService {
1183
1392
  return result;
1184
1393
  });
1185
1394
  }
1395
+ /**
1396
+ * Save cells to a text-format notebook (.py percent / .qmd).
1397
+ * Outputs and execution counts are never serialized — by format design.
1398
+ */
1399
+ async saveTextNotebookCells(adapter, normalizedPath, cells, kernelName, notebookMetadata) {
1400
+ // Text formats never serialize outputs; neutralize any outputs-unchanged
1401
+ // sentinels so nothing downstream sees the marker string.
1402
+ cells = cells.map((c) => c.outputs === types_1.OUTPUTS_UNCHANGED_SENTINEL ? { ...c, outputs: [] } : c);
1403
+ let existingMetadata = {};
1404
+ try {
1405
+ existingMetadata = adapter.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8')).metadata;
1406
+ }
1407
+ catch {
1408
+ // Start fresh — file missing or metadata extraction failed
1409
+ }
1410
+ const finalMetadata = { ...existingMetadata };
1411
+ if (notebookMetadata) {
1412
+ for (const [key, value] of Object.entries(notebookMetadata)) {
1413
+ if (typeof value === 'object' && value !== null && typeof finalMetadata[key] === 'object' && finalMetadata[key] !== null) {
1414
+ finalMetadata[key] = { ...finalMetadata[key], ...value };
1415
+ }
1416
+ else {
1417
+ finalMetadata[key] = value;
1418
+ }
1419
+ }
1420
+ }
1421
+ const text = adapter.serialize(cells, finalMetadata, kernelName);
1422
+ await this.atomicWriteFile(normalizedPath, text);
1423
+ await this.writeTextNotebookLastSave(normalizedPath, text, adapter);
1424
+ const stat = await fs_1.promises.stat(normalizedPath);
1425
+ return { success: true, mtime: stat.mtimeMs / 1000 };
1426
+ }
1186
1427
  /**
1187
1428
  * Get notebook-level metadata
1188
1429
  */
@@ -1191,6 +1432,15 @@ class FilesystemService {
1191
1432
  if (!fs.existsSync(normalizedPath)) {
1192
1433
  return {};
1193
1434
  }
1435
+ const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
1436
+ if (formatAdapter) {
1437
+ try {
1438
+ return formatAdapter.parse(fs.readFileSync(normalizedPath, 'utf-8')).metadata;
1439
+ }
1440
+ catch {
1441
+ return {};
1442
+ }
1443
+ }
1194
1444
  try {
1195
1445
  const notebook = JSON.parse(fs.readFileSync(normalizedPath, 'utf-8'));
1196
1446
  return notebook.metadata || {};
@@ -1199,17 +1449,91 @@ class FilesystemService {
1199
1449
  return {};
1200
1450
  }
1201
1451
  }
1452
+ /**
1453
+ * Async variant of getNotebookMetadata. Nebula-written notebooks put
1454
+ * `metadata` first, so the head-bytes scan avoids reading and parsing the
1455
+ * whole file; externally written notebooks fall back to a full async read.
1456
+ */
1457
+ async getNotebookMetadataAsync(notebookPath) {
1458
+ const normalizedPath = this.normalizePath(notebookPath);
1459
+ const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
1460
+ try {
1461
+ if (formatAdapter) {
1462
+ return formatAdapter.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8')).metadata;
1463
+ }
1464
+ const handle = await fs_1.promises.open(normalizedPath, 'r');
1465
+ let head;
1466
+ try {
1467
+ const buf = Buffer.alloc(NOTEBOOK_METADATA_FAST_PATH_BYTES);
1468
+ const { bytesRead } = await handle.read(buf, 0, NOTEBOOK_METADATA_FAST_PATH_BYTES, 0);
1469
+ head = buf.toString('utf-8', 0, bytesRead);
1470
+ }
1471
+ finally {
1472
+ await handle.close();
1473
+ }
1474
+ const extracted = extractTopLevelObjectField(head, 'metadata');
1475
+ if (extracted) {
1476
+ return extracted;
1477
+ }
1478
+ const notebook = JSON.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8'));
1479
+ return notebook.metadata || {};
1480
+ }
1481
+ catch {
1482
+ return {};
1483
+ }
1484
+ }
1202
1485
  /**
1203
1486
  * Update notebook-level metadata without modifying cells
1204
1487
  */
1205
1488
  async updateNotebookMetadata(notebookPath, metadataUpdates) {
1206
1489
  return await this.withWriteLock(notebookPath, async () => {
1207
1490
  const normalizedPath = this.normalizePath(notebookPath);
1208
- if (!fs.existsSync(normalizedPath)) {
1491
+ try {
1492
+ await fs_1.promises.access(normalizedPath);
1493
+ }
1494
+ catch {
1209
1495
  return { success: false, error: `Notebook not found: ${normalizedPath}` };
1210
1496
  }
1497
+ const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
1498
+ if (formatAdapter) {
1499
+ try {
1500
+ const rawText = await fs_1.promises.readFile(normalizedPath, 'utf-8');
1501
+ // A plain .py script (no markers, no header) must not be
1502
+ // Nebula-fied by metadata side effects — kernel attach and
1503
+ // settings writes are skipped until a content save makes the
1504
+ // file a notebook on purpose.
1505
+ if (formatAdapter.name === 'percent' && !(0, percent_1.isPercentNotebookText)(rawText)) {
1506
+ const stat = await fs_1.promises.stat(normalizedPath);
1507
+ return { success: true, changed: false, mtime: stat.mtimeMs / 1000 };
1508
+ }
1509
+ const parsed = formatAdapter.parse(rawText);
1510
+ const existingMetadata = parsed.metadata;
1511
+ const nextMetadata = { ...existingMetadata };
1512
+ for (const [key, value] of Object.entries(metadataUpdates)) {
1513
+ if (typeof value === 'object' && value !== null && typeof nextMetadata[key] === 'object' && nextMetadata[key] !== null) {
1514
+ nextMetadata[key] = {
1515
+ ...nextMetadata[key],
1516
+ ...value,
1517
+ };
1518
+ }
1519
+ else {
1520
+ nextMetadata[key] = value;
1521
+ }
1522
+ }
1523
+ if ((0, util_1.isDeepStrictEqual)(existingMetadata, nextMetadata)) {
1524
+ const stat = await fs_1.promises.stat(normalizedPath);
1525
+ return { success: true, changed: false, mtime: stat.mtimeMs / 1000 };
1526
+ }
1527
+ await this.atomicWriteFile(normalizedPath, formatAdapter.serialize(parsed.cells, nextMetadata));
1528
+ const stat = await fs_1.promises.stat(normalizedPath);
1529
+ return { success: true, changed: true, mtime: stat.mtimeMs / 1000 };
1530
+ }
1531
+ catch (e) {
1532
+ return { success: false, error: `Failed to update notebook: ${e}` };
1533
+ }
1534
+ }
1211
1535
  try {
1212
- const notebook = JSON.parse(fs.readFileSync(normalizedPath, 'utf-8'));
1536
+ const notebook = JSON.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8'));
1213
1537
  const existingMetadata = notebook.metadata || {};
1214
1538
  const nextMetadata = { ...existingMetadata };
1215
1539
  for (const [key, value] of Object.entries(metadataUpdates)) {
@@ -1224,7 +1548,7 @@ class FilesystemService {
1224
1548
  }
1225
1549
  }
1226
1550
  if ((0, util_1.isDeepStrictEqual)(existingMetadata, nextMetadata)) {
1227
- const stat = fs.statSync(normalizedPath);
1551
+ const stat = await fs_1.promises.stat(normalizedPath);
1228
1552
  return {
1229
1553
  success: true,
1230
1554
  changed: false,
@@ -1232,8 +1556,8 @@ class FilesystemService {
1232
1556
  };
1233
1557
  }
1234
1558
  notebook.metadata = nextMetadata;
1235
- this.writeJsonAtomicSync(normalizedPath, notebook);
1236
- const stat = fs.statSync(normalizedPath);
1559
+ await this.writeJsonAtomic(normalizedPath, notebook);
1560
+ const stat = await fs_1.promises.stat(normalizedPath);
1237
1561
  return {
1238
1562
  success: true,
1239
1563
  changed: true,
@@ -1255,7 +1579,7 @@ class FilesystemService {
1255
1579
  return snapshot;
1256
1580
  }
1257
1581
  buildInitialHistory(notebookPath) {
1258
- const { cells } = this.getNotebookCells(notebookPath);
1582
+ const { cells } = this.getNotebookCellsSync(notebookPath);
1259
1583
  return [{
1260
1584
  type: 'snapshot',
1261
1585
  cells: cells.map(cell => this.stripOutputsForHistorySnapshot(cell)),
@@ -1271,6 +1595,34 @@ class FilesystemService {
1271
1595
  if (!fs.existsSync(normalizedPath)) {
1272
1596
  return { success: false, error: `Notebook not found: ${normalizedPath}` };
1273
1597
  }
1598
+ const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
1599
+ if (formatAdapter) {
1600
+ try {
1601
+ const parsed = formatAdapter.parse(fs.readFileSync(normalizedPath, 'utf-8'));
1602
+ const nebula = (parsed.metadata.nebula || {});
1603
+ const nextMetadata = {
1604
+ ...parsed.metadata,
1605
+ nebula: { ...nebula, agent_permitted: permitted },
1606
+ };
1607
+ this.atomicWriteFileSync(normalizedPath, formatAdapter.serialize(parsed.cells, nextMetadata));
1608
+ if (permitted && !this.hasHistory(notebookPath)) {
1609
+ const historyPath = this.getHistoryPath(notebookPath);
1610
+ const nebulaDir = path.dirname(historyPath);
1611
+ if (!fs.existsSync(nebulaDir)) {
1612
+ fs.mkdirSync(nebulaDir, { recursive: true });
1613
+ }
1614
+ this.writeJsonAtomicSync(historyPath, this.buildInitialHistory(notebookPath));
1615
+ }
1616
+ return {
1617
+ success: true,
1618
+ status: this.getAgentPermissionStatus(notebookPath),
1619
+ mtime: fs.statSync(normalizedPath).mtimeMs / 1000,
1620
+ };
1621
+ }
1622
+ catch (e) {
1623
+ return { success: false, error: `Failed to set agent permission: ${e}` };
1624
+ }
1625
+ }
1274
1626
  try {
1275
1627
  const notebook = JSON.parse(fs.readFileSync(normalizedPath, 'utf-8'));
1276
1628
  const metadata = notebook.metadata || {};
@@ -1294,6 +1646,10 @@ class FilesystemService {
1294
1646
  return {
1295
1647
  success: true,
1296
1648
  status: this.getAgentPermissionStatus(notebookPath),
1649
+ // The permission flag was just written INTO the notebook file; the
1650
+ // caller must adopt this mtime or its next autosave will mistake
1651
+ // our own write for an external change ("changed on server").
1652
+ mtime: fs.statSync(normalizedPath).mtimeMs / 1000,
1297
1653
  };
1298
1654
  }
1299
1655
  catch (e) {
@@ -1306,31 +1662,57 @@ class FilesystemService {
1306
1662
  */
1307
1663
  getAgentPermissionStatus(notebookPath) {
1308
1664
  const metadata = this.getNotebookMetadata(notebookPath);
1309
- const nebula = (metadata.nebula || {});
1310
1665
  const hasHistory = this.hasHistory(notebookPath);
1666
+ return this.deriveAgentPermission(metadata, hasHistory);
1667
+ }
1668
+ /**
1669
+ * Async variant of getAgentPermissionStatus for hot request paths (it runs
1670
+ * on every agent write operation). Reads metadata via the head-bytes fast
1671
+ * path and checks history without parsing it, so the per-operation gate
1672
+ * never blocks the event loop on a slow network filesystem.
1673
+ */
1674
+ async getAgentPermissionStatusAsync(notebookPath) {
1675
+ const [metadata, hasHistory] = await Promise.all([
1676
+ this.getNotebookMetadataAsync(notebookPath),
1677
+ this.hasHistoryAsync(notebookPath),
1678
+ ]);
1679
+ return this.deriveAgentPermission(metadata, hasHistory);
1680
+ }
1681
+ deriveAgentPermission(metadata, hasHistory) {
1682
+ const nebula = (metadata.nebula || {});
1311
1683
  const agentCreated = Boolean(nebula.agent_created);
1312
1684
  const agentPermitted = Boolean(nebula.agent_permitted);
1313
- const canModify = agentCreated || (agentPermitted && hasHistory);
1685
+ // Tri-state: an explicit user revoke (agent_permitted === false) always wins,
1686
+ // even for agent-created notebooks. Agent-created notebooks are permitted by
1687
+ // default (unless revoked); other notebooks need an explicit user grant.
1688
+ const revoked = nebula.agent_permitted === false;
1689
+ const permitted = agentCreated ? !revoked : agentPermitted;
1690
+ // Agent-created notebooks carry history from creation, so they skip the
1691
+ // separate has-history gate that user-permitted notebooks require.
1692
+ const canModify = permitted && (agentCreated || hasHistory);
1314
1693
  return {
1315
1694
  agent_created: agentCreated,
1316
1695
  agent_permitted: agentPermitted,
1317
1696
  has_history: hasHistory,
1318
1697
  can_agent_modify: canModify,
1319
- reason: agentCreated
1320
- ? 'Agent created this notebook'
1698
+ reason: !permitted
1699
+ ? (agentCreated ? 'Agent access revoked' : 'Not permitted for agent modifications')
1321
1700
  : canModify
1322
- ? 'User permitted and history enabled'
1323
- : agentPermitted
1324
- ? 'User permitted but history not enabled'
1325
- : 'Not permitted for agent modifications',
1701
+ ? (agentCreated ? 'Agent created this notebook' : 'User permitted and history enabled')
1702
+ : 'User permitted but history not enabled',
1326
1703
  };
1327
1704
  }
1328
1705
  /**
1329
1706
  * Check if a notebook is permitted for agent modifications
1330
1707
  */
1331
1708
  isAgentPermitted(notebookPath) {
1332
- const status = this.getAgentPermissionStatus(notebookPath);
1333
- return status.agent_created || status.agent_permitted;
1709
+ // Agent-created notebooks are permitted unless explicitly revoked; others
1710
+ // require an explicit grant. A user revoke wins in both cases.
1711
+ const metadata = this.getNotebookMetadata(notebookPath);
1712
+ const nebula = (metadata.nebula || {});
1713
+ const agentCreated = Boolean(nebula.agent_created);
1714
+ const revoked = nebula.agent_permitted === false;
1715
+ return agentCreated ? !revoked : Boolean(nebula.agent_permitted);
1334
1716
  }
1335
1717
  /**
1336
1718
  * Check if a notebook has history tracking enabled
@@ -1348,6 +1730,24 @@ class FilesystemService {
1348
1730
  return false;
1349
1731
  }
1350
1732
  }
1733
+ /**
1734
+ * Async variant of hasHistory that only parses small files: history journals
1735
+ * grow to hundreds of KB, and this check runs on every agent operation — a
1736
+ * file that large necessarily has entries, so its size alone answers.
1737
+ */
1738
+ async hasHistoryAsync(notebookPath) {
1739
+ const historyPath = this.getHistoryPath(notebookPath);
1740
+ try {
1741
+ const stat = await fs_1.promises.stat(historyPath);
1742
+ if (stat.size > 4096)
1743
+ return true;
1744
+ const history = JSON.parse(await fs_1.promises.readFile(historyPath, 'utf-8'));
1745
+ return Array.isArray(history) && history.length > 0;
1746
+ }
1747
+ catch {
1748
+ return false;
1749
+ }
1750
+ }
1351
1751
  /**
1352
1752
  * Save operation history for a notebook
1353
1753
  */
@@ -1355,18 +1755,39 @@ class FilesystemService {
1355
1755
  const historyPath = this.getHistoryPath(notebookPath);
1356
1756
  // Create .nebula directory if needed
1357
1757
  const nebulaDir = path.dirname(historyPath);
1358
- if (!fs.existsSync(nebulaDir)) {
1359
- fs.mkdirSync(nebulaDir, { recursive: true });
1360
- }
1758
+ await fs_1.promises.mkdir(nebulaDir, { recursive: true });
1361
1759
  await this.withWriteLock(notebookPath, async () => {
1362
- this.writeJsonAtomicSync(historyPath, history);
1760
+ await this.writeJsonAtomic(historyPath, history);
1363
1761
  });
1364
1762
  return true;
1365
1763
  }
1366
1764
  /**
1367
- * Load operation history for a notebook
1765
+ * Load operation history for a notebook (async; history files can be many MB)
1766
+ */
1767
+ async loadHistory(notebookPath) {
1768
+ const historyPath = this.getHistoryPath(notebookPath);
1769
+ if (this.hasPendingCommit(notebookPath)) {
1770
+ console.warn(`[FilesystemService] Pending commit detected for ${notebookPath}; skipping history load.`);
1771
+ return [];
1772
+ }
1773
+ let history;
1774
+ try {
1775
+ history = JSON.parse(await fs_1.promises.readFile(historyPath, 'utf-8'));
1776
+ }
1777
+ catch {
1778
+ // Missing or unreadable/corrupt history file
1779
+ return [];
1780
+ }
1781
+ // Text-format notebooks can be edited outside Nebula (that is their
1782
+ // point); reconcile any divergence into history so undo/redo stays
1783
+ // infinite across external edits. No-op for .ipynb.
1784
+ return this.reconcileExternalTextEdits(notebookPath, history);
1785
+ }
1786
+ /**
1787
+ * Sync variant of loadHistory. Kept for cold callers that need a
1788
+ * synchronous read (headless undo/redo state bootstrap).
1368
1789
  */
1369
- loadHistory(notebookPath) {
1790
+ loadHistorySync(notebookPath) {
1370
1791
  const historyPath = this.getHistoryPath(notebookPath);
1371
1792
  if (this.hasPendingCommit(notebookPath)) {
1372
1793
  console.warn(`[FilesystemService] Pending commit detected for ${notebookPath}; skipping history load.`);
@@ -1375,12 +1796,134 @@ class FilesystemService {
1375
1796
  if (!fs.existsSync(historyPath)) {
1376
1797
  return [];
1377
1798
  }
1799
+ let history;
1378
1800
  try {
1379
- return JSON.parse(fs.readFileSync(historyPath, 'utf-8'));
1801
+ history = JSON.parse(fs.readFileSync(historyPath, 'utf-8'));
1380
1802
  }
1381
1803
  catch {
1382
1804
  return [];
1383
1805
  }
1806
+ return this.reconcileExternalTextEdits(notebookPath, history);
1807
+ }
1808
+ /**
1809
+ * Compare the file's current cells against the last-Nebula-save record.
1810
+ * On divergence, append ONE synthesized batch operation describing the
1811
+ * external edit (updateContent / insertCell / deleteCell, source
1812
+ * 'external') — both undo systems replay these, so a single undo step
1813
+ * reverts the external edit and redo reapplies it. Falls back to a plain
1814
+ * snapshot entry if synthesis fails (undo then bottoms out there instead
1815
+ * of corrupting).
1816
+ */
1817
+ reconcileExternalTextEdits(notebookPath, history) {
1818
+ const adapter = (0, registry_1.getFormatAdapter)(notebookPath);
1819
+ if (!adapter || history.length === 0)
1820
+ return history;
1821
+ const normalizedPath = this.normalizePath(notebookPath);
1822
+ const lastSavePath = this.getLastSavePath(notebookPath);
1823
+ if (!fs.existsSync(normalizedPath) || !fs.existsSync(lastSavePath))
1824
+ return history;
1825
+ let currentCells;
1826
+ try {
1827
+ currentCells = adapter.parse(fs.readFileSync(normalizedPath, 'utf-8')).cells;
1828
+ }
1829
+ catch {
1830
+ return history;
1831
+ }
1832
+ try {
1833
+ const record = JSON.parse(fs.readFileSync(lastSavePath, 'utf-8'));
1834
+ const before = record.cells ?? [];
1835
+ const unchanged = before.length === currentCells.length &&
1836
+ before.every((b, i) => currentCells[i].id === b.id &&
1837
+ currentCells[i].type === b.type &&
1838
+ currentCells[i].content === b.content);
1839
+ if (unchanged)
1840
+ return history;
1841
+ const operations = this.synthesizeExternalEditOps(before, currentCells);
1842
+ if (operations.length === 0)
1843
+ return history;
1844
+ const mtime = fs.statSync(normalizedPath).mtimeMs;
1845
+ const entry = {
1846
+ type: 'batch',
1847
+ operations,
1848
+ source: 'external',
1849
+ operationId: `external-${crypto.randomBytes(6).toString('hex')}`,
1850
+ timestamp: mtime,
1851
+ external: true,
1852
+ };
1853
+ const next = [...history, entry];
1854
+ this.writeJsonAtomicSync(this.getHistoryPath(notebookPath), next);
1855
+ this.writeJsonAtomicSync(lastSavePath, {
1856
+ cells: currentCells.map((c) => ({ id: c.id, type: c.type, content: c.content })),
1857
+ });
1858
+ console.log(`[FilesystemService] Reconciled external edit into history for ${notebookPath} (${operations.length} ops)`);
1859
+ return next;
1860
+ }
1861
+ catch (e) {
1862
+ console.warn('[FilesystemService] External-edit reconciliation failed; appending snapshot:', e);
1863
+ try {
1864
+ const snapshot = {
1865
+ type: 'snapshot',
1866
+ cells: currentCells.map((c) => this.stripOutputsForHistorySnapshot(c)),
1867
+ timestamp: fs.statSync(normalizedPath).mtimeMs,
1868
+ source: 'external',
1869
+ };
1870
+ const next = [...history, snapshot];
1871
+ this.writeJsonAtomicSync(this.getHistoryPath(notebookPath), next);
1872
+ this.writeJsonAtomicSync(lastSavePath, {
1873
+ cells: currentCells.map((c) => ({ id: c.id, type: c.type, content: c.content })),
1874
+ });
1875
+ return next;
1876
+ }
1877
+ catch {
1878
+ return history;
1879
+ }
1880
+ }
1881
+ }
1882
+ synthesizeExternalEditOps(before, after) {
1883
+ const toCell = (c) => ({
1884
+ id: c.id,
1885
+ type: c.type,
1886
+ content: c.content,
1887
+ outputs: [],
1888
+ executionCount: null,
1889
+ isExecuting: false,
1890
+ });
1891
+ const afterById = new Map(after.map((c, i) => [c.id, i]));
1892
+ const beforeById = new Map(before.map((c, i) => [c.id, i]));
1893
+ const ops = [];
1894
+ // Deletions (and type changes) — descending index so replay indices hold
1895
+ for (let i = before.length - 1; i >= 0; i--) {
1896
+ const b = before[i];
1897
+ const aIdx = afterById.get(b.id);
1898
+ const a = aIdx !== undefined ? after[aIdx] : null;
1899
+ if (!a || a.type !== b.type) {
1900
+ ops.push({ type: 'deleteCell', index: i, cell: toCell(b), source: 'external' });
1901
+ }
1902
+ }
1903
+ // Insertions (and the insert half of type changes) — ascending index
1904
+ for (let i = 0; i < after.length; i++) {
1905
+ const a = after[i];
1906
+ const bIdx = beforeById.get(a.id);
1907
+ const b = bIdx !== undefined ? before[bIdx] : null;
1908
+ if (!b || b.type !== a.type) {
1909
+ ops.push({
1910
+ type: 'insertCell',
1911
+ index: i,
1912
+ cell: { ...a, outputs: [], executionCount: null, isExecuting: false },
1913
+ source: 'external',
1914
+ });
1915
+ }
1916
+ else if (b.content !== a.content) {
1917
+ ops.push({
1918
+ type: 'updateContent',
1919
+ cellId: a.id,
1920
+ oldContent: b.content,
1921
+ newContent: a.content,
1922
+ source: 'external',
1923
+ });
1924
+ }
1925
+ }
1926
+ return ops;
1384
1927
  }
1385
1928
  /**
1386
1929
  * Save session state for a notebook
@@ -1389,11 +1932,9 @@ class FilesystemService {
1389
1932
  const sessionPath = this.getSessionPath(notebookPath);
1390
1933
  // Create .nebula directory if needed
1391
1934
  const nebulaDir = path.dirname(sessionPath);
1392
- if (!fs.existsSync(nebulaDir)) {
1393
- fs.mkdirSync(nebulaDir, { recursive: true });
1394
- }
1935
+ await fs_1.promises.mkdir(nebulaDir, { recursive: true });
1395
1936
  await this.withWriteLock(notebookPath, async () => {
1396
- this.writeJsonAtomicSync(sessionPath, session);
1937
+ await this.writeJsonAtomic(sessionPath, session);
1397
1938
  });
1398
1939
  return true;
1399
1940
  }