cchost 0.4.1 → 0.4.3

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/CHANGELOG.md CHANGED
@@ -5,6 +5,47 @@ All notable changes to `cchost` are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [0.4.3] - 2026-06-07
9
+
10
+ ### Fixed
11
+
12
+ - **`claude --resume` now finds older sessions after a v0.3 → v0.4 upgrade.**
13
+ The v0.4 migration's `__legacy` collision rename was applied to
14
+ `projects/` folders too, which hid their sessions from `claude --resume`
15
+ (resume filters by cwd-encoded folder name; a `__legacy` suffix never
16
+ matches a real cwd). Four changes:
17
+ - `migrateLegacyPool` now merges `projects/<base>/` children into the
18
+ existing canonical folder instead of renaming the directory.
19
+ Skills/agents/commands keep the directory-level `__legacy` rename —
20
+ only `projects/` changes.
21
+ - Same-UUID session-file collisions during merge reissue a fresh UUID v4
22
+ instead of appending `__legacy.jsonl`, so the file stays a valid Claude
23
+ session filename. Paired `<uuid>/` sub-uuid dirs travel under the same
24
+ new UUID.
25
+ - `cc doctor` detects pre-existing `projects/<base>__legacy*` folders and
26
+ `--fix` merges them into the canonical sibling (or renames the legacy
27
+ folder in place if no sibling exists). Case-insensitive sibling lookup
28
+ on Windows also collapses split `d--…` / `D--…` variants.
29
+ - `cc doctor` also detects damaged `<uuid>__legacy*.jsonl` filenames left
30
+ in `projects/` by earlier heal logic and `--fix` restores them to
31
+ `<uuid>.jsonl` (or reissues a fresh UUID when the canonical name is
32
+ taken).
33
+ - **Self-referencing legacy pool detection.** When `~/.claude/projects/` is
34
+ a junction/symlink pointing into `~/.claude-shared/projects/`
35
+ (a layout some pre-v0.4 setups produced), `migrateLegacyPool` aborts
36
+ cleanly instead of treating it as a real migration — what would otherwise
37
+ have moved every file onto itself and triggered the legacy-suffix path on
38
+ every entry. `cc doctor` surfaces this case as a manual-cleanup warning
39
+ instead of offering a self-move auto-fix.
40
+
41
+ ## [0.4.2] - 2026-06-04
42
+
43
+ ### Fixed
44
+
45
+ - ASCII banner on `cc help` no longer misaligns when the terminal is
46
+ narrower than the banner width. Banner is now top-anchored and stable
47
+ across PowerShell, cmd, bash, zsh, and fish.
48
+
8
49
  ## [0.4.1] - 2026-06-04
9
50
 
10
51
  ### Changed
package/README.md CHANGED
@@ -1,15 +1,13 @@
1
1
  # cchost
2
2
 
