myrlin-workbook 0.9.28 → 0.9.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myrlin-workbook",
3
- "version": "0.9.28",
3
+ "version": "0.9.30",
4
4
  "description": "Browser-based project manager for Claude Code sessions - session discovery, multi-terminal, cost tracking, docs, and kanban board",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,37 +1,77 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Postinstall script: fix node-pty spawn-helper permissions on macOS/Linux.
4
- * The prebuilt spawn-helper binary ships without execute permission (644),
5
- * causing posix_spawnp failures. This sets it to 755.
6
- * See: https://github.com/therealarthur/myrlin-workbook/issues/4
7
- */
8
- 'use strict';
9
-
10
- if (process.platform === 'win32') {
11
- process.exit(0);
12
- }
13
-
14
- const { execSync } = require('child_process');
15
- const path = require('path');
16
- const fs = require('fs');
17
-
18
- const prebuildsDir = path.join(__dirname, '..', 'node_modules', 'node-pty', 'prebuilds');
19
-
20
- if (!fs.existsSync(prebuildsDir)) {
21
- process.exit(0);
22
- }
23
-
24
- try {
25
- // Find all spawn-helper binaries across platform dirs and chmod +x them
26
- const dirs = fs.readdirSync(prebuildsDir, { withFileTypes: true })
27
- .filter(d => d.isDirectory());
28
-
29
- for (const dir of dirs) {
30
- const helper = path.join(prebuildsDir, dir.name, 'spawn-helper');
31
- if (fs.existsSync(helper)) {
32
- execSync(`chmod +x "${helper}"`, { stdio: 'ignore' });
33
- }
34
- }
35
- } catch (_) {
36
- // Non-fatal: if chmod fails, user can still manually fix
37
- }
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Postinstall script: fix node-pty spawn-helper permissions on macOS/Linux.
4
+ *
5
+ * The prebuilt spawn-helper binary in node-pty's package ships without
6
+ * execute permission (mode 644 instead of 755), causing posix_spawnp to
7
+ * fail with "posix_spawnp failed" on first PTY spawn.
8
+ *
9
+ * This is a known node-pty packaging issue. We work around it here by
10
+ * locating node-pty (wherever npm/npx hoisted it), finding all prebuilt
11
+ * spawn-helper binaries across platform dirs, and setting them to 755.
12
+ *
13
+ * See: https://github.com/therealarthur/myrlin-workbook/issues/4
14
+ */
15
+ 'use strict';
16
+
17
+ if (process.platform === 'win32') {
18
+ process.exit(0);
19
+ }
20
+
21
+ const path = require('path');
22
+ const fs = require('fs');
23
+
24
+ /**
25
+ * Locate node-pty's package directory using require.resolve.
26
+ * Works regardless of where npm/npx/yarn placed it (hoisted, nested,
27
+ * pnp-virtual, etc.). Returns null if node-pty isn't installed.
28
+ */
29
+ function findNodePtyDir() {
30
+ try {
31
+ const ptyMain = require.resolve('node-pty', { paths: [path.join(__dirname, '..')] });
32
+ let dir = path.dirname(ptyMain);
33
+ // Walk up until we find package.json with "name": "node-pty"
34
+ for (let i = 0; i < 8; i++) {
35
+ const pkg = path.join(dir, 'package.json');
36
+ if (fs.existsSync(pkg)) {
37
+ try {
38
+ const json = JSON.parse(fs.readFileSync(pkg, 'utf8'));
39
+ if (json && json.name === 'node-pty') return dir;
40
+ } catch (_) {}
41
+ }
42
+ const parent = path.dirname(dir);
43
+ if (parent === dir) break;
44
+ dir = parent;
45
+ }
46
+ } catch (_) {}
47
+ return null;
48
+ }
49
+
50
+ const ptyDir = findNodePtyDir();
51
+ if (!ptyDir) {
52
+ // node-pty not installed yet (rare during postinstall); skip silently.
53
+ process.exit(0);
54
+ }
55
+
56
+ const prebuildsDir = path.join(ptyDir, 'prebuilds');
57
+ if (!fs.existsSync(prebuildsDir)) {
58
+ process.exit(0);
59
+ }
60
+
61
+ try {
62
+ const platforms = fs.readdirSync(prebuildsDir, { withFileTypes: true })
63
+ .filter(d => d.isDirectory());
64
+
65
+ for (const platform of platforms) {
66
+ const helper = path.join(prebuildsDir, platform.name, 'spawn-helper');
67
+ if (fs.existsSync(helper)) {
68
+ try {
69
+ fs.chmodSync(helper, 0o755);
70
+ } catch (_) {
71
+ // Non-fatal: runtime fix will catch this on first PTY spawn
72
+ }
73
+ }
74
+ }
75
+ } catch (_) {
76
+ // Non-fatal
77
+ }
@@ -11,10 +11,52 @@
11
11
  * - Scrollback is capped at ~100KB total characters
