myrlin-workbook 0.9.32 → 0.9.33

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.32",
3
+ "version": "0.9.33",
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": {
@@ -116,6 +116,105 @@ function cwdFromJsonl(sessionId) {
116
116
  // Maximum scrollback buffer size in total characters
117
117
  const MAX_SCROLLBACK_CHARS = 100 * 1024; // 100KB
118
118
 
119
+ /**
120
+ * Watch one or more directories for the appearance of a *.jsonl file that is
121
+ * not in the pre-call snapshot. Emits exactly one result.
122
+ *
123
+ * Hybrid strategy:
124
+ * 1. fs.watch is registered on each candidate dir that exists at call time.
125
+ * 'rename' events for new .jsonl files resolve immediately.
126
+ * 2. At t+timeoutMs, a final rescan diffs the live directory listing against
127
+ * the snapshot and picks the freshest by birthtime/mtime. Catches macOS
128
+ * FSEvents drops and the cold-start case where the candidate dir didn't
129
+ * exist when fs.watch was first attempted.
130
+ * 3. cleanup() is idempotent and runs on match, timeout, or explicit cancel.
131
+ *
132
+ * @param {object} opts
133
+ * @param {() => string[]} opts.candidateDirsFn - returns relative dir names
134
+ * under claudeProjectsDir. Re-evaluated at rescan time.
135
+ * @param {Set<string>} opts.snapshot - "<dirName>/<file>" keys to ignore.
136
+ * @param {number} opts.timeoutMs - final-rescan deadline.
137
+ * @param {string} opts.claudeProjectsDir - absolute path resolving relative
138
+ * names from candidateDirsFn.
139
+ * @param {(err: Error|null, hit: {dirName: string, file: string, bornAt: number}|null) => void} onResult
140
+ * @returns {() => void} cancel function (idempotent)
141
+ */
142
+ function waitForNewJsonl({ candidateDirsFn, snapshot, timeoutMs, claudeProjectsDir }, onResult) {
143
+ let done = false;
144
+ const watchers = [];
145
+ let timer = null;
146
+
147
+ const cleanup = () => {
148
+ if (done) return;
149
+ done = true;
150
+ if (timer) { clearTimeout(timer); timer = null; }
151
+ while (watchers.length) {
152
+ const w = watchers.pop();
153
+ try { w.close(); } catch (_) {}
154
+ }
155
+ };
156
+
157
+ const resolve = (err, hit) => {
158
+ if (done) return;
159
+ cleanup();
160
+ try { onResult(err, hit); } catch (_) {}
161
+ };
162
+
163
+ const tryAcceptFile = (dirName, file) => {
164
+ if (!file || !file.endsWith('.jsonl')) return false;
165
+ if (snapshot.has(dirName + '/' + file)) return false;
166
+ let stat;
167
+ try { stat = fs.statSync(path.join(claudeProjectsDir, dirName, file)); } catch (_) { return false; }
168
+ const bornAt = stat.birthtimeMs || stat.mtimeMs;
169
+ resolve(null, { dirName, file, bornAt });
170
+ return true;
171
+ };
172
+
173
+ // Register watchers on each candidate dir that exists at call time. Failures
174
+ // (EMFILE / ENOSPC / ENOENT) silently fall through to the timeout rescan,
175
+ // which is exactly today's contract.
176
+ for (const dirName of candidateDirsFn()) {
177
+ try {
178
+ const watcher = fs.watch(path.join(claudeProjectsDir, dirName), (event, file) => {
179
+ if (done || event !== 'rename') return;
180
+ tryAcceptFile(dirName, file);
181
+ });
182
+ if (typeof watcher.on === 'function') {
183
+ watcher.on('error', () => { /* swallow; rescan will catch */ });
184
+ }
185
+ watchers.push(watcher);
186
+ } catch (_) {
187
+ // Fall through to rescan
188
+ }
189
+ }
190
+
191
+ // Final-rescan deadline. Also handles the cold-start case where no
192
+ // candidate dir existed at call time (the dir gets created during the wait).
193
+ timer = setTimeout(() => {
194
+ if (done) return;
195
+ const fresh = [];
196
+ for (const dirName of candidateDirsFn()) {
197
+ let entries;
198
+ try { entries = fs.readdirSync(path.join(claudeProjectsDir, dirName)); } catch (_) { continue; }
199
+ for (const f of entries) {
200
+ if (!f.endsWith('.jsonl')) continue;
201
+ if (snapshot.has(dirName + '/' + f)) continue;
202
+ let stat;
203
+ try { stat = fs.statSync(path.join(claudeProjectsDir, dirName, f)); } catch (_) { continue; }
204
+ fresh.push({ dirName, file: f, bornAt: stat.birthtimeMs || stat.mtimeMs });
205
+ }
206
+ }
207
+ if (fresh.length === 0) {
208
+ resolve(null, null);
209
+ return;
210
+ }
211
+ fresh.sort((a, b) => b.bornAt - a.bornAt);
212
+ resolve(null, fresh[0]);
213
+ }, timeoutMs);
214
+
215
+ return cleanup;
216
+ }
217
+
119
218
  /**
120
219
  * Represents a single PTY session with its process, clients, and scrollback.
121
220
  */
@@ -195,32 +294,19 @@ class PtySessionManager {
195
294
  let fullCommand = command;
196
295
  if (resumeSessionId) {
197
296
  fullCommand += ' --resume ' + resumeSessionId;
198
- } else if (cwd && !newSession) {
199
- // Only add --continue when there is actually conversation history for this
200
- // working directory. `claude --continue` exits with code 1 ("No conversation
201
- // found to continue") on a fresh directory — e.g. a brand-new git worktree.
202
- // Scan ~/.claude/projects/ for any JSONL file whose encoded path matches cwd.
203
- let hasHistory = false;
204
- try {
205
- const claudeDir = path.join(os.homedir(), '.claude', 'projects');
206
- if (fs.existsSync(claudeDir)) {
207
- const normalizedCwd = cwd.replace(/[/\\]/g, path.sep);
208
- const match = fs.readdirSync(claudeDir).find(d => {
209
- try {
210
- return decodeURIComponent(d).replace(/[/\\]/g, path.sep) === normalizedCwd;
211
- } catch (_) { return false; }
212
- });
213
- if (match) {
214
- const projDir = path.join(claudeDir, match);
215
- hasHistory = fs.readdirSync(projDir).some(f => f.endsWith('.jsonl'));
216
- }
217
- }
218
- } catch (_) { /* filesystem error — fall through, don't add --continue */ }
219
- if (hasHistory) {
220
- fullCommand += ' --continue';
221
- }
222
- // If no history, run bare `claude` — starts a fresh session without error.
223
297
  }
298
+ // Otherwise run bare `claude` — start a fresh transcript.
299
+ //
300
+ // Auto-`--continue` was previously added whenever the cwd had any prior
301
+ // Claude history. That silently bound a brand-new Myrlin session to
302
+ // whichever transcript was most recent in that directory — frequently a
303
+ // different Myrlin session's, or one from running `claude` directly. The
304
+ // post-spawn UUID detector then persisted the wrong UUID as resumeSessionId
305
+ // and the misbinding became permanent.
306
+ //
307
+ // Resume is now driven strictly by an explicit resumeSessionId. To
308
+ // continue a past transcript, use the project panel's "Open in Terminal"
309
+ // (which sets resumeSessionId), or attach the session via Discover.
224
310
  if (bypassPermissions) {
225
311
  fullCommand += ' --dangerously-skip-permissions';
226
312
  }
@@ -471,19 +557,19 @@ class PtySessionManager {
471
557
 
472
558
  console.log(`[PTY] Spawned session ${sessionId} (PID: ${ptyProcess.pid}) cmd: "${fullCommand}" cwd: "${cwd || process.cwd()}"`);
473
559
 
474
- // ── Async: detect Claude session UUID from newest JSONL after spawn ──
560
+ // ── Async: detect Claude session UUID from new JSONL after spawn ──
475
561
  // Claude Code creates a JSONL file in ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl.
476
- // After a short delay, scan for the newest file and backfill resumeSessionId
477
- // so future restarts use the precise --resume <uuid> instead of --continue.
562
+ // We snapshot the set of existing JSONLs synchronously at spawn time and
563
+ // hand off to waitForNewJsonl, which fires on fs.watch events and falls
564
+ // back to a final rescan at t+8s. Snapshot diff prevents binding to a
565
+ // pre-existing transcript whose mtime drifted; fs.watch makes the binding
566
+ // sub-second on the happy path.
478
567
  if (resolvedCwd && !resumeSessionId) {
479
- setTimeout(() => {
568
+ const claudeDir = path.join(os.homedir(), '.claude', 'projects');
569
+ const findCandidateDirs = () => {
480
570
  try {
481
- const claudeDir = path.join(os.homedir(), '.claude', 'projects');
482
- if (!fs.existsSync(claudeDir)) return;
483
-
484
- // Claude encodes the cwd path as a directory name under ~/.claude/projects/
485
- // Try multiple encoding patterns: URL-encoded, slash-replaced
486
- const candidates = fs.readdirSync(claudeDir).filter(d => {
571
+ if (!fs.existsSync(claudeDir)) return [];
572
+ return fs.readdirSync(claudeDir).filter(d => {
487
573
  try {
488
574
  const decoded = decodeURIComponent(d);
489
575
  const normalizedDecoded = decoded.replace(/[/\\]/g, path.sep);
@@ -493,36 +579,57 @@ class PtySessionManager {
493
579
  return false;
494
580
  }
495
581
  });
582
+ } catch (_) {
583
+ return [];
584
+ }
585
+ };
496
586
 
497
- if (candidates.length === 0) return;
498
-
499
- const projDir = path.join(claudeDir, candidates[0]);
500
- const jsonls = fs.readdirSync(projDir)
501
- .filter(f => f.endsWith('.jsonl'))
502
- .map(f => {
503
- try {
504
- return { name: f, mtime: fs.statSync(path.join(projDir, f)).mtimeMs };
505
- } catch (_) {
506
- return null;
507
- }
508
- })
509
- .filter(Boolean)
510
- .sort((a, b) => b.mtime - a.mtime);
511
-
512
- if (jsonls.length === 0) return;
587
+ // Pre-spawn snapshot of JSONLs that already existed in candidate dirs.
588
+ // Keys are "<dirName>/<file>" so identical UUIDs in sibling dirs stay distinct.
589
+ const preSnapshot = new Set();
590
+ for (const dirName of findCandidateDirs()) {
591
+ try {
592
+ for (const f of fs.readdirSync(path.join(claudeDir, dirName))) {
593
+ if (f.endsWith('.jsonl')) preSnapshot.add(dirName + '/' + f);
594
+ }
595
+ } catch (_) {}
596
+ }
513
597
 
514
- const uuid = jsonls[0].name.replace('.jsonl', '');
598
+ const cancelWatch = waitForNewJsonl(
599
+ { candidateDirsFn: findCandidateDirs, snapshot: preSnapshot, timeoutMs: 8000, claudeProjectsDir: claudeDir },
600
+ (err, hit) => {
601
+ if (err || !hit) {
602
+ console.log(`[PTY] No new JSONL appeared for ${sessionId}; skipping resumeSessionId backfill`);
603
+ return;
604
+ }
605
+ const uuid = hit.file.replace('.jsonl', '');
515
606
  console.log(`[PTY] Detected Claude session UUID for ${sessionId}: ${uuid}`);
516
607
 
517
- // Save to store so future restarts use --resume <uuid>
608
+ // Save to store so future restarts use --resume <uuid>.
609
+ // Defensive: refuse to backfill if another Myrlin session already
610
+ // owns this UUID. That shouldn't be possible now that the snapshot
611
+ // diff filters pre-existing JSONLs, but the check is cheap and
612
+ // prevents two sessions from ever pointing at the same transcript.
613
+ let backfilled = false;
518
614
  try {
519
615
  const store = getStore();
520
- if (store.getSession(sessionId)) {
616
+ const conflict = store.getAllSessionsList().find(s =>
617
+ s.id !== sessionId && s.resumeSessionId === uuid
618
+ );
619
+ if (conflict) {
620
+ console.warn(
621
+ `[PTY] Refusing to backfill resumeSessionId=${uuid} for session ${sessionId}: ` +
622
+ `already owned by session ${conflict.id} ("${conflict.name || ''}")`
623
+ );
624
+ } else if (store.getSession(sessionId)) {
521
625
  store.updateSession(sessionId, { resumeSessionId: uuid });
522
626
  console.log(`[PTY] Backfilled resumeSessionId=${uuid} for session ${sessionId}`);
627
+ backfilled = true;
523
628
  }
524
629
  } catch (_) {}
525
630
 
631
+ if (!backfilled) return;
632
+
526
633
  // Also store on the session object for layout saves
527
634
  session.detectedResumeId = uuid;
528
635
 
@@ -534,10 +641,9 @@ class PtySessionManager {
534
641
  if (ws.readyState === 1) ws.send(backfillMsg);
535
642
  } catch (_) {}
536
643
  }
537
- } catch (err) {
538
- console.log(`[PTY] UUID detection failed for ${sessionId}: ${err.message}`);
539
644
  }
540
- }, 8000); // Wait 8s for Claude to create the JSONL file
645
+ );
646
+ session._cancelWatch = cancelWatch;
541
647
  }
542
648
 
543
649
  return session;
@@ -720,6 +826,12 @@ class PtySessionManager {
720
826
  session.pingInterval = null;
721
827
  }
722
828
 
829
+ // Cancel any in-flight JSONL watcher (idempotent if it already resolved)
830
+ if (typeof session._cancelWatch === 'function') {
831
+ try { session._cancelWatch(); } catch (_) {}
832
+ session._cancelWatch = null;
833
+ }
834
+
723
835
  // Kill the PTY process
724
836
  if (session.alive) {
725
837
  try {
@@ -828,4 +940,4 @@ class PtySessionManager {
828
940
  }
829
941
  }
830
942
 
831
- module.exports = { PtySessionManager };
943
+ module.exports = { PtySessionManager, __test: { waitForNewJsonl } };