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 +1 -1
- package/src/web/pty-manager.js +170 -58
- package/src/web/public/app.js +213 -11
- package/src/web/public/index.html +34 -0
- package/src/web/public/schedules.js +427 -0
- package/src/web/public/styles.css +122 -0
- package/src/web/public/terminal.js +10 -2
- package/src/web/scheduler-routes.js +43 -0
- package/src/web/scheduler.js +349 -0
- package/src/web/server.js +13 -2
package/package.json
CHANGED
package/src/web/pty-manager.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
477
|
-
//
|
|
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
|
-
|
|
568
|
+
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
|
|
569
|
+
const findCandidateDirs = () => {
|
|
480
570
|
try {
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 } };
|