osborn 0.9.17 → 0.9.19

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.
@@ -53,6 +53,23 @@ if [ -f /workspace/.claude/.oauth-token ]; then
53
53
  echo "[sandbox] Restored CLAUDE_CODE_OAUTH_TOKEN from volume"
54
54
  fi
55
55
 
56
+ # Seed default skills into ~/.claude/skills/ — single source of truth.
57
+ # The agent only loads skills from this path; defaults shipped in the npm
58
+ # package get copied here on first boot. Idempotent: existing skills
59
+ # (learned via PostCompact or manually edited) are preserved across restarts.
60
+ HOME_SKILLS_DIR=/root/.claude/skills
61
+ PKG_SKILLS_DIR=/usr/local/lib/node_modules/osborn/.claude/skills
62
+ mkdir -p "$HOME_SKILLS_DIR"
63
+ if [ -d "$PKG_SKILLS_DIR" ]; then
64
+ for d in "$PKG_SKILLS_DIR"/*/; do
65
+ [ -d "$d" ] || continue
66
+ NAME=$(basename "$d")
67
+ [ -d "$HOME_SKILLS_DIR/$NAME" ] && continue
68
+ cp -r "$d" "$HOME_SKILLS_DIR/$NAME"
69
+ echo "[sandbox] seeded default skill: $NAME"
70
+ done
71
+ fi
72
+
56
73
  exec osborn
57
74
  ENTRYPOINT
58
75
 
@@ -22,9 +22,17 @@ export interface ClaudeLLMOptions {
22
22
  voiceMode?: 'direct' | 'realtime';
23
23
  skipTTSQueue?: boolean;
24
24
  onCompactionEvent?: (event: {
25
- type: 'compaction_started' | 'compaction_complete';
25
+ type: 'compaction_started';
26
26
  trigger?: string;
27
+ } | {
28
+ type: 'compaction_progress';
29
+ stage: string;
30
+ detail?: string;
31
+ } | {
32
+ type: 'compaction_complete';
27
33
  skillsWritten?: number;
34
+ skillNames?: string[];
35
+ trigger?: string;
28
36
  }) => void;
29
37
  }
30
38
  /**
@@ -91,37 +91,33 @@ function loadSkillsFromDir(agentDir) {
91
91
  * Merges results, deduplicating by skill directory name — home dir wins on conflicts.
92
92
  * Returns a combined <available-skills> XML block, or '' if no skills found.
93
93
  */
