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 CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="docs/images/logo.png" alt="Myrlin's Workbook" width="250">
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
- On first launch, a random password is generated and printed to the console. Saved to `state/config.json`.
41
+ ### Password
42
42
 
43
- **Custom password:**
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
- ```bash
46
- # Bash/zsh
47
- CWM_PASSWORD=mypassword npm run gui
45
+ To set your own:
48
46
 
49
- # PowerShell
50
- $env:CWM_PASSWORD="mypassword"; npm run gui
47
+ ```bash
48
+ # Option 1: Edit the config file (recommended — persists forever)
49
+ # ~/.myrlin/config.json → { "password": "your-password-here" }
51
50
 
52
- # cmd.exe
53
- set CWM_PASSWORD=mypassword && npm run gui
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myrlin-workbook",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Browser-based workspace manager for Claude Code sessions - session discovery, multi-terminal, cost tracking, docs, and kanban board",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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. state/config.json file
15
- * 3. Auto-generated on first run (saved to state/config.json)
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 CONFIG_DIR = path.join(__dirname, '..', '..', 'state');
27
- const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
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
- * Load or generate the auth password.
71
- * Priority: env var > config file > auto-generate.
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 loadPassword() {
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(CONFIG_FILE)) {
83
- const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
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 - regenerate
87
+ // Corrupted config - skip
90
88
  }
89
+ return null;
90
+ }
91
91
 
92
- // 3. Auto-generate and save
93
- const generated = crypto.randomBytes(16).toString('base64url');
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(CONFIG_DIR)) {
96
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
100
+ if (!fs.existsSync(dir)) {
101
+ fs.mkdirSync(dir, { recursive: true });
97
102
  }
98
103
  const config = {};
99
104
  try {
100
- if (fs.existsSync(CONFIG_FILE)) {
101
- Object.assign(config, JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')));
105
+ if (fs.existsSync(filePath)) {
106
+ Object.assign(config, JSON.parse(fs.readFileSync(filePath, 'utf-8')));
102
107
  }
103
108
  } catch (_) {}
104
- config.password = generated;
105
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
109
+ config.password = password;
110
+ fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf-8');
106
111
  } catch (err) {
107
- console.error('[AUTH] Failed to save generated password to config:', err.message);
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: state/config.json');
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('');
@@ -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 && tp.fitAddon) {
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
- showContextMenu(sessionId, x, y) {
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
- // View details (shows full session info in detail panel)
1735
- items.push({
1736
- label: 'View Details', icon: '&#128269;', 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: '&#9654;', 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: '&#9654;', action: () => this.startSession(sessionId) },
@@ -1819,6 +1808,15 @@ class CWMApp {
1819
1808
  { label: 'Summarize to Docs', icon: '&#128221;', action: () => this.summarizeSessionToDocs(sessionId) },
1820
1809
  );
1821
1810
 
1811
+ // Refocus submenu — distill conversation + reset or compact
1812
+ items.push({
1813
+ label: 'Refocus', icon: '&#128260;',
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: '&#10005;', 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: '&#128269;', action: () => {
1866
+ this.selectSession(sessionId);
1867
+ },
1868
+ });
1869
+
1870
+ // Sidebar-specific: Open in terminal
1871
+ items.push({
1872
+ label: 'Open in Terminal', icon: '&#9654;', 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 && tp.fit) tp.fit();
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 && tp.fitAddon) {
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 && tp.fitAddon) tp.fitAddon.fit();
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 && tp.fitAddon) tp.fitAddon.fit();
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
- items.push({ type: 'sep' });
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
- // Copy session ID
5773
- items.push({
5774
- label: 'Copy Session ID', icon: '&#128203;', 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: '&#128269;', 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 && tp.fitAddon) {
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 && tp.fitAddon) try { tp.fitAddon.fit(); } catch (_) {}
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 && tp.fitAddon) {
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 && tp.fitAddon) {
6729
+ if (tp) {
6681
6730
  requestAnimationFrame(() => {
6682
- try { tp.fitAddon.fit(); } catch (_) {}
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
  /**
@@ -92,7 +92,7 @@
92
92
  </svg>
93
93
  </button>
94
94
  <div class="header-brand">
95
- <img src="logo.png" alt="Myrlin" class="header-logo-img">
95
+ <img src="logo-cropped.png" alt="Myrlin" class="header-logo-img">
96
96
  <span class="header-title">Myrlin's Workbook</span>
97
97
  </div>
98
98
  </div>
Binary file
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
- touch-action: none;
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
- /* Disable native touch scroll - our handler uses term.scrollLines() */
569
- touch-action: none;
571
+ -webkit-overflow-scrolling: touch;
572
+ touch-action: pan-y;
570
573
  }
571
574
 
572
- /* xterm screen layer - disable browser touch handling so our
573
- custom touch scroll handler can manage scrolling directly */
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
- if (this.fitAddon) {
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 via CSS.
613
- // This prevents the keyboard from appearing when scrolling.
614
- // Unlike readonly, this doesn't interfere with programmatic input.
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
- * Custom touch scroll for mobile. Directly manipulates the xterm viewport's
623
- * scrollTop for pixel-smooth scrolling that feels like native scroll.
624
- * xterm.js's .xterm-screen canvas sits on top of .xterm-viewport and
625
- * intercepts touch events, so we handle them manually.
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) return res.json({ isGitRepo: false });
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
- res.json({ isGitRepo: true, repoRoot: root, branch, dirty, remote, ahead, behind });
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
  }