12
12
  */
13
13
 
14
- const pty = require('node-pty');
15
14
  const fs = require('fs');
16
15
  const os = require('os');
17
16
  const path = require('path');
17
+
18
+ // Ensure node-pty's prebuilt spawn-helper is executable BEFORE requiring node-pty.
19
+ // node-pty's prebuild ships with mode 644 instead of 755, causing posix_spawnp
20
+ // to fail on macOS/Linux. The postinstall script handles this in normal installs
21
+ // but doesn't run with --ignore-scripts or in some npx caches. This runtime
22
+ // fallback covers those cases. See: https://github.com/therealarthur/myrlin-workbook/issues/4
23
+ if (process.platform !== 'win32') {
24
+ try {
25
+ const ptyMain = require.resolve('node-pty');
26
+ let dir = path.dirname(ptyMain);
27
+ for (let i = 0; i < 8; i++) {
28
+ const pkg = path.join(dir, 'package.json');
29
+ if (fs.existsSync(pkg)) {
30
+ try {
31
+ const json = JSON.parse(fs.readFileSync(pkg, 'utf8'));
32
+ if (json && json.name === 'node-pty') break;
33
+ } catch (_) {}
34
+ }
35
+ const parent = path.dirname(dir);
36
+ if (parent === dir) { dir = null; break; }
37
+ dir = parent;
38
+ }
39
+ if (dir) {
40
+ const prebuildsDir = path.join(dir, 'prebuilds');
41
+ if (fs.existsSync(prebuildsDir)) {
42
+ for (const p of fs.readdirSync(prebuildsDir)) {
43
+ const helper = path.join(prebuildsDir, p, 'spawn-helper');
44
+ if (fs.existsSync(helper)) {
45
+ try {
46
+ const stat = fs.statSync(helper);
47
+ // Only chmod if not already executable, avoids unnecessary syscalls
48
+ if ((stat.mode & 0o111) === 0) fs.chmodSync(helper, 0o755);
49
+ } catch (_) {}
50
+ }
51
+ }
52
+ }
53
+ }
54
+ } catch (_) {
55
+ // node-pty not yet resolvable; require() below will throw with a clearer error
56
+ }
57
+ }
58
+
59
+ const pty = require('node-pty');
18
60
  const { getStore } = require('../state/store');
19
61
 
