osborn 0.9.17 → 0.9.18

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';
@@ -334,28 +334,158 @@ function startApiServer(workingDir, port) {
334
334
  return;
335
335
  }
336
336
  // GET /sessions/manifest — return mtime+size for all .jsonl files per slug (public, no auth)
337
+ // Helper: merge an extracted tar directory into ~/.claude/projects/ with all 4 fixes:
338
+ // 1. Skip macOS AppleDouble entries (`._*`) that bsdtar emits
339
+ // 2. Apply slug remap when targetWorkDir is supplied (chunked path missed this)
340
+ // 3. Rewrite embedded `cwd` field inside .jsonl entries during remap so
341
+ // Claude Code can resume the conversation in the destination workspace
342
+ // 4. Merge into existing dest dirs instead of failing on rename collision
343
+ const mergeExtractedIntoProjects = async (sourceDir, targetWorkDir) => {
344
+ const claudeDir = join(homedir(), '.claude');
345
+ const projectsDir = join(claudeDir, 'projects');
346
+ mkdirSync(projectsDir, { recursive: true });
347
+ // The archive sometimes wraps content in a 'projects' subdir, sometimes not.
348
+ const extractedProjects = join(sourceDir, 'projects');
349
+ const effectiveSource = existsSync(extractedProjects) ? extractedProjects : sourceDir;
350
+ // Filter out AppleDouble (`._*`) entries that macOS bsdtar emits for
351
+ // resource forks. These crash later steps if they collide with real dirs.
352
+ const sourceSlugs = readdirSync(effectiveSource)
353
+ .filter(s => !s.startsWith('._') && !s.startsWith('.DS_Store'));
354
+ // Build remap table: source-slug → target-slug.
355
+ // Only remaps slugs that differ from the target (no-op if already correct).
356
+ const remapped = {};
357
+ const targetSlug = targetWorkDir ? targetWorkDir.replace(/\//g, '-') : '';
358
+ if (targetSlug) {
359
+ for (const slug of sourceSlugs) {
360
+ if (slug !== targetSlug)
361
+ remapped[slug] = targetSlug;
362
+ }
363
+ }
364
+ // Slug → original cwd path (reverse of slug encoding):
365
+ // '-Users-newupgrade-Desktop-Developer-osborn' → '/Users/newupgrade/Desktop/Developer/osborn'
366
+ // Claude Code's slug rule: replace all '/' with '-', so reverse is replace '-' with '/'.
367
+ // Leading '-' becomes '/'. We don't try to recover '.' (Claude uses '--' for it, but
368
+ // dot-prefixed dirs are uncommon and a best-effort rewrite is enough for resume).
369
+ const slugToCwd = (slug) => '/' + slug.replace(/^-/, '').replace(/-/g, '/');
370
+ let filesWritten = 0;
371
+ for (const sourceSlug of sourceSlugs) {
372
+ const effectiveSlug = remapped[sourceSlug] ?? sourceSlug;
373
+ const destSlug = join(projectsDir, effectiveSlug);
374
+ mkdirSync(destSlug, { recursive: true });
375
+ const sourceSlugPath = join(effectiveSource, sourceSlug);
376
+ const sourceCwd = slugToCwd(sourceSlug);
377
+ const destCwd = targetWorkDir ?? slugToCwd(effectiveSlug);
378
+ const needsCwdRewrite = sourceCwd !== destCwd;
379
+ // Walk the source slug directory and copy files individually so we can:
380
+ // (a) skip AppleDouble per-file too (in case nested)
381
+ // (b) rewrite cwd inside .jsonl files when remapping across workspaces
382
+ // (c) merge into existing destination directories without renameSync collision
383
+ // (d) keep newer-by-mtime when both sides have the same file (the user's
384
+ // requested "overwrite based on timestamp" rule for bidirectional sync)
385
+ const walkAndCopy = (src, dst) => {
386
+ const entries = readdirSync(src, { withFileTypes: true });
387
+ for (const e of entries) {
388
+ if (e.name.startsWith('._') || e.name === '.DS_Store')
389
+ continue;
390
+ const sp = join(src, e.name);
391
+ const dp = join(dst, e.name);
392
+ if (e.isDirectory()) {
393
+ mkdirSync(dp, { recursive: true });
394
+ walkAndCopy(sp, dp);
395
+ }
396
+ else if (e.isFile()) {
397
+ // mtime conflict resolution — when destination already has this file,
398
+ // only overwrite when the source is strictly newer. Preserves work
399
+ // done on the destination side when re-syncing in either direction.
400
+ let shouldWrite = true;
401
+ try {
402
+ const dstStat = statSync(dp);
403
+ const srcStat = statSync(sp);
404
+ if (dstStat.mtimeMs >= srcStat.mtimeMs)
405
+ shouldWrite = false;
406
+ }
407
+ catch { /* dst doesn't exist — write it */ }
408
+ if (!shouldWrite)
409
+ continue;
410
+ if (needsCwdRewrite && e.name.endsWith('.jsonl')) {
411
+ // Read, rewrite "cwd" field, write. JSONL is line-delimited;
412
+ // string match on `"cwd":"<sourceCwd>"` is precise enough.
413
+ const content = readFileSync(sp, 'utf8');
414
+ const find = `"cwd":"${sourceCwd}"`;
415
+ const replace = `"cwd":"${destCwd}"`;
416
+ const rewritten = content.split(find).join(replace);
417
+ writeFileSync(dp, rewritten);
418
+ }
419
+ else {
420
+ cpSync(sp, dp, { force: true });
421
+ }
422
+ filesWritten++;
423
+ }
424
+ // skip symlinks, sockets, etc.
425
+ }
426
+ };
427
+ walkAndCopy(sourceSlugPath, destSlug);
428
+ // Best-effort: ensure the resolved workspace directory exists so Claude
429
+ // can resume conversations whose JSONLs reference it.
430
+ const recoveredPath = effectiveSlug.replace(/^-/, '/').replace(/--/g, '/.').replace(/-/g, '/');
431
+ if (recoveredPath && recoveredPath !== '/') {
432
+ try {
433
+ mkdirSync(recoveredPath, { recursive: true });
434
+ }
435
+ catch { /* ignore */ }
436
+ }
437
+ }
438
+ return { filesWritten, remapped };
439
+ };
337
440
  if (req.method === 'GET' && url.pathname === '/sessions/manifest') {
441
+ // Walks the FULL tree per slug — including sub-agent transcripts
442
+ // (<slug>/<sessionId>/subagents/*.jsonl), tool-results (<slug>/<sessionId>/tool-results/*),
443
+ // osb workspace files (<slug>/osb/<sessionId>/*), and file-history. Files are
444
+ // keyed by their path RELATIVE to the slug dir so the client can preserve
445
+ // structure when computing diffs. mtime is in ms epoch so a simple `>`
446
+ // comparison is the "newer wins" merge rule.
447
+ //
448
+ // Previous version only listed top-level *.jsonl and missed ~270/290 files
449
+ // on a typical session — sub-agent transcripts invisible → resume failed
450
+ // silently because Claude couldn't find the referenced agent_id transcripts.
338
451
  const claudeDir = join(homedir(), '.claude', 'projects');
339
452
  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);
453
+ const walkSlug = (slugDir) => {
454
+ const files = {};
455
+ const walk = (dir, relPrefix) => {
456
+ let entries;
346
457
  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 };
458
+ entries = readdirSync(dir, { withFileTypes: true });
355
459
  }
356
460
  catch {
357
- // skip unreadable dirs
461
+ return;
462
+ }
463
+ for (const e of entries) {
464
+ if (e.name.startsWith('._') || e.name === '.DS_Store')
465
+ continue;
466
+ const sub = join(dir, e.name);
467
+ const rel = relPrefix ? `${relPrefix}/${e.name}` : e.name;
468
+ if (e.isDirectory()) {
469
+ walk(sub, rel);
470
+ }
471
+ else if (e.isFile()) {
472
+ try {
473
+ const st = statSync(sub);
474
+ files[rel] = { mtime: st.mtimeMs, size: st.size };
475
+ }
476
+ catch { /* skip unreadable */ }
477
+ }
358
478
  }
479
+ };
480
+ walk(slugDir, '');
481
+ return files;
482
+ };
483
+ try {
484
+ const slugs = readdirSync(claudeDir, { withFileTypes: true })
485
+ .filter(d => d.isDirectory() && !d.name.startsWith('._'))
486
+ .map(d => d.name);
487
+ for (const slug of slugs) {
488
+ slugMap[slug] = { files: walkSlug(join(claudeDir, slug)) };
359
489
  }
360
490
  }
361
491
  catch {
@@ -419,49 +549,7 @@ function startApiServer(workingDir, port) {
419
549
  res.end(JSON.stringify({ error: 'tar extraction failed', code }));
420
550
  return;
421
551
  }
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
- }
552
+ const { filesWritten, remapped } = await mergeExtractedIntoProjects(tmpDir, targetWorkDir ?? undefined);
465
553
  res.writeHead(200, { 'Content-Type': 'application/json' });
466
554
  res.end(JSON.stringify({ ok: true, filesWritten, remapped }));
467
555
  }
@@ -577,39 +665,9 @@ function startApiServer(workingDir, port) {
577
665
  res.end(JSON.stringify({ error: 'tar extraction failed', code }));
578
666
  return;
579
667
  }
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
- }
668
+ const { filesWritten, remapped } = await mergeExtractedIntoProjects(tmpExtractDir, targetWorkDir);
611
669
  res.writeHead(200, { 'Content-Type': 'application/json' });
612
- res.end(JSON.stringify({ ok: true, filesWritten }));
670
+ res.end(JSON.stringify({ ok: true, filesWritten, remapped }));
613
671
  }
614
672
  catch (err) {
615
673
  console.error('[import-finalize] merge error:', err);
@@ -1308,7 +1366,9 @@ async function main() {
1308
1366
  skipTTSQueue: true,
1309
1367
  onCompactionEvent: (event) => {
1310
1368
  try {
1311
- sendToFrontend({ type: event.type, trigger: event.trigger, skillsWritten: event.skillsWritten });
1369
+ // Forward every field frontend renders stage + detail + skill list during compaction.
1370
+ // Spread covers compaction_started/progress/complete (different fields per type).
1371
+ sendToFrontend({ ...event });
1312
1372
  }
1313
1373
  catch { /* non-fatal */ }
1314
1374
  },
@@ -1641,7 +1701,9 @@ async function main() {
1641
1701
  resumeSessionId,
1642
1702
  onCompactionEvent: (event) => {
1643
1703
  try {
1644
- sendToFrontend({ type: event.type, trigger: event.trigger, skillsWritten: event.skillsWritten });
1704
+ // Forward every field frontend renders stage + detail + skill list during compaction.
1705
+ // Spread covers compaction_started/progress/complete (different fields per type).
1706
+ sendToFrontend({ ...event });
1645
1707
  }
1646
1708
  catch { /* non-fatal */ }
1647
1709
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.17",
3
+ "version": "0.9.18",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {