nebula-notebook 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +124 -24
  2. package/dist/assets/errorwidget-C4r2j2DQ.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-7-YBurka.js +716 -0
  19. package/dist/assets/index-BtWv4MIT.css +7 -0
  20. package/dist/assets/index-CFBUnxSZ.css +32 -0
  21. package/dist/assets/index-CsHoPQy-.js +1 -0
  22. package/dist/assets/index-D5w21_Z8.js +81 -0
  23. package/dist/assets/index-Day3QcNs.js +1 -0
  24. package/dist/assets/services-shim-D6p_A67v.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/cluster/client-registration.js +3 -0
  29. package/node-server/dist/cluster/kernel-proxy.js +24 -9
  30. package/node-server/dist/cluster/server-registry.d.ts +8 -0
  31. package/node-server/dist/cluster/server-registry.js +31 -7
  32. package/node-server/dist/fs/fs-service.d.ts +55 -7
  33. package/node-server/dist/fs/fs-service.js +489 -80
  34. package/node-server/dist/fs/notebook-formats/percent.d.ts +25 -0
  35. package/node-server/dist/fs/notebook-formats/percent.js +286 -0
  36. package/node-server/dist/fs/notebook-formats/qmd.d.ts +29 -0
  37. package/node-server/dist/fs/notebook-formats/qmd.js +307 -0
  38. package/node-server/dist/fs/notebook-formats/registry.d.ts +12 -0
  39. package/node-server/dist/fs/notebook-formats/registry.js +77 -0
  40. package/node-server/dist/fs/notebook-formats/types.d.ts +37 -0
  41. package/node-server/dist/fs/notebook-formats/types.js +13 -0
  42. package/node-server/dist/idle-exit.d.ts +52 -0
  43. package/node-server/dist/idle-exit.js +83 -0
  44. package/node-server/dist/index.js +129 -9
  45. package/node-server/dist/kernel/kernel-service.d.ts +113 -2
  46. package/node-server/dist/kernel/kernel-service.js +762 -60
  47. package/node-server/dist/notebook/cell-hash.d.ts +13 -0
  48. package/node-server/dist/notebook/cell-hash.js +26 -0
  49. package/node-server/dist/notebook/headless-handler.d.ts +9 -0
  50. package/node-server/dist/notebook/headless-handler.js +124 -23
  51. package/node-server/dist/notebook/operation-router.d.ts +36 -0
  52. package/node-server/dist/notebook/operation-router.js +224 -9
  53. package/node-server/dist/notebook/undoRedoManager.d.ts +4 -1
  54. package/node-server/dist/notebook/undoRedoManager.js +10 -2
  55. package/node-server/dist/output/display-data.js +2 -0
  56. package/node-server/dist/routes/cluster.js +2 -2
  57. package/node-server/dist/routes/compute.d.ts +8 -0
  58. package/node-server/dist/routes/compute.js +136 -0
  59. package/node-server/dist/routes/fs.js +2 -2
  60. package/node-server/dist/routes/kernel.js +115 -3
  61. package/node-server/dist/routes/notebook.js +35 -1
  62. package/node-server/dist/scheduler/allocation-service.d.ts +43 -0
  63. package/node-server/dist/scheduler/allocation-service.js +169 -0
  64. package/node-server/dist/scheduler/job-template.d.ts +30 -0
  65. package/node-server/dist/scheduler/job-template.js +85 -0
  66. package/node-server/dist/scheduler/mock-scheduler.d.ts +30 -0
  67. package/node-server/dist/scheduler/mock-scheduler.js +121 -0
  68. package/node-server/dist/scheduler/slurm-scheduler.d.ts +31 -0
  69. package/node-server/dist/scheduler/slurm-scheduler.js +393 -0
  70. package/node-server/dist/scheduler/types.d.ts +117 -0
  71. package/node-server/dist/scheduler/types.js +8 -0
  72. package/node-server/dist/scheduler/util.d.ts +7 -0
  73. package/node-server/dist/scheduler/util.js +20 -0
  74. package/node-server/dist/terminal/pty-manager.js +8 -0
  75. package/node-server/dist/terminal/server.js +43 -2
  76. package/node-server/dist/update-check.d.ts +20 -0
  77. package/node-server/dist/update-check.js +114 -0
  78. package/node-server/package.json +1 -3
  79. package/package.json +3 -5
  80. package/dist/assets/index-C1h_sArD.css +0 -32
  81. package/dist/assets/index-CDSTBon8.js +0 -658
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Content hash for optimistic concurrency control on agent writes.
3
+ *
4
+ * The operation router records the hash of each cell's content as the agent
5
+ * last saw it (from read results); write operations carry that hash and the
6
+ * applier — the live UI when connected, the headless handler otherwise —
7
+ * rejects the write if the cell's current content no longer matches. This is
8
+ * compare-and-swap at the point of application: immune to autosave latency
9
+ * and to browser/server clock skew (which rule out timestamp-based checks).
10
+ *
11
+ * MUST stay in sync with lib/cellHash.ts (frontend copy).
12
+ */
13
+ export declare function hashCellContent(content: string): string;
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hashCellContent = hashCellContent;
4
+ /**
5
+ * Content hash for optimistic concurrency control on agent writes.
6
+ *
7
+ * The operation router records the hash of each cell's content as the agent
8
+ * last saw it (from read results); write operations carry that hash and the
9
+ * applier — the live UI when connected, the headless handler otherwise —
10
+ * rejects the write if the cell's current content no longer matches. This is
11
+ * compare-and-swap at the point of application: immune to autosave latency
12
+ * and to browser/server clock skew (which rule out timestamp-based checks).
13
+ *
14
+ * MUST stay in sync with lib/cellHash.ts (frontend copy).
15
+ */
16
+ function hashCellContent(content) {
17
+ // FNV-1a, 32-bit, hex — stable, fast, and tiny. Collisions are acceptable:
18
+ // a false "unchanged" requires an adversarial collision; the cost of a
19
+ // false conflict is one extra agent read.
20
+ let hash = 0x811c9dc5;
21
+ for (let i = 0; i < content.length; i++) {
22
+ hash ^= content.charCodeAt(i);
23
+ hash = Math.imul(hash, 0x01000193);
24
+ }
25
+ return (hash >>> 0).toString(16).padStart(8, '0');
26
+ }
@@ -52,6 +52,8 @@ export declare class HeadlessOperationHandler {
52
52
  * Persist dirty notebooks to disk.
53
53
  */
54
54
  flush(notebookPath?: string): Promise<void>;
55
+ /** In-flight persist per notebook path (single-flight). */
56
+ private persisting;
55
57
  private asyncPersist;
56
58
  private schedulePersist;
57
59
  /**
@@ -66,6 +68,13 @@ export declare class HeadlessOperationHandler {
66
68
  * Check if agent has permission to modify this notebook.
67
69
  */
68
70
  private checkAgentPermission;
71
+ /**
72
+ * Compare expectedHash/expectedHashes stamped by the operation router
73
+ * against current cell content. Returns a conflict result, or null to
74
+ * proceed. The conflict message carries the current content so the agent
75
+ * can re-apply its intent without an extra read.
76
+ */
77
+ private checkExpectedHashes;
69
78
  /**
70
79
  * Apply a notebook operation.
71
80
  */
@@ -41,9 +41,11 @@ var __importStar = (this && this.__importStar) || (function () {
41
41
  Object.defineProperty(exports, "__esModule", { value: true });
42
42
  exports.HeadlessOperationHandler = void 0;
43
43
  const fs = __importStar(require("fs"));
44
+ const cell_hash_1 = require("./cell-hash");
44
45
  const path = __importStar(require("path"));
45
46
  const os = __importStar(require("os"));
46
47
  const uuid_1 = require("uuid");
48
+ const registry_1 = require("../fs/notebook-formats/registry");
47
49
  const cell_metadata_1 = require("./cell-metadata");
48
50
  const undoRedoManager_1 = require("./undoRedoManager");
49
51
  const kernelspec_1 = require("../kernel/kernelspec");
@@ -79,7 +81,7 @@ class HeadlessOperationHandler {
79
81
  */
80
82
  getCachedNotebook(notebookPath) {
81
83
  if (!this.cache.has(notebookPath)) {
82
- const result = this.fsService.getNotebookCells(notebookPath);
84
+ const result = this.fsService.getNotebookCellsSync(notebookPath);
83
85
  this.cache.set(notebookPath, {
84
86
  cells: result.cells || [],
85
87
  metadata: result.metadata || {},
@@ -108,23 +110,41 @@ class HeadlessOperationHandler {
108
110
  async flush(notebookPath) {
109
111
  const paths = notebookPath ? [notebookPath] : Array.from(this.cache.keys());
110
112
  for (const p of paths) {
111
- const notebook = this.cache.get(p);
112
- if (notebook?.dirty) {
113
+ // A scheduled persist may have already cleared the dirty flag but not
114
+ // finished writing — flush must wait for it, or callers read a stale
115
+ // file. The persist loop keeps writing while dirty, so awaiting the
116
+ // in-flight run covers later mutations too.
117
+ const inFlight = this.persisting.get(p);
118
+ if (inFlight) {
119
+ await inFlight;
120
+ }
121
+ if (this.cache.get(p)?.dirty) {
113
122
  await this.asyncPersist(p);
114
123
  }
115
124
  }
116
125
  }
117
- async asyncPersist(notebookPath) {
118
- const notebook = this.cache.get(notebookPath);
119
- if (!notebook)
120
- return;
121
- // Keep writing while dirty
122
- while (notebook.dirty) {
123
- const cells = JSON.parse(JSON.stringify(notebook.cells));
124
- notebook.dirty = false;
125
- const history = this.undoRedoManager.getHistory(notebookPath, cells);
126
- await this.fsService.saveNotebookBundle(notebookPath, cells, undefined, history);
127
- }
126
+ /** In-flight persist per notebook path (single-flight). */
127
+ persisting = new Map();
128
+ asyncPersist(notebookPath) {
129
+ const existing = this.persisting.get(notebookPath);
130
+ if (existing)
131
+ return existing;
132
+ const run = (async () => {
133
+ const notebook = this.cache.get(notebookPath);
134
+ if (!notebook)
135
+ return;
136
+ // Keep writing while dirty
137
+ while (notebook.dirty) {
138
+ const cells = JSON.parse(JSON.stringify(notebook.cells));
139
+ notebook.dirty = false;
140
+ const history = this.undoRedoManager.getHistory(notebookPath, cells);
141
+ await this.fsService.saveNotebookBundle(notebookPath, cells, undefined, history);
142
+ }
143
+ })().finally(() => {
144
+ this.persisting.delete(notebookPath);
145
+ });
146
+ this.persisting.set(notebookPath, run);
147
+ return run;
128
148
  }
129
149
  schedulePersist(notebookPath) {
130
150
  // Schedule async persist
@@ -142,6 +162,10 @@ class HeadlessOperationHandler {
142
162
  else {
143
163
  this.cache.clear();
144
164
  }
165
+ // Undo/redo state is derived from the persisted history file; when the
166
+ // notebook may have changed underneath us (UI save, external edit
167
+ // reconciliation), rebuild it from disk on next access.
168
+ this.undoRedoManager.clearState(notebookPath);
145
169
  }
146
170
  /**
147
171
  * Check if notebook has unsaved changes.
@@ -154,22 +178,70 @@ class HeadlessOperationHandler {
154
178
  */
155
179
  checkAgentPermission(notebookPath, _operationType) {
156
180
  const status = this.fsService.getAgentPermissionStatus(notebookPath);
157
- if (status.agent_created) {
181
+ // can_agent_modify already encodes the tri-state rule (agent-created notebooks
182
+ // are permitted unless explicitly revoked; user notebooks need permission +
183
+ // history), so an explicit user revoke wins even for agent-created notebooks.
184
+ if (status.can_agent_modify) {
185
+ return null;
186
+ }
187
+ // User-permitted but history not yet enabled (soft, fixable) vs. not permitted.
188
+ if (status.agent_permitted && !status.has_history && !status.agent_created) {
189
+ return {
190
+ success: false,
191
+ 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.`,
192
+ };
193
+ }
194
+ return {
195
+ success: false,
196
+ 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.`,
197
+ };
198
+ }
199
+ /**
200
+ * Compare expectedHash/expectedHashes stamped by the operation router
201
+ * against current cell content. Returns a conflict result, or null to
202
+ * proceed. The conflict message carries the current content so the agent
203
+ * can re-apply its intent without an extra read.
204
+ */
205
+ checkExpectedHashes(operation, notebookPath) {
206
+ const expectedHash = operation.expectedHash;
207
+ const expectedHashes = operation.expectedHashes;
208
+ if (!expectedHash && !expectedHashes)
209
+ return null;
210
+ if (!notebookPath)
158
211
  return null;
212
+ const cells = this.getCells(notebookPath);
213
+ const checks = [];
214
+ if (expectedHash && typeof operation.cellId === 'string') {
215
+ checks.push({ cellId: operation.cellId, expected: expectedHash });
216
+ }
217
+ if (expectedHashes) {
218
+ for (const [cellId, expected] of Object.entries(expectedHashes)) {
219
+ checks.push({ cellId, expected });
220
+ }
159
221
  }
160
- if (status.agent_permitted) {
161
- if (!status.has_history) {
222
+ for (const { cellId, expected } of checks) {
223
+ const cell = cells.find(c => c.id === cellId);
224
+ if (!cell) {
162
225
  return {
163
226
  success: false,
164
- 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.`,
227
+ conflict: true,
228
+ error: `Conflict: cell ${cellId} no longer exists (it changed since you last read it). Re-read the notebook and retry.`,
229
+ };
230
+ }
231
+ const current = (0, cell_hash_1.hashCellContent)(cell.content ?? '');
232
+ if (current !== expected) {
233
+ const preview = (cell.content ?? '').slice(0, 2000);
234
+ return {
235
+ success: false,
236
+ conflict: true,
237
+ error: `Conflict: cell ${cellId} was modified (likely by the user) after you last read it. ` +
238
+ `Current content:\n${preview}${(cell.content ?? '').length > 2000 ? '\n…(truncated)' : ''}\n` +
239
+ `Re-apply your intent against this content and retry — your next write to this cell is validated against the content shown above, so no re-read is needed.`,
240
+ currentContent: cell.content,
165
241
  };
166
242
  }
167
- return null;
168
243
  }
169
- return {
170
- success: false,
171
- 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.`,
172
- };
244
+ return null;
173
245
  }
174
246
  /**
175
247
  * Apply a notebook operation.
@@ -187,6 +259,13 @@ class HeadlessOperationHandler {
187
259
  return permissionError;
188
260
  }
189
261
  }
262
+ // Optimistic concurrency: collaborative agent sessions stamp destructive
263
+ // writes with the content hash the agent last saw (see operation-router).
264
+ // Verify against current content before applying.
265
+ const occConflict = this.checkExpectedHashes(operation, notebookPath);
266
+ if (occConflict) {
267
+ return occConflict;
268
+ }
190
269
  try {
191
270
  let result;
192
271
  switch (opType) {
@@ -726,6 +805,28 @@ class HeadlessOperationHandler {
726
805
  error: `Notebook already exists: ${notebookPath}. Use overwrite=true to replace.`,
727
806
  };
728
807
  }
808
+ // Text notebook formats (.py percent / .qmd): create through the format
809
+ // adapter — agent flags live in the file's own metadata like .ipynb.
810
+ const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
811
+ if (formatAdapter) {
812
+ const dir = path.dirname(normalizedPath);
813
+ if (!fs.existsSync(dir)) {
814
+ fs.mkdirSync(dir, { recursive: true });
815
+ }
816
+ const metadata = {
817
+ kernelspec: { name: kernelName, display_name: kernelDisplayName },
818
+ nebula: { agent_created: true, agent_permitted: true },
819
+ };
820
+ if (formatAdapter.name === 'qmd')
821
+ metadata.__qmd_language = 'python';
822
+ fs.writeFileSync(normalizedPath, formatAdapter.serialize([], metadata), 'utf-8');
823
+ this.invalidate(notebookPath);
824
+ return {
825
+ success: true,
826
+ path: notebookPath,
827
+ mtime: fs.statSync(normalizedPath).mtimeMs / 1000,
828
+ };
829
+ }
729
830
  const notebook = {
730
831
  nbformat: 4,
731
832
  nbformat_minor: 5,
@@ -12,6 +12,7 @@ import { HeadlessOperationHandler } from './headless-handler';
12
12
  interface KernelChangedPayload {
13
13
  kernelName: string;
14
14
  serverId?: string | null;
15
+ mtime?: number;
15
16
  }
16
17
  type Backend = 'ui' | 'headless';
17
18
  interface OperationResult {
@@ -27,12 +28,15 @@ interface AgentLock {
27
28
  expiresAt: number;
28
29
  notebookPath: string;
29
30
  lockedAt: number;
31
+ exclusive: boolean;
32
+ cellHashes: Map<string, string>;
30
33
  }
31
34
  export declare class OperationRouter {
32
35
  private uiConnections;
33
36
  private headlessHandler;
34
37
  private operationTimeout;
35
38
  private agentLocks;
39
+ private preSessionReadHashes;
36
40
  setHeadlessHandler(handler: HeadlessOperationHandler): void;
37
41
  /**
38
42
  * Register a UI connection for a notebook path.
@@ -58,6 +62,7 @@ export declare class OperationRouter {
58
62
  startAgentSession(notebookPath: string, agentId: string, metadata?: {
59
63
  clientName?: string;
60
64
  clientVersion?: string;
65
+ exclusive?: boolean;
61
66
  }): {
62
67
  success: boolean;
63
68
  error?: string;
@@ -97,6 +102,37 @@ export declare class OperationRouter {
97
102
  * Apply a notebook operation.
98
103
  */
99
104
  applyOperation(operation: Record<string, unknown>): Promise<OperationResult>;
105
+ /**
106
+ * OCC preparation for a destructive write in a collaborative session.
107
+ * Returns an error string to reject the operation, or null to proceed
108
+ * (with `expectedHash`/`expectedHashes` stamped onto the operation).
109
+ *
110
+ * Policy:
111
+ * - updateContent / updateMetadata / deleteCell / deleteCells: the agent
112
+ * must have read the cell this session (hash known), and the applier
113
+ * verifies the content still matches before applying.
114
+ * - executeCell: verified only when the hash is known (running slightly
115
+ * stale content is recoverable; destroying user edits is not).
116
+ * - clearNotebook: requires an exclusive session.
117
+ * - Index-addressed destructive writes are rejected: user edits shift
118
+ * indices, so collaborative writes must address cells by id.
119
+ */
120
+ private prepareCollaborativeWrite;
121
+ /**
122
+ * Record the session's view of cell content from operation results.
123
+ * Keyed to the notebook's active lock: the lock guarantees a single writing
124
+ * agent per notebook, so reads from any source refreshing it is sound.
125
+ */
126
+ private recordSessionHashes;
127
+ /**
128
+ * Record hashes for a full-notebook read (used by router.readNotebook,
129
+ * which serves the MCP read_notebook tool outside applyOperation).
130
+ */
131
+ recordNotebookReadHashes(notebookPath: string, cells: Array<{
132
+ id?: string;
133
+ content?: string;
134
+ }>): void;
135
+ private getPreSessionStore;
100
136
  private forwardToUI;
101
137
  /**
102
138
  * Handle operation response from UI.
@@ -47,6 +47,8 @@ const path = __importStar(require("path"));
47
47
  const fs = __importStar(require("fs"));
48
48
  const os = __importStar(require("os"));
49
49
  const ws_1 = require("ws");
50
+ const cell_hash_1 = require("./cell-hash");
51
+ const registry_1 = require("../fs/notebook-formats/registry");
50
52
  const AGENT_LOCK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
51
53
  const UI_STALE_TIMEOUT_MS = 45 * 1000; // 45 seconds
52
54
  function normalizeNotebookPath(notebookPath) {
@@ -62,6 +64,11 @@ class OperationRouter {
62
64
  headlessHandler = null;
63
65
  operationTimeout = 30000; // 30 seconds
64
66
  agentLocks = new Map(); // path -> lock
67
+ // Content hashes from reads that happen OUTSIDE a session (agents read to
68
+ // orient themselves before calling start_agent_session). Seeds the session's
69
+ // cellHashes on start so the first write doesn't hit "read it first".
70
+ // Keyed per path — the per-notebook lock already serializes writing agents.
71
+ preSessionReadHashes = new Map();
65
72
  setHeadlessHandler(handler) {
66
73
  this.headlessHandler = handler;
67
74
  }
@@ -93,6 +100,11 @@ class OperationRouter {
93
100
  pendingRequests: new Map(),
94
101
  lastActivityAt: Date.now(),
95
102
  });
103
+ // The UI is now the source of truth for this notebook; any headless cache
104
+ // is potentially stale (and would be consulted again if this connection
105
+ // goes stale or closes).
106
+ this.headlessHandler?.invalidate(notebookPath);
107
+ this.headlessHandler?.invalidate(normalizedPath);
96
108
  console.log(`[OperationRouter] UI registered for: ${normalizedPath}`);
97
109
  }
98
110
  /**
@@ -111,6 +123,10 @@ class OperationRouter {
111
123
  request.reject(new Error('UI disconnected'));
112
124
  }
113
125
  this.uiConnections.delete(normalizedPath);
126
+ // Subsequent ops route headless — make sure they reload from disk (which
127
+ // has the UI's autosaves) rather than a cache predating the UI session.
128
+ this.headlessHandler?.invalidate(notebookPath);
129
+ this.headlessHandler?.invalidate(normalizedPath);
114
130
  console.log(`[OperationRouter] UI unregistered for: ${normalizedPath}`);
115
131
  }
116
132
  }
@@ -125,6 +141,7 @@ class OperationRouter {
125
141
  type: 'kernelChanged',
126
142
  kernelName: payload.kernelName,
127
143
  serverId: payload.serverId ?? null,
144
+ mtime: payload.mtime,
128
145
  }));
129
146
  }
130
147
  catch (err) {
@@ -169,8 +186,12 @@ class OperationRouter {
169
186
  const existingLock = this.agentLocks.get(normalizedPath);
170
187
  if (existingLock) {
171
188
  if (existingLock.agentId === agentId) {
172
- // Same agent re-acquiring lock - refresh timeout
189
+ // Same agent re-acquiring lock - refresh timeout (and allow it to
190
+ // upgrade/downgrade exclusivity)
173
191
  existingLock.expiresAt = Date.now() + AGENT_LOCK_TIMEOUT_MS;
192
+ if (metadata?.exclusive !== undefined) {
193
+ existingLock.exclusive = metadata.exclusive;
194
+ }
174
195
  console.log(`[OperationRouter] Agent session refreshed for: ${normalizedPath} (agent: ${agentId})`);
175
196
  return { success: true, lock: existingLock };
176
197
  }
@@ -185,7 +206,8 @@ class OperationRouter {
185
206
  };
186
207
  }
187
208
  }
188
- // Acquire new lock
209
+ // Acquire new lock — seeded with hashes from any pre-session reads, so the
210
+ // standard orient-then-edit agent flow doesn't trip "read it first".
189
211
  const now = Date.now();
190
212
  const newLock = {
191
213
  agentId,
@@ -194,6 +216,8 @@ class OperationRouter {
194
216
  expiresAt: now + AGENT_LOCK_TIMEOUT_MS,
195
217
  notebookPath: normalizedPath,
196
218
  lockedAt: now,
219
+ exclusive: metadata?.exclusive === true,
220
+ cellHashes: new Map(this.preSessionReadHashes.get(normalizedPath) ?? []),
197
221
  };
198
222
  this.agentLocks.set(normalizedPath, newLock);
199
223
  console.log(`[OperationRouter] Agent session started for: ${normalizedPath} (agent: ${agentId}, client: ${metadata?.clientName || 'unknown'})`);
@@ -297,9 +321,13 @@ class OperationRouter {
297
321
  const opType = operation.type;
298
322
  const agentId = operation.agentId;
299
323
  // For createNotebook, route to ANY connected UI (not path-specific)
300
- // 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.
301
328
  const isCreateNotebook = opType === 'createNotebook';
302
- const anyUI = isCreateNotebook ? this.getAnyUIConnection() : null;
329
+ const isTextFormatCreate = isCreateNotebook && (0, registry_1.isTextNotebookPath)(operation.notebookPath || '');
330
+ const anyUI = isCreateNotebook && !isTextFormatCreate ? this.getAnyUIConnection() : null;
303
331
  const hasUI = isCreateNotebook ? (anyUI !== null) : (this.getResponsiveUIConnection(normalizedPath) !== null);
304
332
  const backend = hasUI ? 'ui' : 'headless';
305
333
  // Clean up expired locks
@@ -321,7 +349,8 @@ class OperationRouter {
321
349
  const clientName = operation.clientName;
322
350
  const clientVersion = operation.clientVersion;
323
351
  const lastSessionTimestamp = operation.lastSessionTimestamp;
324
- const result = this.startAgentSession(notebookPath, reqAgentId, { clientName, clientVersion });
352
+ const exclusive = operation.exclusive === true;
353
+ const result = this.startAgentSession(notebookPath, reqAgentId, { clientName, clientVersion, exclusive });
325
354
  // Also forward to UI for UI state update (badge display)
326
355
  if (result.success && hasUI) {
327
356
  console.log(` -> Lock acquired, forwarding to UI for badge update`);
@@ -380,19 +409,197 @@ class OperationRouter {
380
409
  if (lock && agentId && lock.agentId === agentId) {
381
410
  lock.expiresAt = Date.now() + AGENT_LOCK_TIMEOUT_MS;
382
411
  }
412
+ // Collaborative sessions: stamp destructive writes with the content hash
413
+ // the agent last saw, to be compared against live content at the applier
414
+ // (the UI when connected — the only place with a freshness-window-free
415
+ // view, since user edits reach the server only on autosave).
416
+ if (isWrite && lock && !lock.exclusive && agentId && lock.agentId === agentId) {
417
+ const occError = this.prepareCollaborativeWrite(lock, operation, opType);
418
+ if (occError) {
419
+ console.log(` -> BLOCKED (OCC): ${occError}`);
420
+ return { success: false, error: occError, conflict: true, backend };
421
+ }
422
+ }
423
+ let result;
383
424
  if (hasUI) {
384
425
  // For createNotebook, use any available UI connection
385
426
  const uiPath = isCreateNotebook && anyUI ? anyUI.path : normalizedPath;
386
427
  console.log(` -> Routing to UI (via ${uiPath})`);
387
- const result = await this.forwardToUI(uiPath, operation);
388
- return { ...result, backend: 'ui' };
428
+ result = { ...(await this.forwardToUI(uiPath, operation)), backend: 'ui' };
389
429
  }
390
430
  else {
391
431
  console.log(` -> Routing to HEADLESS`);
392
- const result = await this.applyHeadless(operation);
393
- return { ...result, backend: 'headless' };
432
+ result = { ...(await this.applyHeadless(operation)), backend: 'headless' };
433
+ }
434
+ // Keep the session's view of cell content fresh from reads and the
435
+ // agent's own successful writes.
436
+ if (result.success) {
437
+ this.recordSessionHashes(normalizedPath, opType, operation, result);
438
+ }
439
+ else if (result.conflict && typeof result.currentContent === 'string' && typeof operation.cellId === 'string') {
440
+ // Self-healing conflicts: the error hands the agent the current content,
441
+ // so re-baseline the session to the same content. The agent's corrected
442
+ // retry then validates against what it was just told — without this, the
443
+ // stale stored hash rejects every retry until an explicit re-read.
444
+ const lock2 = this.agentLocks.get(normalizedPath);
445
+ const hashes = (lock2 && lock2.expiresAt > Date.now())
446
+ ? lock2.cellHashes
447
+ : this.getPreSessionStore(normalizedPath);
448
+ hashes.set(operation.cellId, (0, cell_hash_1.hashCellContent)(result.currentContent));
449
+ }
450
+ return result;
451
+ }
452
+ /**
453
+ * OCC preparation for a destructive write in a collaborative session.
454
+ * Returns an error string to reject the operation, or null to proceed
455
+ * (with `expectedHash`/`expectedHashes` stamped onto the operation).
456
+ *
457
+ * Policy:
458
+ * - updateContent / updateMetadata / deleteCell / deleteCells: the agent
459
+ * must have read the cell this session (hash known), and the applier
460
+ * verifies the content still matches before applying.
461
+ * - executeCell: verified only when the hash is known (running slightly
462
+ * stale content is recoverable; destroying user edits is not).
463
+ * - clearNotebook: requires an exclusive session.
464
+ * - Index-addressed destructive writes are rejected: user edits shift
465
+ * indices, so collaborative writes must address cells by id.
466
+ */
467
+ prepareCollaborativeWrite(lock, operation, opType) {
468
+ const requireHash = (cellId) => {
469
+ if (typeof cellId !== 'string' || !cellId) {
470
+ return `${opType} in a collaborative session must address the cell by cell_id (indices shift when the user edits). Re-read the notebook and use ids.`;
471
+ }
472
+ const hash = lock.cellHashes.get(cellId);
473
+ if (!hash) {
474
+ return `You haven't read cell ${cellId} in this session — read it first (read_cell), then retry. In collaborative sessions the user may edit while you work.`;
475
+ }
476
+ return null;
477
+ };
478
+ switch (opType) {
479
+ case 'updateContent':
480
+ case 'updateMetadata':
481
+ case 'deleteCell': {
482
+ const cellId = operation.cellId ?? null;
483
+ const err = requireHash(cellId);
484
+ if (err)
485
+ return err;
486
+ operation.expectedHash = lock.cellHashes.get(cellId);
487
+ return null;
488
+ }
489
+ case 'deleteCells': {
490
+ const cellIds = Array.isArray(operation.cellIds) ? operation.cellIds : [];
491
+ const hashes = {};
492
+ for (const cellId of cellIds) {
493
+ const err = requireHash(cellId);
494
+ if (err)
495
+ return err;
496
+ hashes[cellId] = lock.cellHashes.get(cellId);
497
+ }
498
+ operation.expectedHashes = hashes;
499
+ return null;
500
+ }
501
+ case 'executeCell': {
502
+ const cellId = operation.cellId;
503
+ if (typeof cellId === 'string' && lock.cellHashes.has(cellId)) {
504
+ operation.expectedHash = lock.cellHashes.get(cellId);
505
+ }
506
+ return null;
507
+ }
508
+ case 'clearNotebook':
509
+ return 'clearNotebook requires an exclusive session (start_agent_session with exclusive=true) — it would destroy any concurrent user edits.';
510
+ case 'undo':
511
+ case 'redo':
512
+ // Content shifts unpredictably — force re-reads afterwards.
513
+ lock.cellHashes.clear();
514
+ return null;
515
+ default:
516
+ return null; // insert/move/duplicate/outputs/kernel ops: not destructive to user content
517
+ }
518
+ }
519
+ /**
520
+ * Record the session's view of cell content from operation results.
521
+ * Keyed to the notebook's active lock: the lock guarantees a single writing
522
+ * agent per notebook, so reads from any source refreshing it is sound.
523
+ */
524
+ recordSessionHashes(normalizedPath, opType, operation, result) {
525
+ const lock = this.agentLocks.get(normalizedPath);
526
+ const hashes = (lock && lock.expiresAt > Date.now())
527
+ ? lock.cellHashes
528
+ : this.getPreSessionStore(normalizedPath); // pre-session reads arm OCC too
529
+ const record = (cellId, content) => {
530
+ if (typeof cellId === 'string' && typeof content === 'string') {
531
+ hashes.set(cellId, (0, cell_hash_1.hashCellContent)(content));
532
+ }
533
+ };
534
+ switch (opType) {
535
+ case 'readCell': {
536
+ const cell = result.cell;
537
+ record(cell?.id, cell?.content);
538
+ break;
539
+ }
540
+ case 'updateContent':
541
+ record(operation.cellId, operation.content);
542
+ break;
543
+ case 'insertCell': {
544
+ const cell = operation.cell;
545
+ record(result.cellId, cell?.content ?? '');
546
+ break;
547
+ }
548
+ case 'insertCells': {
549
+ const cells = operation.cells;
550
+ const insertedIds = result.cellIds;
551
+ if (cells && insertedIds && cells.length === insertedIds.length) {
552
+ insertedIds.forEach((id, i) => record(id, cells[i]?.content ?? ''));
553
+ }
554
+ break;
555
+ }
556
+ case 'deleteCell':
557
+ if (typeof operation.cellId === 'string')
558
+ hashes.delete(operation.cellId);
559
+ break;
560
+ case 'deleteCells':
561
+ if (Array.isArray(operation.cellIds)) {
562
+ for (const id of operation.cellIds) {
563
+ if (typeof id === 'string')
564
+ hashes.delete(id);
565
+ }
566
+ }
567
+ break;
568
+ case 'clearNotebook':
569
+ hashes.clear();
570
+ break;
394
571
  }
395
572
  }
573
+ /**
574
+ * Record hashes for a full-notebook read (used by router.readNotebook,
575
+ * which serves the MCP read_notebook tool outside applyOperation).
576
+ */
577
+ recordNotebookReadHashes(notebookPath, cells) {
578
+ const normalizedPath = normalizeNotebookPath(notebookPath);
579
+ const lock = this.agentLocks.get(normalizedPath);
580
+ const target = (lock && lock.expiresAt > Date.now())
581
+ ? lock.cellHashes
582
+ : this.getPreSessionStore(normalizedPath);
583
+ for (const cell of cells) {
584
+ if (typeof cell?.id === 'string' && typeof cell?.content === 'string') {
585
+ target.set(cell.id, (0, cell_hash_1.hashCellContent)(cell.content));
586
+ }
587
+ }
588
+ }
589
+ getPreSessionStore(normalizedPath) {
590
+ let store = this.preSessionReadHashes.get(normalizedPath);
591
+ if (!store) {
592
+ store = new Map();
593
+ this.preSessionReadHashes.set(normalizedPath, store);
594
+ // Bound total memory: keep at most a handful of notebooks' worth.
595
+ if (this.preSessionReadHashes.size > 16) {
596
+ const oldest = this.preSessionReadHashes.keys().next().value;
597
+ if (oldest && oldest !== normalizedPath)
598
+ this.preSessionReadHashes.delete(oldest);
599
+ }
600
+ }
601
+ return store;
602
+ }
396
603
  async forwardToUI(notebookPath, operation) {
397
604
  const conn = this.getResponsiveUIConnection(notebookPath);
398
605
  if (!conn) {
@@ -492,6 +699,9 @@ class OperationRouter {
492
699
  if (this.getResponsiveUIConnection(normalizedPath)) {
493
700
  const result = await this.readFromUI(normalizedPath);
494
701
  if (result.success) {
702
+ const data = result.data;
703
+ if (data?.cells)
704
+ this.recordNotebookReadHashes(normalizedPath, data.cells);
495
705
  const truncated = this.applyOutputTruncation(result, includeOutputs, maxLines, maxChars, maxLinesError, maxCharsError);
496
706
  if (!truncated.backend) {
497
707
  truncated.backend = 'ui';
@@ -505,6 +715,11 @@ class OperationRouter {
505
715
  }
506
716
  else {
507
717
  const result = await this.readFromFile(notebookPath, includeOutputs, maxLines, maxChars, maxLinesError, maxCharsError);
718
+ if (result.success) {
719
+ const data = result.data;
720
+ if (data?.cells)
721
+ this.recordNotebookReadHashes(normalizedPath, data.cells);
722
+ }
508
723
  if (!result.backend) {
509
724
  result.backend = 'headless';
510
725
  }