mrmd-editor 0.6.0 → 0.7.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.
@@ -3,10 +3,14 @@
3
3
  *
4
4
  * A dialog for browsing and selecting files.
5
5
  * Supports both project files and system-wide browsing.
6
+ * When connected to cloud machines, shows a machine tab bar for multi-machine browsing.
6
7
  */
7
8
 
8
9
  import { createDialog } from './base-dialog.js';
9
10
 
11
+ const FILE_PICKER_HISTORY_KEY = 'mrmd:file-picker-history:v1';
12
+ const FILE_PICKER_HISTORY_LIMIT = 400;
13
+
10
14
  // =============================================================================
11
15
  // FILE PICKER
12
16
  // =============================================================================
@@ -43,11 +47,24 @@ export function showFilePicker(options) {
43
47
  let entries = [];
44
48
  let selectedEntry = null;
45
49
  let isLoading = false;
50
+ let pickerHistory = loadFilePickerHistory();
51
+
52
+ // ── Machine catalog state ──
53
+ let catalogData = null; // {machines: [...], cloudOnlyProjects: [...]}
54
+ let activeMachineTab = null; // machineId or 'local' or 'cloud'
55
+ let catalogView = false; // true when showing catalog project/doc tree
56
+ let catalogRoot = ''; // absolute docs root (e.g. /home/ubuntu)
46
57
 
47
58
  // Create content
48
59
  const content = document.createElement('div');
49
60
  content.className = 'mrmd-filepicker';
50
61
 
62
+ // Machine tab bar (hidden until catalog loads)
63
+ const machineBar = document.createElement('div');
64
+ machineBar.className = 'mrmd-filepicker__machines';
65
+ machineBar.style.display = 'none';
66
+ content.appendChild(machineBar);
67
+
51
68
  // Path bar
52
69
  const pathBar = document.createElement('div');
53
70
  pathBar.className = 'mrmd-filepicker__path';
@@ -58,6 +75,20 @@ export function showFilePicker(options) {
58
75
  fileList.className = 'mrmd-filepicker__list';
59
76
  content.appendChild(fileList);
60
77
 
78
+ // Fetch catalog in background (non-blocking)
79
+ Promise.all([
80
+ orchestratorClient.getCatalog().catch(() => null),
81
+ orchestratorClient.listFiles().catch(() => null),
82
+ ]).then(([data, files]) => {
83
+ if (files?.root) {
84
+ catalogRoot = String(files.root).replace(/\/$/, '');
85
+ }
86
+ if (data?.machines?.length > 0 || data?.cloudOnlyProjects?.length > 0) {
87
+ catalogData = data;
88
+ renderMachineBar();
89
+ }
90
+ }).catch(() => { /* catalog unavailable — filesystem-only mode */ });
91
+
61
92
  // New file input (for save mode)
62
93
  let filenameInput = null;
63
94
  if (mode === 'save') {
@@ -81,6 +112,190 @@ export function showFilePicker(options) {
81
112
  content.appendChild(newFileRow);
82
113
  }
83
114
 
115
+ function catalogDocAbsolutePath(projectName, docPath) {
116
+ const root = catalogRoot || '';
117
+ const normalizedRoot = root.replace(/\/$/, '');
118
+ if (normalizedRoot) return `${normalizedRoot}/${projectName}/${docPath}.md`;
119
+ return `/${projectName}/${docPath}.md`;
120
+ }
121
+
122
+ // ── Machine tab bar ──
123
+ function renderMachineBar() {
124
+ if (!catalogData?.machines?.length) {
125
+ machineBar.style.display = 'none';
126
+ return;
127
+ }
128
+
129
+ machineBar.style.display = 'flex';
130
+ machineBar.innerHTML = '';
131
+
132
+ // "Local" tab (filesystem browsing — current behavior)
133
+ const localTab = document.createElement('button');
134
+ localTab.className = 'mrmd-filepicker__machine-tab' + (!catalogView ? ' mrmd-filepicker__machine-tab--active' : '');
135
+ localTab.textContent = '📁 Files';
136
+ localTab.title = 'Browse local filesystem';
137
+ localTab.addEventListener('click', () => {
138
+ catalogView = false;
139
+ activeMachineTab = null;
140
+ renderMachineBar();
141
+ navigateTo(currentPath);
142
+ });
143
+ machineBar.appendChild(localTab);
144
+
145
+ // One tab per connected machine
146
+ for (const machine of catalogData.machines) {
147
+ const tab = document.createElement('button');
148
+ const isActive = catalogView && activeMachineTab === machine.machineId;
149
+ const online = machine.connected !== false && machine.status !== 'offline';
150
+ tab.className = 'mrmd-filepicker__machine-tab'
151
+ + (isActive ? ' mrmd-filepicker__machine-tab--active' : '')
152
+ + (!online ? ' mrmd-filepicker__machine-tab--offline' : '');
153
+
154
+ const dot = online ? '🟢' : '⚫';
155
+ tab.textContent = `${dot} ${machine.machineName || machine.machineId}`;
156
+ tab.title = `${machine.hostname || machine.machineId}\n${(machine.capabilities || []).join(', ')}\n${online ? 'online' : 'offline (opens cached snapshot)'}`;
157
+
158
+ tab.addEventListener('click', () => {
159
+ catalogView = true;
160
+ activeMachineTab = machine.machineId;
161
+ renderMachineBar();
162
+ renderCatalogView(machine);
163
+ });
164
+ machineBar.appendChild(tab);
165
+ }
166
+
167
+ // Cloud tab (cloud-only projects)
168
+ if (catalogData.cloudOnlyProjects?.length > 0) {
169
+ const cloudTab = document.createElement('button');
170
+ const isActive = catalogView && activeMachineTab === 'cloud';
171
+ cloudTab.className = 'mrmd-filepicker__machine-tab' + (isActive ? ' mrmd-filepicker__machine-tab--active' : '');
172
+ cloudTab.textContent = '☁️ Cloud';
173
+ cloudTab.title = 'Projects only in cloud (not on any machine)';
174
+ cloudTab.addEventListener('click', () => {
175
+ catalogView = true;
176
+ activeMachineTab = 'cloud';
177
+ renderMachineBar();
178
+ renderCloudOnlyView();
179
+ });
180
+ machineBar.appendChild(cloudTab);
181
+ }
182
+ }
183
+
184
+ // ── Catalog view: show projects/docs for a machine ──
185
+ function renderCatalogView(machine) {
186
+ pathBar.innerHTML = '';
187
+ const label = document.createElement('span');
188
+ label.className = 'mrmd-filepicker__path-segment';
189
+ const online = machine.connected !== false && machine.status !== 'offline';
190
+ label.textContent = `${machine.machineName || machine.machineId} — ${machine.projects?.length || 0} projects${online ? '' : ' (offline snapshots)'}`;
191
+ pathBar.appendChild(label);
192
+
193
+ fileList.innerHTML = '';
194
+ const projects = machine.projects || [];
195
+
196
+ if (projects.length === 0) {
197
+ fileList.innerHTML = '<div class="mrmd-filepicker__empty">No projects on this machine</div>';
198
+ return;
199
+ }
200
+
201
+ for (const project of projects) {
202
+ // Project header
203
+ const projectItem = document.createElement('div');
204
+ projectItem.className = 'mrmd-filepicker__item mrmd-filepicker__item--project';
205
+ projectItem.style.cursor = 'default';
206
+
207
+ const icon = document.createElement('span');
208
+ icon.className = 'mrmd-filepicker__item-icon';
209
+ icon.textContent = '📁';
210
+ projectItem.appendChild(icon);
211
+
212
+ const name = document.createElement('span');
213
+ name.className = 'mrmd-filepicker__item-name';
214
+ name.style.fontWeight = '600';
215
+ name.textContent = project.name;
216
+ projectItem.appendChild(name);
217
+
218
+ const info = document.createElement('span');
219
+ info.className = 'mrmd-filepicker__item-info';
220
+ info.textContent = `${project.docCount || project.documents?.length || 0} docs`;
221
+ projectItem.appendChild(info);
222
+
223
+ fileList.appendChild(projectItem);
224
+
225
+ // Documents under this project
226
+ for (const doc of (project.documents || [])) {
227
+ const docItem = document.createElement('div');
228
+ docItem.className = 'mrmd-filepicker__item';
229
+ docItem.style.paddingLeft = '30px';
230
+
231
+ const docIcon = document.createElement('span');
232
+ docIcon.className = 'mrmd-filepicker__item-icon';
233
+ docIcon.textContent = '📄';
234
+ docItem.appendChild(docIcon);
235
+
236
+ const docName = document.createElement('span');
237
+ docName.className = 'mrmd-filepicker__item-name';
238
+ docName.textContent = doc.docPath;
239
+ docItem.appendChild(docName);
240
+
241
+ // Click to select, double-click to open
242
+ docItem.addEventListener('click', () => {
243
+ const fullPath = catalogDocAbsolutePath(project.name, doc.docPath);
244
+ selectedEntry = { name: doc.docPath, path: fullPath, type: 'file' };
245
+ fileList.querySelectorAll('.mrmd-filepicker__item--selected').forEach(el => el.classList.remove('mrmd-filepicker__item--selected'));
246
+ docItem.classList.add('mrmd-filepicker__item--selected');
247
+ });
248
+ docItem.addEventListener('dblclick', () => {
249
+ selectFile(catalogDocAbsolutePath(project.name, doc.docPath));
250
+ });
251
+
252
+ fileList.appendChild(docItem);
253
+ }
254
+ }
255
+ }
256
+
257
+ // ── Cloud-only view ──
258
+ function renderCloudOnlyView() {
259
+ pathBar.innerHTML = '';
260
+ const label = document.createElement('span');
261
+ label.className = 'mrmd-filepicker__path-segment';
262
+ label.textContent = 'Cloud-only projects (not on any machine)';
263
+ pathBar.appendChild(label);
264
+
265
+ fileList.innerHTML = '';
266
+ const projects = catalogData?.cloudOnlyProjects || [];
267
+
268
+ if (projects.length === 0) {
269
+ fileList.innerHTML = '<div class="mrmd-filepicker__empty">No cloud-only projects</div>';
270
+ return;
271
+ }
272
+
273
+ for (const projectName of projects) {
274
+ const item = document.createElement('div');
275
+ item.className = 'mrmd-filepicker__item';
276
+
277
+ const icon = document.createElement('span');
278
+ icon.className = 'mrmd-filepicker__item-icon';
279
+ icon.textContent = '📁';
280
+ item.appendChild(icon);
281
+
282
+ const name = document.createElement('span');
283
+ name.className = 'mrmd-filepicker__item-name';
284
+ name.textContent = projectName;
285
+ item.appendChild(name);
286
+
287
+ item.addEventListener('dblclick', () => {
288
+ const root = catalogRoot || '~';
289
+ navigateTo(`${root.replace(/\/$/, '')}/${projectName}`);
290
+ catalogView = false;
291
+ activeMachineTab = null;
292
+ renderMachineBar();
293
+ });
294
+
295
+ fileList.appendChild(item);
296
+ }
297
+ }
298
+
84
299
  // Render path bar
85
300
  function renderPathBar() {
86
301
  pathBar.innerHTML = '';
@@ -139,10 +354,20 @@ export function showFilePicker(options) {
139
354
  return;
140
355
  }
141
356
 
142
- // Sort: directories first, then files
357
+ // Sort: directories first, then recency/frequency score, then name
358
+ const nowMs = Date.now();
143
359
  const sorted = [...entries].sort((a, b) => {
144
360
  if (a.type === 'directory' && b.type !== 'directory') return -1;
145
361
  if (a.type !== 'directory' && b.type === 'directory') return 1;
362
+
363
+ const aScore = getHistoryScore(pickerHistory.get(a.path), nowMs);
364
+ const bScore = getHistoryScore(pickerHistory.get(b.path), nowMs);
365
+ if (aScore !== bScore) return bScore - aScore;
366
+
367
+ const aModified = Number(a.modified || 0);
368
+ const bModified = Number(b.modified || 0);
369
+ if (aModified !== bModified) return bModified - aModified;
370
+
146
371
  return a.name.localeCompare(b.name);
147
372
  });
148
373
 
@@ -164,11 +389,26 @@ export function showFilePicker(options) {
164
389
  name.textContent = entry.name;
165
390
  item.appendChild(name);
166
391
 
167
- if (entry.type === 'file' && entry.size !== undefined) {
168
- const info = document.createElement('span');
169
- info.className = 'mrmd-filepicker__item-info';
170
- info.textContent = formatSize(entry.size);
171
- item.appendChild(info);
392
+ if (entry.type === 'file') {
393
+ const infoParts = [];
394
+ if (entry.size !== undefined) {
395
+ infoParts.push(formatSize(entry.size));
396
+ }
397
+
398
+ const history = pickerHistory.get(entry.path);
399
+ if (history?.lastOpened) {
400
+ infoParts.push(formatTimeAgo(history.lastOpened));
401
+ }
402
+ if ((history?.count || 0) > 1) {
403
+ infoParts.push(`${Math.round(history.count)}x`);
404
+ }
405
+
406
+ if (infoParts.length > 0) {
407
+ const info = document.createElement('span');
408
+ info.className = 'mrmd-filepicker__item-info';
409
+ info.textContent = infoParts.join(' • ');
410
+ item.appendChild(info);
411
+ }
172
412
  }
173
413
 
174
414
  // Click handler
@@ -206,6 +446,13 @@ export function showFilePicker(options) {
206
446
  const result = await orchestratorClient.browse({ path, type: 'all' });
207
447
  currentPath = result.path;
208
448
  entries = result.entries;
449
+
450
+ // Track directory navigation with a lighter weight than file opens
451
+ pickerHistory = touchFilePickerHistory(pickerHistory, currentPath, {
452
+ timestamp: new Date().toISOString(),
453
+ weight: 0.25,
454
+ });
455
+ saveFilePickerHistory(pickerHistory);
209
456
  } catch (error) {
210
457
  console.error('Failed to browse:', error);
211
458
  entries = [];
@@ -218,6 +465,12 @@ export function showFilePicker(options) {
218
465
 
219
466
  // Select a file
220
467
  function selectFile(path) {
468
+ pickerHistory = touchFilePickerHistory(pickerHistory, path, {
469
+ timestamp: new Date().toISOString(),
470
+ weight: 1,
471
+ });
472
+ saveFilePickerHistory(pickerHistory);
473
+
221
474
  dialog.close();
222
475
  onSelect(path);
223
476
  }
@@ -449,3 +702,122 @@ function formatSize(bytes) {
449
702
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
450
703
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
451
704
  }
705
+
706
+ function formatTimeAgo(value) {
707
+ if (!value) return '';
708
+ const ts = Date.parse(value);
709
+ if (!Number.isFinite(ts)) return '';
710
+
711
+ const diffMs = Math.max(0, Date.now() - ts);
712
+ const mins = Math.floor(diffMs / 60000);
713
+ if (mins < 1) return 'just now';
714
+ if (mins < 60) return `${mins}m ago`;
715
+
716
+ const hours = Math.floor(mins / 60);
717
+ if (hours < 24) return `${hours}h ago`;
718
+
719
+ const days = Math.floor(hours / 24);
720
+ if (days < 30) return `${days}d ago`;
721
+
722
+ const months = Math.floor(days / 30);
723
+ return `${months}mo ago`;
724
+ }
725
+
726
+ function getStorage() {
727
+ if (typeof window === 'undefined') return null;
728
+ try {
729
+ return window.localStorage || null;
730
+ } catch {
731
+ return null;
732
+ }
733
+ }
734
+
735
+ function loadFilePickerHistory() {
736
+ const storage = getStorage();
737
+ if (!storage) return new Map();
738
+
739
+ try {
740
+ const raw = storage.getItem(FILE_PICKER_HISTORY_KEY);
741
+ if (!raw) return new Map();
742
+
743
+ const parsed = JSON.parse(raw);
744
+ if (!parsed || typeof parsed !== 'object') return new Map();
745
+
746
+ const byPath = new Map();
747
+ for (const [filePath, value] of Object.entries(parsed)) {
748
+ if (!filePath || !value || typeof value !== 'object') continue;
749
+
750
+ const count = Number(value.count || 0);
751
+ byPath.set(filePath, {
752
+ path: filePath,
753
+ lastOpened: typeof value.lastOpened === 'string' ? value.lastOpened : null,
754
+ count: Number.isFinite(count) && count > 0 ? count : 1,
755
+ });
756
+ }
757
+
758
+ return byPath;
759
+ } catch {
760
+ return new Map();
761
+ }
762
+ }
763
+
764
+ function saveFilePickerHistory(historyMap) {
765
+ const storage = getStorage();
766
+ if (!storage) return;
767
+
768
+ try {
769
+ const items = Array.from(historyMap.values())
770
+ .filter(item => item?.path)
771
+ .sort((a, b) => {
772
+ const aTs = Date.parse(a.lastOpened || 0) || 0;
773
+ const bTs = Date.parse(b.lastOpened || 0) || 0;
774
+ if (aTs !== bTs) return bTs - aTs;
775
+ return (b.count || 0) - (a.count || 0);
776
+ })
777
+ .slice(0, FILE_PICKER_HISTORY_LIMIT);
778
+
779
+ const serialized = {};
780
+ for (const item of items) {
781
+ serialized[item.path] = {
782
+ lastOpened: item.lastOpened || null,
783
+ count: item.count || 1,
784
+ };
785
+ }
786
+
787
+ storage.setItem(FILE_PICKER_HISTORY_KEY, JSON.stringify(serialized));
788
+ } catch {
789
+ // Best-effort persistence only
790
+ }
791
+ }
792
+
793
+ function touchFilePickerHistory(historyMap, filePath, { timestamp, weight = 1 } = {}) {
794
+ if (!filePath) return historyMap;
795
+
796
+ const next = new Map(historyMap);
797
+ const existing = next.get(filePath);
798
+ const nextCount = Math.max(0.1, Number(existing?.count || 0) + Math.max(0.1, Number(weight) || 0.1));
799
+
800
+ next.set(filePath, {
801
+ path: filePath,
802
+ lastOpened: timestamp || new Date().toISOString(),
803
+ count: nextCount,
804
+ });
805
+
806
+ return next;
807
+ }
808
+
809
+ function getHistoryScore(historyEntry, nowMs = Date.now()) {
810
+ if (!historyEntry) return 0;
811
+
812
+ const count = Math.max(1, Number(historyEntry.count) || 1);
813
+ const openedMs = Date.parse(historyEntry.lastOpened || '');
814
+
815
+ let recencyScore = 0;
816
+ if (Number.isFinite(openedMs)) {
817
+ const ageHours = Math.max(0, (nowMs - openedMs) / 3600000);
818
+ recencyScore = Math.max(0, 220 - Math.log2(ageHours + 1) * 36);
819
+ }
820
+
821
+ const frequencyScore = Math.min(180, Math.log2(count + 1) * 60);
822
+ return recencyScore + frequencyScore;
823
+ }
@@ -212,9 +212,9 @@ export async function createStudio(target, options = {}) {
212
212
  throw new Error('No sync URL returned from orchestrator');
213
213
  }
214
214
 
215
- // Register the shared session in shell state
215
+ // Register the shared runtime in shell state
216
216
  if (runtimeUrls.python) {
217
- shellState.registerSession('shared', {
217
+ shellState.registerRuntime('shared', {
218
218
  id: 'shared',
219
219
  url: runtimeUrls.python,
220
220
  status: 'ready',
@@ -329,7 +329,6 @@ export async function createStudio(target, options = {}) {
329
329
  ydoc: handle.ydoc,
330
330
  awareness: handle.awareness,
331
331
  runtimeUrl: monitorRuntimeUrl,
332
- session: docName,
333
332
  });
334
333
  }
335
334
 
@@ -519,11 +518,23 @@ export async function createStudio(target, options = {}) {
519
518
  editor = createEditorForDocument(handle, normalizedName);
520
519
  currentDocName = normalizedName;
521
520
 
522
- // Ensure session exists (starts monitor if needed)
521
+ // Place cursor on first empty line (after frontmatter) for a clean first impression.
522
+ // Without this, cursor lands at position 0, showing raw YAML frontmatter.
523
+ if (mrmd.findInitialCursorPosition) {
524
+ const pos = mrmd.findInitialCursorPosition(editor.view.state.doc.toString());
525
+ if (pos > 0) {
526
+ editor.view.dispatch({
527
+ selection: { anchor: pos },
528
+ scrollIntoView: true,
529
+ });
530
+ }
531
+ }
532
+
533
+ // Ensure runtime attachment exists (starts monitor if needed)
523
534
  try {
524
- await orchestratorClient.createSession(normalizedName, 'shared');
535
+ await orchestratorClient.createRuntimeAttachment(normalizedName, 'shared');
525
536
  } catch (e) {
526
- console.warn('Failed to create session for', normalizedName, e);
537
+ console.warn('Failed to create runtime attachment for', normalizedName, e);
527
538
  }
528
539
 
529
540
  // Update shell state
@@ -558,11 +569,22 @@ export async function createStudio(target, options = {}) {
558
569
  editor = createEditorForDocument(handle, docToOpen);
559
570
  currentDocName = docToOpen;
560
571
 
561
- // Ensure session exists (starts monitor if needed)
572
+ // Place cursor on first empty line (after frontmatter) for a clean first impression
573
+ if (mrmd.findInitialCursorPosition) {
574
+ const pos = mrmd.findInitialCursorPosition(editor.view.state.doc.toString());
575
+ if (pos > 0) {
576
+ editor.view.dispatch({
577
+ selection: { anchor: pos },
578
+ scrollIntoView: true,
579
+ });
580
+ }
581
+ }
582
+
583
+ // Ensure runtime attachment exists (starts monitor if needed)
562
584
  try {
563
- await orchestratorClient.createSession(docToOpen, 'shared');
585
+ await orchestratorClient.createRuntimeAttachment(docToOpen, 'shared');
564
586
  } catch (e) {
565
- console.warn('Failed to create session for initial doc:', e);
587
+ console.warn('Failed to create runtime attachment for initial doc:', e);
566
588
  }
567
589
  } catch (e) {
568
590
  console.error('Failed to open initial document:', e);
@@ -218,6 +218,51 @@ export class OrchestratorClient {
218
218
  return this._fetch(`/api/browse${query ? '?' + query : ''}`);
219
219
  }
220
220
 
221
+ // ===========================================================================
222
+ // Machine Catalog (multi-machine sync)
223
+ // ===========================================================================
224
+
225
+ /**
226
+ * Get catalog of files across all connected machines.
227
+ * @param {Object} [options]
228
+ * @param {string} [options.project] - Filter to a specific project
229
+ * @returns {Promise<{userId: string, machines: Array, cloudOnlyProjects?: string[]}>}
230
+ */
231
+ async getCatalog(options = {}) {
232
+ const params = new URLSearchParams();
233
+ if (options.project) params.set('project', options.project);
234
+ const query = params.toString();
235
+ return this._fetch(`/api/catalog${query ? '?' + query : ''}`);
236
+ }
237
+
238
+ /**
239
+ * Get list of connected machines.
240
+ * @returns {Promise<{userId: string, machines: Array}>}
241
+ */
242
+ async getMachines() {
243
+ return this._fetch('/api/machines');
244
+ }
245
+
246
+ /**
247
+ * Get currently active runtime machine.
248
+ * @returns {Promise<{activeMachineId: string|null, provider: Object|null}>}
249
+ */
250
+ async getActiveMachine() {
251
+ return this._fetch('/api/machines/active');
252
+ }
253
+
254
+ /**
255
+ * Set active runtime machine.
256
+ * @param {string|null} machineId
257
+ * @returns {Promise<{ok: boolean, activeMachineId: string|null, provider: Object|null}>}
258
+ */
259
+ async setActiveMachine(machineId) {
260
+ return this._fetch('/api/machines/active', {
261
+ method: 'POST',
262
+ body: JSON.stringify({ machineId: machineId ?? null }),
263
+ });
264
+ }
265
+
221
266
  // ===========================================================================
222
267
  // Environment Management
223
268
  // ===========================================================================
@@ -280,53 +325,95 @@ export class OrchestratorClient {
280
325
  }
281
326
 
282
327
  // ===========================================================================
283
- // Session Management
328
+ // Runtime Attachments
284
329
  // ===========================================================================
285
330
 
286
331
  /**
287
- * Create a session for a document
332
+ * Create a runtime attachment for a document
288
333
  * @param {string} doc - Document name
289
334
  * @param {'shared'|'dedicated'} python - Python runtime mode
290
335
  * @param {string} [venv] - Path to virtual environment (for dedicated runtimes)
291
336
  * @returns {Promise<Object>}
292
337
  */
293
- async createSession(doc, python = 'shared', venv = null) {
338
+ async createRuntimeAttachment(doc, python = 'shared', venv = null) {
294
339
  const body = { doc, python };
295
340
  if (venv) {
296
341
  body.venv = venv;
297
342
  }
298
- return this._fetch('/api/sessions', {
299
- method: 'POST',
300
- body: JSON.stringify(body),
301
- });
343
+
344
+ try {
345
+ return await this._fetch('/api/runtimes', {
346
+ method: 'POST',
347
+ body: JSON.stringify(body),
348
+ });
349
+ } catch (err) {
350
+ // Legacy orchestrator compatibility
351
+ return this._fetch('/api/sessions', {
352
+ method: 'POST',
353
+ body: JSON.stringify(body),
354
+ });
355
+ }
302
356
  }
303
357
 
304
358
  /**
305
- * Get session info for a document
359
+ * Get runtime attachment info for a document
306
360
  * @param {string} doc - Document name
307
361
  * @returns {Promise<Object>}
308
362
  */
309
- async getSession(doc) {
310
- return this._fetch(`/api/sessions/${encodeURIComponent(doc)}`);
363
+ async getRuntimeAttachment(doc) {
364
+ const encoded = encodeURIComponent(doc);
365
+ try {
366
+ return await this._fetch(`/api/runtimes/${encoded}`);
367
+ } catch (err) {
368
+ return this._fetch(`/api/sessions/${encoded}`);
369
+ }
311
370
  }
312
371
 
313
372
  /**
314
- * Destroy a session
373
+ * Destroy a runtime attachment
315
374
  * @param {string} doc - Document name
316
375
  * @returns {Promise<{doc: string, status: string}>}
317
376
  */
318
- async destroySession(doc) {
319
- return this._fetch(`/api/sessions/${encodeURIComponent(doc)}`, {
320
- method: 'DELETE',
321
- });
377
+ async destroyRuntimeAttachment(doc) {
378
+ const encoded = encodeURIComponent(doc);
379
+ try {
380
+ return await this._fetch(`/api/runtimes/${encoded}`, {
381
+ method: 'DELETE',
382
+ });
383
+ } catch (err) {
384
+ return this._fetch(`/api/sessions/${encoded}`, {
385
+ method: 'DELETE',
386
+ });
387
+ }
322
388
  }
323
389
 
324
390
  /**
325
- * List all active sessions
326
- * @returns {Promise<{sessions: Array}>}
391
+ * List all runtime attachments
392
+ * @returns {Promise<{runtimes?: Array, sessions?: Array}>}
327
393
  */
394
+ async listRuntimeAttachments() {
395
+ try {
396
+ return await this._fetch('/api/runtimes');
397
+ } catch (err) {
398
+ return this._fetch('/api/sessions');
399
+ }
400
+ }
401
+
402
+ // Legacy aliases
403
+ async createSession(doc, python = 'shared', venv = null) {
404
+ return this.createRuntimeAttachment(doc, python, venv);
405
+ }
406
+
407
+ async getSession(doc) {
408
+ return this.getRuntimeAttachment(doc);
409
+ }
410
+
411
+ async destroySession(doc) {
412
+ return this.destroyRuntimeAttachment(doc);
413
+ }
414
+
328
415
  async listSessions() {
329
- return this._fetch('/api/sessions');
416
+ return this.listRuntimeAttachments();
330
417
  }
331
418
 
332
419
  // ===========================================================================