20
62
  /**
@@ -3109,10 +3109,16 @@ class CWMApp {
3109
3109
  this.showToast('Select or create a project first', 'warning');
3110
3110
  return;
3111
3111
  }
3112
- // Use project folder name as friendly default name instead of raw UUID
3113
- const projectName = projectPath ? projectPath.split('\\').pop() || projectPath.split('/').pop() || sessionName : sessionName;
3114
- const shortId = sessionName.length > 8 ? sessionName.substring(0, 8) : sessionName;
3115
- const friendlyName = projectName + ' (' + shortId + ')';
3112
+ // Prefer custom title from Claude session, fall back to folder name + short UUID
3113
+ const customTitle = this.getProjectSessionTitle(sessionName);
3114
+ let friendlyName;
3115
+ if (customTitle) {
3116
+ friendlyName = customTitle;
3117
+ } else {
3118
+ const projectName = projectPath ? projectPath.split('\\').pop() || projectPath.split('/').pop() || sessionName : sessionName;
3119
+ const shortId = sessionName.length > 8 ? sessionName.substring(0, 8) : sessionName;
3120
+ friendlyName = projectName + ' (' + shortId + ')';
3121
+ }
3116
3122
  this.api('POST', '/api/sessions', {
3117
3123
  name: friendlyName,
3118
3124
  workspaceId: this.state.activeWorkspace.id,
@@ -7230,8 +7236,10 @@ class CWMApp {
7230
7236
  !p.dirExists ? '<span class="discover-badge discover-badge-missing">missing</span>' : '',
7231
7237
  ].filter(Boolean).join(' ');
7232
7238
 
7233
- const latestSessionId = p.sessions && p.sessions.length > 0 ? p.sessions[0].name : '';
7234
- return `<div class="discover-row" data-path="${this.escapeHtml(p.realPath)}" data-name="${this.escapeHtml(name)}" data-session-id="${this.escapeHtml(latestSessionId)}">
7239
+ const latestSession = p.sessions && p.sessions.length > 0 ? p.sessions[0] : null;
7240
+ const latestSessionId = latestSession ? latestSession.name : '';
7241
+ const latestSessionTitle = latestSession ? (latestSession.title || '') : '';
7242
+ return `<div class="discover-row" data-path="${this.escapeHtml(p.realPath)}" data-name="${this.escapeHtml(name)}" data-session-id="${this.escapeHtml(latestSessionId)}" data-session-title="${this.escapeHtml(latestSessionTitle)}">
7235
7243
  <div class="discover-check">
7236
7244
  <input type="checkbox" class="discover-cb" ${p.dirExists ? 'checked' : ''} ${!p.dirExists ? 'disabled' : ''}>
7237
7245
  </div>
@@ -7293,7 +7301,7 @@ class CWMApp {
7293
7301
  const cb = row.querySelector('.discover-cb');
7294
7302
  if (cb && cb.checked) {
7295
7303
  selected.push({
7296
- name: row.dataset.name,
7304
+ name: row.dataset.sessionTitle || row.dataset.name,
7297
7305
  path: row.dataset.path,
7298
7306
  sessionId: row.dataset.sessionId || '',
7299
7307
  });
@@ -9546,9 +9554,9 @@ class CWMApp {
9546
9554
  const path = p.realPath || '';
9547
9555
  // Match against project name, encoded name, or path
9548
9556
  if (name.toLowerCase().includes(query) || encoded.toLowerCase().includes(query) || path.toLowerCase().includes(query)) return true;
9549
- // Match against any session ID/name within this project
9557
+ // Match against any session ID, title, or Claude custom-title within this project
9550
9558
  const allSessions = p.sessions || [];
9551
- return allSessions.some(s => (s.name || '').toLowerCase().includes(query));
9559
+ return allSessions.some(s => (s.name || '').toLowerCase().includes(query) || (s.title || '').toLowerCase().includes(query));
9552
9560
  });
9553
9561
  }
9554
9562
 
@@ -9579,8 +9587,9 @@ class CWMApp {
9579
9587
  if (!projectMatches) {
9580
9588
  sessions = sessions.filter(s => {
9581
9589
  const sName = (s.name || '').toLowerCase();
9590
+ const sClaudeTitle = (s.title || '').toLowerCase();
9582
9591
  const sTitle = (this.getProjectSessionTitle(s.name) || '').toLowerCase();
9583
- return sName.includes(query) || sTitle.includes(query);
9592
+ return sName.includes(query) || sClaudeTitle.includes(query) || sTitle.includes(query);
9584
9593
  });
9585
9594
  }
9586
9595
  }
@@ -9588,13 +9597,19 @@ class CWMApp {
9588
9597
  // Build session sub-items
9589
9598
  const sessionItems = sessions.map(s => {
9590
9599
  const sessName = s.name || 'unnamed';
9600
+ const claudeTitle = s.title || null;
9601
+ if (claudeTitle && !this.getProjectSessionTitle(sessName)) {
9602
+ const titles = JSON.parse(localStorage.getItem('cwm_projectSessionTitles') || '{}');
9603
+ titles[sessName] = claudeTitle;
9604
+ localStorage.setItem('cwm_projectSessionTitles', JSON.stringify(titles));
9605
+ }
9591
9606
  const storedTitle = this.getProjectSessionTitle(sessName);
9592
- const displayName = storedTitle || (sessName.length > 24 ? sessName.substring(0, 24) + '...' : sessName);
9607
+ const displayName = storedTitle || claudeTitle || (sessName.length > 24 ? sessName.substring(0, 24) + '...' : sessName);
9593
9608
  const sessSize = s.size ? this.formatSize(s.size) : '';
9594
9609
  const sessTime = s.modified ? this.relativeTime(s.modified) : '';
9595
- // Tooltip: show title + session ID so user sees both on hover
9596
- const tooltip = storedTitle
9597
- ? `${storedTitle}\n\nSession: ${sessName}`
9610
+ const effectiveTitle = storedTitle || claudeTitle;
9611
+ const tooltip = effectiveTitle
9612
+ ? `${effectiveTitle}\n\nSession: ${sessName}`
9598
9613
  : sessName;
9599
9614
  return `<div class="project-session-item" draggable="true" data-session-name="${this.escapeHtml(sessName)}" data-project-path="${this.escapeHtml(p.realPath || '')}" data-project-encoded="${this.escapeHtml(encoded)}" title="${this.escapeHtml(tooltip)}">
9600
9615
  <span class="project-session-name">${this.escapeHtml(displayName)}</span>
package/src/web/server.js CHANGED
@@ -1646,6 +1646,13 @@ app.get('/api/discover', requireAuth, (req, res) => {
1646
1646
  // skip if can't read
1647
1647
  }
1648
1648
 
1649
+ // Extract the last custom-title from each JSONL file
1650
+ const sessionTitles = {};
1651
+ for (const sf of sessionFiles) {
1652
+ const title = extractCustomTitle(path.join(projectDir, sf.name + '.jsonl'));
1653
+ if (title) sessionTitles[sf.name] = title;
1654
+ }
1655
+
1649
1656
  // Check for CLAUDE.md
1650
1657
  let hasClaudeMd = false;
1651
1658
  try {
@@ -1672,7 +1679,7 @@ app.get('/api/discover', requireAuth, (req, res) => {
1672
1679
  sessionCount: sessionFiles.length,
1673
1680
  totalSize,
1674
1681
  lastActive: sessionFiles.length > 0 ? sessionFiles[0].modified : null,
1675
- sessions: sessionFiles.map(s => ({ name: s.name, modified: s.modified, size: s.size })),
1682
+ sessions: sessionFiles.map(s => ({ name: s.name, modified: s.modified, size: s.size, title: sessionTitles[s.name] || null })),
1676
1683
  });
1677
1684
  }
1678
1685
 
@@ -1801,48 +1808,34 @@ function greedyFsWalk(root, tokens) {
1801
1808
  */
