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 +1 -1
- package/src/core/session-manager.js +184 -183
- package/src/state/store.js +2 -1
- package/src/utils/path-utils.js +23 -0
- package/src/web/public/app.js +9 -7
- package/src/web/server.js +139 -6
package/package.json
CHANGED
|
@@ -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
|
-
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
'/bin/
|
|
18
|
-
'/bin/
|
|
19
|
-
'/bin/
|
|
20
|
-
'/bin/
|
|
21
|
-
'/bin/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
*
|
|
40
|
-
* On
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* @
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
store.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
store.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* @
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
store.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
store.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
*
|
|
137
|
-
* @
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
*
|
|
160
|
-
* @
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
+
};
|
package/src/state/store.js
CHANGED
|
@@ -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 };
|
package/src/web/public/app.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
1274
|
-
*
|
|
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 =
|
|
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
|
|
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 =
|
|
6489
|
+
const projectName = getProjectDisplayName(entry.name, realPath);
|
|
6357
6490
|
|
|
6358
6491
|
try {
|
|
6359
6492
|
const dirFiles = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
|