osborn 0.9.22 → 0.9.24
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/dist/config.js +22 -2
- package/dist/index.js +129 -51
- package/package.json +1 -1
package/dist/config.js
CHANGED
|
@@ -649,11 +649,31 @@ export async function listAllClaudeSessions(limit = 100) {
|
|
|
649
649
|
]);
|
|
650
650
|
if (preview.messageCount < 2)
|
|
651
651
|
continue;
|
|
652
|
+
// SLUG-FIRST: prefer the slug-derived path over the content cwd.
|
|
653
|
+
//
|
|
654
|
+
// Claude Code's `--resume <id>` looks up sessions by file LOCATION
|
|
655
|
+
// (slug folder under ~/.claude/projects/), not by the cwd field inside
|
|
656
|
+
// the JSONL. The JSONL content cwd is immutable historical metadata —
|
|
657
|
+
// it's where the session was ORIGINALLY recorded (e.g. sprite path
|
|
658
|
+
// /home/sprite/workspace or /workspaces/codespaces-blank). On migration
|
|
659
|
+
// (sprite → fly), files were finalized into a new slug but their
|
|
660
|
+
// content still records the original cwd.
|
|
661
|
+
//
|
|
662
|
+
// The dashboard forwards this field as `workingDirectory` to the
|
|
663
|
+
// agent, which uses it as Claude Code's spawn cwd. If it doesn't
|
|
664
|
+
// match the slug, the spawn lands in the wrong folder and resume
|
|
665
|
+
// errors with "No conversation found". The slug is the source of
|
|
666
|
+
// truth for where the file lives on disk; use it.
|
|
667
|
+
//
|
|
668
|
+
// Content cwd is kept as a last-resort fallback only when slug
|
|
669
|
+
// reversal fails (slugToPath validates with existsSync and returns
|
|
670
|
+
// '' on ambiguous encodings like dir names containing literal '-').
|
|
671
|
+
const slugPath = slugToPath(c.slug);
|
|
652
672
|
sessions.push({
|
|
653
673
|
sessionId: c.sessionId,
|
|
654
674
|
projectSlug: c.slug,
|
|
655
|
-
projectPath:
|
|
656
|
-
cwd:
|
|
675
|
+
projectPath: slugPath || cwd,
|
|
676
|
+
cwd: slugPath || cwd,
|
|
657
677
|
timestamp: c.mtime,
|
|
658
678
|
lastMessage: preview.lastMessage,
|
|
659
679
|
messageCount: preview.messageCount,
|
package/dist/index.js
CHANGED
|
@@ -323,8 +323,13 @@ function startApiServer(workingDir, port) {
|
|
|
323
323
|
console.log('[events] SSE client connected');
|
|
324
324
|
return;
|
|
325
325
|
}
|
|
326
|
-
// GET /sessions/export — stream a gzipped tar of ~/.claude/projects/
|
|
327
|
-
//
|
|
326
|
+
// GET /sessions/export — stream a gzipped tar of ~/.claude/projects/ AND
|
|
327
|
+
// ~/.claude/skills/ to the client. Both directories ship in one archive so
|
|
328
|
+
// a sync covers conversations (projects/) and learned skills together —
|
|
329
|
+
// e.g. PostCompact-written `decisions/SKILL.md` and `learned-behaviors/SKILL.md`
|
|
330
|
+
// travel with the user's session data.
|
|
331
|
+
// Optional ?workDir= query param accepted for backwards compat but ignored
|
|
332
|
+
// (full export is always returned).
|
|
328
333
|
if (req.method === 'GET' && url.pathname === '/sessions/export') {
|
|
329
334
|
if (syncToken) {
|
|
330
335
|
const authHeader = req.headers['authorization'] ?? '';
|
|
@@ -336,17 +341,24 @@ function startApiServer(workingDir, port) {
|
|
|
336
341
|
}
|
|
337
342
|
const claudeDir = join(homedir(), '.claude');
|
|
338
343
|
const projectsDir = join(claudeDir, 'projects');
|
|
344
|
+
const skillsDir = join(claudeDir, 'skills');
|
|
339
345
|
const workDir = url.searchParams.get('workDir');
|
|
340
|
-
|
|
346
|
+
void workDir;
|
|
347
|
+
// Collect which top-level dirs exist — tar fails if we list one that doesn't.
|
|
348
|
+
const topLevel = [];
|
|
349
|
+
if (existsSync(projectsDir))
|
|
350
|
+
topLevel.push('projects');
|
|
351
|
+
if (existsSync(skillsDir))
|
|
352
|
+
topLevel.push('skills');
|
|
353
|
+
if (topLevel.length === 0) {
|
|
341
354
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
342
|
-
res.end(JSON.stringify({ error: 'No sessions found' }));
|
|
355
|
+
res.end(JSON.stringify({ error: 'No sessions or skills found' }));
|
|
343
356
|
return;
|
|
344
357
|
}
|
|
345
|
-
const tarArgs = ['-czf', '-', '-C', claudeDir,
|
|
346
|
-
void workDir; // workDir param accepted but full projects/ export is returned
|
|
358
|
+
const tarArgs = ['-czf', '-', '-C', claudeDir, ...topLevel];
|
|
347
359
|
res.writeHead(200, {
|
|
348
360
|
'Content-Type': 'application/gzip',
|
|
349
|
-
'Content-Disposition': 'attachment; filename="claude-
|
|
361
|
+
'Content-Disposition': 'attachment; filename="claude-export.tar.gz"',
|
|
350
362
|
'Access-Control-Allow-Origin': '*',
|
|
351
363
|
});
|
|
352
364
|
// Stream tar output directly to response
|
|
@@ -358,23 +370,39 @@ function startApiServer(workingDir, port) {
|
|
|
358
370
|
return;
|
|
359
371
|
}
|
|
360
372
|
// GET /sessions/manifest — return mtime+size for all .jsonl files per slug (public, no auth)
|
|
361
|
-
// Helper: merge an extracted tar directory into ~/.claude/projects
|
|
373
|
+
// Helper: merge an extracted tar directory into ~/.claude/{projects,skills}/.
|
|
374
|
+
//
|
|
375
|
+
// Behavior:
|
|
362
376
|
// 1. Skip macOS AppleDouble entries (`._*`) that bsdtar emits
|
|
363
|
-
// 2.
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
|
|
377
|
+
// 2. Project slugs: apply slug remap when targetWorkDir is supplied so
|
|
378
|
+
// laptop/codespaces sessions land at the destination's slug.
|
|
379
|
+
// 3. Skills: copy each <skillName>/ directory verbatim (no slug remap —
|
|
380
|
+
// skill identity is the directory name, same across all environments).
|
|
381
|
+
// PostCompact-learned skills like 'decisions' and 'learned-behaviors'
|
|
382
|
+
// from a sprite travel through to the destination this way.
|
|
383
|
+
// 4. Per-file mtime-newer-wins resolves collisions in either dir (preserves
|
|
384
|
+
// whichever side has the more-recent version).
|
|
385
|
+
// 5. Byte-exact copy — no content mutation in either projects/ or skills/.
|
|
386
|
+
const mergeExtractedClaudeDir = async (sourceDir, targetWorkDir) => {
|
|
368
387
|
const claudeDir = join(homedir(), '.claude');
|
|
369
388
|
const projectsDir = join(claudeDir, 'projects');
|
|
389
|
+
const skillsDir = join(claudeDir, 'skills');
|
|
370
390
|
mkdirSync(projectsDir, { recursive: true });
|
|
371
|
-
|
|
391
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
392
|
+
// The archive may wrap content in a 'projects' / 'skills' subdir, or be
|
|
393
|
+
// a flat dir of slugs. Detect both.
|
|
372
394
|
const extractedProjects = join(sourceDir, 'projects');
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
395
|
+
const extractedSkills = join(sourceDir, 'skills');
|
|
396
|
+
const hasProjectsWrapper = existsSync(extractedProjects);
|
|
397
|
+
const hasSkillsWrapper = existsSync(extractedSkills);
|
|
398
|
+
// If neither wrapper exists, treat the source dir as a flat slug dir
|
|
399
|
+
// (back-compat with older client tar layouts).
|
|
400
|
+
const effectiveSource = (hasProjectsWrapper || hasSkillsWrapper) ? null : sourceDir;
|
|
401
|
+
// ─── PROJECTS extraction ─────────────────────────────────────────
|
|
402
|
+
const projectsSource = hasProjectsWrapper ? extractedProjects : effectiveSource;
|
|
403
|
+
const sourceSlugs = projectsSource
|
|
404
|
+
? readdirSync(projectsSource).filter(s => !s.startsWith('._') && !s.startsWith('.DS_Store'))
|
|
405
|
+
: [];
|
|
378
406
|
// Build remap table: source-slug → target-slug.
|
|
379
407
|
// Only remaps slugs that differ from the target (no-op if already correct).
|
|
380
408
|
const remapped = {};
|
|
@@ -396,7 +424,7 @@ function startApiServer(workingDir, port) {
|
|
|
396
424
|
const effectiveSlug = remapped[sourceSlug] ?? sourceSlug;
|
|
397
425
|
const destSlug = join(projectsDir, effectiveSlug);
|
|
398
426
|
mkdirSync(destSlug, { recursive: true });
|
|
399
|
-
const sourceSlugPath = join(
|
|
427
|
+
const sourceSlugPath = join(projectsSource, sourceSlug);
|
|
400
428
|
// NO content mutation. Earlier versions rewrote the embedded `"cwd":"..."`
|
|
401
429
|
// field inside JSONL entries to match the destination workspace. That was
|
|
402
430
|
// wrong on two counts:
|
|
@@ -460,27 +488,71 @@ function startApiServer(workingDir, port) {
|
|
|
460
488
|
catch { /* ignore */ }
|
|
461
489
|
}
|
|
462
490
|
}
|
|
463
|
-
|
|
491
|
+
// ─── SKILLS extraction ───────────────────────────────────────────
|
|
492
|
+
// Skill identity is the directory name (`decisions`, `learned-behaviors`,
|
|
493
|
+
// etc.) so there's no slug remap — each <skillName>/ dir copies into
|
|
494
|
+
// ~/.claude/skills/<skillName>/. Mtime-newer-wins handles collisions:
|
|
495
|
+
// - Default skills seeded by Docker entrypoint have the boot mtime
|
|
496
|
+
// - Learned skills from source have whatever mtime they had on origin
|
|
497
|
+
// - Newer side wins per file
|
|
498
|
+
let skillsWritten = 0;
|
|
499
|
+
if (hasSkillsWrapper) {
|
|
500
|
+
const sourceSkillNames = readdirSync(extractedSkills)
|
|
501
|
+
.filter(s => !s.startsWith('._') && !s.startsWith('.DS_Store'));
|
|
502
|
+
for (const skillName of sourceSkillNames) {
|
|
503
|
+
const srcSkillPath = join(extractedSkills, skillName);
|
|
504
|
+
const dstSkillPath = join(skillsDir, skillName);
|
|
505
|
+
mkdirSync(dstSkillPath, { recursive: true });
|
|
506
|
+
// Reuse the same mtime-aware walkAndCopy from the projects loop —
|
|
507
|
+
// it's still in scope from the last iteration. If sourceSlugs was
|
|
508
|
+
// empty, define it inline here. (Define standalone to be safe.)
|
|
509
|
+
const walkSkill = (src, dst) => {
|
|
510
|
+
const entries = readdirSync(src, { withFileTypes: true });
|
|
511
|
+
for (const e of entries) {
|
|
512
|
+
if (e.name.startsWith('._') || e.name === '.DS_Store')
|
|
513
|
+
continue;
|
|
514
|
+
const sp = join(src, e.name);
|
|
515
|
+
const dp = join(dst, e.name);
|
|
516
|
+
if (e.isDirectory()) {
|
|
517
|
+
mkdirSync(dp, { recursive: true });
|
|
518
|
+
walkSkill(sp, dp);
|
|
519
|
+
}
|
|
520
|
+
else if (e.isFile()) {
|
|
521
|
+
let shouldWrite = true;
|
|
522
|
+
try {
|
|
523
|
+
const dstStat = statSync(dp);
|
|
524
|
+
const srcStat = statSync(sp);
|
|
525
|
+
if (dstStat.mtimeMs >= srcStat.mtimeMs)
|
|
526
|
+
shouldWrite = false;
|
|
527
|
+
}
|
|
528
|
+
catch { }
|
|
529
|
+
if (!shouldWrite)
|
|
530
|
+
continue;
|
|
531
|
+
cpSync(sp, dp, { force: true });
|
|
532
|
+
skillsWritten++;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
walkSkill(srcSkillPath, dstSkillPath);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return { filesWritten, remapped, skillsWritten };
|
|
464
540
|
};
|
|
465
541
|
if (req.method === 'GET' && url.pathname === '/sessions/manifest') {
|
|
466
|
-
// Walks
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
// silently because Claude couldn't find the referenced agent_id transcripts.
|
|
476
|
-
const claudeDir = join(homedir(), '.claude', 'projects');
|
|
477
|
-
const slugMap = {};
|
|
478
|
-
const walkSlug = (slugDir) => {
|
|
542
|
+
// Walks BOTH ~/.claude/projects/ AND ~/.claude/skills/ — full tree per
|
|
543
|
+
// top-level directory.
|
|
544
|
+
// - projects/<slug>/<...> → session JSONLs, sub-agent transcripts, tool-results, osb/
|
|
545
|
+
// - skills/<skillName>/<...> → SKILL.md + any subfiles
|
|
546
|
+
// Files keyed by path RELATIVE to the slug/skill-name dir so the client
|
|
547
|
+
// can preserve structure when computing diffs. mtime in ms epoch.
|
|
548
|
+
const projectsRoot = join(homedir(), '.claude', 'projects');
|
|
549
|
+
const skillsRoot = join(homedir(), '.claude', 'skills');
|
|
550
|
+
const walkDir = (dir) => {
|
|
479
551
|
const files = {};
|
|
480
|
-
const walk = (
|
|
552
|
+
const walk = (curr, relPrefix) => {
|
|
481
553
|
let entries;
|
|
482
554
|
try {
|
|
483
|
-
entries = readdirSync(
|
|
555
|
+
entries = readdirSync(curr, { withFileTypes: true });
|
|
484
556
|
}
|
|
485
557
|
catch {
|
|
486
558
|
return;
|
|
@@ -488,11 +560,10 @@ function startApiServer(workingDir, port) {
|
|
|
488
560
|
for (const e of entries) {
|
|
489
561
|
if (e.name.startsWith('._') || e.name === '.DS_Store')
|
|
490
562
|
continue;
|
|
491
|
-
const sub = join(
|
|
563
|
+
const sub = join(curr, e.name);
|
|
492
564
|
const rel = relPrefix ? `${relPrefix}/${e.name}` : e.name;
|
|
493
|
-
if (e.isDirectory())
|
|
565
|
+
if (e.isDirectory())
|
|
494
566
|
walk(sub, rel);
|
|
495
|
-
}
|
|
496
567
|
else if (e.isFile()) {
|
|
497
568
|
try {
|
|
498
569
|
const st = statSync(sub);
|
|
@@ -502,22 +573,29 @@ function startApiServer(workingDir, port) {
|
|
|
502
573
|
}
|
|
503
574
|
}
|
|
504
575
|
};
|
|
505
|
-
walk(
|
|
576
|
+
walk(dir, '');
|
|
506
577
|
return files;
|
|
507
578
|
};
|
|
579
|
+
const slugMap = {};
|
|
508
580
|
try {
|
|
509
|
-
const
|
|
581
|
+
for (const slug of readdirSync(projectsRoot, { withFileTypes: true })
|
|
510
582
|
.filter(d => d.isDirectory() && !d.name.startsWith('._'))
|
|
511
|
-
.map(d => d.name)
|
|
512
|
-
|
|
513
|
-
slugMap[slug] = { files: walkSlug(join(claudeDir, slug)) };
|
|
583
|
+
.map(d => d.name)) {
|
|
584
|
+
slugMap[slug] = { files: walkDir(join(projectsRoot, slug)) };
|
|
514
585
|
}
|
|
515
586
|
}
|
|
516
|
-
catch {
|
|
517
|
-
|
|
587
|
+
catch { /* projects dir doesn't exist yet — leave empty */ }
|
|
588
|
+
const skillsMap = {};
|
|
589
|
+
try {
|
|
590
|
+
for (const name of readdirSync(skillsRoot, { withFileTypes: true })
|
|
591
|
+
.filter(d => d.isDirectory() && !d.name.startsWith('._'))
|
|
592
|
+
.map(d => d.name)) {
|
|
593
|
+
skillsMap[name] = { files: walkDir(join(skillsRoot, name)) };
|
|
594
|
+
}
|
|
518
595
|
}
|
|
596
|
+
catch { /* skills dir doesn't exist yet — leave empty */ }
|
|
519
597
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
520
|
-
res.end(JSON.stringify({ slugs: slugMap }));
|
|
598
|
+
res.end(JSON.stringify({ slugs: slugMap, skills: skillsMap }));
|
|
521
599
|
return;
|
|
522
600
|
}
|
|
523
601
|
// POST /sessions/import — accept a gzipped tar and extract into ~/.claude/projects/
|
|
@@ -574,9 +652,9 @@ function startApiServer(workingDir, port) {
|
|
|
574
652
|
res.end(JSON.stringify({ error: 'tar extraction failed', code }));
|
|
575
653
|
return;
|
|
576
654
|
}
|
|
577
|
-
const { filesWritten, remapped } = await
|
|
655
|
+
const { filesWritten, remapped, skillsWritten } = await mergeExtractedClaudeDir(tmpDir, targetWorkDir ?? undefined);
|
|
578
656
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
579
|
-
res.end(JSON.stringify({ ok: true, filesWritten, remapped }));
|
|
657
|
+
res.end(JSON.stringify({ ok: true, filesWritten, remapped, skillsWritten }));
|
|
580
658
|
}
|
|
581
659
|
catch (err) {
|
|
582
660
|
console.error('[import] merge error:', err);
|
|
@@ -690,9 +768,9 @@ function startApiServer(workingDir, port) {
|
|
|
690
768
|
res.end(JSON.stringify({ error: 'tar extraction failed', code }));
|
|
691
769
|
return;
|
|
692
770
|
}
|
|
693
|
-
const { filesWritten, remapped } = await
|
|
771
|
+
const { filesWritten, remapped, skillsWritten } = await mergeExtractedClaudeDir(tmpExtractDir, targetWorkDir);
|
|
694
772
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
695
|
-
res.end(JSON.stringify({ ok: true, filesWritten, remapped }));
|
|
773
|
+
res.end(JSON.stringify({ ok: true, filesWritten, remapped, skillsWritten }));
|
|
696
774
|
}
|
|
697
775
|
catch (err) {
|
|
698
776
|
console.error('[import-finalize] merge error:', err);
|