myrlin-workbook 0.9.8 → 0.9.9

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.
package/package.json CHANGED
@@ -1,64 +1,64 @@
1
- {
2
- "name": "myrlin-workbook",
3
- "version": "0.9.8",
4
- "description": "Browser-based project manager for Claude Code sessions - session discovery, multi-terminal, cost tracking, docs, and kanban board",
5
- "main": "src/index.js",
6
- "bin": {
7
- "myrlin-workbook": "./src/gui.js",
8
- "myrlin": "./src/gui.js",
9
- "myrlin-tui": "./src/index.js",
10
- "cwm": "./src/index.js"
11
- },
12
- "scripts": {
13
- "start": "node src/index.js",
14
- "demo": "node src/demo.js",
15
- "gui": "node src/supervisor.js",
16
- "gui:bare": "node src/gui.js",
17
- "gui:demo": "node src/supervisor.js --demo",
18
- "test": "node test/run.js",
19
- "mcp:visual-qa": "node src/mcp/visual-qa.js",
20
- "gui:cdp": "node src/supervisor.js --cdp",
21
- "postinstall": "node scripts/postinstall.js",
22
- "restart": "bash scripts/restart-gui.sh"
23
- },
24
- "repository": {
25
- "type": "git",
26
- "url": "https://github.com/therealarthur/myrlin-workbook.git"
27
- },
28
- "homepage": "https://github.com/therealarthur/myrlin-workbook",
29
- "engines": {
30
- "node": ">=18.0.0"
31
- },
32
- "keywords": [
33
- "claude",
34
- "workspace",
35
- "manager",
36
- "terminal",
37
- "tui",
38
- "ai",
39
- "coding-assistant",
40
- "session-manager",
41
- "developer-tools",
42
- "xterm",
43
- "myrlin"
44
- ],
45
- "author": "Arthur",
46
- "license": "AGPL-3.0-only",
47
- "dependencies": {
48
- "blessed": "^0.1.81",
49
- "blessed-contrib": "^4.11.0",
50
- "chalk": "^5.6.2",
51
- "chrome-remote-interface": "^0.34.0",
52
- "express": "^5.2.1",
53
- "node-pty": "^1.1.0",
54
- "ws": "^8.19.0"
55
- },
56
- "devDependencies": {
57
- "@playwright/test": "^1.58.2",
58
- "@xterm/addon-fit": "^0.11.0",
59
- "@xterm/addon-web-links": "^0.12.0",
60
- "@xterm/xterm": "^6.0.0",
61
- "ffmpeg-static": "^5.3.0",
62
- "sharp": "^0.34.5"
63
- }
64
- }
1
+ {
2
+ "name": "myrlin-workbook",
3
+ "version": "0.9.9",
4
+ "description": "Browser-based project manager for Claude Code sessions - session discovery, multi-terminal, cost tracking, docs, and kanban board",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "myrlin-workbook": "./src/gui.js",
8
+ "myrlin": "./src/gui.js",
9
+ "myrlin-tui": "./src/index.js",
10
+ "cwm": "./src/index.js"
11
+ },
12
+ "scripts": {
13
+ "start": "node src/index.js",
14
+ "demo": "node src/demo.js",
15
+ "gui": "node src/supervisor.js",
16
+ "gui:bare": "node src/gui.js",
17
+ "gui:demo": "node src/supervisor.js --demo",
18
+ "test": "node test/run.js",
19
+ "mcp:visual-qa": "node src/mcp/visual-qa.js",
20
+ "gui:cdp": "node src/supervisor.js --cdp",
21
+ "postinstall": "node scripts/postinstall.js",
22
+ "restart": "bash scripts/restart-gui.sh"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/therealarthur/myrlin-workbook.git"
27
+ },
28
+ "homepage": "https://github.com/therealarthur/myrlin-workbook",
29
+ "engines": {
30
+ "node": ">=18.0.0"
31
+ },
32
+ "keywords": [
33
+ "claude",
34
+ "workspace",
35
+ "manager",
36
+ "terminal",
37
+ "tui",
38
+ "ai",
39
+ "coding-assistant",
40
+ "session-manager",
41
+ "developer-tools",
42
+ "xterm",
43
+ "myrlin"
44
+ ],
45
+ "author": "Arthur",
46
+ "license": "AGPL-3.0-only",
47
+ "dependencies": {
48
+ "blessed": "^0.1.81",
49
+ "blessed-contrib": "^4.11.0",
50
+ "chalk": "^5.6.2",
51
+ "chrome-remote-interface": "^0.34.0",
52
+ "express": "^5.2.1",
53
+ "node-pty": "^1.1.0",
54
+ "ws": "^8.19.0"
55
+ },
56
+ "devDependencies": {
57
+ "@playwright/test": "^1.58.2",
58
+ "@xterm/addon-fit": "^0.11.0",
59
+ "@xterm/addon-web-links": "^0.12.0",
60
+ "@xterm/xterm": "^6.0.0",
61
+ "ffmpeg-static": "^5.3.0",
62
+ "sharp": "^0.34.5"
63
+ }
64
+ }
package/src/index.js CHANGED
@@ -13,6 +13,7 @@ const { getStore } = require('./state/store');
13
13
  const { getNotificationManager } = require('./core/notifications');
14
14
  const { markStaleSessionsStopped } = require('./core/recovery');
15
15
  const { createApp } = require('./ui/app');
16
+ const { getDataDir } = require('./utils/data-dir');
16
17
 
17
18
  function main() {
18
19
  const args = process.argv.slice(2);
@@ -26,7 +27,7 @@ function main() {
26
27
  if (isReset) {
27
28
  const fs = require('fs');
28
29
  const path = require('path');
29
- const stateFile = path.join(__dirname, '..', 'state', 'workspaces.json');
30
+ const stateFile = path.join(getDataDir(), 'workspaces.json');
30
31
  if (fs.existsSync(stateFile)) {
31
32
  fs.unlinkSync(stateFile);
32
33
  console.log('State cleared. Restart without --reset.');
@@ -11,8 +11,9 @@
11
11
 
12
12
  const fs = require('fs');
13
13
  const path = require('path');
14
+ const { getDataDir } = require('../utils/data-dir');
14
15
 
15
- const DOCS_DIR = path.join(__dirname, '..', '..', 'state', 'docs');
16
+ const DOCS_DIR = path.join(getDataDir(), 'docs');
16
17
 
17
18
  /**
18
19
  * Ensure the state/docs/ directory exists.
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Core state store for Claude Workspace Manager
3
3
  * Handles JSON persistence, CRUD operations, and state transitions.
4
- * All state is persisted to ./state/workspaces.json
4
+ * All state is persisted to ~/.myrlin/workspaces.json so that every
5
+ * launch method (npm run gui, npx, global install) shares the same data.
5
6
  */
6
7
 
7
8
  const fs = require('fs');
@@ -10,8 +11,12 @@ const crypto = require('crypto');
10
11
  const { EventEmitter } = require('events');
11
12
  const docsManager = require('./docs-manager');
12
13
  const { expandHome } = require('../utils/path-utils');
14
+ const { getDataDir, migrateFromLegacy } = require('../utils/data-dir');
13
15
 
14
- const STATE_DIR = path.join(__dirname, '..', '..', 'state');
16
+ // Legacy project-local state dir (for migration on first run)
17
+ const LEGACY_STATE_DIR = path.join(__dirname, '..', '..', 'state');
18
+
19
+ const STATE_DIR = getDataDir();
15
20
  const BACKUP_DIR = path.join(STATE_DIR, 'backups');
16
21
  const STATE_FILE = path.join(STATE_DIR, 'workspaces.json');
17
22
  const BACKUP_FILE = path.join(STATE_DIR, 'workspaces.backup.json');
@@ -57,6 +62,8 @@ class Store extends EventEmitter {
57
62
  if (!fs.existsSync(STATE_DIR)) {
58
63
  fs.mkdirSync(STATE_DIR, { recursive: true });
59
64
  }
65
+ // Migrate legacy project-local state to ~/.myrlin/ on first run
66
+ migrateFromLegacy(LEGACY_STATE_DIR);
60
67
  docsManager.ensureDocsDir();
61
68
  // Create a timestamped backup BEFORE loading (preserves last known good state)
62
69
  this.createTimestampedBackup();
@@ -161,18 +168,61 @@ class Store extends EventEmitter {
161
168
  /**
162
169
  * Save state to disk (with backup).
163
170
  * Uses write-to-temp-then-rename for atomic writes on crash.
171
+ * Verifies written data after rename to detect zero-fill corruption.
164
172
  */
165
173
  save() {
166
174
  try {
167
- // Backup current file before overwriting
175
+ // Only backup current file if it contains real data (not zero-filled)
168
176
  if (fs.existsSync(STATE_FILE)) {
169
- fs.copyFileSync(STATE_FILE, BACKUP_FILE);
177
+ if (this._isFileValid(STATE_FILE)) {
178
+ fs.copyFileSync(STATE_FILE, BACKUP_FILE);
179
+ } else {
180
+ console.warn('[Store] Skipping backup of corrupt primary file');
181
+ }
170
182
  }
171
183
  // Atomic write: write to PID-unique temp file, then rename over the target.
172
184
  // PID suffix prevents collisions when TUI and GUI write concurrently.
185
+ const json = JSON.stringify(this._state, null, 2);
173
186
  const tmpFile = STATE_FILE + '.' + process.pid + '.tmp';
174
- fs.writeFileSync(tmpFile, JSON.stringify(this._state, null, 2), 'utf-8');
187
+ fs.writeFileSync(tmpFile, json, 'utf-8');
188
+
189
+ // Verify the temp file before renaming: re-read and check for zero-fill
190
+ // corruption (Windows write-cache failure mode)
191
+ const written = fs.readFileSync(tmpFile, 'utf-8');
192
+ if (!written.trim() || written.charCodeAt(0) === 0) {
193
+ console.error('[Store] CORRUPTION DETECTED: temp file is zero-filled, aborting save');
194
+ try { fs.unlinkSync(tmpFile); } catch (_) {}
195
+ this.emit('error', { type: 'save_corruption', error: 'Written file was zero-filled' });
196
+ return;
197
+ }
198
+ // Sanity check: verify it parses as valid JSON with workspaces
199
+ try {
200
+ const check = JSON.parse(written);
201
+ if (!check.workspaces) {
202
+ throw new Error('Missing workspaces key');
203
+ }
204
+ } catch (parseErr) {
205
+ console.error('[Store] CORRUPTION DETECTED: temp file not valid JSON, aborting save');
206
+ try { fs.unlinkSync(tmpFile); } catch (_) {}
207
+ this.emit('error', { type: 'save_corruption', error: parseErr.message });
208
+ return;
209
+ }
210
+
175
211
  fs.renameSync(tmpFile, STATE_FILE);
212
+
213
+ // Post-rename verification: re-read the final file to catch filesystem-level corruption
214
+ try {
215
+ const final = fs.readFileSync(STATE_FILE, 'utf-8');
216
+ if (!final.trim() || final.charCodeAt(0) === 0) {
217
+ console.error('[Store] POST-RENAME CORRUPTION: primary file is zero-filled after rename');
218
+ // Restore from backup if available
219
+ if (fs.existsSync(BACKUP_FILE) && this._isFileValid(BACKUP_FILE)) {
220
+ fs.copyFileSync(BACKUP_FILE, STATE_FILE);
221
+ console.warn('[Store] Restored primary from backup after corruption');
222
+ }
223
+ }
224
+ } catch (_) {}
225
+
176
226
  this._recordDiskMtime();
177
227
  this._dirty = false;
178
228
  } catch (err) {
@@ -180,6 +230,27 @@ class Store extends EventEmitter {
180
230
  }
181
231
  }
182
232
 
233
+ /**
234
+ * Check if a file contains real data (not zero-filled or empty).
235
+ * Returns false for zero-filled files, empty files, or unreadable files.
236
+ * @param {string} filePath - Path to check
237
+ * @returns {boolean} true if the file has valid non-zero content
238
+ */
239
+ _isFileValid(filePath) {
240
+ try {
241
+ const buf = fs.readFileSync(filePath);
242
+ if (buf.length === 0) return false;
243
+ // Check first 64 bytes for any non-zero content
244
+ const checkLen = Math.min(buf.length, 64);
245
+ for (let i = 0; i < checkLen; i++) {
246
+ if (buf[i] !== 0) return true;
247
+ }
248
+ return false;
249
+ } catch (_) {
250
+ return false;
251
+ }
252
+ }
253
+
183
254
  /**
184
255
  * Create a timestamped backup. Called on server startup to preserve
185
256
  * state before any mutations. Keeps up to MAX_TIMESTAMPED_BACKUPS files.
@@ -187,6 +258,11 @@ class Store extends EventEmitter {
187
258
  createTimestampedBackup() {
188
259
  try {
189
260
  if (!fs.existsSync(STATE_FILE)) return;
261
+ // Never back up a corrupt/zero-filled file
262
+ if (!this._isFileValid(STATE_FILE)) {
263
+ console.warn('[Store] Skipping timestamped backup: primary file is corrupt/zero-filled');
264
+ return;
265
+ }
190
266
  if (!fs.existsSync(BACKUP_DIR)) {
191
267
  fs.mkdirSync(BACKUP_DIR, { recursive: true });
192
268
  }
@@ -208,12 +284,54 @@ class Store extends EventEmitter {
208
284
  }
209
285
 
210
286
  /**
211
- * Debounced save - batches rapid changes
287
+ * Async save - performs all disk I/O off the event loop.
288
+ * Falls back to sync save() on error.
289
+ */
290
+ async saveAsync() {
291
+ try {
292
+ const json = JSON.stringify(this._state, null, 2);
293
+ const tmpFile = STATE_FILE + '.' + process.pid + '.tmp';
294
+
295
+ // Backup current file if it exists and is valid
296
+ if (fs.existsSync(STATE_FILE) && this._isFileValid(STATE_FILE)) {
297
+ await fs.promises.copyFile(STATE_FILE, BACKUP_FILE);
298
+ }
299
+
300
+ await fs.promises.writeFile(tmpFile, json, 'utf-8');
301
+
302
+ // Verify temp file before rename
303
+ const written = await fs.promises.readFile(tmpFile, 'utf-8');
304
+ if (!written.trim() || written.charCodeAt(0) === 0) {
305
+ console.error('[Store] CORRUPTION DETECTED in async save, aborting');
306
+ try { await fs.promises.unlink(tmpFile); } catch (_) {}
307
+ return;
308
+ }
309
+ try {
310
+ const check = JSON.parse(written);
311
+ if (!check.workspaces) throw new Error('Missing workspaces key');
312
+ } catch (parseErr) {
313
+ console.error('[Store] CORRUPTION DETECTED: invalid JSON in async save');
314
+ try { await fs.promises.unlink(tmpFile); } catch (_) {}
315
+ return;
316
+ }
317
+
318
+ await fs.promises.rename(tmpFile, STATE_FILE);
319
+ this._recordDiskMtime();
320
+ this._dirty = false;
321
+ } catch (err) {
322
+ console.error('[Store] Async save failed, falling back to sync:', err.message);
323
+ this.save();
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Debounced save - batches rapid changes, uses async I/O
329
+ * to avoid blocking the event loop during frequent updates.
212
330
  */
213
331
  _debouncedSave() {
214
332
  this._dirty = true;
215
333
  if (this._saveTimer) clearTimeout(this._saveTimer);
216
- this._saveTimer = setTimeout(() => this.save(), 150);
334
+ this._saveTimer = setTimeout(() => this.saveAsync(), 150);
217
335
  }
218
336
 
219
337
  // ─── Getters ─────────────────────────────────────────────
@@ -7420,6 +7420,27 @@ class CWMApp {
7420
7420
  }
7421
7421
  }
7422
7422
 
7423
+ /**
7424
+ * Throttled versions of loadSessions and loadStats to prevent
7425
+ * rapid-fire SSE events from triggering dozens of API calls.
7426
+ * At most one call per 500ms for sessions, 2000ms for stats.
7427
+ */
7428
+ _throttledLoadSessions() {
7429
+ if (this._loadSessionsTimer) return;
7430
+ this._loadSessionsTimer = setTimeout(() => {
7431
+ this._loadSessionsTimer = null;
7432
+ this.loadSessions().then(() => { if (this._smOpen) this.renderSessionManager(); });
7433
+ }, 500);
7434
+ }
7435
+
7436
+ _throttledLoadStats() {
7437
+ if (this._loadStatsTimer) return;
7438
+ this._loadStatsTimer = setTimeout(() => {
7439
+ this._loadStatsTimer = null;
7440
+ this.loadStats();
7441
+ }, 2000);
7442
+ }
7443
+
7423
7444
  handleSSEEvent(data) {
7424
7445
  // Queue events while a modal is open to prevent UI glitches and race conditions
7425
7446
  if (this._modalOpen) {
@@ -7431,30 +7452,30 @@ class CWMApp {
7431
7452
  switch (data.type) {
7432
7453
  case 'session:started':
7433
7454
  this.showToast(`Session "${data.name || 'unknown'}" started`, 'success');
7434
- this.loadSessions().then(() => { if (this._smOpen) this.renderSessionManager(); });
7435
- this.loadStats();
7455
+ this._throttledLoadSessions();
7456
+ this._throttledLoadStats();
7436
7457
  break;
7437
7458
  case 'session:stopped':
7438
7459
  this.showToast(`Session "${data.name || 'unknown'}" stopped`, 'info');
7439
- this.loadSessions().then(() => { if (this._smOpen) this.renderSessionManager(); });
7440
- this.loadStats();
7460
+ this._throttledLoadSessions();
7461
+ this._throttledLoadStats();
7441
7462
  break;
7442
7463
  case 'session:error':
7443
7464
  this.showToast(`Session "${data.name || 'unknown'}" encountered an error`, 'error');
7444
- this.loadSessions().then(() => { if (this._smOpen) this.renderSessionManager(); });
7445
- this.loadStats();
7465
+ this._throttledLoadSessions();
7466
+ this._throttledLoadStats();
7446
7467
  break;
7447
7468
  case 'session:created':
7448
7469
  case 'session:deleted':
7449
7470
  case 'session:updated':
7450
- this.loadSessions().then(() => { if (this._smOpen) this.renderSessionManager(); });
7451
- this.loadStats();
7471
+ this._throttledLoadSessions();
7472
+ this._throttledLoadStats();
7452
7473
  break;
7453
7474
  case 'workspace:created':
7454
7475
  case 'workspace:deleted':
7455
7476
  case 'workspace:updated':
7456
7477
  this.loadWorkspaces();
7457
- this.loadStats();
7478
+ this._throttledLoadStats();
7458
7479
  break;
7459
7480
  case 'stats:updated':
7460
7481
  if (data.stats) {
@@ -9035,6 +9056,11 @@ class CWMApp {
9035
9056
  ═══════════════════════════════════════════════════════════ */
9036
9057
 
9037
9058
  openTerminalInPane(slotIdx, sessionId, sessionName, spawnOpts) {
9059
+ // Check localStorage for a previously saved name for this session
9060
+ const savedTitle = this.getProjectSessionTitle(sessionId);
9061
+ if (savedTitle && (!sessionName || sessionName === sessionId)) {
9062
+ sessionName = savedTitle;
9063
+ }
9038
9064
  console.log('[DnD] openTerminalInPane slot:', slotIdx, 'session:', sessionId, 'name:', sessionName);
9039
9065
  // If the target slot already has an active terminal, find the next empty slot
9040
9066
  if (this.terminalPanes[slotIdx]) {
@@ -10097,6 +10123,8 @@ class CWMApp {
10097
10123
  this.terminalPanes.forEach(tp => {
10098
10124
  if (tp) tp.safeFit();
10099
10125
  });
10126
+ // Persist split ratios for this tab group
10127
+ this.saveTerminalLayout();
10100
10128
  };
10101
10129
 
10102
10130
  document.addEventListener('mousemove', onMove);
@@ -11475,6 +11503,10 @@ class CWMApp {
11475
11503
  this.syncSessionTitle(sessionId, newName);
11476
11504
  }
11477
11505
 
11506
+ // Always persist to localStorage keyed by the terminal's sessionId
11507
+ // so ad-hoc sessions (not in store, not in projects) keep their names
11508
+ this.syncSessionTitle(sessionId, newName);
11509
+
11478
11510
  // Update TerminalPane instance
11479
11511
  const tp = this.terminalPanes[slotIdx];
11480
11512
  if (tp) tp.sessionName = newName;
@@ -11567,6 +11599,14 @@ class CWMApp {
11567
11599
  }
11568
11600
  });
11569
11601
  }
11602
+
11603
+ // Restore split ratios for the active tab group
11604
+ if (group && group.gridColSizes) {
11605
+ this._gridColSizes = [...group.gridColSizes];
11606
+ }
11607
+ if (group && group.gridRowSizes) {
11608
+ this._gridRowSizes = [...group.gridRowSizes];
11609
+ }
11570
11610
  this._layoutRestored = true;
11571
11611
  }
11572
11612
 
@@ -11923,6 +11963,19 @@ class CWMApp {
11923
11963
 
11924
11964
  this._activeGroupId = groupId;
11925
11965
 
11966
+ // ── Restore this tab group's split ratios (or reset to equal) ──
11967
+ const targetGroup = this._tabGroups.find(g => g.id === groupId);
11968
+ if (targetGroup && targetGroup.gridColSizes) {
11969
+ this._gridColSizes = [...targetGroup.gridColSizes];
11970
+ } else {
11971
+ this._gridColSizes = [1, 1];
11972
+ }
11973
+ if (targetGroup && targetGroup.gridRowSizes) {
11974
+ this._gridRowSizes = [...targetGroup.gridRowSizes];
11975
+ } else {
11976
+ this._gridRowSizes = [1, 1];
11977
+ }
11978
+
11926
11979
  // ── Restore target group: try cache first, fall back to fresh connections ──
11927
11980
  const cached = this._groupPaneCache[groupId];
11928
11981
  if (cached) {
@@ -12008,6 +12061,10 @@ class CWMApp {
12008
12061
  });
12009
12062
  }
12010
12063
  }
12064
+
12065
+ // Persist this tab group's split ratios so switching tabs restores layout
12066
+ group.gridColSizes = [...this._gridColSizes];
12067
+ group.gridRowSizes = [...this._gridRowSizes];
12011
12068
  }
12012
12069
 
12013
12070
  /**
package/src/web/server.js CHANGED
@@ -19,6 +19,70 @@ const { getStore } = require('../state/store');
19
19
  const { launchSession, stopSession, restartSession } = require('../core/session-manager');
20
20
  const { backupFrontend, restoreFrontend, getBackupStatus } = require('./backup');
21
21
  const td = require('../core/td-adapter');
22
+ const { getDataDir } = require('../utils/data-dir');
23
+ const { Worker } = require('worker_threads');
24
+
25
+ // ─── Cost Worker Thread ──────────────────────────────────
26
+ // Offloads JSONL parsing to a background thread to prevent
27
+ // terminal I/O freezes during cost calculation.
28
+ let _costWorker = null;
29
+ let _costWorkerId = 0;
30
+ const _costWorkerCallbacks = new Map();
31
+
32
+ /**
33
+ * Get or create the cost calculation worker thread.
34
+ * Lazy-initialized on first cost request.
35
+ * @returns {Worker} The cost worker thread
36
+ */
37
+ function getCostWorker() {
38
+ if (_costWorker) return _costWorker;
39
+ _costWorker = new Worker(path.join(__dirname, 'cost-worker.js'));
40
+ _costWorker.on('message', (msg) => {
41
+ const cb = _costWorkerCallbacks.get(msg.id);
42
+ if (cb) {
43
+ _costWorkerCallbacks.delete(msg.id);
44
+ if (msg.error) cb.reject(new Error(msg.error));
45
+ else cb.resolve(msg.result);
46
+ }
47
+ });
48
+ _costWorker.on('error', (err) => {
49
+ console.error('[CostWorker] Error:', err.message);
50
+ });
51
+ _costWorker.on('exit', (code) => {
52
+ console.warn('[CostWorker] Exited with code', code);
53
+ _costWorker = null;
54
+ // Reject any pending callbacks
55
+ for (const [id, cb] of _costWorkerCallbacks) {
56
+ cb.reject(new Error('Worker exited'));
57
+ _costWorkerCallbacks.delete(id);
58
+ }
59
+ });
60
+ return _costWorker;
61
+ }
62
+
63
+ /**
64
+ * Calculate session cost asynchronously via the worker thread.
65
+ * Falls back to sync calculation if the worker fails.
66
+ * @param {string} jsonlPath - Path to the JSONL file
67
+ * @returns {Promise<object>} Cost breakdown
68
+ */
69
+ function calculateSessionCostAsync(jsonlPath) {
70
+ return new Promise((resolve, reject) => {
71
+ const id = ++_costWorkerId;
72
+ _costWorkerCallbacks.set(id, { resolve, reject });
73
+ try {
74
+ getCostWorker().postMessage({
75
+ id,
76
+ jsonlPath,
77
+ pricing: TOKEN_PRICING,
78
+ defaultPricing: DEFAULT_PRICING,
79
+ });
80
+ } catch (err) {
81
+ _costWorkerCallbacks.delete(id);
82
+ reject(err);
83
+ }
84
+ });
85
+ }
22
86
 
23
87
  /**
24
88
  * Resolve the td binary path in priority order:
@@ -2561,17 +2625,27 @@ app.get('/api/sessions/:id/cost', requireAuth, (req, res) => {
2561
2625
  return res.json(cached.result);
2562
2626
  }
2563
2627
 
2564
- const costData = calculateSessionCost(jsonlPath);
2565
- const result = {
2566
- sessionId: req.params.id,
2567
- resumeSessionId,
2568
- ...costData,
2569
- };
2570
-
2571
- // Store in cache
2572
- _costCache.set(resumeSessionId, { mtimeMs, timestamp: now, result });
2573
-
2574
- return res.json(result);
2628
+ // Use worker thread for async cost calculation to avoid blocking terminal I/O
2629
+ calculateSessionCostAsync(jsonlPath).then((costData) => {
2630
+ const result = {
2631
+ sessionId: req.params.id,
2632
+ resumeSessionId,
2633
+ ...costData,
2634
+ };
2635
+ // Store in cache
2636
+ _costCache.set(resumeSessionId, { mtimeMs, timestamp: now, result });
2637
+ res.json(result);
2638
+ }).catch((err) => {
2639
+ // Fallback to sync calculation if worker fails
2640
+ try {
2641
+ const costData = calculateSessionCost(jsonlPath);
2642
+ const result = { sessionId: req.params.id, resumeSessionId, ...costData };
2643
+ _costCache.set(resumeSessionId, { mtimeMs, timestamp: now, result });
2644
+ res.json(result);
2645
+ } catch (syncErr) {
2646
+ res.status(500).json({ error: 'Failed to calculate cost: ' + syncErr.message });
2647
+ }
2648
+ });
2575
2649
  } catch (err) {
2576
2650
  return res.status(500).json({ error: 'Failed to calculate cost: ' + err.message });
2577
2651
  }
@@ -4881,7 +4955,7 @@ function attachStoreEvents() {
4881
4955
  // LAYOUT PERSISTENCE
4882
4956
  // ──────────────────────────────────────────────────────────
4883
4957
 
4884
- const LAYOUT_FILE = path.join(__dirname, '..', '..', 'state', 'layout.json');
4958
+ const LAYOUT_FILE = path.join(getDataDir(), 'layout.json');
4885
4959
 
4886
4960
  /**
4887
4961
  * GET /api/layout
@@ -4905,9 +4979,9 @@ app.get('/api/layout', requireAuth, (req, res) => {
4905
4979
  */
4906
4980
  app.put('/api/layout', requireAuth, (req, res) => {
4907
4981
  try {
4908
- const stateDir = path.join(__dirname, '..', '..', 'state');
4909
- if (!fs.existsSync(stateDir)) {
4910
- fs.mkdirSync(stateDir, { recursive: true });
4982
+ const dataDir = getDataDir();
4983
+ if (!fs.existsSync(dataDir)) {
4984
+ fs.mkdirSync(dataDir, { recursive: true });
4911
4985
  }
4912
4986
  fs.writeFileSync(LAYOUT_FILE, JSON.stringify(req.body, null, 2), 'utf-8');
4913
4987
  return res.json({ success: true });
@@ -6292,7 +6366,7 @@ app.delete('/api/tunnels/:id', requireAuth, (req, res) => {
6292
6366
  // NAMED TUNNEL (Cloudflare token-based, persistent domain)
6293
6367
  // ──────────────────────────────────────────────────────────
6294
6368
 
6295
- const NAMED_TUNNEL_HOME_CONFIG = path.join(os.homedir(), '.myrlin', 'config.json');
6369
+ const NAMED_TUNNEL_HOME_CONFIG = path.join(getDataDir(), 'config.json');
6296
6370
  const NAMED_TUNNEL_LOCAL_CONFIG = path.join(__dirname, '..', '..', 'state', 'config.json');
6297
6371
 
6298
6372
  function readMyrlinConfig() {