myrlin-workbook 0.9.5 → 0.9.7

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,6 +1,6 @@
1
1
  {
2
2
  "name": "myrlin-workbook",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "description": "Browser-based project manager for Claude Code sessions - session discovery, multi-terminal, cost tracking, docs, and kanban board",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,183 +1,184 @@
1
- /**
2
- * Session Manager - Manages Claude Code session lifecycle
3
- * Handles launching, stopping, and restarting session processes.
4
- * Supports Windows (cmd.exe), macOS, and Linux/WSL.
5
- */
6
-
7
- const { spawn } = require('child_process');
8
- const { getStore } = require('../state/store');
9
-
10
- /**
11
- * Allowlist of known-safe login shells.
12
- * Used to validate process.env.SHELL on non-Windows platforms to prevent
13
- * arbitrary binary execution if the environment is compromised.
14
- */
15
- const ALLOWED_SHELLS = [
16
- '/bin/bash', '/usr/bin/bash',
17
- '/bin/sh', '/usr/bin/sh',
18
- '/bin/zsh', '/usr/bin/zsh',
19
- '/bin/fish', '/usr/bin/fish',
20
- '/bin/dash', '/usr/bin/dash',
21
- '/bin/ash',
22
- ];
23
-
24
- /**
25
- * Get a safe shell path for the current platform.
26
- * Validates process.env.SHELL against an allowlist; falls back to /bin/bash.
27
- * @returns {string} Absolute path to a safe shell binary
28
- */
29
- function getSafeShell() {
30
- const envShell = process.env.SHELL;
31
- if (envShell && ALLOWED_SHELLS.includes(envShell)) {
32
- return envShell;
33
- }
34
- return '/bin/bash';
35
- }
36
-
37
- /**
38
- * Launch a Claude Code session by spawning a new detached process.
39
- * On Windows, opens a new cmd.exe console window.
40
- * On Linux/macOS, spawns a detached shell process (primarily used by TUI;
41
- * the web GUI uses PTY terminals via pty-manager.js instead).
42
- * Updates the store with the new PID and sets status to 'running'.
43
- * @param {string} sessionId - The session ID to launch
44
- * @returns {{ success: boolean, pid?: number, error?: string }}
45
- */
46
- function launchSession(sessionId) {
47
- const store = getStore();
48
- const session = store.getSession(sessionId);
49
-
50
- if (!session) {
51
- return { success: false, error: `Session ${sessionId} not found` };
52
- }
53
-
54
- if (session.status === 'running' && session.pid) {
55
- return { success: false, error: `Session ${sessionId} is already running (PID: ${session.pid})` };
56
- }
57
-
58
- try {
59
- const baseCommand = session.command || 'claude';
60
- const bypassFlag = session.bypassPermissions ? ' --dangerously-skip-permissions' : '';
61
- const command = baseCommand + bypassFlag;
62
- const workingDir = session.workingDir || process.cwd();
63
-
64
- let child;
65
- if (process.platform === 'win32') {
66
- // Windows: open a new console window via `cmd /c start cmd /k`
67
- child = spawn('cmd', ['/c', 'start', 'cmd', '/k', command], {
68
- detached: true,
69
- stdio: 'ignore',
70
- cwd: workingDir,
71
- shell: false,
72
- });
73
- } else {
74
- // Linux/macOS/WSL: spawn a detached login shell
75
- // Note: without a TTY, interactive CLI tools will exit immediately.
76
- // The web GUI uses pty-manager.js (with a real PTY) for terminal sessions.
77
- // This code path is kept for TUI compatibility and headless/scripted launches.
78
- const shell = getSafeShell();
79
- child = spawn(shell, ['-l', '-c', command], {
80
- detached: true,
81
- stdio: 'ignore',
82
- cwd: workingDir,
83
- });
84
- }
85
-
86
- const pid = child.pid;
87
-
88
- // Unref so the parent process can exit independently
89
- child.unref();
90
-
91
- store.updateSessionStatus(sessionId, 'running', pid);
92
- store.addSessionLog(sessionId, `Session launched with PID ${pid} (command: ${command})`);
93
-
94
- return { success: true, pid, process: child };
95
- } catch (err) {
96
- store.updateSessionStatus(sessionId, 'error', null);
97
- store.addSessionLog(sessionId, `Failed to launch session: ${err.message}`);
98
- return { success: false, error: err.message };
99
- }
100
- }
101
-
102
- /**
103
- * Stop a running session by killing its process.
104
- * Updates the store status to 'stopped'.
105
- * @param {string} sessionId - The session ID to stop
106
- * @returns {{ success: boolean, error?: string }}
107
- */
108
- function stopSession(sessionId) {
109
- const store = getStore();
110
- const session = store.getSession(sessionId);
111
-
112
- if (!session) {
113
- return { success: false, error: `Session ${sessionId} not found` };
114
- }
115
-
116
- if (session.status !== 'running' || !session.pid) {
117
- store.updateSessionStatus(sessionId, 'stopped', null);
118
- return { success: true };
119
- }
120
-
121
- try {
122
- process.kill(session.pid);
123
- store.updateSessionStatus(sessionId, 'stopped', null);
124
- store.addSessionLog(sessionId, `Session stopped (PID ${session.pid} killed)`);
125
- return { success: true };
126
- } catch (err) {
127
- // Process may already be dead - that's fine, mark as stopped anyway
128
- store.updateSessionStatus(sessionId, 'stopped', null);
129
- store.addSessionLog(sessionId, `Session stop - process already exited (${err.message})`);
130
- return { success: true };
131
- }
132
- }
133
-
134
- /**
135
- * Restart a session by stopping it first, then relaunching.
136
- * @param {string} sessionId - The session ID to restart
137
- * @returns {{ success: boolean, pid?: number, error?: string }}
138
- */
139
- function restartSession(sessionId) {
140
- const store = getStore();
141
- const session = store.getSession(sessionId);
142
-
143
- if (!session) {
144
- return { success: false, error: `Session ${sessionId} not found` };
145
- }
146
-
147
- store.addSessionLog(sessionId, 'Restarting session...');
148
-
149
- const stopResult = stopSession(sessionId);
150
- if (!stopResult.success) {
151
- return { success: false, error: `Failed to stop before restart: ${stopResult.error}` };
152
- }
153
-
154
- return launchSession(sessionId);
155
- }
156
-
157
- /**
158
- * Get process info for a session.
159
- * @param {string} sessionId - The session ID
160
- * @returns {{ pid: number|null, status: string, command: string }|null}
161
- */
162
- function getSessionProcess(sessionId) {
163
- const store = getStore();
164
- const session = store.getSession(sessionId);
165
-
166
- if (!session) {
167
- return null;
168
- }
169
-
170
- return {
171
- pid: session.pid,
172
- status: session.status,
173
- command: session.command || 'claude',
174
- workingDir: session.workingDir || '',
175
- };
176
- }
177
-
178
- module.exports = {
179
- launchSession,
180
- stopSession,
181
- restartSession,
182
- getSessionProcess,
183
- };
1
+ /**
2
+ * Session Manager - Manages Claude Code session lifecycle
3
+ * Handles launching, stopping, and restarting session processes.
4
+ * Supports Windows (cmd.exe), macOS, and Linux/WSL.
5
+ */
6
+
7
+ const { spawn } = require('child_process');
8
+ const { getStore } = require('../state/store');
9
+ const { expandHome } = require('../utils/path-utils');
10
+
11
+ /**
12
+ * Allowlist of known-safe login shells.
13
+ * Used to validate process.env.SHELL on non-Windows platforms to prevent
14
+ * arbitrary binary execution if the environment is compromised.
15
+ */
16
+ const ALLOWED_SHELLS = [
17
+ '/bin/bash', '/usr/bin/bash',
18
+ '/bin/sh', '/usr/bin/sh',
19
+ '/bin/zsh', '/usr/bin/zsh',
20
+ '/bin/fish', '/usr/bin/fish',
21
+ '/bin/dash', '/usr/bin/dash',
22
+ '/bin/ash',
23
+ ];
24
+
25
+ /**
26
+ * Get a safe shell path for the current platform.
27
+ * Validates process.env.SHELL against an allowlist; falls back to /bin/bash.
28
+ * @returns {string} Absolute path to a safe shell binary
29
+ */
30
+ function getSafeShell() {
31
+ const envShell = process.env.SHELL;
32
+ if (envShell && ALLOWED_SHELLS.includes(envShell)) {
33
+ return envShell;
34
+ }
35
+ return '/bin/bash';
36
+ }
37
+
38
+ /**
39
+ * Launch a Claude Code session by spawning a new detached process.
40
+ * On Windows, opens a new cmd.exe console window.
41
+ * On Linux/macOS, spawns a detached shell process (primarily used by TUI;
42
+ * the web GUI uses PTY terminals via pty-manager.js instead).
43
+ * Updates the store with the new PID and sets status to 'running'.
44
+ * @param {string} sessionId - The session ID to launch
45
+ * @returns {{ success: boolean, pid?: number, error?: string }}
46
+ */
47
+ function launchSession(sessionId) {
48
+ const store = getStore();
49
+ const session = store.getSession(sessionId);
50
+
51
+ if (!session) {
52
+ return { success: false, error: `Session ${sessionId} not found` };
53
+ }
54
+
55
+ if (session.status === 'running' && session.pid) {
56
+ return { success: false, error: `Session ${sessionId} is already running (PID: ${session.pid})` };
57
+ }
58
+
59
+ try {
60
+ const baseCommand = session.command || 'claude';
61
+ const bypassFlag = session.bypassPermissions ? ' --dangerously-skip-permissions' : '';
62
+ const command = baseCommand + bypassFlag;
63
+ const workingDir = expandHome(session.workingDir) || process.cwd();
64
+
65
+ let child;
66
+ if (process.platform === 'win32') {
67
+ // Windows: open a new console window via `cmd /c start cmd /k`
68
+ child = spawn('cmd', ['/c', 'start', 'cmd', '/k', command], {
69
+ detached: true,
70
+ stdio: 'ignore',
71
+ cwd: workingDir,
72
+ shell: false,
73
+ });
74
+ } else {
75
+ // Linux/macOS/WSL: spawn a detached login shell
76
+ // Note: without a TTY, interactive CLI tools will exit immediately.
77
+ // The web GUI uses pty-manager.js (with a real PTY) for terminal sessions.
78
+ // This code path is kept for TUI compatibility and headless/scripted launches.
79
+ const shell = getSafeShell();
80
+ child = spawn(shell, ['-l', '-c', command], {
81
+ detached: true,
82
+ stdio: 'ignore',
83
+ cwd: workingDir,
84
+ });
85
+ }
86
+
87
+ const pid = child.pid;
88
+
89
+ // Unref so the parent process can exit independently
90
+ child.unref();
91
+
92
+ store.updateSessionStatus(sessionId, 'running', pid);
93
+ store.addSessionLog(sessionId, `Session launched with PID ${pid} (command: ${command})`);
94
+
95
+ return { success: true, pid, process: child };
96
+ } catch (err) {
97
+ store.updateSessionStatus(sessionId, 'error', null);
98
+ store.addSessionLog(sessionId, `Failed to launch session: ${err.message}`);
99
+ return { success: false, error: err.message };
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Stop a running session by killing its process.
105
+ * Updates the store status to 'stopped'.
106
+ * @param {string} sessionId - The session ID to stop
107
+ * @returns {{ success: boolean, error?: string }}
108
+ */
109
+ function stopSession(sessionId) {
110
+ const store = getStore();
111
+ const session = store.getSession(sessionId);
112
+
113
+ if (!session) {
114
+ return { success: false, error: `Session ${sessionId} not found` };
115
+ }
116
+
117
+ if (session.status !== 'running' || !session.pid) {
118
+ store.updateSessionStatus(sessionId, 'stopped', null);
119
+ return { success: true };
120
+ }
121
+
122
+ try {
123
+ process.kill(session.pid);
124
+ store.updateSessionStatus(sessionId, 'stopped', null);
125
+ store.addSessionLog(sessionId, `Session stopped (PID ${session.pid} killed)`);
126
+ return { success: true };
127
+ } catch (err) {
128
+ // Process may already be dead - that's fine, mark as stopped anyway
129
+ store.updateSessionStatus(sessionId, 'stopped', null);
130
+ store.addSessionLog(sessionId, `Session stop - process already exited (${err.message})`);
131
+ return { success: true };
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Restart a session by stopping it first, then relaunching.
137
+ * @param {string} sessionId - The session ID to restart
138
+ * @returns {{ success: boolean, pid?: number, error?: string }}
139
+ */
140
+ function restartSession(sessionId) {
141
+ const store = getStore();
142
+ const session = store.getSession(sessionId);
143
+
144
+ if (!session) {
145
+ return { success: false, error: `Session ${sessionId} not found` };
146
+ }
147
+
148
+ store.addSessionLog(sessionId, 'Restarting session...');
149
+
150
+ const stopResult = stopSession(sessionId);
151
+ if (!stopResult.success) {
152
+ return { success: false, error: `Failed to stop before restart: ${stopResult.error}` };
153
+ }
154
+
155
+ return launchSession(sessionId);
156
+ }
157
+
158
+ /**
159
+ * Get process info for a session.
160
+ * @param {string} sessionId - The session ID
161
+ * @returns {{ pid: number|null, status: string, command: string }|null}
162
+ */
163
+ function getSessionProcess(sessionId) {
164
+ const store = getStore();
165
+ const session = store.getSession(sessionId);
166
+
167
+ if (!session) {
168
+ return null;
169
+ }
170
+
171
+ return {
172
+ pid: session.pid,
173
+ status: session.status,
174
+ command: session.command || 'claude',
175
+ workingDir: session.workingDir || '',
176
+ };
177
+ }
178
+
179
+ module.exports = {
180
+ launchSession,
181
+ stopSession,
182
+ restartSession,
183
+ getSessionProcess,
184
+ };
@@ -9,6 +9,7 @@ const path = require('path');
9
9
  const crypto = require('crypto');
10
10
  const { EventEmitter } = require('events');
11
11
  const docsManager = require('./docs-manager');
12
+ const { expandHome } = require('../utils/path-utils');
12
13
 
13
14
  const STATE_DIR = path.join(__dirname, '..', '..', 'state');
14
15
  const BACKUP_DIR = path.join(STATE_DIR, 'backups');
@@ -320,7 +321,7 @@ class Store extends EventEmitter {
320
321
  id,
321
322
  name,
322
323
  workspaceId,
323
- workingDir,
324
+ workingDir: expandHome(workingDir) || '',
324
325
  topic,
325
326
  command,
326
327
  resumeSessionId,
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Path utilities for cross-platform path handling
3
+ */
4
+
5
+ const os = require('os');
6
+
7
+ /**
8
+ * Expand ~ to home directory
9
+ * Supports: ~, ~/path, ~\path
10
+ * @param {string} filepath
11
+ * @returns {string}
12
+ */
13
+ function expandHome(filepath) {
14
+ if (!filepath || typeof filepath !== 'string') {
15
+ return filepath;
16
+ }
17
+ if (filepath === '~' || filepath.startsWith('~/') || filepath.startsWith('~\\')) {
18
+ return filepath.replace('~', os.homedir());
19
+ }
20
+ return filepath;
21
+ }
22
+
23
+ module.exports = { expandHome };
@@ -1820,7 +1820,7 @@ class CWMApp {
1820
1820
  this.showApp();
1821
1821
  this.initDragAndDrop();
1822
1822
  this.initTerminalResize();
1823
- this.initTerminalGroups();
1823
+ await this.initTerminalGroups();
1824
1824
  this.initTerminalPaneSwipe();
1825
1825
  this.initNotesEditor();
1826
1826
  this.initAIInsights();
@@ -11521,15 +11521,16 @@ class CWMApp {
11521
11521
  PHASE 4: TERMINAL TAB GROUPS
11522
11522
  ═══════════════════════════════════════════════════════════ */
11523
11523
 
11524
- initTerminalGroups() {
11524
+ async initTerminalGroups() {
11525
11525
  // Load layout from server
11526
11526
  this._tabGroups = [];
11527
11527
  this._tabFolders = []; // Tab group folders: { id, name, color, collapsed }
11528
11528
  this._activeGroupId = null;
11529
11529
  this._layoutSaveTimer = null;
11530
+ this._layoutRestored = false;
11530
11531
 
11531
- // Load saved layout
11532
- this.loadTerminalLayout();
11532
+ // Load saved layout (must complete before SSE or other init touches panes)
11533
+ await this.loadTerminalLayout();
11533
11534
  }
11534
11535
 
11535
11536
  async loadTerminalLayout() {
@@ -11557,11 +11558,12 @@ class CWMApp {
11557
11558
  const group = this._tabGroups.find(g => g.id === this._activeGroupId);
11558
11559
  if (group && group.panes && group.panes.length > 0) {
11559
11560
  group.panes.forEach(p => {
11560
- if (p.sessionId) {
11561
+ if (p.sessionId && !this.terminalPanes[p.slot]) {
11561
11562
  this.openTerminalInPane(p.slot, p.sessionId, p.sessionName || 'Terminal', p.spawnOpts || {});
11562
11563
  }
11563
11564
  });
11564
11565
  }
11566
+ this._layoutRestored = true;
11565
11567
  }
11566
11568
 
11567
11569
  /**
@@ -11959,11 +11961,11 @@ class CWMApp {
11959
11961
  }
11960
11962
  });
11961
11963
  } else {
11962
- // No cache create fresh connections (first time opening this group)
11964
+ // No cache, create fresh connections (first time opening this group)
11963
11965
  const group = this._tabGroups.find(g => g.id === groupId);
11964
11966
  if (group && group.panes) {
11965
11967
  group.panes.forEach(p => {
11966
- if (p.sessionId) {
11968
+ if (p.sessionId && !this.terminalPanes[p.slot]) {
11967
11969
  this.openTerminalInPane(p.slot, p.sessionId, p.sessionName || 'Terminal', p.spawnOpts || {});
11968
11970
  }
11969
11971
  });
package/src/web/server.js CHANGED
@@ -1141,9 +1141,15 @@ app.get('/api/discover', requireAuth, (req, res) => {
1141
1141
  dirExists = fs.existsSync(realPath);
1142
1142
  } catch (_) {}
1143
1143
 
1144
+ // Generate a display name that handles failed CJK decoding gracefully
1145
+ const displayName = getProjectDisplayName(entry.name, realPath);
1146
+ const failedDecode = isLikelyFailedCJKDecode(entry.name);
1147
+
1144
1148
  projects.push({
1145
1149
  encodedName: entry.name,
1146
1150
  realPath,
1151
+ displayName,
1152
+ failedDecode,
1147
1153
  dirExists,
1148
1154
  hasClaudeMd,
1149
1155
  sessionCount: sessionFiles.length,
@@ -1268,10 +1274,67 @@ function greedyFsWalk(root, tokens) {
1268
1274
  return resolved;
1269
1275
  }
1270
1276
 
1277
+ /**
1278
+ * Read the original path from a jsonl file's cwd field.
1279
+ * This contains the full path with Chinese characters, unlike the encoded directory name.
1280
+ * Only reads the first 4KB to avoid loading large files into memory.
1281
+ *
1282
+ * @param {string} projectDir - Absolute path to the project dir
1283
+ * @returns {string|null} The original path from cwd field, or null if not found
1284
+ */
1285
+ function getOriginalPathFromJsonl(projectDir) {
1286
+ try {
1287
+ const files = fs.readdirSync(projectDir);
1288
+ // Find the first .jsonl file (skip subdirectories)
1289
+ const jsonlFile = files.find(f => {
1290
+ if (!f.endsWith('.jsonl')) return false;
1291
+ try {
1292
+ return !fs.statSync(path.join(projectDir, f)).isDirectory();
1293
+ } catch (_) {
1294
+ return false;
1295
+ }
1296
+ });
1297
+ if (!jsonlFile) return null;
1298
+
1299
+ const jsonlPath = path.join(projectDir, jsonlFile);
1300
+
1301
+ // Only read first 4KB to find cwd field (avoids loading large files)
1302
+ const fd = fs.openSync(jsonlPath, 'r');
1303
+ const buffer = Buffer.alloc(4096);
1304
+ let content;
1305
+ try {
1306
+ const bytesRead = fs.readSync(fd, buffer, 0, 4096, 0);
1307
+ content = buffer.toString('utf-8', 0, bytesRead);
1308
+ } finally {
1309
+ fs.closeSync(fd);
1310
+ }
1311
+
1312
+ // Parse first 3 lines to find cwd field
1313
+ const lines = content.split('\n').slice(0, 3);
1314
+ for (const line of lines) {
1315
+ if (!line.trim()) continue;
1316
+ try {
1317
+ const record = JSON.parse(line);
1318
+ if (record.cwd && typeof record.cwd === 'string') {
1319
+ return record.cwd;
1320
+ }
1321
+ } catch (_) {
1322
+ // Skip invalid JSON lines
1323
+ continue;
1324
+ }
1325
+ }
1326
+ } catch (_) {
1327
+ // Silently fail and return null
1328
+ }
1329
+ return null;
1330
+ }
1331
+
1271
1332
  /**
1272
1333
  * Resolve the real filesystem path for a Claude projects directory.
1273
- * Reads originalPath from sessions-index.json when available (reliable on
1274
- * all platforms), falling back to decodeClaudePath for legacy/Windows dirs.
1334
+ * Tries sources in order of reliability:
1335
+ * 1. sessions-index.json (reliable on all platforms)
1336
+ * 2. jsonl file's cwd field (contains original Chinese path)
1337
+ * 3. decodeClaudePath (legacy fallback)
1275
1338
  *
1276
1339
  * @param {string} projectDir - Absolute path to the project dir under ~/.claude/projects/
1277
1340
  * @param {string} encodedName - The encoded directory name (e.g. "-Users-jane-project")
@@ -1279,6 +1342,7 @@ function greedyFsWalk(root, tokens) {
1279
1342
  */
1280
1343
  function resolveProjectPath(projectDir, encodedName) {
1281
1344
  try {
1345
+ // 1. Try sessions-index.json first
1282
1346
  const indexPath = path.join(projectDir, 'sessions-index.json');
1283
1347
  if (fs.existsSync(indexPath)) {
1284
1348
  const index = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
@@ -1290,9 +1354,79 @@ function resolveProjectPath(projectDir, encodedName) {
1290
1354
  }
1291
1355
  }
1292
1356
  } catch (_) {}
1357
+
1358
+ // 2. Try to read from jsonl file's cwd field (for Chinese paths)
1359
+ const jsonlPath = getOriginalPathFromJsonl(projectDir);
1360
+ if (jsonlPath) return jsonlPath;
1361
+
1362
+ // 3. Fall back to decoding the directory name
1293
1363
  return decodeClaudePath(encodedName);
1294
1364
  }
1295
1365
 
1366
+ /**
1367
+ * Regex to match CJK characters (Chinese, Japanese, Korean).
1368
+ * Covers: Hiragana, Katakana, CJK Extension A, CJK Unified Ideographs,
1369
+ * Hangul Syllables, and Fullwidth/Halfwidth forms.
1370
+ */
1371
+ const CJK_REGEX = /[\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uFF00-\uFFEF]/;
1372
+
1373
+ /**
1374
+ * Extract a readable display name from an encoded directory name.
1375
+ * When realPath contains CJK characters (from jsonl cwd field), use it directly.
1376
+ * Otherwise, fall back to extracting from realPath or showing drive info.
1377
+ *
1378
+ * @param {string} encodedName - The encoded directory name (e.g. "D--Projects-CU------")
1379
+ * @param {string} realPath - The decoded path from resolveProjectPath
1380
+ * @returns {string} A human-readable display name
1381
+ */
1382
+ function getProjectDisplayName(encodedName, realPath) {
1383
+ // If realPath contains CJK characters, it came from jsonl cwd - use it directly
1384
+ if (realPath && CJK_REGEX.test(realPath)) {
1385
+ const parts = realPath.split(/[\\/]/).filter(Boolean);
1386
+ return parts.length > 0 ? parts[parts.length - 1] : encodedName;
1387
+ }
1388
+
1389
+ // Check if the encoded name has failed CJK encoding indicators
1390
+ // Claude Code replaces CJK characters with "-" during encoding
1391
+ // This creates long sequences of "-" in the encoded name (e.g., "CU------" where 6 dashes = 3 CJK chars)
1392
+ const longDashSequence = encodedName.match(/-{3,}/);
1393
+ if (longDashSequence) {
1394
+ // Extract drive letter and the rest (supports A-Z and a-z)
1395
+ const driveMatch = encodedName.match(/^([A-Za-z])--(.*)/);
1396
+ if (driveMatch) {
1397
+ const drive = driveMatch[1];
1398
+ const rest = driveMatch[2];
1399
+ // Try to extract meaningful parts (non-dash sequences)
1400
+ const parts = rest.split('-').filter(p => p.length > 0);
1401
+ // Take the last meaningful part as project name
1402
+ const name = parts.length > 0 ? parts[parts.length - 1] : encodedName;
1403
+ return `[${drive}:] ${name}`;
1404
+ }
1405
+ }
1406
+
1407
+ // Default: extract the last path component from realPath
1408
+ if (realPath) {
1409
+ const parts = realPath.split(/[\\/]/).filter(Boolean);
1410
+ return parts.length > 0 ? parts[parts.length - 1] : encodedName;
1411
+ }
1412
+
1413
+ return encodedName;
1414
+ }
1415
+
1416
+ /**
1417
+ * Check if an encoded directory name likely contains CJK characters that were
1418
+ * replaced with dashes during encoding.
1419
+ * Returns true if the name contains long sequences of consecutive dashes.
1420
+ *
1421
+ * @param {string} encodedName - The encoded directory name
1422
+ * @returns {boolean} True if encoding likely replaced CJK characters
1423
+ */
1424
+ function isLikelyFailedCJKDecode(encodedName) {
1425
+ if (!encodedName) return false;
1426
+ // Long sequences of dashes (3 or more) indicate CJK chars were replaced
1427
+ return /-{3,}/.test(encodedName);
1428
+ }
1429
+
1296
1430
  // ──────────────────────────────────────────────────────────
1297
1431
  // Session Auto-Title
1298
1432
  // ──────────────────────────────────────────────────────────
@@ -1745,7 +1879,7 @@ app.post('/api/search-conversations', requireAuth, async (req, res) => {
1745
1879
 
1746
1880
  const projectDir = path.join(claudeProjectsDir, dir.name);
1747
1881
  const realPath = resolveProjectPath(projectDir, dir.name);
1748
- const projectName = realPath.split('\\').pop() || realPath.split('/').pop() || dir.name;
1882
+ const projectName = getProjectDisplayName(dir.name, realPath);
1749
1883
 
1750
1884
  let jsonlFiles;
1751
1885
  try {
@@ -1926,8 +2060,7 @@ app.post('/api/ai/find-session', requireAuth, async (req, res) => {
1926
2060
  discoveredProjects = dirs.map(d => {
1927
2061
  const projectDir = path.join(claudeProjectsDir, d.name);
1928
2062
  const realPath = resolveProjectPath(projectDir, d.name);
1929
- const pathParts = realPath.replace(/\\/g, '/').split('/').filter(Boolean);
1930
- const name = pathParts[pathParts.length - 1] || d.name;
2063
+ const name = getProjectDisplayName(d.name, realPath);
1931
2064
  let sessionCount = 0;
1932
2065
  let lastActive = null;
1933
2066
  try {
@@ -6353,7 +6486,7 @@ function getSearchableFiles() {
6353
6486
 
6354
6487
  const projectDir = path.join(claudeDir, entry.name);
6355
6488
  const realPath = resolveProjectPath(projectDir, entry.name);
6356
- const projectName = realPath.split('\\').pop() || realPath.split('/').pop() || entry.name;
6489
+ const projectName = getProjectDisplayName(entry.name, realPath);
6357
6490
 
6358
6491
  try {
6359
6492
  const dirFiles = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));