osborn 0.9.16 → 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.
- package/Dockerfile.sandbox +17 -0
- package/dist/claude-llm.d.ts +9 -1
- package/dist/claude-llm.js +47 -29
- package/dist/index.js +155 -93
- package/package.json +1 -1
package/Dockerfile.sandbox
CHANGED
|
@@ -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
|
|
package/dist/claude-llm.d.ts
CHANGED
|
@@ -22,9 +22,17 @@ export interface ClaudeLLMOptions {
|
|
|
22
22
|
voiceMode?: 'direct' | 'realtime';
|
|
23
23
|
skipTTSQueue?: boolean;
|
|
24
24
|
onCompactionEvent?: (event: {
|
|
25
|
-
type: 'compaction_started'
|
|
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
|
/**
|
package/dist/claude-llm.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
|
@@ -593,8 +589,11 @@ export class ClaudeLLM extends llm.LLM {
|
|
|
593
589
|
* @param callbacks - Event callbacks for the background consumer
|
|
594
590
|
*/
|
|
595
591
|
pushMessage(userText, sdkOptions, callbacks) {
|
|
596
|
-
//
|
|
597
|
-
|
|
592
|
+
// Auto-compact threshold — fires PreCompact when context fills to this %.
|
|
593
|
+
// Higher = uses more of the window before compacting (more context retained
|
|
594
|
+
// per turn, but less headroom for the next reply). 85% is the sweet spot
|
|
595
|
+
// before Claude starts hard-capping replies near the limit.
|
|
596
|
+
process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = '85';
|
|
598
597
|
const userMessage = {
|
|
599
598
|
type: 'user',
|
|
600
599
|
message: { role: 'user', content: [{ type: 'text', text: userText }] },
|
|
@@ -994,6 +993,16 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
994
993
|
const today = new Date().toISOString().split('T')[0];
|
|
995
994
|
const sessionId = this.#sessionId || 'unknown';
|
|
996
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`);
|
|
997
1006
|
// Helper: extract text between a marker and the next === marker (or end of string)
|
|
998
1007
|
const extractSection = (marker) => {
|
|
999
1008
|
const idx = summary.indexOf(marker);
|
|
@@ -1007,6 +1016,7 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
1007
1016
|
const handoff = extractSection('=== HANDOFF_STATE ===');
|
|
1008
1017
|
if (handoff) {
|
|
1009
1018
|
console.log(`🧠 PostCompact: HANDOFF_STATE present (${handoff.length} chars) — not written to disk`);
|
|
1019
|
+
progress('Parsed handoff state', `${handoff.length} chars`);
|
|
1010
1020
|
}
|
|
1011
1021
|
// ── Section 2: DECISIONS — append project-scoped decisions ──
|
|
1012
1022
|
try {
|
|
@@ -1016,6 +1026,7 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
1016
1026
|
.split('\n')
|
|
1017
1027
|
.filter(l => /DECISION:.*SCOPE:\s*project/i.test(l));
|
|
1018
1028
|
if (projectLines.length) {
|
|
1029
|
+
progress('Extracting decisions', `${projectLines.length} project-scoped`);
|
|
1019
1030
|
const decFolder = join(skillDir, '.claude', 'skills', 'decisions');
|
|
1020
1031
|
const decPath = join(decFolder, 'SKILL.md');
|
|
1021
1032
|
mkdirSync(decFolder, { recursive: true });
|
|
@@ -1025,6 +1036,8 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
1025
1036
|
writeSyncFs(decPath, header + existing + entry, 'utf-8');
|
|
1026
1037
|
console.log(`🧠 PostCompact: appended ${projectLines.length} decision(s) to ${decPath}`);
|
|
1027
1038
|
skillsWritten++;
|
|
1039
|
+
skillNames.push('decisions');
|
|
1040
|
+
progress('Wrote skill', 'decisions');
|
|
1028
1041
|
}
|
|
1029
1042
|
}
|
|
1030
1043
|
}
|
|
@@ -1052,6 +1065,8 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
1052
1065
|
writeSyncFs(skillPath, header + body + '\n', 'utf-8');
|
|
1053
1066
|
console.log(`🧠 PostCompact: wrote skill '${name}' to ${skillPath}`);
|
|
1054
1067
|
skillsWritten++;
|
|
1068
|
+
skillNames.push(name);
|
|
1069
|
+
progress('Wrote skill', name);
|
|
1055
1070
|
}
|
|
1056
1071
|
}
|
|
1057
1072
|
}
|
|
@@ -1062,6 +1077,7 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
1062
1077
|
try {
|
|
1063
1078
|
const learnings = extractSection('=== BEHAVIORAL_LEARNINGS ===');
|
|
1064
1079
|
if (learnings.length >= 30) {
|
|
1080
|
+
progress('Extracting learnings', `${learnings.length} chars`);
|
|
1065
1081
|
const skillFolder = join(skillDir, '.claude', 'skills', 'learned-behaviors');
|
|
1066
1082
|
const skillPath = join(skillFolder, 'SKILL.md');
|
|
1067
1083
|
mkdirSync(skillFolder, { recursive: true });
|
|
@@ -1069,6 +1085,8 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
1069
1085
|
writeSyncFs(skillPath, header + learnings + '\n', 'utf-8');
|
|
1070
1086
|
console.log(`🧠 PostCompact: wrote learned behaviors to ${skillPath} (${learnings.length} chars)`);
|
|
1071
1087
|
skillsWritten++;
|
|
1088
|
+
skillNames.push('learned-behaviors');
|
|
1089
|
+
progress('Wrote skill', 'learned-behaviors');
|
|
1072
1090
|
}
|
|
1073
1091
|
else {
|
|
1074
1092
|
console.log('🧠 PostCompact: no BEHAVIORAL_LEARNINGS section found or too short — skipping');
|
|
@@ -1077,8 +1095,8 @@ class ClaudeLLMStream extends llm.LLMStream {
|
|
|
1077
1095
|
catch (blErr) {
|
|
1078
1096
|
console.error('⚠️ PostCompact: BEHAVIORAL_LEARNINGS write failed:', blErr instanceof Error ? blErr.message : blErr);
|
|
1079
1097
|
}
|
|
1080
|
-
this.#opts.onCompactionEvent?.({ type: 'compaction_complete', skillsWritten });
|
|
1081
|
-
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(', ')}]`);
|
|
1082
1100
|
}
|
|
1083
1101
|
catch (err) {
|
|
1084
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,
|
|
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
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
},
|