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 +64 -64
- package/src/index.js +2 -1
- package/src/state/docs-manager.js +2 -1
- package/src/state/store.js +125 -7
- package/src/web/public/app.js +66 -9
- package/src/web/server.js +90 -16
package/package.json
CHANGED
|
@@ -1,64 +1,64 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "myrlin-workbook",
|
|
3
|
-
"version": "0.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
|
-
}
|
|
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(
|
|
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(
|
|
16
|
+
const DOCS_DIR = path.join(getDataDir(), 'docs');
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Ensure the state/docs/ directory exists.
|
package/src/state/store.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
//
|
|
175
|
+
// Only backup current file if it contains real data (not zero-filled)
|
|
168
176
|
if (fs.existsSync(STATE_FILE)) {
|
|
169
|
-
|
|
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,
|
|
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
|
-
*
|
|
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.
|
|
334
|
+
this._saveTimer = setTimeout(() => this.saveAsync(), 150);
|
|
217
335
|
}
|
|
218
336
|
|
|
219
337
|
// ─── Getters ─────────────────────────────────────────────
|
package/src/web/public/app.js
CHANGED
|
@@ -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.
|
|
7435
|
-
this.
|
|
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.
|
|
7440
|
-
this.
|
|
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.
|
|
7445
|
-
this.
|
|
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.
|
|
7451
|
-
this.
|
|
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.
|
|
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
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
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(
|
|
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
|
|
4909
|
-
if (!fs.existsSync(
|
|
4910
|
-
fs.mkdirSync(
|
|
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(
|
|
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() {
|