3
- > Run multiple isolated Claude Code accounts on one machine — switch between them with a single `cc` command.
4
-
5
3
  [![npm version](https://img.shields.io/npm/v/cchost.svg)](https://www.npmjs.com/package/cchost)
6
4
  [![CI](https://github.com/allenhack638/cchost/actions/workflows/ci.yml/badge.svg)](https://github.com/allenhack638/cchost/actions/workflows/ci.yml)
7
5
  [![license](https://img.shields.io/npm/l/cchost.svg)](./LICENSE)
8
6
  [![node](https://img.shields.io/node/v/cchost.svg)](https://nodejs.org)
9
7
 
10
- <!-- Demo: record a ~15s clip of `cc add` → `cc use` → `cc list`, save it as
11
- docs/demo.gif, and uncomment the line below. -->
12
- <!-- ![cchost in action](docs/demo.gif) -->
8
+ <p align="center">
9
+ <img src="https://raw.githubusercontent.com/allenhack638/cchost/master/docs/demo.png" alt="cchost CLI — cc list showing masked emails and shared resource counts" width="100%">
10
+ </p>
13
11
 
14
12
  Claude Code keeps one account's login, history, and settings in a single config directory. To use a second account you normally have to log out and back in, which loses your place. cchost gives each account its own directory so you can keep several side by side and pick one when you launch.
15
13
 
package/lib/banner.js CHANGED
@@ -1,29 +1,22 @@
1
1
  'use strict';
2
2
 
3
- // ASCII banner shown on `cc`, `cc help`, `cc --help`, and the unknown-command
4
- // fallback. Generated once with figlet's "ANSI Shadow" font and frozen as a
5
- // literal so the runtime has no dependency on figlet. Plain ASCII / no color —
6
- // works in any terminal, including ones with NO_COLOR or no TTY.
7
-
8
3
  const BANNER =
9
4
  '\n' +
10
- ' ██████╗ ██████╗██╗ ██╗ ██████╗ ███████╗████████╗\n' +
11
- '██╔════╝██╔════╝██║ ██║██╔═══██╗██╔════╝╚══██╔══╝\n' +
12
- '██║ ██║ ███████║██║ ██║███████╗ ██║ \n' +
13
- '██║ ██║ ██╔══██║██║ ██║╚════██║ ██║ \n' +
14
- '╚██████╗╚██████╗██║ ██║╚██████╔╝███████║ ██║ \n' +
15
- ' ╚═════╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ \n';
5
+ ' ██████╗ ██████╗██╗ ██╗ ██████╗ ███████╗████████╗\n' +
6
+ ' ██╔════╝██╔════╝██║ ██║██╔═══██╗██╔════╝╚══██╔══╝\n' +
7
+ ' ██║ ██║ ███████║██║ ██║███████╗ ██║ \n' +
8
+ ' ██║ ██║ ██╔══██║██║ ██║╚════██║ ██║ \n' +
9
+ ' ╚██████╗╚██████╗██║ ██║╚██████╔╝███████║ ██║ \n' +
10
+ ' ╚═════╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ \n';
16
11
 
17
12
  function renderBanner() {
18
13
  let version = '';
19
14
  try {
20
15
  version = require('../package.json').version;
21
16
  } catch {
22
- // package.json missing — banner still renders without a version.
23
17
  }
24
18
  const tagline = 'Multi-profile launcher for Claude Code';
25
- const versionLine = version ? `v${version} — ${tagline}` : tagline;
26
- return BANNER + ' ' + versionLine + '\n\n';
19
+ return BANNER + ' ' + '\n\n';
27
20
  }
28
21
 
29
22
  module.exports = { renderBanner, BANNER };
package/lib/doctor.js CHANGED
@@ -281,6 +281,27 @@ function subsetForResource(resource) {
281
281
  function checkLegacyPool() {
282
282
  const legacy = profiles.sharedDir();
283
283
  if (!fs.existsSync(legacy)) return null;
284
+ // Self-referencing pre-v0.4 setup: `~/.claude/projects/` was made a junction
285
+ // into `~/.claude-shared/projects/`. The two locations are the same physical
286
+ // dir on disk — no migration is possible (or needed). Flag it as a warning
287
+ // with a manual cleanup hint instead of a self-move auto-fix.
288
+ const pool = profiles.defaultClaudeDir();
289
+ for (const sub of share.SHARED_DIRS) {
290
+ const poolSub = path.join(pool, sub);
291
+ const legacySub = path.join(legacy, sub);
292
+ try {
293
+ if (fs.existsSync(poolSub) && fs.realpathSync(poolSub) === fs.realpathSync(legacySub)) {
294
+ return mk('Storage', '~/.claude-shared/ (legacy)', 'warn',
295
+ `~/.claude/${sub}/ is a junction into ~/.claude-shared/${sub}/ — same physical dir, safe to remove the junction and migrate by hand`,
296
+ {
297
+ safe: false,
298
+ description: 'Manually move ~/.claude-shared/<sub>/ contents to ~/.claude/<sub>/, remove the junction, then delete ~/.claude-shared/',
299
+ cmd: null,
300
+ apply: null,
301
+ });
302
+ }
303
+ } catch { /* fall through */ }
304
+ }
284
305
  let hasContent = false;
285
306
  try {
286
307
  hasContent = fs.readdirSync(legacy).length > 0;
@@ -314,6 +335,107 @@ function checkLegacyPool() {
314
335
  });
315
336
  }
316
337
 
338
+ // Damaged session filenames left inside projects/<X>/ by an earlier
339
+ // (v0.4.2-and-earlier) heal that injected `__legacy` into UUID filenames on
340
+ // collision. These break `claude --resume` because it expects strict
341
+ // <uuid>.jsonl. Safe auto-fix strips the suffix (or reissues a fresh UUID
342
+ // when the canonical name is taken).
343
+ function checkDamagedSessionFilenames() {
344
+ const projectsDir = path.join(profiles.defaultClaudeDir(), 'projects');
345
+ if (!fs.existsSync(projectsDir)) return null;
346
+ let folders;
347
+ try {
348
+ folders = fs.readdirSync(projectsDir);
349
+ } catch {
350
+ return null;
351
+ }
352
+ const damagedRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:__legacy(?:_\d+)?)+(\.jsonl)?$/i;
353
+ let count = 0;
354
+ const sampleFolders = new Set();
355
+ for (const folder of folders) {
356
+ const folderPath = path.join(projectsDir, folder);
357
+ let st;
358
+ try { st = fs.statSync(folderPath); } catch { continue; }
359
+ if (!st.isDirectory()) continue;
360
+ let children;
361
+ try { children = fs.readdirSync(folderPath); } catch { continue; }
362
+ for (const child of children) {
363
+ if (damagedRe.test(child)) {
364
+ count += 1;
365
+ if (sampleFolders.size < 3) sampleFolders.add(folder);
366
+ }
367
+ }
368
+ }
369
+ if (count === 0) return null;
370
+ const sample = Array.from(sampleFolders).join(', ') + (sampleFolders.size < 3 ? '' : ', ...');
371
+ return mk(
372
+ 'Storage',
373
+ '~/.claude/projects/ damaged session filenames',
374
+ 'warn',
375
+ `${count} session file${count === 1 ? '' : 's'} with __legacy in UUID — invisible to claude --resume (${sample})`,
376
+ {
377
+ safe: true,
378
+ description: 'Restore <uuid>.jsonl filenames (reissue a fresh UUID when canonical is taken)',
379
+ cmd: null,
380
+ apply: async () => {
381
+ const result = share.repairProjectFilenames({
382
+ log: (line) => process.stdout.write(`${line}\n`),
383
+ });
384
+ process.stdout.write(
385
+ ` Done: ${result.repaired.length} filename${result.repaired.length === 1 ? '' : 's'} restored.\n`,
386
+ );
387
+ if (result.failures.length) {
388
+ for (const f of result.failures) {
389
+ process.stdout.write(` Failure (${f.kind}): ${f.folder ? f.folder + '/' : ''}${f.name || ''} ${f.message}\n`);
390
+ }
391
+ }
392
+ },
393
+ },
394
+ );
395
+ }
396
+
397
+ // Stranded projects/<base>__legacy* folders left by older v0.4 migrations.
398
+ // Each one hides its session files from `claude --resume` because resume
399
+ // filters by cwd-encoded folder name. Safe auto-fix merges them into the
400
+ // canonical sibling (or renames in place if no sibling exists).
401
+ function checkLegacyProjectFolders() {
402
+ const projectsDir = path.join(profiles.defaultClaudeDir(), 'projects');
403
+ if (!fs.existsSync(projectsDir)) return null;
404
+ let entries;
405
+ try {
406
+ entries = fs.readdirSync(projectsDir);
407
+ } catch {
408
+ return null;
409
+ }
410
+ const legacy = entries.filter((e) => /__legacy(?:_\d+)?$/.test(e));
411
+ if (legacy.length === 0) return null;
412
+ const sample = legacy.slice(0, 3).join(', ') + (legacy.length > 3 ? ', ...' : '');
413
+ return mk(
414
+ 'Storage',
415
+ '~/.claude/projects/ legacy folders',
416
+ 'warn',
417
+ `${legacy.length} folder${legacy.length === 1 ? '' : 's'} with __legacy suffix hide sessions from --resume (${sample})`,
418
+ {
419
+ safe: true,
420
+ description: 'Merge each legacy projects/ folder into its canonical sibling',
421
+ cmd: null,
422
+ apply: async () => {
423
+ const result = share.healProjectsLegacy({
424
+ log: (line) => process.stdout.write(` ${line}\n`),
425
+ });
426
+ process.stdout.write(
427
+ ` Done: ${result.healed.length} folder${result.healed.length === 1 ? '' : 's'} healed.\n`,
428
+ );
429
+ if (result.failures.length) {
430
+ for (const f of result.failures) {
431
+ process.stdout.write(` Failure (${f.kind}): ${f.name || ''} ${f.message}\n`);
432
+ }
433
+ }
434
+ },
435
+ },
436
+ );
437
+ }
438
+
317
439
  // --- Platform ---
318
440
 
319
441
  function checkOneDrive() {
@@ -378,6 +500,8 @@ function runChecks() {
378
500
  checkProfilesDirWritable(),
379
501
  ];
380
502
  results.push(checkLegacyPool());
503
+ results.push(checkLegacyProjectFolders());
504
+ results.push(checkDamagedSessionFilenames());
381
505
  for (const prof of profiles.listProfiles()) {
382
506
  results.push(checkProfileClaudeJson(prof));
383
507
  results.push(...checkProfileLinks(prof));
package/lib/share.js CHANGED
@@ -2,9 +2,25 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const crypto = require('crypto');
5
6
 
6
7
  const profiles = require('./profiles');
7
8
 
9
+ // Strict UUID v4-ish pattern. Used to identify session files / sub-uuid dirs
10
+ // in projects/<X>/ so we can preserve a Claude-`--resume`-readable filename
11
+ // when merging two folders that hold the same session UUID.
12
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13
+ const UUID_FILE_RE = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(\.jsonl)$/i;
14
+
15
+ function isUuidName(name) {
16
+ return UUID_RE.test(name);
17
+ }
18
+ function uuidStemOf(name) {
19
+ if (UUID_RE.test(name)) return name;
20
+ const m = name.match(UUID_FILE_RE);
21
+ return m ? m[1] : null;
22
+ }
23
+
8
24
  // v0.4.1: every profile shares 7 resources with ~/.claude/ (the pool).
9
25
  // - 4 directories: projects/, skills/, agents/, commands/
10
26
  // - 3 single files: mcp.json, settings.json, CLAUDE.md
@@ -316,10 +332,110 @@ function shareUnlink(profileName, opts = {}) {
316
332
  return results;
317
333
  }
318
334
 
335
+ // Merge children of src/ into dst/ in place. Used for `projects/` subfolder
336
+ // collisions, where the folder name encodes the cwd and renaming the *folder*
337
+ // would orphan its sessions from `claude --resume`.
338
+ //
339
+ // Collision strategy:
340
+ // - For UUID-keyed session files (`<uuid>.jsonl`) and their paired
341
+ // sub-uuid dirs: generate a fresh UUID v4 on collision so the file stays
342
+ // a valid Claude session filename. Pair the file and dir under the same
343
+ // new UUID so they travel together.
344
+ // - For other names (`memory/`, `sessions-index.json`, anything custom):
345
+ // fall back to a `__legacy[_i]` suffix on the file name. These aren't
346
+ // parsed by `--resume`, so the suffix is harmless.
347
+ function mergeFolderInto(src, dst, { log = () => {} } = {}) {
348
+ fs.mkdirSync(dst, { recursive: true });
349
+ let moved = 0;
350
+ const failures = [];
351
+ const entries = fs.readdirSync(src);
352
+
353
+ // First pass: group UUID-keyed entries by their stem so the .jsonl file
354
+ // and its paired sub-uuid dir can be renamed under the same new UUID on
355
+ // collision.
356
+ const groups = new Map(); // stem -> { dir?: name, file?: name }
357
+ const passthrough = [];
358
+ for (const ent of entries) {
359
+ const stem = uuidStemOf(ent);
360
+ if (!stem) { passthrough.push(ent); continue; }
361
+ if (!groups.has(stem)) groups.set(stem, {});
362
+ const g = groups.get(stem);
363
+ if (UUID_RE.test(ent)) g.dir = ent;
364
+ else g.file = ent;
365
+ }
366
+
367
+ for (const [stem, g] of groups) {
368
+ const dirCollides = g.dir && fs.existsSync(path.join(dst, g.dir));
369
+ const fileCollides = g.file && fs.existsSync(path.join(dst, g.file));
370
+ let newStem = stem;
371
+ if (dirCollides || fileCollides) {
372
+ newStem = crypto.randomUUID();
373
+ log(` ${stem} collides — reissuing as ${newStem}`);
374
+ }
375
+ if (g.dir) {
376
+ const from = path.join(src, g.dir);
377
+ const to = path.join(dst, newStem);
378
+ try {
379
+ fs.renameSync(from, to);
380
+ moved += 1;
381
+ log(` ${g.dir} → ${path.basename(to)}`);
382
+ } catch (err) {
383
+ failures.push({ name: g.dir, message: err.message });
384
+ }
385
+ }
386
+ if (g.file) {
387
+ const from = path.join(src, g.file);
388
+ const to = path.join(dst, `${newStem}.jsonl`);
389
+ try {
390
+ fs.renameSync(from, to);
391
+ moved += 1;
392
+ log(` ${g.file} → ${path.basename(to)}`);
393
+ } catch (err) {
394
+ failures.push({ name: g.file, message: err.message });
395
+ }
396
+ }
397
+ }
398
+
399
+ for (const ent of passthrough) {
400
+ const from = path.join(src, ent);
401
+ let to = path.join(dst, ent);
402
+ if (fs.existsSync(to)) {
403
+ const dot = ent.lastIndexOf('.');
404
+ const stem = dot > 0 ? ent.slice(0, dot) : ent;
405
+ const ext = dot > 0 ? ent.slice(dot) : '';
406
+ to = path.join(dst, `${stem}__legacy${ext}`);
407
+ let i = 2;
408
+ while (fs.existsSync(to)) {
409
+ to = path.join(dst, `${stem}__legacy_${i}${ext}`);
410
+ i += 1;
411
+ }
412
+ }
413
+ try {
414
+ fs.renameSync(from, to);
415
+ moved += 1;
416
+ log(` ${ent} → ${path.basename(to)}`);
417
+ } catch (err) {
418
+ failures.push({ name: ent, message: err.message });
419
+ }
420
+ }
421
+ return { moved, failures };
422
+ }
423
+
319
424
  // One-time migration from the v0.2/v0.3 legacy pool at ~/.claude-shared/ to
320
425
  // the v0.4 pool at ~/.claude/. Idempotent: a second call is a no-op once the
321
- // legacy dir is gone. Non-destructive: existing entries in ~/.claude/ survive
322
- // via the `__legacy` collision rename used here.
426
+ // legacy dir is gone.
427
+ //
428
+ // Collision strategy differs by sub:
429
+ // - skills/, agents/, commands/: named artifacts — rename the incoming
430
+ // folder with a `__legacy` suffix so both versions survive for the user
431
+ // to inspect.
432
+ // - projects/: opaque cwd-keyed buckets of *.jsonl sessions. Renaming the
433
+ // folder would make `claude --resume` invisible to the renamed sessions
434
+ // (it filters by cwd-encoded folder name). Merge children into the
435
+ // existing folder instead. UUID-keyed session-file collisions (rare —
436
+ // happens when the same session UUID was checkpointed across pool
437
+ // migrations) reissue a fresh UUID so the file stays a valid Claude
438
+ // session filename.
323
439
  //
324
440
  // Returns { migrated, moves: [...], relinked: [...], failures: [...] }
325
441
  function migrateLegacyPool({ log = () => {} } = {}) {
@@ -328,6 +444,21 @@ function migrateLegacyPool({ log = () => {} } = {}) {
328
444
  return { migrated: false, moves: [], relinked: [], failures: [] };
329
445
  }
330
446
  const pool = poolDir();
447
+ // Self-referencing setup: a pre-v0.4 install may have created
448
+ // `~/.claude/projects/` as a junction/symlink pointing into
449
+ // `~/.claude-shared/projects/`. Treating that as a "migration" would move
450
+ // files onto themselves and (worse) trigger merge-collision suffixing on
451
+ // every entry. Detect and abort cleanly.
452
+ for (const sub of SHARED_DIRS) {
453
+ const poolSub = path.join(pool, sub);
454
+ const legacySub = path.join(legacy, sub);
455
+ try {
456
+ if (fs.existsSync(poolSub) && fs.realpathSync(poolSub) === fs.realpathSync(legacySub)) {
457
+ log(` ${sub}/ in ~/.claude/ is a junction into ~/.claude-shared/ — nothing to migrate.`);
458
+ return { migrated: false, moves: [], relinked: [], failures: [], selfReferencing: true };
459
+ }
460
+ } catch { /* fall through and try the move */ }
461
+ }
331
462
  const moves = [];
332
463
  const relinked = [];
333
464
  const failures = [];
@@ -349,9 +480,29 @@ function migrateLegacyPool({ log = () => {} } = {}) {
349
480
  failures.push({ kind: 'readdir', sub, message: err.message });
350
481
  continue;
351
482
  }
483
+ const isProjects = PROJECT_SUBDIRS.includes(sub);
352
484
  for (const ent of entries) {
353
485
  const from = path.join(legacySub, ent);
354
- let to = path.join(pool, sub, ent);
486
+ const canonical = path.join(pool, sub, ent);
487
+
488
+ if (isProjects && fs.existsSync(canonical)) {
489
+ try {
490
+ const r = mergeFolderInto(from, canonical, { log });
491
+ moves.push({ sub, name: ent, to: canonical, merged: true, items: r.moved });
492
+ log(` ${sub}/${ent} → merged into existing (${r.moved} item${r.moved === 1 ? '' : 's'})`);
493
+ for (const f of r.failures) {
494
+ failures.push({ kind: 'move', sub, name: `${ent}/${f.name}`, message: f.message });
495
+ }
496
+ try {
497
+ if (fs.readdirSync(from).length === 0) fs.rmdirSync(from);
498
+ } catch { /* best-effort */ }
499
+ } catch (err) {
500
+ failures.push({ kind: 'merge', sub, name: ent, message: err.message });
501
+ }
502
+ continue;
503
+ }
504
+
505
+ let to = canonical;
355
506
  if (fs.existsSync(to)) {
356
507
  to = path.join(pool, sub, `${ent}__legacy`);
357
508
  let i = 2;
@@ -417,6 +568,211 @@ function migrateLegacyPool({ log = () => {} } = {}) {
417
568
  return { migrated: true, moves, relinked, failures };
418
569
  }
419
570
 
571
+ // Strip the __legacy / __legacy_N suffix(es) added by the v0.4 migration's
572
+ // collision handler. Repeated migrations could compound suffixes (e.g.
573
+ // `foo__legacy__legacy`, `foo__legacy_2`), so peel until stable.
574
+ function stripLegacySuffix(name) {
575
+ let s = name;
576
+ while (true) {
577
+ const m = s.match(/^(.+?)__legacy(?:_\d+)?$/);
578
+ if (!m) return s;
579
+ s = m[1];
580
+ }
581
+ }
582
+
583
+ // Find an entry in `entries` whose name matches `target` — exact match first,
584
+ // case-insensitive fallback on Windows (NTFS is case-insensitive by default).
585
+ function findSibling(entries, target) {
586
+ if (entries.includes(target)) return target;
587
+ if (process.platform === 'win32') {
588
+ const lower = target.toLowerCase();
589
+ for (const e of entries) {
590
+ if (e.toLowerCase() === lower) return e;
591
+ }
592
+ }
593
+ return null;
594
+ }
595
+
596
+ // Heal projects/<base>__legacy* folders left by older migrations. These were
597
+ // created when the v0.4 migration collision-renamed a `projects/<base>`
598
+ // folder, which orphans its sessions from `claude --resume` (filters by
599
+ // cwd-encoded folder name).
600
+ //
601
+ // For each `projects/<base>__legacy*`:
602
+ // - if a canonical sibling `projects/<base>` exists, merge .jsonl files and
603
+ // sub-uuid dirs into it (per-file __legacy suffix on rare collisions);
604
+ // - otherwise, rename the legacy folder to its canonical name in place.
605
+ //
606
+ // Returns { healed: [{ from, to, items, renamed }], failures: [...] }
607
+ function healProjectsLegacy({ log = () => {} } = {}) {
608
+ const projectsDir = path.join(poolDir(), 'projects');
609
+ if (!fs.existsSync(projectsDir)) {
610
+ return { healed: [], failures: [] };
611
+ }
612
+ let entries;
613
+ try {
614
+ entries = fs.readdirSync(projectsDir);
615
+ } catch (err) {
616
+ return { healed: [], failures: [{ kind: 'readdir', message: err.message }] };
617
+ }
618
+ const legacyEntries = entries.filter((e) => /__legacy(?:_\d+)?$/.test(e));
619
+ const healed = [];
620
+ const failures = [];
621
+
622
+ for (const ent of legacyEntries) {
623
+ const base = stripLegacySuffix(ent);
624
+ const from = path.join(projectsDir, ent);
625
+ if (!fs.statSync(from).isDirectory()) continue;
626
+ const canonical = findSibling(entries, base);
627
+ if (canonical) {
628
+ const to = path.join(projectsDir, canonical);
629
+ try {
630
+ const r = mergeFolderInto(from, to, { log });
631
+ for (const f of r.failures) {
632
+ failures.push({ kind: 'move', name: `${ent}/${f.name}`, message: f.message });
633
+ }
634
+ try {
635
+ if (fs.readdirSync(from).length === 0) {
636
+ fs.rmdirSync(from);
637
+ log(` removed empty ${ent}`);
638
+ }
639
+ } catch { /* best-effort */ }
640
+ healed.push({ from: ent, to: canonical, items: r.moved, renamed: false });
641
+ log(` ${ent} → merged into ${canonical} (${r.moved} item${r.moved === 1 ? '' : 's'})`);
642
+ } catch (err) {
643
+ failures.push({ kind: 'merge', name: ent, message: err.message });
644
+ }
645
+ } else {
646
+ const to = path.join(projectsDir, base);
647
+ try {
648
+ fs.renameSync(from, to);
649
+ entries.push(base);
650
+ healed.push({ from: ent, to: base, items: null, renamed: true });
651
+ log(` ${ent} → renamed to ${base}`);
652
+ } catch (err) {
653
+ failures.push({ kind: 'rename', name: ent, message: err.message });
654
+ }
655
+ }
656
+ }
657
+ return { healed, failures };
658
+ }
659
+
660
+ // Repair pass for pools already damaged by the v0.4.2-and-earlier heal
661
+ // behavior, which appended `__legacy` to UUID-keyed filenames on collision
662
+ // and made them invisible to `claude --resume`. Walks every projects/<X>/
663
+ // folder and, for each `<uuid>__legacy*.jsonl` file (and paired `<uuid>__legacy*`
664
+ // sub-uuid dir), restores a valid `<uuid>.jsonl` filename:
665
+ // - if the canonical `<uuid>.jsonl` is free, strip the suffix;
666
+ // - else reissue a fresh UUID v4.
667
+ //
668
+ // `memory__legacy` and `sessions-index__legacy.json` are also restored when
669
+ // the canonical name is free; otherwise left as-is (they're not parsed by
670
+ // `--resume`, so the harm is cosmetic).
671
+ //
672
+ // Returns { repaired: [{ folder, from, to }], failures: [...] }
673
+ function repairProjectFilenames({ log = () => {} } = {}) {
674
+ const projectsDir = path.join(poolDir(), 'projects');
675
+ if (!fs.existsSync(projectsDir)) {
676
+ return { repaired: [], failures: [] };
677
+ }
678
+ const repaired = [];
679
+ const failures = [];
680
+ let folders;
681
+ try {
682
+ folders = fs.readdirSync(projectsDir);
683
+ } catch (err) {
684
+ return { repaired: [], failures: [{ kind: 'readdir', message: err.message }] };
685
+ }
686
+
687
+ // Match a UUID stem optionally followed by `__legacy(_N)?` suffix(es),
688
+ // optionally followed by `.jsonl`. Captures the UUID stem and whether it's
689
+ // a .jsonl file.
690
+ const damaged = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:__legacy(?:_\d+)?)+(\.jsonl)?$/i;
691
+
692
+ for (const folder of folders) {
693
+ const folderPath = path.join(projectsDir, folder);
694
+ let st;
695
+ try { st = fs.statSync(folderPath); } catch { continue; }
696
+ if (!st.isDirectory()) continue;
697
+ let children;
698
+ try { children = fs.readdirSync(folderPath); } catch { continue; }
699
+
700
+ // Pair damaged file/dir by their exact damaged name (minus `.jsonl`).
701
+ // Different damaged names sharing the same UUID stem are *different*
702
+ // sessions that collided during the prior heal — each needs its own
703
+ // unique restored name. e.g. `<u>__legacy.jsonl` and `<u>__legacy_2.jsonl`
704
+ // both restore to *some* `<uuid>.jsonl`, but not the same one.
705
+ const pairs = new Map(); // damaged-key-without-.jsonl -> { stem, dir?: name, file?: name }
706
+ for (const ent of children) {
707
+ const m = ent.match(damaged);
708
+ if (!m) continue;
709
+ const stem = m[1];
710
+ const isFile = !!m[2];
711
+ const key = isFile ? ent.slice(0, -'.jsonl'.length) : ent;
712
+ if (!pairs.has(key)) pairs.set(key, { stem });
713
+ const p = pairs.get(key);
714
+ if (isFile) p.file = ent; else p.dir = ent;
715
+ }
716
+
717
+ const childSet = new Set(children);
718
+ for (const [, p] of pairs) {
719
+ const canonicalFile = `${p.stem}.jsonl`;
720
+ const dirFree = !childSet.has(p.stem);
721
+ const fileFree = !childSet.has(canonicalFile);
722
+ let newStem = p.stem;
723
+ if (!dirFree || !fileFree) {
724
+ newStem = crypto.randomUUID();
725
+ log(` ${folder}/${p.stem} occupied — reissuing as ${newStem}`);
726
+ }
727
+ if (p.dir) {
728
+ const from = path.join(folderPath, p.dir);
729
+ const to = path.join(folderPath, newStem);
730
+ try {
731
+ fs.renameSync(from, to);
732
+ childSet.delete(p.dir);
733
+ childSet.add(newStem);
734
+ repaired.push({ folder, from: p.dir, to: newStem });
735
+ log(` ${folder}/${p.dir} → ${newStem}`);
736
+ } catch (err) {
737
+ failures.push({ kind: 'rename', folder, name: p.dir, message: err.message });
738
+ }
739
+ }
740
+ if (p.file) {
741
+ const from = path.join(folderPath, p.file);
742
+ const to = path.join(folderPath, `${newStem}.jsonl`);
743
+ try {
744
+ fs.renameSync(from, to);
745
+ childSet.delete(p.file);
746
+ childSet.add(`${newStem}.jsonl`);
747
+ repaired.push({ folder, from: p.file, to: `${newStem}.jsonl` });
748
+ log(` ${folder}/${p.file} → ${newStem}.jsonl`);
749
+ } catch (err) {
750
+ failures.push({ kind: 'rename', folder, name: p.file, message: err.message });
751
+ }
752
+ }
753
+ }
754
+
755
+ // Restore memory/ and sessions-index.json names if the canonical is free.
756
+ for (const [damagedName, canonical] of [
757
+ ['memory__legacy', 'memory'],
758
+ ['sessions-index__legacy.json', 'sessions-index.json'],
759
+ ]) {
760
+ if (childSet.has(damagedName) && !childSet.has(canonical)) {
761
+ try {
762
+ fs.renameSync(path.join(folderPath, damagedName), path.join(folderPath, canonical));
763
+ childSet.delete(damagedName);
764
+ childSet.add(canonical);
765
+ repaired.push({ folder, from: damagedName, to: canonical });
766
+ log(` ${folder}/${damagedName} → ${canonical}`);
767
+ } catch (err) {
768
+ failures.push({ kind: 'rename', folder, name: damagedName, message: err.message });
769
+ }
770
+ }
771
+ }
772
+ }
773
+ return { repaired, failures };
774
+ }
775
+
420
776
  module.exports = {
421
777
  SHARED_RESOURCES,
422
778
  SHARED_DIRS,
@@ -434,4 +790,6 @@ module.exports = {
434
790
  poolDir,
435
791
  ensurePoolResources,
436
792
  migrateLegacyPool,
793
+ healProjectsLegacy,
794
+ repairProjectFilenames,
437
795
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cchost",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Host multiple isolated Claude Code accounts on one machine via the `cc` command.",
5
5
  "keywords": [
6
6
  "claude",
@@ -44,4 +44,4 @@
44
44
  "devDependencies": {
45
45
  "vitest": "^2.1.0"
46
46
  }
47
- }
47
+ }