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 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: cwd || slugToPath(c.slug),
656
- cwd: cwd || slugToPath(c.slug),
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/ to the client
327
- // Optional ?workDir= query param: if present, export only that project's slug folder.
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
- if (!existsSync(projectsDir)) {
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, 'projects'];
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-sessions.tar.gz"',
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/ with all 4 fixes:
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. Apply slug remap when targetWorkDir is supplied (chunked path missed this)
364
- // 3. Rewrite embedded `cwd` field inside .jsonl entries during remap so
365
- // Claude Code can resume the conversation in the destination workspace
366
- // 4. Merge into existing dest dirs instead of failing on rename collision
367
- const mergeExtractedIntoProjects = async (sourceDir, targetWorkDir) => {
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
- // The archive sometimes wraps content in a 'projects' subdir, sometimes not.
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 effectiveSource = existsSync(extractedProjects) ? extractedProjects : sourceDir;
374
- // Filter out AppleDouble (`._*`) entries that macOS bsdtar emits for
375
- // resource forks. These crash later steps if they collide with real dirs.
376
- const sourceSlugs = readdirSync(effectiveSource)
377
- .filter(s => !s.startsWith('._') && !s.startsWith('.DS_Store'));
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(effectiveSource, sourceSlug);
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
- return { filesWritten, remapped };
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 the FULL tree per slug including sub-agent transcripts
467
- // (<slug>/<sessionId>/subagents/*.jsonl), tool-results (<slug>/<sessionId>/tool-results/*),
468
- // osb workspace files (<slug>/osb/<sessionId>/*), and file-history. Files are
469
- // keyed by their path RELATIVE to the slug dir so the client can preserve
470
- // structure when computing diffs. mtime is in ms epoch so a simple `>`
471
- // comparison is the "newer wins" merge rule.
472
- //
473
- // Previous version only listed top-level *.jsonl and missed ~270/290 files
474
- // on a typical session — sub-agent transcripts invisible → resume failed
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 = (dir, relPrefix) => {
552
+ const walk = (curr, relPrefix) => {
481
553
  let entries;
482
554
  try {
483
- entries = readdirSync(dir, { withFileTypes: true });
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(dir, e.name);
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(slugDir, '');
576
+ walk(dir, '');
506
577
  return files;
507
578
  };
579
+ const slugMap = {};
508
580
  try {
509
- const slugs = readdirSync(claudeDir, { withFileTypes: true })
581
+ for (const slug of readdirSync(projectsRoot, { withFileTypes: true })
510
582
  .filter(d => d.isDirectory() && !d.name.startsWith('._'))
511
- .map(d => d.name);
512
- for (const slug of slugs) {
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
- // projects dir doesn't exist yet — return empty
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 mergeExtractedIntoProjects(tmpDir, targetWorkDir ?? undefined);
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 mergeExtractedIntoProjects(tmpExtractDir, targetWorkDir);
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.22",
3
+ "version": "0.9.24",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {