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.
- package/Dockerfile.sandbox +17 -0
- package/dist/claude-llm.d.ts +9 -1
- package/dist/claude-llm.js +42 -27
- package/dist/index.js +180 -94
- 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
|
|
@@ -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,
|
|
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
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
},
|