94
- function loadAllSkills(workingDir) {
94
+ function loadAllSkills(_workingDir) {
95
+ // Single source of truth: ~/.claude/skills/
96
+ // Defaults are seeded into this dir by the provisioning bootstrap (sprites.ts
97
+ // buildOsbornBootstrap + Dockerfile.sandbox entrypoint). PostCompact writes
98
+ // newly-learned skills here too. By unifying on one path we avoid the older
99
+ // confusion where defaults loaded from node_modules/osborn/.claude/skills/
100
+ // while PostCompact wrote to home — meaning learnings were second-class.
95
101
  const homeSkillsDir = join(homedir(), '.claude', 'skills');
96
- const projectSkillsDir = join(workingDir, '.claude', 'skills');
97
- // skill name content; home dir loaded first so it wins on conflicts
102
+ if (!existsSync(homeSkillsDir)) {
103
+ console.log(`📚 No skills dir at ${homeSkillsDir} bootstrap may not have run yet`);
104
+ return '';
105
+ }
98
106
  const skillMap = new Map();
99
- const loadFromDir = (dir) => {
100
- if (!existsSync(dir))
101
- return;
102
- try {
103
- for (const skillName of readdirSync(dir)) {
104
- if (skillMap.has(skillName))
105
- continue; // home dir already set this one
106
- const skillFile = join(dir, skillName, 'SKILL.md');
107
- if (existsSync(skillFile)) {
108
- skillMap.set(skillName, readFileSync(skillFile, 'utf-8').trim());
109
- }
107
+ try {
108
+ for (const skillName of readdirSync(homeSkillsDir)) {
109
+ const skillFile = join(homeSkillsDir, skillName, 'SKILL.md');
110
+ if (existsSync(skillFile)) {
111
+ skillMap.set(skillName, readFileSync(skillFile, 'utf-8').trim());
110
112
  }
111
113
  }
112
- catch (err) {
113
- console.warn('⚠️ Failed to load skills from', dir, ':', err);
114
- }
115
- };
116
- loadFromDir(homeSkillsDir);
117
- loadFromDir(projectSkillsDir);
114
+ }
115
+ catch (err) {
116
+ console.warn('⚠️ Failed to load skills from', homeSkillsDir, ':', err);
117
+ }
118
118
  if (skillMap.size === 0)
119
119
  return '';
120
- const sources = [
121
- existsSync(homeSkillsDir) ? homeSkillsDir : null,
122
- existsSync(projectSkillsDir) ? projectSkillsDir : null,
123
- ].filter(Boolean).join(', ');
124
- console.log(`📚 Loaded ${skillMap.size} skill(s) from ${sources}`);
120
+ console.log(`📚 Loaded ${skillMap.size} skill(s) from ${homeSkillsDir}`);
125
121
  return `<available-skills>\n${[...skillMap.values()].join('\n\n---\n\n')}\n</available-skills>`;
126
122
  }
127
123
  // Research mode tools — full research capabilities
@@ -997,6 +993,16 @@ class ClaudeLLMStream extends llm.LLMStream {
997
993
  const today = new Date().toISOString().split('T')[0];
998
994
  const sessionId = this.#sessionId || 'unknown';
999
995
  let skillsWritten = 0;
996
+ const skillNames = [];
997
+ // Emit fine-grained progress events so the UI can render a multi-step
998
+ // "crystallizing..." panel instead of a single 3-second flash. Each
999
+ // stage gets a human-readable label + optional detail (skill name,
1000
+ // char counts) so the user sees something happening through the whole
1001
+ // ~3 minute compaction window.
1002
+ const progress = (stage, detail) => {
1003
+ this.#opts.onCompactionEvent?.({ type: 'compaction_progress', stage, detail });
1004
+ };
1005
+ progress('Reading compact summary', `${summary.length} chars`);
1000
1006
  // Helper: extract text between a marker and the next === marker (or end of string)
1001
1007
  const extractSection = (marker) => {
1002
1008
  const idx = summary.indexOf(marker);
@@ -1010,6 +1016,7 @@ class ClaudeLLMStream extends llm.LLMStream {
1010
1016
  const handoff = extractSection('=== HANDOFF_STATE ===');
1011
1017
  if (handoff) {
1012
1018
  console.log(`🧠 PostCompact: HANDOFF_STATE present (${handoff.length} chars) — not written to disk`);
1019
+ progress('Parsed handoff state', `${handoff.length} chars`);
1013
1020
  }
1014
1021
  // ── Section 2: DECISIONS — append project-scoped decisions ──
1015
1022
  try {
@@ -1019,6 +1026,7 @@ class ClaudeLLMStream extends llm.LLMStream {
1019
1026
  .split('\n')
1020
1027
  .filter(l => /DECISION:.*SCOPE:\s*project/i.test(l));
1021
1028
  if (projectLines.length) {
1029
+ progress('Extracting decisions', `${projectLines.length} project-scoped`);
1022
1030
  const decFolder = join(skillDir, '.claude', 'skills', 'decisions');
1023
1031
  const decPath = join(decFolder, 'SKILL.md');
1024
1032
  mkdirSync(decFolder, { recursive: true });
@@ -1028,6 +1036,8 @@ class ClaudeLLMStream extends llm.LLMStream {
1028
1036
  writeSyncFs(decPath, header + existing + entry, 'utf-8');
1029
1037
  console.log(`🧠 PostCompact: appended ${projectLines.length} decision(s) to ${decPath}`);
1030
1038
  skillsWritten++;
1039
+ skillNames.push('decisions');
1040
+ progress('Wrote skill', 'decisions');
1031
1041
  }
1032
1042
  }
1033
1043
  }
@@ -1055,6 +1065,8 @@ class ClaudeLLMStream extends llm.LLMStream {
1055
1065
  writeSyncFs(skillPath, header + body + '\n', 'utf-8');
1056
1066
  console.log(`🧠 PostCompact: wrote skill '${name}' to ${skillPath}`);
1057
1067
  skillsWritten++;
1068
+ skillNames.push(name);
1069
+ progress('Wrote skill', name);
1058
1070
  }
1059
1071
  }
1060
1072
  }
@@ -1065,6 +1077,7 @@ class ClaudeLLMStream extends llm.LLMStream {
1065
1077
  try {
1066
1078
  const learnings = extractSection('=== BEHAVIORAL_LEARNINGS ===');
1067
1079
  if (learnings.length >= 30) {
1080
+ progress('Extracting learnings', `${learnings.length} chars`);
1068
1081
  const skillFolder = join(skillDir, '.claude', 'skills', 'learned-behaviors');
1069
1082
  const skillPath = join(skillFolder, 'SKILL.md');
1070
1083
  mkdirSync(skillFolder, { recursive: true });
@@ -1072,6 +1085,8 @@ class ClaudeLLMStream extends llm.LLMStream {
1072
1085
  writeSyncFs(skillPath, header + learnings + '\n', 'utf-8');
1073
1086
  console.log(`🧠 PostCompact: wrote learned behaviors to ${skillPath} (${learnings.length} chars)`);
1074
1087
  skillsWritten++;
1088
+ skillNames.push('learned-behaviors');
1089
+ progress('Wrote skill', 'learned-behaviors');
1075
1090
  }
1076
1091
  else {
1077
1092
  console.log('🧠 PostCompact: no BEHAVIORAL_LEARNINGS section found or too short — skipping');
@@ -1080,8 +1095,8 @@ class ClaudeLLMStream extends llm.LLMStream {
1080
1095
  catch (blErr) {
1081
1096
  console.error('⚠️ PostCompact: BEHAVIORAL_LEARNINGS write failed:', blErr instanceof Error ? blErr.message : blErr);
1082
1097
  }
1083
- this.#opts.onCompactionEvent?.({ type: 'compaction_complete', skillsWritten });
1084
- console.log(`🧠 PostCompact: complete — ${skillsWritten} skill file(s) written`);
1098
+ this.#opts.onCompactionEvent?.({ type: 'compaction_complete', skillsWritten, skillNames });
1099
+ console.log(`🧠 PostCompact: complete — ${skillsWritten} skill file(s) written: [${skillNames.join(', ')}]`);
1085
1100
  }
1086
1101
  catch (err) {
1087
1102
  console.error('⚠️ PostCompact hook error:', err instanceof Error ? err.message : err);
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ initializeLogger({ pretty: true, level: 'info' });
10
10
  import { setMaxListeners } from 'node:events';
11
11
  setMaxListeners(50);
12
12
  import { createServer } from 'http';
13
- import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, mkdtempSync, cpSync, rmSync, renameSync, statSync, createWriteStream } from 'node:fs';
13
+ import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, mkdtempSync, cpSync, rmSync, statSync, createWriteStream } from 'node:fs';
14
14
  import { dirname, join } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { spawn } from 'node:child_process';
@@ -187,8 +187,32 @@ function startApiServer(workingDir, port) {
187
187
  return;
188
188
  }
189
189
  if (req.method === 'GET' && url.pathname === '/health') {
190
+ // Include osborn version — primary signal used by machines.readInstalledOsbornVersion()
191
+ // to detect which agent version is running. Without this the consumer falls back to
192
+ // parsing the Docker image tag (e.g. ":latest" → rejected) and returns null, which
193
+ // breaks the dashboard's version badge + the upgrade-needed comparison.
194
+ // Read once at module load? No — package.json is small and resolveFromPackage() handles
195
+ // both `dist/` (installed) and `src/` (local dev) layouts.
196
+ let version;
197
+ try {
198
+ // Walk up from this file's dirname to find package.json. Works whether running
199
+ // from src/ (tsx local dev) or dist/ (compiled npm install).
200
+ const { readFileSync } = await import('node:fs');
201
+ const { join } = await import('node:path');
202
+ for (const rel of ['../package.json', '../../package.json']) {
203
+ try {
204
+ const pkg = JSON.parse(readFileSync(join(__dirname, rel), 'utf8'));
205
+ if (pkg.name === 'osborn' && pkg.version) {
206
+ version = pkg.version;
207
+ break;
208
+ }
209
+ }
210
+ catch { /* try next */ }
211
+ }
212
+ }
213
+ catch { /* version optional */ }
190
214
  res.writeHead(200, { 'Content-Type': 'application/json' });
191
- res.end(JSON.stringify({ status: 'ok', workingDir }));
215
+ res.end(JSON.stringify({ status: 'ok', workingDir, version }));
192
216
  return;
193
217
  }
194
218
  // POST /webhook/recall — Recall.ai real-time transcript webhooks
@@ -334,28 +358,158 @@ function startApiServer(workingDir, port) {
334
358
  return;
335
359
  }
336
360
  // 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:
362
+ // 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) => {
368
+ const claudeDir = join(homedir(), '.claude');
369
+ const projectsDir = join(claudeDir, 'projects');
370
+ mkdirSync(projectsDir, { recursive: true });
371
+ // The archive sometimes wraps content in a 'projects' subdir, sometimes not.
372
+ 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'));
378
+ // Build remap table: source-slug → target-slug.
379
+ // Only remaps slugs that differ from the target (no-op if already correct).
380
+ const remapped = {};
381
+ const targetSlug = targetWorkDir ? targetWorkDir.replace(/\//g, '-') : '';
382
+ if (targetSlug) {
383
+ for (const slug of sourceSlugs) {
384
+ if (slug !== targetSlug)
385
+ remapped[slug] = targetSlug;
386
+ }
387
+ }
388
+ // Slug → original cwd path (reverse of slug encoding):
389
+ // '-Users-newupgrade-Desktop-Developer-osborn' → '/Users/newupgrade/Desktop/Developer/osborn'
390
+ // Claude Code's slug rule: replace all '/' with '-', so reverse is replace '-' with '/'.
391
+ // Leading '-' becomes '/'. We don't try to recover '.' (Claude uses '--' for it, but
392
+ // dot-prefixed dirs are uncommon and a best-effort rewrite is enough for resume).
393
+ const slugToCwd = (slug) => '/' + slug.replace(/^-/, '').replace(/-/g, '/');
394
+ let filesWritten = 0;
395
+ for (const sourceSlug of sourceSlugs) {
396
+ const effectiveSlug = remapped[sourceSlug] ?? sourceSlug;
397
+ const destSlug = join(projectsDir, effectiveSlug);
398
+ mkdirSync(destSlug, { recursive: true });
399
+ const sourceSlugPath = join(effectiveSource, sourceSlug);
400
+ const sourceCwd = slugToCwd(sourceSlug);
401
+ const destCwd = targetWorkDir ?? slugToCwd(effectiveSlug);
402
+ const needsCwdRewrite = sourceCwd !== destCwd;
403
+ // Walk the source slug directory and copy files individually so we can:
404
+ // (a) skip AppleDouble per-file too (in case nested)
405
+ // (b) rewrite cwd inside .jsonl files when remapping across workspaces
406
+ // (c) merge into existing destination directories without renameSync collision
407
+ // (d) keep newer-by-mtime when both sides have the same file (the user's
408
+ // requested "overwrite based on timestamp" rule for bidirectional sync)
409
+ const walkAndCopy = (src, dst) => {
410
+ const entries = readdirSync(src, { withFileTypes: true });
411
+ for (const e of entries) {
412
+ if (e.name.startsWith('._') || e.name === '.DS_Store')
413
+ continue;
414
+ const sp = join(src, e.name);
415
+ const dp = join(dst, e.name);
416
+ if (e.isDirectory()) {
417
+ mkdirSync(dp, { recursive: true });
418
+ walkAndCopy(sp, dp);
419
+ }
420
+ else if (e.isFile()) {
421
+ // mtime conflict resolution — when destination already has this file,
422
+ // only overwrite when the source is strictly newer. Preserves work
423
+ // done on the destination side when re-syncing in either direction.
424
+ let shouldWrite = true;
425
+ try {
426
+ const dstStat = statSync(dp);
427
+ const srcStat = statSync(sp);
428
+ if (dstStat.mtimeMs >= srcStat.mtimeMs)
429
+ shouldWrite = false;
430
+ }
431
+ catch { /* dst doesn't exist — write it */ }
432
+ if (!shouldWrite)
433
+ continue;
434
+ if (needsCwdRewrite && e.name.endsWith('.jsonl')) {
435
+ // Read, rewrite "cwd" field, write. JSONL is line-delimited;
436
+ // string match on `"cwd":"<sourceCwd>"` is precise enough.
437
+ const content = readFileSync(sp, 'utf8');
438
+ const find = `"cwd":"${sourceCwd}"`;
439
+ const replace = `"cwd":"${destCwd}"`;
440
+ const rewritten = content.split(find).join(replace);
441
+ writeFileSync(dp, rewritten);
442
+ }
443
+ else {
444
+ cpSync(sp, dp, { force: true });
445
+ }
446
+ filesWritten++;
447
+ }
448
+ // skip symlinks, sockets, etc.
449
+ }
450
+ };
451
+ walkAndCopy(sourceSlugPath, destSlug);
452
+ // Best-effort: ensure the resolved workspace directory exists so Claude
453
+ // can resume conversations whose JSONLs reference it.
454
+ const recoveredPath = effectiveSlug.replace(/^-/, '/').replace(/--/g, '/.').replace(/-/g, '/');
455
+ if (recoveredPath && recoveredPath !== '/') {
456
+ try {
457
+ mkdirSync(recoveredPath, { recursive: true });
458
+ }
459
+ catch { /* ignore */ }
460
+ }
461
+ }
462
+ return { filesWritten, remapped };
463
+ };
337
464
  if (req.method === 'GET' && url.pathname === '/sessions/manifest') {
465
+ // Walks the FULL tree per slug — including sub-agent transcripts
466
+ // (<slug>/<sessionId>/subagents/*.jsonl), tool-results (<slug>/<sessionId>/tool-results/*),
467
+ // osb workspace files (<slug>/osb/<sessionId>/*), and file-history. Files are
468
+ // keyed by their path RELATIVE to the slug dir so the client can preserve
469
+ // structure when computing diffs. mtime is in ms epoch so a simple `>`
470
+ // comparison is the "newer wins" merge rule.
471
+ //
472
+ // Previous version only listed top-level *.jsonl and missed ~270/290 files
473
+ // on a typical session — sub-agent transcripts invisible → resume failed
474
+ // silently because Claude couldn't find the referenced agent_id transcripts.
338
475
  const claudeDir = join(homedir(), '.claude', 'projects');
339
476
  const slugMap = {};
340
- try {
341
- const slugs = readdirSync(claudeDir, { withFileTypes: true })
342
- .filter(d => d.isDirectory())
343
- .map(d => d.name);
344
- for (const slug of slugs) {
345
- const slugDir = join(claudeDir, slug);
477
+ const walkSlug = (slugDir) => {
478
+ const files = {};
479
+ const walk = (dir, relPrefix) => {
480
+ let entries;
346
481
  try {
347
- const jsonlFiles = readdirSync(slugDir)
348
- .filter(f => f.endsWith('.jsonl'));
349
- const fileStats = {};
350
- for (const file of jsonlFiles) {
351
- const st = statSync(join(slugDir, file));
352
- fileStats[file] = { mtime: st.mtimeMs, size: st.size };
353
- }
354
- slugMap[slug] = { files: fileStats };
482
+ entries = readdirSync(dir, { withFileTypes: true });
355
483
  }
356
484
  catch {
357
- // skip unreadable dirs
485
+ return;
486
+ }
487
+ for (const e of entries) {
488
+ if (e.name.startsWith('._') || e.name === '.DS_Store')
489
+ continue;
490
+ const sub = join(dir, e.name);
491
+ const rel = relPrefix ? `${relPrefix}/${e.name}` : e.name;
492
+ if (e.isDirectory()) {
493
+ walk(sub, rel);
494
+ }
495
+ else if (e.isFile()) {
496
+ try {
497
+ const st = statSync(sub);
498
+ files[rel] = { mtime: st.mtimeMs, size: st.size };
499
+ }
500
+ catch { /* skip unreadable */ }
501
+ }
358
502
  }
503
+ };
504
+ walk(slugDir, '');
505
+ return files;
506
+ };
507
+ try {
508
+ const slugs = readdirSync(claudeDir, { withFileTypes: true })
509
+ .filter(d => d.isDirectory() && !d.name.startsWith('._'))
510
+ .map(d => d.name);
511
+ for (const slug of slugs) {
512
+ slugMap[slug] = { files: walkSlug(join(claudeDir, slug)) };
359
513
  }
360
514
  }
361
515
  catch {
@@ -419,49 +573,7 @@ function startApiServer(workingDir, port) {
419
573
  res.end(JSON.stringify({ error: 'tar extraction failed', code }));
420
574
  return;
421
575
  }
422
- const claudeDir = join(homedir(), '.claude');
423
- const projectsDir = join(claudeDir, 'projects');
424
- mkdirSync(projectsDir, { recursive: true });
425
- // The archive should contain a 'projects' subdirectory
426
- const extractedProjects = join(tmpDir, 'projects');
427
- const sourceDir = existsSync(extractedProjects) ? extractedProjects : tmpDir;
428
- // Optionally remap slug: if targetWorkDir is provided, find slug(s)
429
- // that don't match the target and rename them
430
- const remapped = {};
431
- if (targetWorkDir) {
432
- const targetSlug = targetWorkDir.replace(/\//g, '-');
433
- const sourceSlugs = readdirSync(sourceDir);
434
- for (const slug of sourceSlugs) {
435
- if (slug !== targetSlug && !slug.startsWith('.')) {
436
- remapped[slug] = targetSlug;
437
- }
438
- }
439
- }
440
- // Copy subdirectories into ~/.claude/projects/, merging and updating existing files
441
- let filesWritten = 0;
442
- const slugsInSource = readdirSync(sourceDir);
443
- for (const slug of slugsInSource) {
444
- const effectiveSlug = remapped[slug] ?? slug;
445
- const destSlug = join(projectsDir, effectiveSlug);
446
- mkdirSync(destSlug, { recursive: true });
447
- try {
448
- renameSync(join(sourceDir, slug), destSlug);
449
- }
450
- catch (e) {
451
- if (e.code === 'EXDEV') {
452
- // Cross-filesystem fallback
453
- cpSync(join(sourceDir, slug), destSlug, { recursive: true, force: true, errorOnExist: false });
454
- }
455
- else {
456
- throw e;
457
- }
458
- }
459
- filesWritten++;
460
- const recoveredPath = effectiveSlug.replace(/^-/, '/').replace(/--/g, '/.').replace(/-/g, '/');
461
- if (recoveredPath && recoveredPath !== '/') {
462
- mkdirSync(recoveredPath, { recursive: true });
463
- }
464
- }
576
+ const { filesWritten, remapped } = await mergeExtractedIntoProjects(tmpDir, targetWorkDir ?? undefined);
465
577
  res.writeHead(200, { 'Content-Type': 'application/json' });
466
578
  res.end(JSON.stringify({ ok: true, filesWritten, remapped }));
467
579
  }
@@ -577,39 +689,9 @@ function startApiServer(workingDir, port) {
577
689
  res.end(JSON.stringify({ error: 'tar extraction failed', code }));
578
690
  return;
579
691
  }
580
- const claudeDir = join(homedir(), '.claude');
581
- const projectsDir = join(claudeDir, 'projects');
582
- mkdirSync(projectsDir, { recursive: true });
583
- // The archive should contain a 'projects' subdirectory
584
- const extractedProjects = join(tmpExtractDir, 'projects');
585
- const sourceDir = existsSync(extractedProjects) ? extractedProjects : tmpExtractDir;
586
- // Copy subdirectories into ~/.claude/projects/, merging and updating existing files
587
- let filesWritten = 0;
588
- const slugsInSource = readdirSync(sourceDir);
589
- for (const slug of slugsInSource) {
590
- const destSlug = join(projectsDir, slug);
591
- mkdirSync(destSlug, { recursive: true });
592
- try {
593
- renameSync(join(sourceDir, slug), destSlug);
594
- }
595
- catch (e) {
596
- if (e.code === 'EXDEV') {
597
- // Cross-filesystem fallback
598
- cpSync(join(sourceDir, slug), destSlug, { recursive: true, force: true, errorOnExist: false });
599
- }
600
- else {
601
- throw e;
602
- }
603
- }
604
- // Also create the corresponding workspace directory so Claude can resume
605
- const recoveredPath = slug.replace(/^-/, '/').replace(/--/g, '/.').replace(/-/g, '/');
606
- if (recoveredPath && recoveredPath !== '/') {
607
- mkdirSync(recoveredPath, { recursive: true });
608
- }
609
- filesWritten++;
610
- }
692
+ const { filesWritten, remapped } = await mergeExtractedIntoProjects(tmpExtractDir, targetWorkDir);
611
693
  res.writeHead(200, { 'Content-Type': 'application/json' });
612
- res.end(JSON.stringify({ ok: true, filesWritten }));
694
+ res.end(JSON.stringify({ ok: true, filesWritten, remapped }));
613
695
  }
614
696
  catch (err) {
615
697
  console.error('[import-finalize] merge error:', err);
@@ -1308,7 +1390,9 @@ async function main() {
1308
1390
  skipTTSQueue: true,
1309
1391
  onCompactionEvent: (event) => {
1310
1392
  try {
1311
- sendToFrontend({ type: event.type, trigger: event.trigger, skillsWritten: event.skillsWritten });
1393
+ // Forward every field frontend renders stage + detail + skill list during compaction.
1394
+ // Spread covers compaction_started/progress/complete (different fields per type).
1395
+ sendToFrontend({ ...event });
1312
1396
  }
1313
1397
  catch { /* non-fatal */ }
1314
1398
  },
@@ -1641,7 +1725,9 @@ async function main() {
1641
1725
  resumeSessionId,
1642
1726
  onCompactionEvent: (event) => {
1643
1727
  try {
1644
- sendToFrontend({ type: event.type, trigger: event.trigger, skillsWritten: event.skillsWritten });
1728
+ // Forward every field frontend renders stage + detail + skill list during compaction.
1729
+ // Spread covers compaction_started/progress/complete (different fields per type).
1730
+ sendToFrontend({ ...event });
1645
1731
  }
1646
1732
  catch { /* non-fatal */ }
1647
1733
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.17",
3
+ "version": "0.9.19",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {