@series-inc/stowkit-cli 0.1.25 → 0.1.27

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/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { initProject } from './init.js';
9
9
  import { cleanupProject } from './cleanup.js';
10
10
  import { createMaterial } from './create-material.js';
11
11
  import { renameAsset, moveAsset, deleteAsset, setStringId } from './asset-commands.js';
12
+ import { inspectPack } from './inspect.js';
12
13
  const args = process.argv.slice(2);
13
14
  const thisDir = path.dirname(fileURLToPath(import.meta.url));
14
15
  function checkForUpdate() {
@@ -49,6 +50,7 @@ Usage:
49
50
  stowkit move <path> <folder> Move an asset to a different folder
50
51
  stowkit delete <path> Delete an asset and its sidecar files
51
52
  stowkit set-id <path> <id> Change an asset's stringId
53
+ stowkit inspect <file.stow> Show manifest of a built .stow pack
52
54
  stowkit update Update CLI to latest version and refresh skill files
53
55
  stowkit version Show installed version
54
56
  stowkit packer [dir] Open the packer GUI
@@ -208,6 +210,15 @@ async function main() {
208
210
  await setStringId('.', positional[0], positional[1]);
209
211
  break;
210
212
  }
213
+ case 'inspect': {
214
+ const stowPath = args.find(a => !a.startsWith('-') && a !== command);
215
+ if (!stowPath) {
216
+ console.error('Usage: stowkit inspect <file.stow>');
217
+ process.exit(1);
218
+ }
219
+ await inspectPack(stowPath, { verbose });
220
+ break;
221
+ }
211
222
  case 'packer': {
212
223
  const packerDir = resolveAppDir('@series-inc/stowkit-packer-gui', 'stowkit-packer-gui');
213
224
  if (!packerDir) {
@@ -0,0 +1,3 @@
1
+ export declare function inspectPack(filePath: string, opts?: {
2
+ verbose?: boolean;
3
+ }): Promise<void>;
@@ -0,0 +1,165 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import { BinaryReader } from './core/binary.js';
4
+ import { AssetType } from './core/types.js';
5
+ import { STOW_MAGIC, FILE_HEADER_SIZE, STRING_ID_SIZE, } from './core/constants.js';
6
+ import { unwrapMetadata } from './format/metadata.js';
7
+ const ASSET_TYPE_NAMES = {
8
+ [AssetType.Unknown]: 'unknown',
9
+ [AssetType.StaticMesh]: 'staticMesh',
10
+ [AssetType.Texture2D]: 'texture',
11
+ [AssetType.Audio]: 'audio',
12
+ [AssetType.MaterialSchema]: 'material',
13
+ [AssetType.SkinnedMesh]: 'skinnedMesh',
14
+ [AssetType.AnimationClip]: 'animation',
15
+ [AssetType.GlbContainer]: 'glbContainer',
16
+ };
17
+ function formatBytes(bytes) {
18
+ if (bytes < 1024)
19
+ return `${bytes} B`;
20
+ if (bytes < 1024 * 1024)
21
+ return `${(bytes / 1024).toFixed(1)} KB`;
22
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
23
+ }
24
+ /** Read stringId from the start of unwrapped asset metadata based on type. */
25
+ function readStringIdAndDetails(type, meta) {
26
+ const r = new BinaryReader(meta);
27
+ switch (type) {
28
+ case AssetType.Texture2D: {
29
+ const width = r.readUint32();
30
+ const height = r.readUint32();
31
+ const channels = r.readUint32();
32
+ r.readUint32(); // channelFormat
33
+ const stringId = r.readFixedString(STRING_ID_SIZE);
34
+ return { stringId, details: `${width}x${height}, ${channels}ch` };
35
+ }
36
+ case AssetType.Audio: {
37
+ const stringId = r.readFixedString(STRING_ID_SIZE);
38
+ const sampleRate = r.readUint32();
39
+ const channels = r.readUint32();
40
+ const durationMs = r.readUint32();
41
+ const durSec = (durationMs / 1000).toFixed(1);
42
+ return { stringId, details: `${durSec}s, ${sampleRate}Hz, ${channels}ch` };
43
+ }
44
+ case AssetType.StaticMesh: {
45
+ const geoCount = r.readUint32();
46
+ const matCount = r.readUint32();
47
+ const nodeCount = r.readUint32();
48
+ const stringId = r.readFixedString(STRING_ID_SIZE);
49
+ return { stringId, details: `${geoCount} geo, ${matCount} mat, ${nodeCount} nodes` };
50
+ }
51
+ case AssetType.SkinnedMesh: {
52
+ const geoCount = r.readUint32();
53
+ const matCount = r.readUint32();
54
+ const nodeCount = r.readUint32();
55
+ const boneCount = r.readUint32();
56
+ const stringId = r.readFixedString(STRING_ID_SIZE);
57
+ return { stringId, details: `${geoCount} geo, ${boneCount} bones` };
58
+ }
59
+ case AssetType.AnimationClip: {
60
+ const stringId = r.readFixedString(STRING_ID_SIZE);
61
+ r.readFixedString(STRING_ID_SIZE); // targetMeshId
62
+ r.readUint32(); // metadataVersion
63
+ const duration = r.readFloat32();
64
+ const trackCount = r.readUint32();
65
+ return { stringId, details: `${duration.toFixed(2)}s, ${trackCount} tracks` };
66
+ }
67
+ case AssetType.MaterialSchema: {
68
+ const stringId = r.readFixedString(STRING_ID_SIZE);
69
+ const schemaName = r.readFixedString(64);
70
+ const fieldCount = r.readUint32();
71
+ return { stringId, details: `schema: ${schemaName}, ${fieldCount} fields` };
72
+ }
73
+ default:
74
+ return { stringId: '?', details: '' };
75
+ }
76
+ }
77
+ export async function inspectPack(filePath, opts) {
78
+ const absPath = path.resolve(filePath);
79
+ const data = await fs.readFile(absPath);
80
+ const buf = new Uint8Array(data);
81
+ if (buf.length < FILE_HEADER_SIZE) {
82
+ throw new Error(`File too small to be a .stow pack: ${buf.length} bytes`);
83
+ }
84
+ // Read header
85
+ const hr = new BinaryReader(buf);
86
+ const magic = hr.readUint32();
87
+ if (magic !== STOW_MAGIC) {
88
+ throw new Error(`Invalid magic: 0x${magic.toString(16)} (expected 0x${STOW_MAGIC.toString(16)})`);
89
+ }
90
+ const version = hr.readUint32();
91
+ const assetCount = hr.readUint32();
92
+ const dirOffset = hr.readUint64AsNumber();
93
+ // Read directory entries
94
+ const entries = [];
95
+ const dr = new BinaryReader(buf, dirOffset);
96
+ for (let i = 0; i < assetCount; i++) {
97
+ entries.push({
98
+ uid: dr.readUint64(),
99
+ type: dr.readUint32(),
100
+ dataOffset: dr.readUint64AsNumber(),
101
+ dataSize: dr.readUint64AsNumber(),
102
+ metadataOffset: dr.readUint64AsNumber(),
103
+ metadataSize: dr.readUint32(),
104
+ });
105
+ }
106
+ // Parse each asset's metadata
107
+ const assets = [];
108
+ for (const entry of entries) {
109
+ const typeName = ASSET_TYPE_NAMES[entry.type] ?? `type(${entry.type})`;
110
+ let stringId = '?';
111
+ let tags = [];
112
+ let details = '';
113
+ if (entry.metadataOffset > 0 && entry.metadataSize > 0) {
114
+ const rawMeta = buf.slice(entry.metadataOffset, entry.metadataOffset + entry.metadataSize);
115
+ // Try tag-wrapped first; if tagCsvLength looks unreasonable, treat as raw metadata
116
+ let assetMeta;
117
+ try {
118
+ const unwrapped = unwrapMetadata(rawMeta);
119
+ // Sanity check: tagCsvLength should be small (< 4096); if it's huge, it's raw metadata
120
+ const tagCsvLen = new DataView(rawMeta.buffer, rawMeta.byteOffset, 4).getUint32(0, true);
121
+ if (tagCsvLen < 4096 && tagCsvLen < rawMeta.length) {
122
+ tags = unwrapped.tags;
123
+ assetMeta = unwrapped.assetMetadata;
124
+ }
125
+ else {
126
+ assetMeta = rawMeta;
127
+ }
128
+ }
129
+ catch {
130
+ assetMeta = rawMeta;
131
+ }
132
+ try {
133
+ const parsed = readStringIdAndDetails(entry.type, assetMeta);
134
+ stringId = parsed.stringId;
135
+ details = parsed.details;
136
+ }
137
+ catch {
138
+ // Metadata format not recognized — show what we can
139
+ }
140
+ }
141
+ assets.push({ type: typeName, stringId, dataSize: entry.dataSize, tags, details });
142
+ }
143
+ // Sort by type then stringId
144
+ assets.sort((a, b) => a.type.localeCompare(b.type) || a.stringId.localeCompare(b.stringId));
145
+ // Print
146
+ const packName = path.basename(absPath);
147
+ const totalSize = buf.length;
148
+ console.log(`\nPack: ${packName} (${formatBytes(totalSize)}, v${version})`);
149
+ console.log(`Assets: ${assetCount}\n`);
150
+ // Calculate column widths
151
+ const typeWidth = Math.max(4, ...assets.map(a => a.type.length));
152
+ const idWidth = Math.max(8, ...assets.map(a => a.stringId.length));
153
+ for (const a of assets) {
154
+ const type = `[${a.type}]`.padEnd(typeWidth + 2);
155
+ const id = a.stringId.padEnd(idWidth);
156
+ const size = formatBytes(a.dataSize).padStart(10);
157
+ let line = ` ${type} ${id} ${size}`;
158
+ if (opts?.verbose && a.details)
159
+ line += ` ${a.details}`;
160
+ if (a.tags.length > 0 && a.tags[0] !== '')
161
+ line += ` [${a.tags.join(', ')}]`;
162
+ console.log(line);
163
+ }
164
+ console.log('');
165
+ }
@@ -453,22 +453,45 @@ export async function showStatus(projectDir) {
453
453
  console.log(`Meta files: ${scan.metaFiles.length}`);
454
454
  const packs = config.config.packs ?? [{ name: 'default' }];
455
455
  console.log(`Packs: ${packs.map(p => p.name).join(', ')}`);
456
- // Count stale
456
+ // Count stale (including GLB children)
457
457
  let cached = 0;
458
458
  let stale = 0;
459
+ const staleIds = [];
459
460
  for (const file of scan.sourceFiles) {
460
461
  const meta = await readStowmeta(config.srcArtDir, file.relativePath);
461
462
  if (!meta) {
462
463
  stale++;
464
+ staleIds.push(file.relativePath);
463
465
  continue;
464
466
  }
465
467
  const { type, settings } = stowmetaToAssetSettings(meta);
466
468
  if (meta.cache && isCacheValid(meta, file, type, settings)) {
467
469
  cached++;
468
470
  }
469
- else {
471
+ else if (type !== AssetType.GlbContainer) {
470
472
  stale++;
473
+ staleIds.push(file.relativePath);
474
+ }
475
+ // Check GLB children
476
+ if (meta.type === 'glbContainer') {
477
+ const glbMeta = meta;
478
+ for (const child of glbMeta.children ?? []) {
479
+ if (child.childType === 'materialSchema')
480
+ continue;
481
+ const childId = `${file.relativePath}/${child.name}`;
482
+ if (child.cache) {
483
+ cached++;
484
+ }
485
+ else {
486
+ stale++;
487
+ staleIds.push(childId);
488
+ }
489
+ }
471
490
  }
472
491
  }
473
492
  console.log(`Cached: ${cached}, Needs processing: ${stale}`);
493
+ if (staleIds.length > 0) {
494
+ for (const id of staleIds)
495
+ console.log(` ${id}`);
496
+ }
474
497
  }
package/dist/server.js CHANGED
@@ -89,12 +89,18 @@ function queueProcessing(opts = {}) {
89
89
  }
90
90
  }
91
91
  }
92
- // Build the processing queue — process children directly (their source data
93
- // is already in BlobStore from the initial GLB extraction). Only promote to
94
- // parent container when the container itself is a target (re-export, preserveHierarchy change).
92
+ // Build the processing queue.
93
+ // If a GLB container is in the queue, skip its children processGlbContainer
94
+ // handles all child processing internally. Without this, drain workers race:
95
+ // a child worker and the container worker both process the same children,
96
+ // and the container replaces the assets array mid-flight.
97
+ const containerIds = new Set(targets.filter(a => a.type === AssetType.GlbContainer).map(a => a.id));
95
98
  const queue = [];
96
99
  const seen = new Set();
97
100
  for (const a of targets) {
101
+ // Skip children whose parent container is also being processed
102
+ if (a.parentId && containerIds.has(a.parentId))
103
+ continue;
98
104
  if (!seen.has(a.id)) {
99
105
  seen.add(a.id);
100
106
  queue.push(a.id);
@@ -115,6 +121,16 @@ function queueProcessing(opts = {}) {
115
121
  workers.push(drain());
116
122
  }
117
123
  await Promise.all(workers);
124
+ // Diagnostic: log any assets that aren't ready after processing completes
125
+ const notReady = assets.filter(a => a.status !== 'ready' && a.type !== AssetType.MaterialSchema);
126
+ if (notReady.length > 0) {
127
+ console.log(`[server] WARNING: ${notReady.length} asset(s) still not ready after processing:`);
128
+ for (const a of notReady)
129
+ console.log(` ${a.id} → ${a.status}${a.error ? ` (${a.error})` : ''}`);
130
+ }
131
+ else {
132
+ console.log(`[server] All ${assets.length} assets ready.`);
133
+ }
118
134
  broadcast({ type: 'processing-complete' });
119
135
  })();
120
136
  if (opts.await)
@@ -155,7 +171,8 @@ async function expandGlbChildren(srcArtDir, containerId, containerMeta, configur
155
171
  fileName: child.name,
156
172
  stringId: child.stringId || baseName,
157
173
  type: childType,
158
- status: 'pending',
174
+ // Materials are metadata-only — always ready, no processing needed
175
+ status: childType === AssetType.MaterialSchema ? 'ready' : 'pending',
159
176
  settings: childSettings,
160
177
  sourceSize: 0,
161
178
  processedSize: 0,
@@ -263,7 +280,14 @@ async function processGlbContainer(containerId) {
263
280
  const existing = existingChildren.get(anim.name);
264
281
  childrenManifest.push(existing ?? generateDefaultGlbChild(anim.name, 'animationClip'));
265
282
  }
266
- // Update container stowmeta with full inline children
283
+ // Clear old cache stamps on all children — processGlbContainer always re-processes
284
+ // everything (the container was marked pending, so children must be reprocessed too).
285
+ // Without this, expandGlbChildren would restore children from stale cache and skip
286
+ // re-processing (e.g. after a preserveHierarchy toggle).
287
+ for (const child of childrenManifest) {
288
+ delete child.cache;
289
+ }
290
+ // Update container stowmeta with full inline children (cache stamps cleared)
267
291
  if (containerStowmeta) {
268
292
  containerStowmeta.children = childrenManifest;
269
293
  await writeStowmeta(projectConfig.srcArtDir, containerId, containerStowmeta);
@@ -271,7 +295,7 @@ async function processGlbContainer(containerId) {
271
295
  // Remove old child assets from the list
272
296
  const prefix = containerId + '/';
273
297
  assets = assets.filter(a => !a.id.startsWith(prefix));
274
- // Create child assets from inline entries
298
+ // Create child assets from inline entries (all will be 'pending' since cache was cleared)
275
299
  const childAssets = await expandGlbChildren(projectConfig.srcArtDir, containerId, { ...containerStowmeta, children: childrenManifest }, configuredPacks);
276
300
  // Set up material configs for extracted GLB materials (runtime config from inline)
277
301
  for (const mat of extract.materials) {
@@ -352,16 +376,56 @@ async function processGlbContainer(containerId) {
352
376
  }
353
377
  }
354
378
  }
355
- // Persist updated stowmeta (may have auto-assigned materialOverrides)
379
+ // Process texture children (their raw image data is in BlobStore from extraction)
380
+ const pendingTextures = childAssets.filter(a => a.status === 'pending' && a.type === AssetType.Texture2D);
381
+ await Promise.all(pendingTextures.map(child => processOneAsset(child.id)));
382
+ // Write cache blobs and stamps for ALL children processed in this container.
383
+ // Without this, a server restart / rescan would see no cache → children restart as 'pending'.
384
+ const parentSnapshot = await getFileSnapshot(projectConfig.srcArtDir, containerId);
385
+ for (const childAsset of childAssets) {
386
+ if (childAsset.status !== 'ready')
387
+ continue;
388
+ if (childAsset.type === AssetType.MaterialSchema)
389
+ continue;
390
+ const childName = childAsset.id.split('/').pop();
391
+ const inlineChild = childrenManifest.find(c => c.name === childName);
392
+ if (!inlineChild)
393
+ continue;
394
+ // Collect cache blobs for this child
395
+ const cacheEntries = new Map();
396
+ const processed = BlobStore.getProcessed(childAsset.id);
397
+ if (processed)
398
+ cacheEntries.set(childAsset.id, processed);
399
+ if (childAsset.metadata) {
400
+ cacheEntries.set(`${childAsset.id}:__metadata__`, new TextEncoder().encode(JSON.stringify(childAsset.metadata)));
401
+ }
402
+ for (const suffix of [':skinnedMeta', ':animMeta', ':animCount']) {
403
+ const blob = BlobStore.getProcessed(`${childAsset.id}${suffix}`);
404
+ if (blob)
405
+ cacheEntries.set(`${childAsset.id}${suffix}`, blob);
406
+ }
407
+ const animCountBlob = BlobStore.getProcessed(`${childAsset.id}:animCount`);
408
+ const clipCount = animCountBlob ? animCountBlob[0] : 0;
409
+ for (let ci = 0; ci < clipCount; ci++) {
410
+ for (const key of [`${childAsset.id}:anim:${ci}`, `${childAsset.id}:animMeta:${ci}`]) {
411
+ const blob = BlobStore.getProcessed(key);
412
+ if (blob)
413
+ cacheEntries.set(key, blob);
414
+ }
415
+ }
416
+ if (cacheEntries.size > 0) {
417
+ await writeCacheBlobs(projectConfig.srcArtDir, childAsset.id, cacheEntries);
418
+ }
419
+ // Set cache stamp on the inline child entry (uses parent file snapshot)
420
+ if (parentSnapshot) {
421
+ inlineChild.cache = buildCacheStamp(parentSnapshot, childAsset.type, childAsset.settings);
422
+ }
423
+ }
424
+ // Persist updated stowmeta with materialOverrides AND cache stamps
356
425
  if (containerStowmeta) {
357
426
  containerStowmeta.children = childrenManifest;
358
427
  await writeStowmeta(projectConfig.srcArtDir, containerId, containerStowmeta);
359
428
  }
360
- // Queue texture children for processing (their raw image data is in BlobStore)
361
- const pendingTextures = childAssets.filter(a => a.status === 'pending' && a.type === AssetType.Texture2D);
362
- for (const child of pendingTextures) {
363
- processOneAsset(child.id);
364
- }
365
429
  }
366
430
  async function readBody(req) {
367
431
  const chunks = [];
@@ -528,26 +592,21 @@ async function processOneAsset(id) {
528
592
  throw new Error(`Could not read parent GLB: ${asset.parentId}`);
529
593
  parentSource = data;
530
594
  }
531
- const extract = await parseGlb(parentSource);
595
+ // Read parent's preserveHierarchy setting
596
+ const parentMeta = await readStowmeta(projectConfig.srcArtDir, asset.parentId);
597
+ const preserveHierarchy = parentMeta?.preserveHierarchy ?? false;
598
+ const extract = await parseGlb(parentSource, { preserveHierarchy });
532
599
  const childName = id.split('/').pop();
600
+ let childResult = null;
533
601
  if (asset.type === AssetType.Texture2D) {
534
602
  const tex = extract.textures.find(t => t.name === childName);
535
603
  if (!tex)
536
604
  throw new Error(`Texture "${childName}" not found in GLB`);
537
605
  BlobStore.setSource(id, tex.data);
538
- // Process via worker pool
539
606
  const { result, blobs } = await workerPool.processAsset({ id, sourceData: tex.data, type: asset.type, stringId: asset.stringId, settings: asset.settings }, (pid, msg) => broadcast({ type: 'progress', id: pid, message: msg }));
540
607
  for (const [key, data] of blobs)
541
608
  BlobStore.setProcessed(key, data);
542
- if (asset.status !== 'pending') {
543
- asset.status = 'ready';
544
- asset.metadata = result.metadata;
545
- asset.processedSize = result.processedSize;
546
- broadcast({
547
- type: 'asset-update', id,
548
- updates: { status: 'ready', metadata: result.metadata, processedSize: result.processedSize },
549
- });
550
- }
609
+ childResult = result;
551
610
  }
552
611
  else if (asset.type === AssetType.StaticMesh || asset.type === AssetType.SkinnedMesh) {
553
612
  const mesh = extract.meshes.find(m => m.name === childName);
@@ -556,15 +615,7 @@ async function processOneAsset(id) {
556
615
  const { result, blobs } = await workerPool.processExtractedMesh({ childId: id, imported: mesh.imported, hasSkeleton: mesh.hasSkeleton, stringId: asset.stringId, settings: asset.settings }, (pid, msg) => broadcast({ type: 'progress', id: pid, message: msg }));
557
616
  for (const [key, data] of blobs)
558
617
  BlobStore.setProcessed(key, data);
559
- if (asset.status !== 'pending') {
560
- asset.status = 'ready';
561
- asset.metadata = result.metadata;
562
- asset.processedSize = result.processedSize;
563
- broadcast({
564
- type: 'asset-update', id,
565
- updates: { status: 'ready', metadata: result.metadata, processedSize: result.processedSize },
566
- });
567
- }
618
+ childResult = result;
568
619
  }
569
620
  else {
570
621
  // Animation or other — fall back to full container reprocess
@@ -575,7 +626,52 @@ async function processOneAsset(id) {
575
626
  }
576
627
  return;
577
628
  }
578
- console.log(`[server] Re-processed GLB child ${id}`);
629
+ if (childResult && asset.status !== 'pending') {
630
+ asset.status = 'ready';
631
+ asset.metadata = childResult.metadata;
632
+ asset.processedSize = childResult.processedSize;
633
+ broadcast({
634
+ type: 'asset-update', id,
635
+ updates: { status: 'ready', metadata: childResult.metadata, processedSize: childResult.processedSize },
636
+ });
637
+ }
638
+ // Write cache for this child
639
+ const cacheEntries = new Map();
640
+ const processed = BlobStore.getProcessed(id);
641
+ if (processed)
642
+ cacheEntries.set(id, processed);
643
+ if (childResult?.metadata) {
644
+ cacheEntries.set(`${id}:__metadata__`, new TextEncoder().encode(JSON.stringify(childResult.metadata)));
645
+ }
646
+ for (const suffix of [':skinnedMeta', ':animMeta', ':animCount']) {
647
+ const blob = BlobStore.getProcessed(`${id}${suffix}`);
648
+ if (blob)
649
+ cacheEntries.set(`${id}${suffix}`, blob);
650
+ }
651
+ const animCountBlob = BlobStore.getProcessed(`${id}:animCount`);
652
+ const clipCount = animCountBlob ? animCountBlob[0] : 0;
653
+ for (let ci = 0; ci < clipCount; ci++) {
654
+ for (const key of [`${id}:anim:${ci}`, `${id}:animMeta:${ci}`]) {
655
+ const blob = BlobStore.getProcessed(key);
656
+ if (blob)
657
+ cacheEntries.set(key, blob);
658
+ }
659
+ }
660
+ if (cacheEntries.size > 0) {
661
+ await writeCacheBlobs(projectConfig.srcArtDir, id, cacheEntries);
662
+ // Update cache stamp in parent's stowmeta for this child
663
+ if (parentMeta) {
664
+ const inlineChild = parentMeta.children.find(c => c.name === childName);
665
+ if (inlineChild) {
666
+ const parentSnapshot = await getFileSnapshot(projectConfig.srcArtDir, asset.parentId);
667
+ if (parentSnapshot) {
668
+ inlineChild.cache = buildCacheStamp(parentSnapshot, asset.type, asset.settings);
669
+ await writeStowmeta(projectConfig.srcArtDir, asset.parentId, parentMeta);
670
+ }
671
+ }
672
+ }
673
+ }
674
+ console.log(`[server] Re-processed GLB child ${id} (status: ${asset.status}, in assets: ${assets.some(a => a.id === id)})`);
579
675
  }
580
676
  catch (err) {
581
677
  asset.status = 'error';
@@ -1473,10 +1569,12 @@ export async function startServer(opts = {}) {
1473
1569
  }));
1474
1570
  ws.on('close', () => wsClients.delete(ws));
1475
1571
  });
1476
- // Initialize worker pool
1477
- workerPool = initWorkerPool(opts.wasmDir);
1478
- // Open project if specified
1479
- if (opts.projectDir) {
1572
+ // Initialize worker pool (shared across servers in the same process)
1573
+ if (!workerPool) {
1574
+ workerPool = initWorkerPool(opts.wasmDir);
1575
+ }
1576
+ // Open project if specified (skip if already loaded — e.g. stowkit editor starts two servers)
1577
+ if (opts.projectDir && (!projectConfig || projectConfig.projectDir !== opts.projectDir)) {
1480
1578
  await openProject(opts.projectDir);
1481
1579
  queueProcessing();
1482
1580
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@series-inc/stowkit-cli",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "stowkit": "./dist/cli.js"
package/skill.md CHANGED
@@ -50,6 +50,7 @@ stowkit rename <path> <name> # Rename an asset file (preserves extension, upd
50
50
  stowkit move <path> <folder> # Move an asset to a different folder (updates GLB child refs)
51
51
  stowkit delete <path> # Delete an asset and its .stowmeta/.stowcache files
52
52
  stowkit set-id <path> <id> # Change an asset's stringId
53
+ stowkit inspect <file.stow> # Show manifest of a built .stow pack (use -v for details)
53
54
  stowkit packer [dir] # Open the packer GUI in browser
54
55
  stowkit editor [dir] # Open the level editor in browser
55
56
  stowkit serve [dir] # Start API server only (no GUI)