1802
1809
  function getOriginalPathFromJsonl(projectDir) {
1803
1810
  try {
1804
- const files = fs.readdirSync(projectDir);
1805
- // Find the first .jsonl file (skip subdirectories)
1806
- const jsonlFile = files.find(f => {
1811
+ const jsonlFiles = fs.readdirSync(projectDir).filter(f => {
1807
1812
  if (!f.endsWith('.jsonl')) return false;
1808
- try {
1809
- return !fs.statSync(path.join(projectDir, f)).isDirectory();
1810
- } catch (_) {
1811
- return false;
1812
- }
1813
+ try { return !fs.statSync(path.join(projectDir, f)).isDirectory(); } catch (_) { return false; }
1813
1814
  });
1814
- if (!jsonlFile) return null;
1815
-
1816
- const jsonlPath = path.join(projectDir, jsonlFile);
1817
1815
 
1818
- // Only read first 4KB to find cwd field (avoids loading large files)
1819
- const fd = fs.openSync(jsonlPath, 'r');
1820
- const buffer = Buffer.alloc(4096);
1821
- let content;
1822
- try {
1823
- const bytesRead = fs.readSync(fd, buffer, 0, 4096, 0);
1824
- content = buffer.toString('utf-8', 0, bytesRead);
1825
- } finally {
1826
- fs.closeSync(fd);
1827
- }
1828
-
1829
- // Parse first 3 lines to find cwd field
1830
- const lines = content.split('\n').slice(0, 3);
1831
- for (const line of lines) {
1832
- if (!line.trim()) continue;
1816
+ // Try each file until one yields a cwd stub sessions may lack it
1817
+ for (const jsonlFile of jsonlFiles) {
1833
1818
  try {
1834
- const record = JSON.parse(line);
1835
- if (record.cwd && typeof record.cwd === 'string') {
1836
- return record.cwd;
1819
+ const fd = fs.openSync(path.join(projectDir, jsonlFile), 'r');
1820
+ let content;
1821
+ try {
1822
+ const buffer = Buffer.alloc(16384);
1823
+ const bytesRead = fs.readSync(fd, buffer, 0, 16384, 0);
1824
+ content = buffer.toString('utf-8', 0, bytesRead);
1825
+ } finally {
1826
+ fs.closeSync(fd);
1837
1827
  }
1838
- } catch (_) {
1839
- // Skip invalid JSON lines
1840
- continue;
1841
- }
1828
+
1829
+ for (const line of content.split('\n')) {
1830
+ if (!line.includes('"cwd"')) continue;
1831
+ try {
1832
+ const record = JSON.parse(line);
1833
+ if (record.cwd && typeof record.cwd === 'string') return record.cwd;
1834
+ } catch (_) { continue; }
1835
+ }
1836
+ } catch (_) { continue; }
1842
1837
  }
1843
- } catch (_) {
1844
- // Silently fail and return null
1845
- }
1838
+ } catch (_) {}
1846
1839
  return null;
1847
1840
  }
1848
1841
 
@@ -2524,7 +2517,7 @@ app.post('/api/search-conversations', requireAuth, async (req, res) => {
2524
2517
  */
2525
2518
  app.get('/api/keys/anthropic', requireAuth, (req, res) => {
2526
2519
  const store = getStore();
2527
- const key = (store.getState().settings || {}).anthropicApiKey || '';
2520
+ const key = (store.state.settings || {}).anthropicApiKey || '';
2528
2521
  if (!key) return res.json({ configured: false, masked: null });
2529
2522
  const masked = '...' + key.slice(-8);
2530
2523
  return res.json({ configured: true, masked });
@@ -2560,7 +2553,7 @@ app.post('/api/ai/punctuate', requireAuth, async (req, res) => {
2560
2553
  }
2561
2554
 
2562
2555
  const store = getStore();
2563
- const apiKey = (store.getState().settings || {}).anthropicApiKey || '';
2556
+ const apiKey = (store.state.settings || {}).anthropicApiKey || '';
2564
2557
  if (!apiKey) {
2565
2558
  return res.status(400).json({ error: 'No Anthropic API key configured' });
2566
2559
  }
@@ -2612,7 +2605,7 @@ app.post('/api/ai/find-session', requireAuth, async (req, res) => {
2612
2605
  }
2613
2606
 
2614
2607
  const store = getStore();
2615
- const apiKey = (store.getState().settings || {}).anthropicApiKey || '';
2608
+ const apiKey = (store.state.settings || {}).anthropicApiKey || '';
2616
2609
 
2617
2610
  // Gather all metadata
2618
2611
  const workspaces = store.getAllWorkspacesList();
@@ -2631,6 +2624,7 @@ app.post('/api/ai/find-session', requireAuth, async (req, res) => {
2631
2624
  const name = getProjectDisplayName(d.name, realPath);
2632
2625
  let sessionCount = 0;
2633
2626
  let lastActive = null;
2627
+ const sessionTitles = [];
2634
2628
  try {
2635
2629
  const files = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
2636
2630
  sessionCount = files.length;
@@ -2639,9 +2633,11 @@ app.post('/api/ai/find-session', requireAuth, async (req, res) => {
2639
2633
  const stat = fs.statSync(path.join(projectDir, f));
2640
2634
  if (!lastActive || stat.mtime > new Date(lastActive)) lastActive = stat.mtime;
2641
2635
  } catch (_) {}
2636
+ const title = extractCustomTitle(path.join(projectDir, f));
2637
+ if (title) sessionTitles.push(title);
2642
2638
  }
2643
2639
  } catch (_) {}
2644
- return { encodedName: d.name, name, path: realPath, sessionCount, lastActive };
2640
+ return { encodedName: d.name, name, path: realPath, sessionCount, lastActive, sessionTitles };
2645
2641
  });
2646
2642
  } catch (_) {}
2647
2643
  }
@@ -2658,7 +2654,8 @@ app.post('/api/ai/find-session', requireAuth, async (req, res) => {
2658
2654
  })),
2659
2655
  discoveredProjects: discoveredProjects.map(p => ({
2660
2656
  encodedName: p.encodedName, name: p.name, path: p.path,
2661
- sessionCount: p.sessionCount, lastActive: p.lastActive
2657
+ sessionCount: p.sessionCount, lastActive: p.lastActive,
2658
+ sessionTitles: p.sessionTitles
2662
2659
  }))
2663
2660
  };
2664
2661
 
@@ -2722,9 +2719,9 @@ app.post('/api/ai/find-session', requireAuth, async (req, res) => {
2722
2719
  if (score > 0.2) scored.push({ type: 'session', id: s.id, confidence: score, summary: 'Matched by name, topic, or path' });
2723
2720
  }
2724
2721
  for (const p of discoveredProjects) {
2725
- const text = `${p.name} ${p.path}`.toLowerCase();
2722
+ const text = `${p.name} ${p.path} ${(p.sessionTitles || []).join(' ')}`.toLowerCase();
2726
2723
  const score = terms.filter(t => text.includes(t)).length / terms.length;
2727
- if (score > 0.2) scored.push({ type: 'project', id: p.encodedName, confidence: score, summary: 'Matched by project name or path' });
2724
+ if (score > 0.2) scored.push({ type: 'project', id: p.encodedName, confidence: score, summary: 'Matched by project name, path, or session title' });
2728
2725
  }
2729
2726
 
2730
2727
  scored.sort((a, b) => b.confidence - a.confidence);
@@ -7243,6 +7240,30 @@ function getSearchableFiles() {
7243
7240
  * @param {string} sessionId - Fallback UUID
7244
7241
  * @returns {string} A human-readable session name
7245
7242
  */
7243
+ function extractCustomTitle(jsonlPath) {
7244
+ try {
7245
+ const fd = fs.openSync(jsonlPath, 'r');
7246
+ try {
7247
+ const size = fs.fstatSync(fd).size;
7248
+ const tailSize = Math.min(131072, size);
7249
+ const buf = Buffer.alloc(tailSize);
7250
+ fs.readSync(fd, buf, 0, tailSize, size - tailSize);
7251
+ const lines = buf.toString('utf8').split('\n');
7252
+ for (let i = lines.length - 1; i >= 0; i--) {
7253
+ if (lines[i].includes('"custom-title"')) {
7254
+ try {
7255
+ const obj = JSON.parse(lines[i]);
7256
+ if (obj.customTitle) return obj.customTitle;
7257
+ } catch (_) {}
7258
+ }
7259
+ }
7260
+ } finally {
7261
+ fs.closeSync(fd);
7262
+ }
7263
+ } catch (_) {}
7264
+ return null;
7265
+ }
7266
+
7246
7267
  function extractSessionName(filePath, sessionId) {
7247
7268
  try {
7248
7269
  // Read just the first 10KB to find the first meaningful message
@@ -8054,4 +8075,5 @@ module.exports = {
8054
8075
  startServer,
8055
8076
  getPtyManager,
8056
8077
  structuredError,
8078
+ extractCustomTitle,
8057
8079
  };