myrlin-workbook 0.4.0 → 0.5.0
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/README.md +11 -10
- package/logo-cropped.png +0 -0
- package/package.json +1 -1
- package/src/web/auth.js +67 -27
- package/src/web/public/app.js +185 -69
- package/src/web/public/index.html +1 -1
- package/src/web/public/logo-cropped.png +0 -0
- package/src/web/public/logo.png +0 -0
- package/src/web/public/styles-mobile.css +11 -7
- package/src/web/public/terminal.js +45 -115
- package/src/web/server.js +304 -3
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="docs/images/logo.
|
|
2
|
+
<img src="docs/images/logo-animated.svg" alt="Myrlin's Workbook" width="250">
|
|
3
3
|
</p>
|
|
4
4
|
<h1 align="center">Myrlin's Workbook</h1>
|
|
5
5
|
<p align="center">
|
|
@@ -38,21 +38,22 @@ npm run gui # Real sessions
|
|
|
38
38
|
npm run gui:demo # Sample data
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
### Password
|
|
42
42
|
|
|
43
|
-
**
|
|
43
|
+
On first launch, a random password is generated and saved to `~/.myrlin/config.json`. This password **persists across updates, reinstalls, and npx cache clears** — you'll always use the same password.
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
# Bash/zsh
|
|
47
|
-
CWM_PASSWORD=mypassword npm run gui
|
|
45
|
+
To set your own:
|
|
48
46
|
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
```bash
|
|
48
|
+
# Option 1: Edit the config file (recommended — persists forever)
|
|
49
|
+
# ~/.myrlin/config.json → { "password": "your-password-here" }
|
|
51
50
|
|
|
52
|
-
#
|
|
53
|
-
|
|
51
|
+
# Option 2: Environment variable (overrides config, per-session)
|
|
52
|
+
CWM_PASSWORD=mypassword npx myrlin-workbook
|
|
54
53
|
```
|
|
55
54
|
|
|
55
|
+
Password lookup order: `CWM_PASSWORD` env var > `~/.myrlin/config.json` > `./state/config.json` > auto-generate.
|
|
56
|
+
|
|
56
57
|
### Prerequisites
|
|
57
58
|
|
|
58
59
|
- **Node.js 18+** ([download](https://nodejs.org))
|
package/logo-cropped.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/web/auth.js
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Password is loaded from (in priority order):
|
|
13
13
|
* 1. CWM_PASSWORD environment variable
|
|
14
|
-
* 2.
|
|
15
|
-
* 3.
|
|
14
|
+
* 2. ~/.myrlin/config.json (persists across npx updates/reinstalls)
|
|
15
|
+
* 3. ./state/config.json (local project config)
|
|
16
|
+
* 4. Auto-generated on first run (saved to both locations)
|
|
16
17
|
*
|
|
17
18
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
18
19
|
*/
|
|
@@ -20,11 +21,14 @@
|
|
|
20
21
|
const crypto = require('crypto');
|
|
21
22
|
const fs = require('fs');
|
|
22
23
|
const path = require('path');
|
|
24
|
+
const os = require('os');
|
|
23
25
|
|
|
24
26
|
// ─── Configuration ─────────────────────────────────────────
|
|
25
27
|
const TOKEN_BYTE_LENGTH = 32;
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
+
const HOME_CONFIG_DIR = path.join(os.homedir(), '.myrlin');
|
|
29
|
+
const HOME_CONFIG_FILE = path.join(HOME_CONFIG_DIR, 'config.json');
|
|
30
|
+
const LOCAL_CONFIG_DIR = path.join(__dirname, '..', '..', 'state');
|
|
31
|
+
const LOCAL_CONFIG_FILE = path.join(LOCAL_CONFIG_DIR, 'config.json');
|
|
28
32
|
|
|
29
33
|
// ─── Rate Limiting ─────────────────────────────────────────
|
|
30
34
|
// Simple in-memory rate limiter: max 5 login attempts per IP per 60 seconds
|
|
@@ -67,50 +71,86 @@ setInterval(() => {
|
|
|
67
71
|
// ─── Password Management ──────────────────────────────────
|
|
68
72
|
|
|
69
73
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* @returns {string}
|
|
74
|
+
* Read password from a config file, returns null if not found.
|
|
75
|
+
* @param {string} filePath - Path to config.json
|
|
76
|
+
* @returns {string|null}
|
|
73
77
|
*/
|
|
74
|
-
function
|
|
75
|
-
// 1. Environment variable
|
|
76
|
-
if (process.env.CWM_PASSWORD) {
|
|
77
|
-
return process.env.CWM_PASSWORD;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// 2. Config file
|
|
78
|
+
function readPasswordFromFile(filePath) {
|
|
81
79
|
try {
|
|
82
|
-
if (fs.existsSync(
|
|
83
|
-
const config = JSON.parse(fs.readFileSync(
|
|
80
|
+
if (fs.existsSync(filePath)) {
|
|
81
|
+
const config = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
84
82
|
if (config.password && typeof config.password === 'string') {
|
|
85
83
|
return config.password;
|
|
86
84
|
}
|
|
87
85
|
}
|
|
88
86
|
} catch (_) {
|
|
89
|
-
// Corrupted config -
|
|
87
|
+
// Corrupted config - skip
|
|
90
88
|
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Save password to a config file (merges with existing keys).
|
|
94
|
+
* @param {string} dir - Config directory path
|
|
95
|
+
* @param {string} filePath - Config file path
|
|
96
|
+
* @param {string} password - Password to save
|
|
97
|
+
*/
|
|
98
|
+
function savePasswordToFile(dir, filePath, password) {
|
|
94
99
|
try {
|
|
95
|
-
if (!fs.existsSync(
|
|
96
|
-
fs.mkdirSync(
|
|
100
|
+
if (!fs.existsSync(dir)) {
|
|
101
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
97
102
|
}
|
|
98
103
|
const config = {};
|
|
99
104
|
try {
|
|
100
|
-
if (fs.existsSync(
|
|
101
|
-
Object.assign(config, JSON.parse(fs.readFileSync(
|
|
105
|
+
if (fs.existsSync(filePath)) {
|
|
106
|
+
Object.assign(config, JSON.parse(fs.readFileSync(filePath, 'utf-8')));
|
|
102
107
|
}
|
|
103
108
|
} catch (_) {}
|
|
104
|
-
config.password =
|
|
105
|
-
fs.writeFileSync(
|
|
109
|
+
config.password = password;
|
|
110
|
+
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf-8');
|
|
106
111
|
} catch (err) {
|
|
107
|
-
|
|
112
|
+
// Non-fatal: password still works in memory for this session
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Load or generate the auth password.
|
|
118
|
+
* Priority: env var > ~/.myrlin/config.json > ./state/config.json > auto-generate.
|
|
119
|
+
* When auto-generating, saves to both ~/.myrlin/ and ./state/ so the password
|
|
120
|
+
* persists across npx cache clears and project reinstalls.
|
|
121
|
+
* @returns {string}
|
|
122
|
+
*/
|
|
123
|
+
function loadPassword() {
|
|
124
|
+
// 1. Environment variable (highest priority, always wins)
|
|
125
|
+
if (process.env.CWM_PASSWORD) {
|
|
126
|
+
return process.env.CWM_PASSWORD;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 2. Home directory config (~/.myrlin/config.json) — persists across reinstalls
|
|
130
|
+
const homePassword = readPasswordFromFile(HOME_CONFIG_FILE);
|
|
131
|
+
if (homePassword) {
|
|
132
|
+
// Also sync to local config so it's visible in the project
|
|
133
|
+
savePasswordToFile(LOCAL_CONFIG_DIR, LOCAL_CONFIG_FILE, homePassword);
|
|
134
|
+
return homePassword;
|
|
108
135
|
}
|
|
109
136
|
|
|
137
|
+
// 3. Local project config (./state/config.json)
|
|
138
|
+
const localPassword = readPasswordFromFile(LOCAL_CONFIG_FILE);
|
|
139
|
+
if (localPassword) {
|
|
140
|
+
// Promote to home config for persistence across reinstalls
|
|
141
|
+
savePasswordToFile(HOME_CONFIG_DIR, HOME_CONFIG_FILE, localPassword);
|
|
142
|
+
return localPassword;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 4. Auto-generate and save to both locations
|
|
146
|
+
const generated = crypto.randomBytes(16).toString('base64url');
|
|
147
|
+
savePasswordToFile(HOME_CONFIG_DIR, HOME_CONFIG_FILE, generated);
|
|
148
|
+
savePasswordToFile(LOCAL_CONFIG_DIR, LOCAL_CONFIG_FILE, generated);
|
|
149
|
+
|
|
110
150
|
console.log('');
|
|
111
151
|
console.log('══════════════════════════════════════════════════');
|
|
112
152
|
console.log(' CWM auto-generated password: ' + generated);
|
|
113
|
-
console.log(' Saved to:
|
|
153
|
+
console.log(' Saved to: ~/.myrlin/config.json');
|
|
114
154
|
console.log(' Set CWM_PASSWORD env var to override.');
|
|
115
155
|
console.log('══════════════════════════════════════════════════');
|
|
116
156
|
console.log('');
|
package/src/web/public/app.js
CHANGED
|
@@ -822,9 +822,7 @@ class CWMApp {
|
|
|
822
822
|
// Refit terminal panes
|
|
823
823
|
if (this.state.viewMode === 'terminal') {
|
|
824
824
|
this.terminalPanes.forEach(tp => {
|
|
825
|
-
if (tp
|
|
826
|
-
try { tp.fitAddon.fit(); } catch (_) {}
|
|
827
|
-
}
|
|
825
|
+
if (tp) tp.safeFit();
|
|
828
826
|
});
|
|
829
827
|
}
|
|
830
828
|
}, 150);
|
|
@@ -1580,6 +1578,19 @@ class CWMApp {
|
|
|
1580
1578
|
this.state.selectedSession = updated;
|
|
1581
1579
|
this.renderSessionDetail();
|
|
1582
1580
|
}
|
|
1581
|
+
// Sync terminal pane titles — if this session is open in a terminal,
|
|
1582
|
+
// update the TerminalPane instance and the DOM tab header.
|
|
1583
|
+
if (result.name) {
|
|
1584
|
+
for (let i = 0; i < this.terminalPanes.length; i++) {
|
|
1585
|
+
const tp = this.terminalPanes[i];
|
|
1586
|
+
if (tp && tp.sessionId === id) {
|
|
1587
|
+
tp.sessionName = result.name;
|
|
1588
|
+
const paneEl = document.getElementById(`term-pane-${i}`);
|
|
1589
|
+
const titleEl = paneEl && paneEl.querySelector('.terminal-pane-title');
|
|
1590
|
+
if (titleEl) titleEl.textContent = result.name;
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1583
1594
|
} catch (err) {
|
|
1584
1595
|
this.showToast(err.message || 'Failed to update session', 'error');
|
|
1585
1596
|
}
|
|
@@ -1714,9 +1725,16 @@ class CWMApp {
|
|
|
1714
1725
|
CONTEXT MENU
|
|
1715
1726
|
═══════════════════════════════════════════════════════════ */
|
|
1716
1727
|
|
|
1717
|
-
|
|
1728
|
+
/**
|
|
1729
|
+
* Build the shared session management context menu items.
|
|
1730
|
+
* Used by both the sidebar context menu and the terminal pane context menu
|
|
1731
|
+
* so that all session actions are available from either location.
|
|
1732
|
+
* @param {string} sessionId - The session to build items for
|
|
1733
|
+
* @returns {Array|null} Array of menu items, or null if session not found
|
|
1734
|
+
*/
|
|
1735
|
+
_buildSessionContextItems(sessionId) {
|
|
1718
1736
|
const session = (this.state.allSessions || this.state.sessions).find(s => s.id === sessionId);
|
|
1719
|
-
if (!session) return;
|
|
1737
|
+
if (!session) return null;
|
|
1720
1738
|
|
|
1721
1739
|
const isRunning = session.status === 'running' || session.status === 'idle';
|
|
1722
1740
|
const isBypassed = !!session.bypassPermissions;
|
|
@@ -1731,36 +1749,7 @@ class CWMApp {
|
|
|
1731
1749
|
|
|
1732
1750
|
const items = [];
|
|
1733
1751
|
|
|
1734
|
-
//
|
|
1735
|
-
items.push({
|
|
1736
|
-
label: 'View Details', icon: '🔍', action: () => {
|
|
1737
|
-
this.selectSession(sessionId);
|
|
1738
|
-
},
|
|
1739
|
-
});
|
|
1740
|
-
|
|
1741
|
-
// Open in terminal (quick action) - pass all session flags as spawnOpts
|
|
1742
|
-
items.push({
|
|
1743
|
-
label: 'Open in Terminal', icon: '▶', action: () => {
|
|
1744
|
-
const emptySlot = this.terminalPanes.findIndex(p => p === null);
|
|
1745
|
-
if (emptySlot !== -1) {
|
|
1746
|
-
this.setViewMode('terminal');
|
|
1747
|
-
const spawnOpts = {};
|
|
1748
|
-
if (session.resumeSessionId) spawnOpts.resumeSessionId = session.resumeSessionId;
|
|
1749
|
-
if (session.workingDir) spawnOpts.cwd = session.workingDir;
|
|
1750
|
-
if (session.command) spawnOpts.command = session.command;
|
|
1751
|
-
if (session.bypassPermissions) spawnOpts.bypassPermissions = true;
|
|
1752
|
-
if (session.verbose) spawnOpts.verbose = true;
|
|
1753
|
-
if (session.model) spawnOpts.model = session.model;
|
|
1754
|
-
if (session.agentTeams) spawnOpts.agentTeams = true;
|
|
1755
|
-
this.openTerminalInPane(emptySlot, sessionId, session.name, spawnOpts);
|
|
1756
|
-
} else {
|
|
1757
|
-
this.showToast('All terminal panes full. Close one first.', 'warning');
|
|
1758
|
-
}
|
|
1759
|
-
},
|
|
1760
|
-
});
|
|
1761
|
-
|
|
1762
|
-
items.push({ type: 'sep' });
|
|
1763
|
-
|
|
1752
|
+
// Start / Stop / Restart
|
|
1764
1753
|
if (!isRunning) {
|
|
1765
1754
|
items.push(
|
|
1766
1755
|
{ label: 'Start', icon: '▶', action: () => this.startSession(sessionId) },
|
|
@@ -1819,6 +1808,15 @@ class CWMApp {
|
|
|
1819
1808
|
{ label: 'Summarize to Docs', icon: '📝', action: () => this.summarizeSessionToDocs(sessionId) },
|
|
1820
1809
|
);
|
|
1821
1810
|
|
|
1811
|
+
// Refocus submenu — distill conversation + reset or compact
|
|
1812
|
+
items.push({
|
|
1813
|
+
label: 'Refocus', icon: '🔄',
|
|
1814
|
+
submenu: [
|
|
1815
|
+
{ label: 'Reset & Refocus', action: () => this.refocusSession(sessionId, 'reset') },
|
|
1816
|
+
{ label: 'Compact & Refocus', action: () => this.refocusSession(sessionId, 'compact') },
|
|
1817
|
+
],
|
|
1818
|
+
});
|
|
1819
|
+
|
|
1822
1820
|
// If the session has a working directory, add git worktree option
|
|
1823
1821
|
if (session.workingDir) {
|
|
1824
1822
|
items.push({
|
|
@@ -1853,6 +1851,49 @@ class CWMApp {
|
|
|
1853
1851
|
// Remove from workspace (actually deletes the session record)
|
|
1854
1852
|
items.push({ label: 'Remove from Workspace', icon: '✕', danger: true, action: () => this.removeSessionFromWorkspace(sessionId) });
|
|
1855
1853
|
|
|
1854
|
+
return items;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
showContextMenu(sessionId, x, y) {
|
|
1858
|
+
const session = (this.state.allSessions || this.state.sessions).find(s => s.id === sessionId);
|
|
1859
|
+
if (!session) return;
|
|
1860
|
+
|
|
1861
|
+
const items = [];
|
|
1862
|
+
|
|
1863
|
+
// Sidebar-specific: View details
|
|
1864
|
+
items.push({
|
|
1865
|
+
label: 'View Details', icon: '🔍', action: () => {
|
|
1866
|
+
this.selectSession(sessionId);
|
|
1867
|
+
},
|
|
1868
|
+
});
|
|
1869
|
+
|
|
1870
|
+
// Sidebar-specific: Open in terminal
|
|
1871
|
+
items.push({
|
|
1872
|
+
label: 'Open in Terminal', icon: '▶', action: () => {
|
|
1873
|
+
const emptySlot = this.terminalPanes.findIndex(p => p === null);
|
|
1874
|
+
if (emptySlot !== -1) {
|
|
1875
|
+
this.setViewMode('terminal');
|
|
1876
|
+
const spawnOpts = {};
|
|
1877
|
+
if (session.resumeSessionId) spawnOpts.resumeSessionId = session.resumeSessionId;
|
|
1878
|
+
if (session.workingDir) spawnOpts.cwd = session.workingDir;
|
|
1879
|
+
if (session.command) spawnOpts.command = session.command;
|
|
1880
|
+
if (session.bypassPermissions) spawnOpts.bypassPermissions = true;
|
|
1881
|
+
if (session.verbose) spawnOpts.verbose = true;
|
|
1882
|
+
if (session.model) spawnOpts.model = session.model;
|
|
1883
|
+
if (session.agentTeams) spawnOpts.agentTeams = true;
|
|
1884
|
+
this.openTerminalInPane(emptySlot, sessionId, session.name, spawnOpts);
|
|
1885
|
+
} else {
|
|
1886
|
+
this.showToast('All terminal panes full. Close one first.', 'warning');
|
|
1887
|
+
}
|
|
1888
|
+
},
|
|
1889
|
+
});
|
|
1890
|
+
|
|
1891
|
+
items.push({ type: 'sep' });
|
|
1892
|
+
|
|
1893
|
+
// Shared session management items
|
|
1894
|
+
const sessionItems = this._buildSessionContextItems(sessionId);
|
|
1895
|
+
if (sessionItems) items.push(...sessionItems);
|
|
1896
|
+
|
|
1856
1897
|
this._renderContextItems(session.name, items, x, y);
|
|
1857
1898
|
}
|
|
1858
1899
|
|
|
@@ -2200,6 +2241,16 @@ class CWMApp {
|
|
|
2200
2241
|
this.state.selectedSession = this.state.sessions.find(s => s.id === sessionId);
|
|
2201
2242
|
this.renderSessionDetail();
|
|
2202
2243
|
}
|
|
2244
|
+
// Sync terminal pane titles
|
|
2245
|
+
for (let i = 0; i < this.terminalPanes.length; i++) {
|
|
2246
|
+
const tp = this.terminalPanes[i];
|
|
2247
|
+
if (tp && tp.sessionId === sessionId) {
|
|
2248
|
+
tp.sessionName = data.title;
|
|
2249
|
+
const paneEl = document.getElementById(`term-pane-${i}`);
|
|
2250
|
+
const titleEl = paneEl && paneEl.querySelector('.terminal-pane-title');
|
|
2251
|
+
if (titleEl) titleEl.textContent = data.title;
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2203
2254
|
}
|
|
2204
2255
|
} catch (err) {
|
|
2205
2256
|
this.showToast(err.message || 'Failed to auto-title', 'error');
|
|
@@ -2220,6 +2271,16 @@ class CWMApp {
|
|
|
2220
2271
|
this.showToast(`Titled: "${data.title}"`, 'success');
|
|
2221
2272
|
this.renderProjects();
|
|
2222
2273
|
this.renderWorkspaces();
|
|
2274
|
+
// Sync terminal pane titles (project sessions use Claude UUID as sessionId)
|
|
2275
|
+
for (let i = 0; i < this.terminalPanes.length; i++) {
|
|
2276
|
+
const tp = this.terminalPanes[i];
|
|
2277
|
+
if (tp && tp.sessionId === claudeSessionId) {
|
|
2278
|
+
tp.sessionName = data.title;
|
|
2279
|
+
const paneEl = document.getElementById(`term-pane-${i}`);
|
|
2280
|
+
const titleEl = paneEl && paneEl.querySelector('.terminal-pane-title');
|
|
2281
|
+
if (titleEl) titleEl.textContent = data.title;
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2223
2284
|
}
|
|
2224
2285
|
} catch (err) {
|
|
2225
2286
|
this.showToast(err.message || 'Failed to auto-title', 'error');
|
|
@@ -2360,7 +2421,7 @@ class CWMApp {
|
|
|
2360
2421
|
// Refit all terminal panes after a brief delay for zoom to take effect
|
|
2361
2422
|
setTimeout(() => {
|
|
2362
2423
|
this.terminalPanes.forEach(tp => {
|
|
2363
|
-
if (tp
|
|
2424
|
+
if (tp) tp.safeFit();
|
|
2364
2425
|
});
|
|
2365
2426
|
}, 100);
|
|
2366
2427
|
}
|
|
@@ -2839,9 +2900,7 @@ class CWMApp {
|
|
|
2839
2900
|
// Refit all terminal panes after view switch (viewport size may differ)
|
|
2840
2901
|
requestAnimationFrame(() => {
|
|
2841
2902
|
this.terminalPanes.forEach(tp => {
|
|
2842
|
-
if (tp
|
|
2843
|
-
try { tp.fitAddon.fit(); } catch (_) {}
|
|
2844
|
-
}
|
|
2903
|
+
if (tp) tp.safeFit();
|
|
2845
2904
|
});
|
|
2846
2905
|
});
|
|
2847
2906
|
} else {
|
|
@@ -2890,7 +2949,7 @@ class CWMApp {
|
|
|
2890
2949
|
// Trigger resize on terminal panes after animation
|
|
2891
2950
|
setTimeout(() => {
|
|
2892
2951
|
this.terminalPanes.forEach(tp => {
|
|
2893
|
-
if (tp
|
|
2952
|
+
if (tp) tp.safeFit();
|
|
2894
2953
|
});
|
|
2895
2954
|
}, 250);
|
|
2896
2955
|
}
|
|
@@ -2943,7 +3002,7 @@ class CWMApp {
|
|
|
2943
3002
|
|
|
2944
3003
|
// Refit terminal panes
|
|
2945
3004
|
this.terminalPanes.forEach(tp => {
|
|
2946
|
-
if (tp
|
|
3005
|
+
if (tp) tp.safeFit();
|
|
2947
3006
|
});
|
|
2948
3007
|
|
|
2949
3008
|
document.removeEventListener('mousemove', onMouseMove);
|
|
@@ -5723,6 +5782,8 @@ class CWMApp {
|
|
|
5723
5782
|
|
|
5724
5783
|
const items = [];
|
|
5725
5784
|
|
|
5785
|
+
// ── Terminal-specific actions ──────────────────────────────
|
|
5786
|
+
|
|
5726
5787
|
// Copy selected text (only show when there's a selection)
|
|
5727
5788
|
if (tp.term && tp.term.hasSelection()) {
|
|
5728
5789
|
items.push({
|
|
@@ -5767,15 +5828,15 @@ class CWMApp {
|
|
|
5767
5828
|
},
|
|
5768
5829
|
});
|
|
5769
5830
|
|
|
5770
|
-
|
|
5831
|
+
// ── Shared session management items ───────────────────────
|
|
5832
|
+
const sessionItems = this._buildSessionContextItems(tp.sessionId);
|
|
5833
|
+
if (sessionItems) {
|
|
5834
|
+
items.push({ type: 'sep' });
|
|
5835
|
+
items.push(...sessionItems);
|
|
5836
|
+
}
|
|
5771
5837
|
|
|
5772
|
-
//
|
|
5773
|
-
items.push({
|
|
5774
|
-
label: 'Copy Session ID', icon: '📋', action: () => {
|
|
5775
|
-
navigator.clipboard.writeText(tp.sessionId);
|
|
5776
|
-
this.showToast('Session ID copied', 'success');
|
|
5777
|
-
},
|
|
5778
|
-
});
|
|
5838
|
+
// ── Pane management ───────────────────────────────────────
|
|
5839
|
+
items.push({ type: 'sep' });
|
|
5779
5840
|
|
|
5780
5841
|
// Close pane
|
|
5781
5842
|
items.push({
|
|
@@ -5784,15 +5845,10 @@ class CWMApp {
|
|
|
5784
5845
|
},
|
|
5785
5846
|
});
|
|
5786
5847
|
|
|
5787
|
-
items.push({ type: 'sep' });
|
|
5788
|
-
|
|
5789
5848
|
// Inspect - open browser DevTools console
|
|
5790
5849
|
items.push({
|
|
5791
5850
|
label: 'Inspect', icon: '🔍', action: () => {
|
|
5792
|
-
// Try to open DevTools programmatically (only works in Electron/NW.js)
|
|
5793
|
-
// For regular browsers, show a hint about the keyboard shortcut
|
|
5794
5851
|
if (window.__TAURI__ || (window.process && window.process.versions && window.process.versions.electron)) {
|
|
5795
|
-
// Electron / Tauri - can open devtools directly
|
|
5796
5852
|
try { require('electron').remote.getCurrentWindow().webContents.openDevTools(); } catch (_) {}
|
|
5797
5853
|
} else {
|
|
5798
5854
|
this.showToast('Press F12 or Ctrl+Shift+I to open DevTools', 'info');
|
|
@@ -5915,9 +5971,7 @@ class CWMApp {
|
|
|
5915
5971
|
requestAnimationFrame(() => {
|
|
5916
5972
|
[srcSlot, dstSlot].forEach(slot => {
|
|
5917
5973
|
const tp = this.terminalPanes[slot];
|
|
5918
|
-
if (tp
|
|
5919
|
-
try { tp.fitAddon.fit(); } catch (_) {}
|
|
5920
|
-
}
|
|
5974
|
+
if (tp) tp.safeFit();
|
|
5921
5975
|
});
|
|
5922
5976
|
});
|
|
5923
5977
|
}
|
|
@@ -5958,7 +6012,7 @@ class CWMApp {
|
|
|
5958
6012
|
// Double-rAF ensures browser has fully laid out the grid before fitting.
|
|
5959
6013
|
requestAnimationFrame(() => { requestAnimationFrame(() => {
|
|
5960
6014
|
this.terminalPanes.forEach(tp => {
|
|
5961
|
-
if (tp
|
|
6015
|
+
if (tp) tp.safeFit();
|
|
5962
6016
|
});
|
|
5963
6017
|
}); });
|
|
5964
6018
|
}
|
|
@@ -6247,14 +6301,9 @@ class CWMApp {
|
|
|
6247
6301
|
document.removeEventListener('mousemove', onMove);
|
|
6248
6302
|
document.removeEventListener('mouseup', onUp);
|
|
6249
6303
|
// Refit all terminals after resize completes
|
|
6304
|
+
// safeFit() handles both the fit and sending resize to the server
|
|
6250
6305
|
this.terminalPanes.forEach(tp => {
|
|
6251
|
-
if (tp
|
|
6252
|
-
try { tp.fitAddon.fit(); } catch (_) {}
|
|
6253
|
-
// Notify server of new dimensions
|
|
6254
|
-
if (tp.ws && tp.ws.readyState === WebSocket.OPEN && tp.term) {
|
|
6255
|
-
tp.ws.send(JSON.stringify({ type: 'resize', cols: tp.term.cols, rows: tp.term.rows }));
|
|
6256
|
-
}
|
|
6257
|
-
}
|
|
6306
|
+
if (tp) tp.safeFit();
|
|
6258
6307
|
});
|
|
6259
6308
|
};
|
|
6260
6309
|
|
|
@@ -6675,11 +6724,11 @@ class CWMApp {
|
|
|
6675
6724
|
// Set as active pane and focus it
|
|
6676
6725
|
this.setActiveTerminalPane(slotIdx);
|
|
6677
6726
|
|
|
6678
|
-
// Refit the terminal after switching
|
|
6727
|
+
// Refit the terminal after switching (safeFit guards against hidden panes)
|
|
6679
6728
|
const tp = this.terminalPanes[slotIdx];
|
|
6680
|
-
if (tp
|
|
6729
|
+
if (tp) {
|
|
6681
6730
|
requestAnimationFrame(() => {
|
|
6682
|
-
|
|
6731
|
+
tp.safeFit();
|
|
6683
6732
|
});
|
|
6684
6733
|
}
|
|
6685
6734
|
|
|
@@ -7076,6 +7125,16 @@ class CWMApp {
|
|
|
7076
7125
|
// Project session - sync everywhere (localStorage + any linked workspace sessions)
|
|
7077
7126
|
this.syncSessionTitle(sessionId, newName);
|
|
7078
7127
|
}
|
|
7128
|
+
// Sync terminal pane titles if this session is open in a terminal
|
|
7129
|
+
for (let i = 0; i < this.terminalPanes.length; i++) {
|
|
7130
|
+
const tp = this.terminalPanes[i];
|
|
7131
|
+
if (tp && tp.sessionId === sessionId) {
|
|
7132
|
+
tp.sessionName = newName;
|
|
7133
|
+
const paneEl = document.getElementById(`term-pane-${i}`);
|
|
7134
|
+
const titleEl = paneEl && paneEl.querySelector('.terminal-pane-title');
|
|
7135
|
+
if (titleEl) titleEl.textContent = newName;
|
|
7136
|
+
}
|
|
7137
|
+
}
|
|
7079
7138
|
nameEl.textContent = newName;
|
|
7080
7139
|
nameEl.classList.add('rename-flash');
|
|
7081
7140
|
setTimeout(() => nameEl.classList.remove('rename-flash'), 600);
|
|
@@ -7159,8 +7218,9 @@ class CWMApp {
|
|
|
7159
7218
|
nameEl.classList.add('rename-flash');
|
|
7160
7219
|
setTimeout(() => nameEl.classList.remove('rename-flash'), 600);
|
|
7161
7220
|
|
|
7162
|
-
// Refresh sidebar
|
|
7221
|
+
// Refresh sidebar and project sessions view
|
|
7163
7222
|
this.renderWorkspaces();
|
|
7223
|
+
this.renderProjects();
|
|
7164
7224
|
} catch (err) {
|
|
7165
7225
|
nameEl.textContent = currentName;
|
|
7166
7226
|
this.showToast('Rename failed: ' + (err.message || ''), 'error');
|
|
@@ -9609,6 +9669,62 @@ class CWMApp {
|
|
|
9609
9669
|
}
|
|
9610
9670
|
}
|
|
9611
9671
|
|
|
9672
|
+
/**
|
|
9673
|
+
* Refocus a session by distilling the conversation into a structured context
|
|
9674
|
+
* document, then sending /clear (reset) or /compact to the terminal and
|
|
9675
|
+
* injecting the document back in for Claude to ingest.
|
|
9676
|
+
*
|
|
9677
|
+
* @param {string} sessionId - The session ID to refocus
|
|
9678
|
+
* @param {'reset'|'compact'} mode - Whether to clear or compact the conversation
|
|
9679
|
+
*/
|
|
9680
|
+
async refocusSession(sessionId, mode) {
|
|
9681
|
+
// Find the terminal pane for this session
|
|
9682
|
+
const tp = this.terminalPanes.find(p => p && p.sessionId === sessionId);
|
|
9683
|
+
if (!tp || !tp.ws || tp.ws.readyState !== WebSocket.OPEN) {
|
|
9684
|
+
this.showToast('Session must be open in a terminal pane to refocus', 'warning');
|
|
9685
|
+
return;
|
|
9686
|
+
}
|
|
9687
|
+
|
|
9688
|
+
this.showToast('Generating refocus document...', 'info');
|
|
9689
|
+
|
|
9690
|
+
try {
|
|
9691
|
+
// Generate the refocus document on the server
|
|
9692
|
+
const data = await this.api('POST', `/api/sessions/${sessionId}/refocus`, { mode });
|
|
9693
|
+
|
|
9694
|
+
if (!data || !data.success) {
|
|
9695
|
+
this.showToast(data?.error || 'Failed to generate refocus document', 'error');
|
|
9696
|
+
return;
|
|
9697
|
+
}
|
|
9698
|
+
|
|
9699
|
+
const filePath = data.filePath;
|
|
9700
|
+
|
|
9701
|
+
// Send /clear or /compact to the terminal
|
|
9702
|
+
const command = mode === 'reset' ? '/clear' : '/compact';
|
|
9703
|
+
tp.sendCommand(command + '\r');
|
|
9704
|
+
|
|
9705
|
+
// Wait for Claude to process the command, then inject the refocus prompt
|
|
9706
|
+
setTimeout(() => {
|
|
9707
|
+
const refocusPrompt = 'Read the file .refocus-context.md in this directory. It contains a comprehensive summary of our previous conversation including what was accomplished, key decisions, open issues, and next steps. Use this to fully orient yourself on the project state. After reading, briefly confirm what you understand and ask what I\'d like to work on next.';
|
|
9708
|
+
tp.sendCommand(refocusPrompt + '\r');
|
|
9709
|
+
|
|
9710
|
+
this.showToast(`Session refocused (${mode}) — context document injected`, 'success');
|
|
9711
|
+
}, 3000);
|
|
9712
|
+
|
|
9713
|
+
// Clean up the refocus file after a delay
|
|
9714
|
+
const cleanupDelay = mode === 'reset' ? 60000 : 120000;
|
|
9715
|
+
setTimeout(async () => {
|
|
9716
|
+
try {
|
|
9717
|
+
await this.api('DELETE', `/api/refocus-cleanup?filePath=${encodeURIComponent(filePath)}`);
|
|
9718
|
+
} catch (_) {
|
|
9719
|
+
// Non-critical — file may already be gone
|
|
9720
|
+
}
|
|
9721
|
+
}, cleanupDelay);
|
|
9722
|
+
|
|
9723
|
+
} catch (err) {
|
|
9724
|
+
this.showToast(err.message || 'Failed to refocus session', 'error');
|
|
9725
|
+
}
|
|
9726
|
+
}
|
|
9727
|
+
|
|
9612
9728
|
/* ─── Image Upload for Terminal Sessions ──────────────────── */
|
|
9613
9729
|
|
|
9614
9730
|
/**
|
|
Binary file
|
package/src/web/public/logo.png
CHANGED
|
Binary file
|
|
@@ -557,20 +557,24 @@
|
|
|
557
557
|
flex: 1;
|
|
558
558
|
min-height: 0;
|
|
559
559
|
overflow: hidden;
|
|
560
|
-
/* Isolate scroll: our custom touch handler manages scrolling */
|
|
561
560
|
overscroll-behavior: contain;
|
|
562
|
-
|
|
561
|
+
/* Allow native vertical scrolling — compositor-thread smooth */
|
|
562
|
+
touch-action: pan-y;
|
|
563
563
|
}
|
|
564
564
|
|
|
565
|
-
/* xterm viewport (the actual scrollable area inside xterm.js)
|
|
565
|
+
/* xterm viewport (the actual scrollable area inside xterm.js).
|
|
566
|
+
Native touch scroll is enabled via pan-y — the browser handles momentum,
|
|
567
|
+
deceleration, and overscroll on the compositor thread for 60fps smoothness.
|
|
568
|
+
-webkit-overflow-scrolling: touch enables iOS inertial scroll. */
|
|
566
569
|
.terminal-pane.mobile-active .xterm-viewport {
|
|
567
570
|
overscroll-behavior: contain;
|
|
568
|
-
|
|
569
|
-
touch-action:
|
|
571
|
+
-webkit-overflow-scrolling: touch;
|
|
572
|
+
touch-action: pan-y;
|
|
570
573
|
}
|
|
571
574
|
|
|
572
|
-
/* xterm screen layer
|
|
573
|
-
|
|
575
|
+
/* xterm screen layer (canvas) — pointer-events toggled by JS:
|
|
576
|
+
Scroll mode: pointer-events: none → touches pass through to viewport for native scroll
|
|
577
|
+
Type mode: pointer-events: auto → xterm.js handles touch for cursor/selection */
|
|
574
578
|
.terminal-pane.mobile-active .xterm-screen {
|
|
575
579
|
touch-action: none;
|
|
576
580
|
}
|
|
@@ -371,16 +371,17 @@ class TerminalPane {
|
|
|
371
371
|
}
|
|
372
372
|
});
|
|
373
373
|
|
|
374
|
-
this._resizeObserver = new ResizeObserver(() => {
|
|
374
|
+
this._resizeObserver = new ResizeObserver((entries) => {
|
|
375
|
+
// Guard: skip fit when container is hidden (e.g., tab switch sets display:none).
|
|
376
|
+
// A 0×0 contentRect causes fitAddon to calculate 1×1 cols/rows, which sends a
|
|
377
|
+
// resize to the PTY server and permanently garbles the terminal's buffered output.
|
|
378
|
+
const entry = entries[0];
|
|
379
|
+
if (entry && (entry.contentRect.width === 0 || entry.contentRect.height === 0)) return;
|
|
380
|
+
|
|
375
381
|
// Debounce resize to prevent layout thrashing during mobile tab switches
|
|
376
382
|
clearTimeout(this._fitTimer);
|
|
377
383
|
this._fitTimer = setTimeout(() => {
|
|
378
|
-
|
|
379
|
-
try { this.fitAddon.fit(); } catch (_) {}
|
|
380
|
-
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
381
|
-
this.ws.send(JSON.stringify({ type: 'resize', cols: this.term.cols, rows: this.term.rows }));
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
+
this.safeFit();
|
|
384
385
|
}, 100);
|
|
385
386
|
});
|
|
386
387
|
this._resizeObserver.observe(container);
|
|
@@ -574,6 +575,25 @@ class TerminalPane {
|
|
|
574
575
|
}
|
|
575
576
|
}
|
|
576
577
|
|
|
578
|
+
/**
|
|
579
|
+
* Visibility-safe fit: only calls fitAddon.fit() when the container is visible.
|
|
580
|
+
* Hidden panes (display:none from tab switching) report 0×0 dimensions, which
|
|
581
|
+
* causes fitAddon to resize the PTY to 1×1 — permanently garbling scrollback.
|
|
582
|
+
* All external callers should use safeFit() instead of fitAddon.fit() directly.
|
|
583
|
+
*/
|
|
584
|
+
safeFit() {
|
|
585
|
+
if (!this.fitAddon || !this.term) return;
|
|
586
|
+
const container = document.getElementById(this.containerId);
|
|
587
|
+
if (!container) return;
|
|
588
|
+
const rect = container.getBoundingClientRect();
|
|
589
|
+
if (rect.width === 0 || rect.height === 0) return;
|
|
590
|
+
try { this.fitAddon.fit(); } catch (_) { return; }
|
|
591
|
+
// Notify server of new dimensions
|
|
592
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
593
|
+
this.ws.send(JSON.stringify({ type: 'resize', cols: this.term.cols, rows: this.term.rows }));
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
577
597
|
/**
|
|
578
598
|
* Mobile scroll/type mode.
|
|
579
599
|
* On mobile, touching the terminal to scroll triggers the keyboard because
|
|
@@ -608,135 +628,45 @@ class TerminalPane {
|
|
|
608
628
|
if (!textarea) return;
|
|
609
629
|
|
|
610
630
|
this._xtermTextarea = textarea;
|
|
631
|
+
this._xtermScreen = container.querySelector('.xterm-screen');
|
|
611
632
|
|
|
612
|
-
// Default to scroll mode: block touch from reaching textarea
|
|
613
|
-
//
|
|
614
|
-
//
|
|
633
|
+
// Default to scroll mode: block touch from reaching textarea and screen.
|
|
634
|
+
// textarea: prevents keyboard popup on scroll
|
|
635
|
+
// screen: lets touches pass through to .xterm-viewport for native scroll
|
|
615
636
|
textarea.style.pointerEvents = 'none';
|
|
616
|
-
|
|
617
|
-
// ── Custom Touch Scroll Handler ──────────────────────────────
|
|
618
|
-
this._initTouchScroll(container);
|
|
637
|
+
if (this._xtermScreen) this._xtermScreen.style.pointerEvents = 'none';
|
|
619
638
|
}
|
|
620
639
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
*/
|
|
627
|
-
_initTouchScroll(container) {
|
|
628
|
-
let touchStartY = 0;
|
|
629
|
-
let touchLastY = 0;
|
|
630
|
-
let touchLastTime = 0;
|
|
631
|
-
let velocity = 0;
|
|
632
|
-
let momentumId = null;
|
|
633
|
-
let isScrolling = false;
|
|
634
|
-
|
|
635
|
-
// Get the xterm viewport element (the actual scrollable div)
|
|
636
|
-
const viewport = container.querySelector('.xterm-viewport');
|
|
637
|
-
if (!viewport) {
|
|
638
|
-
this._log('Touch scroll: .xterm-viewport not found');
|
|
639
|
-
return;
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
const cancelMomentum = () => {
|
|
643
|
-
if (momentumId) {
|
|
644
|
-
cancelAnimationFrame(momentumId);
|
|
645
|
-
momentumId = null;
|
|
646
|
-
}
|
|
647
|
-
};
|
|
648
|
-
|
|
649
|
-
const applyMomentum = () => {
|
|
650
|
-
if (Math.abs(velocity) < 0.3) {
|
|
651
|
-
velocity = 0;
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
// Scroll the viewport directly - pixel-smooth, no line snapping
|
|
656
|
-
viewport.scrollTop += velocity;
|
|
657
|
-
|
|
658
|
-
// Decelerate - 0.95 gives a smooth, native-feeling coast
|
|
659
|
-
velocity *= 0.95;
|
|
660
|
-
|
|
661
|
-
momentumId = requestAnimationFrame(applyMomentum);
|
|
662
|
-
};
|
|
663
|
-
|
|
664
|
-
container.addEventListener('touchstart', (e) => {
|
|
665
|
-
if (this._mobileTypeMode) return;
|
|
666
|
-
|
|
667
|
-
cancelMomentum();
|
|
668
|
-
const touch = e.touches[0];
|
|
669
|
-
touchStartY = touch.clientY;
|
|
670
|
-
touchLastY = touch.clientY;
|
|
671
|
-
touchLastTime = Date.now();
|
|
672
|
-
velocity = 0;
|
|
673
|
-
isScrolling = false;
|
|
674
|
-
}, { passive: true });
|
|
675
|
-
|
|
676
|
-
container.addEventListener('touchmove', (e) => {
|
|
677
|
-
if (this._mobileTypeMode) return;
|
|
678
|
-
|
|
679
|
-
const touch = e.touches[0];
|
|
680
|
-
const deltaY = touchLastY - touch.clientY; // positive = scroll down
|
|
681
|
-
const now = Date.now();
|
|
682
|
-
const dt = Math.max(now - touchLastTime, 1);
|
|
683
|
-
|
|
684
|
-
// Determine if this is a scroll gesture (>5px vertical movement)
|
|
685
|
-
if (!isScrolling) {
|
|
686
|
-
if (Math.abs(touchStartY - touch.clientY) > 5) {
|
|
687
|
-
isScrolling = true;
|
|
688
|
-
} else {
|
|
689
|
-
return;
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
// Prevent page scroll - we're handling it
|
|
694
|
-
e.preventDefault();
|
|
695
|
-
|
|
696
|
-
// Directly scroll the viewport - pixel smooth, no line quantization
|
|
697
|
-
viewport.scrollTop += deltaY;
|
|
698
|
-
|
|
699
|
-
// Track velocity for momentum (pixels per 16ms frame)
|
|
700
|
-
velocity = deltaY * (16 / dt);
|
|
701
|
-
|
|
702
|
-
touchLastY = touch.clientY;
|
|
703
|
-
touchLastTime = now;
|
|
704
|
-
}, { passive: false });
|
|
705
|
-
|
|
706
|
-
container.addEventListener('touchend', () => {
|
|
707
|
-
if (this._mobileTypeMode) return;
|
|
708
|
-
if (!isScrolling) return;
|
|
709
|
-
|
|
710
|
-
// Apply momentum scrolling with deceleration
|
|
711
|
-
if (Math.abs(velocity) > 0.5) {
|
|
712
|
-
momentumId = requestAnimationFrame(applyMomentum);
|
|
713
|
-
}
|
|
714
|
-
isScrolling = false;
|
|
715
|
-
}, { passive: true });
|
|
716
|
-
|
|
717
|
-
this._touchScrollCleanup = () => cancelMomentum();
|
|
718
|
-
}
|
|
640
|
+
// Touch scrolling is handled natively by the browser.
|
|
641
|
+
// In scroll mode, .xterm-screen has pointer-events: none, so touches
|
|
642
|
+
// pass through to .xterm-viewport which scrolls natively via CSS
|
|
643
|
+
// touch-action: pan-y. This runs on the compositor thread for 60fps
|
|
644
|
+
// smoothness with native momentum/deceleration — no JS needed.
|
|
719
645
|
|
|
720
646
|
/**
|
|
721
|
-
* Switch to type mode - keyboard appears, user can type into terminal
|
|
647
|
+
* Switch to type mode - keyboard appears, user can type into terminal.
|
|
648
|
+
* Restores pointer-events on both textarea (keyboard input) and screen
|
|
649
|
+
* (xterm.js touch handling for cursor/selection).
|
|
722
650
|
*/
|
|
723
651
|
setMobileTypeMode() {
|
|
724
652
|
if (!this._xtermTextarea || !this.term) return;
|
|
725
653
|
this._mobileTypeMode = true;
|
|
726
|
-
// Allow touch to reach textarea so keyboard can appear
|
|
727
654
|
this._xtermTextarea.style.pointerEvents = 'auto';
|
|
655
|
+
if (this._xtermScreen) this._xtermScreen.style.pointerEvents = 'auto';
|
|
728
656
|
this.term.focus();
|
|
729
657
|
if (this.onMobileModeChange) this.onMobileModeChange('type');
|
|
730
658
|
}
|
|
731
659
|
|
|
732
660
|
/**
|
|
733
|
-
* Switch to scroll mode - keyboard hidden, touch scrolls terminal output
|
|
661
|
+
* Switch to scroll mode - keyboard hidden, touch scrolls terminal output.
|
|
662
|
+
* Disables pointer-events on textarea (prevents keyboard popup) and screen
|
|
663
|
+
* (lets touches pass through to viewport for native compositor-thread scroll).
|
|
734
664
|
*/
|
|
735
665
|
setMobileScrollMode() {
|
|
736
666
|
if (!this._xtermTextarea) return;
|
|
737
667
|
this._mobileTypeMode = false;
|
|
738
|
-
// Block touch from reaching textarea - prevents keyboard on scroll
|
|
739
668
|
this._xtermTextarea.style.pointerEvents = 'none';
|
|
669
|
+
if (this._xtermScreen) this._xtermScreen.style.pointerEvents = 'none';
|
|
740
670
|
if (this.term) this.term.blur();
|
|
741
671
|
if (this.onMobileModeChange) this.onMobileModeChange('scroll');
|
|
742
672
|
}
|
package/src/web/server.js
CHANGED
|
@@ -2603,6 +2603,278 @@ app.get('/api/sessions/:id/export-context', requireAuth, (req, res) => {
|
|
|
2603
2603
|
}
|
|
2604
2604
|
});
|
|
2605
2605
|
|
|
2606
|
+
// ──────────────────────────────────────────────────────────
|
|
2607
|
+
// SESSION REFOCUS (DISTILL + RESET/COMPACT)
|
|
2608
|
+
// ──────────────────────────────────────────────────────────
|
|
2609
|
+
|
|
2610
|
+
/**
|
|
2611
|
+
* POST /api/sessions/:id/refocus
|
|
2612
|
+
* Generates a comprehensive refocus document from the session's conversation,
|
|
2613
|
+
* writes it to the session's working directory as .refocus-context.md, and
|
|
2614
|
+
* returns the file path + content. The frontend then sends /clear or /compact
|
|
2615
|
+
* to the terminal and injects the document back into the session.
|
|
2616
|
+
*
|
|
2617
|
+
* Request body: { mode: 'reset' | 'compact' }
|
|
2618
|
+
* Response: { success, filePath, content, sessionName }
|
|
2619
|
+
*/
|
|
2620
|
+
app.post('/api/sessions/:id/refocus', requireAuth, (req, res) => {
|
|
2621
|
+
const store = getStore();
|
|
2622
|
+
const session = store.getSession(req.params.id);
|
|
2623
|
+
const mode = req.body.mode;
|
|
2624
|
+
|
|
2625
|
+
if (!mode || (mode !== 'reset' && mode !== 'compact')) {
|
|
2626
|
+
return res.status(400).json({ error: 'Invalid mode. Must be "reset" or "compact".' });
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
if (!session) {
|
|
2630
|
+
return res.status(404).json({ error: 'Session not found' });
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
const claudeSessionId = session.resumeSessionId || req.params.id;
|
|
2634
|
+
const sessionName = session.name || claudeSessionId || 'Unknown Session';
|
|
2635
|
+
|
|
2636
|
+
if (!claudeSessionId) {
|
|
2637
|
+
return res.status(400).json({ error: 'No Claude session ID available' });
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
// Need a working directory to write the refocus file
|
|
2641
|
+
const workingDir = session.workingDir;
|
|
2642
|
+
if (!workingDir || !fs.existsSync(workingDir)) {
|
|
2643
|
+
return res.status(400).json({ error: 'Session has no valid working directory' });
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
// Find the JSONL conversation file
|
|
2647
|
+
const jsonlPath = findJsonlFile(claudeSessionId);
|
|
2648
|
+
if (!jsonlPath) {
|
|
2649
|
+
return res.status(404).json({ error: 'No conversation data found for this session' });
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
try {
|
|
2653
|
+
const stat = fs.statSync(jsonlPath);
|
|
2654
|
+
const fileSize = stat.size;
|
|
2655
|
+
|
|
2656
|
+
// Read more of the conversation than export-context for comprehensive coverage
|
|
2657
|
+
// Head: first 50KB for early messages, Tail: last 200KB for recent context
|
|
2658
|
+
const headSize = Math.min(50 * 1024, fileSize);
|
|
2659
|
+
const tailSize = Math.min(200 * 1024, fileSize);
|
|
2660
|
+
const tailOffset = Math.max(0, fileSize - tailSize);
|
|
2661
|
+
|
|
2662
|
+
const fd = fs.openSync(jsonlPath, 'r');
|
|
2663
|
+
|
|
2664
|
+
const headBuf = Buffer.alloc(headSize);
|
|
2665
|
+
fs.readSync(fd, headBuf, 0, headSize, 0);
|
|
2666
|
+
|
|
2667
|
+
const tailBuf = Buffer.alloc(tailSize);
|
|
2668
|
+
fs.readSync(fd, tailBuf, 0, tailSize, tailOffset);
|
|
2669
|
+
|
|
2670
|
+
fs.closeSync(fd);
|
|
2671
|
+
|
|
2672
|
+
// Parse head messages — collect first 10 user messages
|
|
2673
|
+
const headContent = headBuf.toString('utf-8');
|
|
2674
|
+
const headLines = headContent.split('\n').filter(l => l.trim());
|
|
2675
|
+
const firstUserMessages = [];
|
|
2676
|
+
for (const line of headLines) {
|
|
2677
|
+
if (firstUserMessages.length >= 10) break;
|
|
2678
|
+
const parsed = extractExportMessageText(line);
|
|
2679
|
+
if (parsed && parsed.role === 'user') {
|
|
2680
|
+
firstUserMessages.push(parsed.text);
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
// Parse tail messages — collect last 15 user + last 15 assistant messages
|
|
2685
|
+
const tailContent = tailBuf.toString('utf-8');
|
|
2686
|
+
const tailLines = tailContent.split('\n').filter(l => l.trim());
|
|
2687
|
+
// Drop partial first line if we started mid-file
|
|
2688
|
+
if (tailOffset > 0 && tailLines.length > 0) tailLines.shift();
|
|
2689
|
+
|
|
2690
|
+
const lastUserMessages = [];
|
|
2691
|
+
const lastAssistantMessages = [];
|
|
2692
|
+
for (let i = tailLines.length - 1; i >= 0; i--) {
|
|
2693
|
+
const parsed = extractExportMessageText(tailLines[i]);
|
|
2694
|
+
if (!parsed) continue;
|
|
2695
|
+
if (parsed.role === 'user' && lastUserMessages.length < 15) {
|
|
2696
|
+
lastUserMessages.unshift(parsed);
|
|
2697
|
+
}
|
|
2698
|
+
if (parsed.role === 'assistant' && lastAssistantMessages.length < 15) {
|
|
2699
|
+
lastAssistantMessages.unshift(parsed);
|
|
2700
|
+
}
|
|
2701
|
+
if (lastUserMessages.length >= 15 && lastAssistantMessages.length >= 15) break;
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
// Extract file paths from the full conversation (combine head + tail text)
|
|
2705
|
+
const allText = [];
|
|
2706
|
+
for (const line of headLines) {
|
|
2707
|
+
const parsed = extractExportMessageText(line);
|
|
2708
|
+
if (parsed) allText.push(parsed.text.substring(0, 2000));
|
|
2709
|
+
}
|
|
2710
|
+
for (const line of tailLines) {
|
|
2711
|
+
const parsed = extractExportMessageText(line);
|
|
2712
|
+
if (parsed) allText.push(parsed.text.substring(0, 2000));
|
|
2713
|
+
}
|
|
2714
|
+
const filesTouched = extractFilePaths(allText.join('\n'));
|
|
2715
|
+
|
|
2716
|
+
// ── Build the structured refocus document ──
|
|
2717
|
+
const timestamp = new Date().toISOString();
|
|
2718
|
+
const mdParts = [];
|
|
2719
|
+
|
|
2720
|
+
mdParts.push(`# Session Refocus: ${sessionName}`);
|
|
2721
|
+
mdParts.push(`_Generated: ${timestamp} | This file will be auto-deleted after ingestion._`);
|
|
2722
|
+
mdParts.push('');
|
|
2723
|
+
|
|
2724
|
+
// Project Overview — the original request/goal
|
|
2725
|
+
mdParts.push('## Project Overview');
|
|
2726
|
+
if (firstUserMessages.length > 0) {
|
|
2727
|
+
const overview = firstUserMessages[0].length > 3000
|
|
2728
|
+
? firstUserMessages[0].substring(0, 3000) + '...'
|
|
2729
|
+
: firstUserMessages[0];
|
|
2730
|
+
mdParts.push(overview);
|
|
2731
|
+
} else {
|
|
2732
|
+
mdParts.push('_No initial user message found._');
|
|
2733
|
+
}
|
|
2734
|
+
mdParts.push('');
|
|
2735
|
+
|
|
2736
|
+
// What Was Accomplished — last 3-5 assistant messages summarized
|
|
2737
|
+
mdParts.push('## What Was Accomplished');
|
|
2738
|
+
if (lastAssistantMessages.length > 0) {
|
|
2739
|
+
const workMsgs = lastAssistantMessages.slice(-5);
|
|
2740
|
+
for (const msg of workMsgs) {
|
|
2741
|
+
const truncated = msg.text.length > 800
|
|
2742
|
+
? msg.text.substring(0, 800).replace(/\s+\S*$/, '') + '...'
|
|
2743
|
+
: msg.text;
|
|
2744
|
+
mdParts.push(`- ${truncated}`);
|
|
2745
|
+
}
|
|
2746
|
+
} else {
|
|
2747
|
+
mdParts.push('_No assistant messages found._');
|
|
2748
|
+
}
|
|
2749
|
+
mdParts.push('');
|
|
2750
|
+
|
|
2751
|
+
// Key Decisions & Context — early user follow-ups (decisions, clarifications)
|
|
2752
|
+
mdParts.push('## Key Decisions & Context');
|
|
2753
|
+
if (firstUserMessages.length > 1) {
|
|
2754
|
+
const decisions = firstUserMessages.slice(1, 8);
|
|
2755
|
+
for (const msg of decisions) {
|
|
2756
|
+
const truncated = msg.length > 600
|
|
2757
|
+
? msg.substring(0, 600).replace(/\s+\S*$/, '') + '...'
|
|
2758
|
+
: msg;
|
|
2759
|
+
mdParts.push(`- ${truncated}`);
|
|
2760
|
+
}
|
|
2761
|
+
} else {
|
|
2762
|
+
mdParts.push('_No additional context decisions found._');
|
|
2763
|
+
}
|
|
2764
|
+
mdParts.push('');
|
|
2765
|
+
|
|
2766
|
+
// Files Modified
|
|
2767
|
+
mdParts.push('## Files Modified');
|
|
2768
|
+
if (filesTouched.length > 0) {
|
|
2769
|
+
for (const fp of filesTouched) {
|
|
2770
|
+
mdParts.push(`- \`${fp}\``);
|
|
2771
|
+
}
|
|
2772
|
+
} else {
|
|
2773
|
+
mdParts.push('_No file paths detected in conversation._');
|
|
2774
|
+
}
|
|
2775
|
+
mdParts.push('');
|
|
2776
|
+
|
|
2777
|
+
// Current State — last assistant message
|
|
2778
|
+
mdParts.push('## Current State');
|
|
2779
|
+
if (lastAssistantMessages.length > 0) {
|
|
2780
|
+
const lastMsg = lastAssistantMessages[lastAssistantMessages.length - 1];
|
|
2781
|
+
const truncated = lastMsg.text.length > 3000
|
|
2782
|
+
? lastMsg.text.substring(0, 3000).replace(/\s+\S*$/, '') + '...'
|
|
2783
|
+
: lastMsg.text;
|
|
2784
|
+
mdParts.push(truncated);
|
|
2785
|
+
} else {
|
|
2786
|
+
mdParts.push('_No assistant messages found._');
|
|
2787
|
+
}
|
|
2788
|
+
mdParts.push('');
|
|
2789
|
+
|
|
2790
|
+
// Open Issues — scan recent messages for TODO/FIXME/error/issue/bug patterns
|
|
2791
|
+
mdParts.push('## Open Issues');
|
|
2792
|
+
const issuePatterns = /\b(?:TODO|FIXME|HACK|BUG|ERROR|ISSUE|PROBLEM|BROKEN|FAILING|BLOCKED)\b/i;
|
|
2793
|
+
const issues = [];
|
|
2794
|
+
for (const msg of [...lastUserMessages, ...lastAssistantMessages].slice(-10)) {
|
|
2795
|
+
if (issuePatterns.test(msg.text)) {
|
|
2796
|
+
const truncated = msg.text.length > 400
|
|
2797
|
+
? msg.text.substring(0, 400).replace(/\s+\S*$/, '') + '...'
|
|
2798
|
+
: msg.text;
|
|
2799
|
+
issues.push(`- [${msg.role}] ${truncated}`);
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
if (issues.length > 0) {
|
|
2803
|
+
mdParts.push(...issues);
|
|
2804
|
+
} else {
|
|
2805
|
+
mdParts.push('_No explicit issues/TODOs detected in recent messages._');
|
|
2806
|
+
}
|
|
2807
|
+
mdParts.push('');
|
|
2808
|
+
|
|
2809
|
+
// Next Steps — derived from recent user messages
|
|
2810
|
+
mdParts.push('## Next Steps');
|
|
2811
|
+
if (lastUserMessages.length > 0) {
|
|
2812
|
+
const recentUserMsgs = lastUserMessages.slice(-3);
|
|
2813
|
+
for (const msg of recentUserMsgs) {
|
|
2814
|
+
const truncated = msg.text.length > 600
|
|
2815
|
+
? msg.text.substring(0, 600).replace(/\s+\S*$/, '') + '...'
|
|
2816
|
+
: msg.text;
|
|
2817
|
+
mdParts.push(`- ${truncated}`);
|
|
2818
|
+
}
|
|
2819
|
+
} else {
|
|
2820
|
+
mdParts.push('_No recent user instructions found._');
|
|
2821
|
+
}
|
|
2822
|
+
mdParts.push('');
|
|
2823
|
+
|
|
2824
|
+
// Important Notes — environment info from the session
|
|
2825
|
+
mdParts.push('## Important Notes');
|
|
2826
|
+
mdParts.push(`- Working directory: \`${workingDir}\``);
|
|
2827
|
+
if (session.model) mdParts.push(`- Model: ${session.model}`);
|
|
2828
|
+
if (session.command) mdParts.push(`- Command: ${session.command}`);
|
|
2829
|
+
mdParts.push(`- Mode used: ${mode === 'reset' ? 'Reset & Refocus' : 'Compact & Refocus'}`);
|
|
2830
|
+
mdParts.push('');
|
|
2831
|
+
|
|
2832
|
+
const content = mdParts.join('\n');
|
|
2833
|
+
const filePath = path.join(workingDir, '.refocus-context.md');
|
|
2834
|
+
|
|
2835
|
+
// Write the refocus document to the session's working directory
|
|
2836
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
2837
|
+
|
|
2838
|
+
return res.json({
|
|
2839
|
+
success: true,
|
|
2840
|
+
filePath,
|
|
2841
|
+
content,
|
|
2842
|
+
sessionName,
|
|
2843
|
+
});
|
|
2844
|
+
} catch (err) {
|
|
2845
|
+
return res.status(500).json({ error: 'Failed to generate refocus document: ' + err.message });
|
|
2846
|
+
}
|
|
2847
|
+
});
|
|
2848
|
+
|
|
2849
|
+
/**
|
|
2850
|
+
* DELETE /api/refocus-cleanup
|
|
2851
|
+
* Cleans up a .refocus-context.md file after ingestion.
|
|
2852
|
+
* Only deletes files that end with '.refocus-context.md' for safety.
|
|
2853
|
+
*
|
|
2854
|
+
* Query param: filePath - absolute path to the file to delete
|
|
2855
|
+
*/
|
|
2856
|
+
app.delete('/api/refocus-cleanup', requireAuth, (req, res) => {
|
|
2857
|
+
const filePath = req.query.filePath;
|
|
2858
|
+
|
|
2859
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
2860
|
+
return res.status(400).json({ error: 'Missing filePath query parameter' });
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
// Safety: only allow deleting .refocus-context.md files
|
|
2864
|
+
if (!filePath.endsWith('.refocus-context.md')) {
|
|
2865
|
+
return res.status(403).json({ error: 'Can only delete .refocus-context.md files' });
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
try {
|
|
2869
|
+
if (fs.existsSync(filePath)) {
|
|
2870
|
+
fs.unlinkSync(filePath);
|
|
2871
|
+
}
|
|
2872
|
+
return res.json({ success: true });
|
|
2873
|
+
} catch (err) {
|
|
2874
|
+
return res.status(500).json({ error: 'Failed to delete refocus file: ' + err.message });
|
|
2875
|
+
}
|
|
2876
|
+
});
|
|
2877
|
+
|
|
2606
2878
|
// ──────────────────────────────────────────────────────────
|
|
2607
2879
|
// SUBAGENT TRACKING
|
|
2608
2880
|
// ──────────────────────────────────────────────────────────
|
|
@@ -3574,7 +3846,7 @@ app.post('/api/resources/kill-process', requireAuth, (req, res) => {
|
|
|
3574
3846
|
|
|
3575
3847
|
function gitExec(args, cwd) {
|
|
3576
3848
|
return new Promise((resolve, reject) => {
|
|
3577
|
-
execFile('git', args, { cwd, timeout: 5000 }, (err, stdout, stderr) => {
|
|
3849
|
+
execFile('git', args, { cwd, timeout: 5000, maxBuffer: 1024 * 512 }, (err, stdout, stderr) => {
|
|
3578
3850
|
if (err) {
|
|
3579
3851
|
const msg = (stderr || err.message || '').trim();
|
|
3580
3852
|
return reject(new Error(msg || 'git command failed'));
|
|
@@ -3584,6 +3856,22 @@ function gitExec(args, cwd) {
|
|
|
3584
3856
|
});
|
|
3585
3857
|
}
|
|
3586
3858
|
|
|
3859
|
+
// ─── Server-side git status cache ─────────────────────────
|
|
3860
|
+
// Prevents OOM from excessive child process spawning when the
|
|
3861
|
+
// frontend polls git status for every visible session.
|
|
3862
|
+
const GIT_STATUS_CACHE_TTL = 15000; // 15 seconds
|
|
3863
|
+
const gitStatusCache = new Map();
|
|
3864
|
+
|
|
3865
|
+
// Evict stale entries every 60 seconds to prevent unbounded growth
|
|
3866
|
+
setInterval(() => {
|
|
3867
|
+
const now = Date.now();
|
|
3868
|
+
for (const [key, entry] of gitStatusCache) {
|
|
3869
|
+
if (now - entry.ts > GIT_STATUS_CACHE_TTL * 2) {
|
|
3870
|
+
gitStatusCache.delete(key);
|
|
3871
|
+
}
|
|
3872
|
+
}
|
|
3873
|
+
}, 60000).unref();
|
|
3874
|
+
|
|
3587
3875
|
async function gitRepoRoot(dir) {
|
|
3588
3876
|
try {
|
|
3589
3877
|
const root = await gitExec(['rev-parse', '--show-toplevel'], dir);
|
|
@@ -3596,9 +3884,20 @@ async function gitRepoRoot(dir) {
|
|
|
3596
3884
|
app.get('/api/git/status', requireAuth, async (req, res) => {
|
|
3597
3885
|
const dir = req.query.dir;
|
|
3598
3886
|
if (!dir) return res.status(400).json({ error: 'dir query parameter required' });
|
|
3887
|
+
|
|
3888
|
+
// Return cached result if fresh enough
|
|
3889
|
+
const cached = gitStatusCache.get(dir);
|
|
3890
|
+
if (cached && Date.now() - cached.ts < GIT_STATUS_CACHE_TTL) {
|
|
3891
|
+
return res.json(cached.data);
|
|
3892
|
+
}
|
|
3893
|
+
|
|
3599
3894
|
try {
|
|
3600
3895
|
const root = await gitRepoRoot(dir);
|
|
3601
|
-
if (!root)
|
|
3896
|
+
if (!root) {
|
|
3897
|
+
const result = { isGitRepo: false };
|
|
3898
|
+
gitStatusCache.set(dir, { data: result, ts: Date.now() });
|
|
3899
|
+
return res.json(result);
|
|
3900
|
+
}
|
|
3602
3901
|
const branch = (await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'], dir)).trim();
|
|
3603
3902
|
let dirty = false;
|
|
3604
3903
|
try {
|
|
@@ -3618,7 +3917,9 @@ app.get('/api/git/status', requireAuth, async (req, res) => {
|
|
|
3618
3917
|
behind = b || 0;
|
|
3619
3918
|
} catch {}
|
|
3620
3919
|
}
|
|
3621
|
-
|
|
3920
|
+
const result = { isGitRepo: true, repoRoot: root, branch, dirty, remote, ahead, behind };
|
|
3921
|
+
gitStatusCache.set(dir, { data: result, ts: Date.now() });
|
|
3922
|
+
res.json(result);
|
|
3622
3923
|
} catch (err) {
|
|
3623
3924
|
res.status(500).json({ error: err.message });
|
|
3624
3925
|
}
|