@waica/cli 0.5.0 → 0.6.0

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.
Files changed (58) hide show
  1. package/README.md +42 -37
  2. package/dist/editor/assets/{cssMode-2_8FrcPt.js → cssMode-DfLN2qUF.js} +1 -1
  3. package/dist/editor/assets/{freemarker2-BgSLfjNS.js → freemarker2-BM-wWcXR.js} +1 -1
  4. package/dist/editor/assets/{handlebars-CZIkUhwZ.js → handlebars-C68SV5M9.js} +1 -1
  5. package/dist/editor/assets/{html-B66ooLhD.js → html-dQgLbIki.js} +1 -1
  6. package/dist/editor/assets/{htmlMode-BiIT--Mv.js → htmlMode-Dx91fUdd.js} +1 -1
  7. package/dist/editor/assets/{index-Br-M9Tdo.js → index-592ABIaz.js} +2787 -406
  8. package/dist/editor/assets/{index-q7IkLe-P.css → index-Cy5pmBhN.css} +1 -1
  9. package/dist/editor/assets/{javascript-BZrbbzWQ.js → javascript-BmsSq5ka.js} +1 -1
  10. package/dist/editor/assets/{jsonMode-CABkbZgt.js → jsonMode-CWoPtX2M.js} +1 -1
  11. package/dist/editor/assets/{liquid-CT7pyNYe.js → liquid-DwnJlNJZ.js} +1 -1
  12. package/dist/editor/assets/{mdx-DW7MPqdm.js → mdx-DaLkvlR1.js} +1 -1
  13. package/dist/editor/assets/{python-CgBwEaNf.js → python-Cs5X5zFp.js} +1 -1
  14. package/dist/editor/assets/{razor-Dvggr_Q1.js → razor-PTwWoRk8.js} +1 -1
  15. package/dist/editor/assets/{tsMode-OfEpAJE7.js → tsMode-BqUQdgsR.js} +1 -1
  16. package/dist/editor/assets/{typescript-BQbhlbBK.js → typescript-BsJ7r2RJ.js} +1 -1
  17. package/dist/editor/assets/{xml-5qToUR1k.js → xml-DyyoPey4.js} +1 -1
  18. package/dist/editor/assets/{yaml-CFM1jPNu.js → yaml-Cjqtlp29.js} +1 -1
  19. package/dist/editor/index.html +2 -2
  20. package/dist/mcp/introspection.js +2 -0
  21. package/dist/mcp/node_modules/@waica/archetype-platformer/package.json +3 -3
  22. package/dist/mcp/node_modules/@waica/behaviors/dist/health.d.ts +4 -3
  23. package/dist/mcp/node_modules/@waica/behaviors/dist/health.js +12 -15
  24. package/dist/mcp/node_modules/@waica/behaviors/dist/out-of-bounds.d.ts +1 -0
  25. package/dist/mcp/node_modules/@waica/behaviors/dist/out-of-bounds.js +1 -0
  26. package/dist/mcp/node_modules/@waica/behaviors/package.json +2 -2
  27. package/dist/mcp/node_modules/@waica/engine/README.md +85 -0
  28. package/dist/mcp/node_modules/@waica/engine/dist/component-update-schedule.d.ts +39 -0
  29. package/dist/mcp/node_modules/@waica/engine/dist/component-update-schedule.js +157 -0
  30. package/dist/mcp/node_modules/@waica/engine/dist/component.d.ts +5 -0
  31. package/dist/mcp/node_modules/@waica/engine/dist/component.js +1 -0
  32. package/dist/mcp/node_modules/@waica/engine/dist/components/animated-sprite.d.ts +1 -0
  33. package/dist/mcp/node_modules/@waica/engine/dist/components/animated-sprite.js +1 -0
  34. package/dist/mcp/node_modules/@waica/engine/dist/game.d.ts +7 -0
  35. package/dist/mcp/node_modules/@waica/engine/dist/game.js +79 -1
  36. package/dist/mcp/node_modules/@waica/engine/dist/index.d.ts +7 -1
  37. package/dist/mcp/node_modules/@waica/engine/dist/index.js +3 -0
  38. package/dist/mcp/node_modules/@waica/engine/dist/input.d.ts +10 -0
  39. package/dist/mcp/node_modules/@waica/engine/dist/input.js +40 -2
  40. package/dist/mcp/node_modules/@waica/engine/dist/runtime-bridge.d.ts +69 -0
  41. package/dist/mcp/node_modules/@waica/engine/dist/runtime-bridge.js +105 -0
  42. package/dist/mcp/node_modules/@waica/engine/dist/runtime-inspection.d.ts +71 -0
  43. package/dist/mcp/node_modules/@waica/engine/dist/runtime-inspection.js +279 -0
  44. package/dist/mcp/node_modules/@waica/engine/package.json +3 -2
  45. package/dist/mcp/project-component-loader.js +506 -240
  46. package/dist/mcp/project-component-runner.js +237 -0
  47. package/dist/mcp/runtime-browser.js +336 -0
  48. package/dist/mcp/runtime-dev-server.js +273 -0
  49. package/dist/mcp/runtime-preflight.js +249 -0
  50. package/dist/mcp/runtime-service.js +8 -0
  51. package/dist/mcp/runtime-session-manager.js +265 -0
  52. package/dist/mcp/scaffolds.js +126 -6
  53. package/dist/mcp/server.js +330 -9
  54. package/dist/mcp/stdio.js +0 -25
  55. package/dist/mcp/template/src/main.ts +6 -1
  56. package/dist/mcp/validation.js +77 -6
  57. package/package.json +2 -1
  58. package/dist/mcp/native-import.cjs +0 -3
@@ -1,287 +1,553 @@
1
- import { createHash } from 'node:crypto';
2
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { execFile, fork, } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { existsSync, rmSync } from 'node:fs';
4
+ import { copyFile, cp, mkdir, mkdtemp } from 'node:fs/promises';
3
5
  import * as nodeModule from 'node:module';
6
+ import { tmpdir } from 'node:os';
4
7
  import path from 'node:path';
5
- import { fileURLToPath, pathToFileURL } from 'node:url';
6
- import { classDefaults, objectRecord } from './component-metadata.js';
7
- import { causeText, PackageResolver, projectAnchoredRequire } from './package-resolver.js';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { promisify } from 'node:util';
8
10
  import { directFiles } from './project-path.js';
9
- const { createRequire } = nodeModule;
10
- // Two independent identifiers travel on every module URL this loader hands
11
- // out: PROJECT_PARAM routes a resolve-hook call back to the validation run
12
- // that started it (an ephemeral, in-memory lookup, cleaned up per run), and
13
- // LOAD_PARAM is a content hash that only changes when the file's own bytes
14
- // change. Splitting them is what lets re-validating unchanged files reuse
15
- // Node's module cache instead of pinning a fresh entry on every run (see
16
- // loadProjectComponents below).
17
- const PROJECT_PARAM = 'waica-component-project';
18
- const LOAD_PARAM = 'waica-component-load';
19
- const hookContexts = new Map();
20
- // Loading this tiny CommonJS trampoline through Node keeps Vitest from
21
- // rewriting fixture imports. The build copies the same file byte-for-byte.
22
- const nativeImport = createRequire(import.meta.url)('./native-import.cjs');
11
+ export const PROJECT_COMPONENT_PROTOCOL_VERSION = 1;
12
+ export const PROJECT_COMPONENT_DEADLINE_MS = 5_000;
13
+ export const PROJECT_COMPONENT_DIAGNOSTIC_BYTES = 64 * 1_024;
23
14
  const MODULE_HOOKS_MIN_NODE = '22.15';
24
- /** Pure so it can be exercised directly, independent of the running Node. */
15
+ const REF_KINDS = new Set(['prefab', 'clip', 'action', 'stat']);
16
+ const FALLBACK_PACKAGE_DIRECTORIES = [
17
+ ['@waica/engine', 'engine'],
18
+ ['@waica/behaviors', 'behaviors'],
19
+ ['@waica/archetype-platformer', 'archetype-platformer'],
20
+ ];
21
+ const execFileAsync = promisify(execFile);
22
+ let sourceFallbackEntries;
23
+ export class ProjectComponentRunnerUnavailableError extends Error {
24
+ constructor(message, options) {
25
+ super(message, options);
26
+ this.name = 'ProjectComponentRunnerUnavailableError';
27
+ }
28
+ }
29
+ /** Pure feature probe retained for callers that report the native Node capability. */
25
30
  export function nodeSupportsModuleHooks(moduleApi = nodeModule) {
26
31
  return typeof moduleApi.registerHooks === 'function';
27
32
  }
28
- /**
29
- * `node:module`'s `registerHooks` landed in Node 22.15; this MCP server is
30
- * still expected to start (and serve every other tool) on the older Node
31
- * hosts an agent may be running. A static named import of `registerHooks`
32
- * would fail ESM linking on those hosts before the transport ever connects
33
- * (see workspace-runtime.ts for the same guard on the same API), so this is
34
- * a namespace import feature-checked at runtime instead.
35
- */
36
- const hooksSupported = nodeSupportsModuleHooks();
37
- /** The single finding emitted in place of real component loading on a host too old for module hooks. */
33
+ /** Compatibility diagnostic for hosts below the package's declared Node floor. */
38
34
  export function unsupportedNodeFailure(nodeVersion = process.version) {
39
35
  return {
40
36
  code: 'component-load-unsupported',
41
37
  file: 'src',
42
38
  message: `Deep component validation requires Node >= ${MODULE_HOOKS_MIN_NODE} (node:module registerHooks) ` +
43
- `to run project components, roles and states in-process; this host runs Node ${nodeVersion}. ` +
44
- 'Skipping in-process module loading: component params and instance defaults from project code ' +
45
- 'will not appear in validate_project or list_components until the host upgrades.',
39
+ `to run isolated project-entry children; this host runs Node ${nodeVersion}. ` +
40
+ 'Skipping validate_project component metadata loading until the host upgrades.',
46
41
  };
47
42
  }
48
- function runFromParent(parentURL) {
49
- if (!parentURL)
43
+ function record(value) {
44
+ return value && typeof value === 'object' && !Array.isArray(value)
45
+ ? value
46
+ : undefined;
47
+ }
48
+ function exactKeys(value, required, optional = []) {
49
+ const allowed = new Set([...required, ...optional]);
50
+ return (required.every((key) => Object.hasOwn(value, key)) &&
51
+ Object.keys(value).every((key) => allowed.has(key)));
52
+ }
53
+ function validReady(value) {
54
+ const candidate = record(value);
55
+ return (candidate !== undefined &&
56
+ exactKeys(candidate, ['kind', 'version']) &&
57
+ candidate.kind === 'project-entry-ready' &&
58
+ candidate.version === PROJECT_COMPONENT_PROTOCOL_VERSION);
59
+ }
60
+ function parseParam(value) {
61
+ const candidate = record(value);
62
+ if (!candidate ||
63
+ !exactKeys(candidate, ['name', 'ref', 'hasOptions'], ['default']) ||
64
+ typeof candidate.name !== 'string' ||
65
+ candidate.name.length === 0 ||
66
+ typeof candidate.ref !== 'string' ||
67
+ !REF_KINDS.has(candidate.ref) ||
68
+ typeof candidate.hasOptions !== 'boolean' ||
69
+ (Object.hasOwn(candidate, 'default') && typeof candidate.default !== 'string')) {
50
70
  return undefined;
51
- try {
52
- const token = new URL(parentURL).searchParams.get(PROJECT_PARAM);
53
- const context = token ? hookContexts.get(token) : undefined;
54
- return token && context ? { token, context } : undefined;
55
71
  }
56
- catch {
72
+ return {
73
+ name: candidate.name,
74
+ ref: candidate.ref,
75
+ hasOptions: candidate.hasOptions,
76
+ ...(Object.hasOwn(candidate, 'default')
77
+ ? { default: candidate.default }
78
+ : {}),
79
+ };
80
+ }
81
+ function parseComponent(value, expectedFile) {
82
+ const candidate = record(value);
83
+ if (!candidate ||
84
+ !exactKeys(candidate, [
85
+ 'name',
86
+ 'file',
87
+ 'params',
88
+ 'hasOnUpdate',
89
+ 'hasUpdateAfter',
90
+ 'updateAfter',
91
+ ]) ||
92
+ typeof candidate.name !== 'string' ||
93
+ candidate.name.length === 0 ||
94
+ candidate.file !== expectedFile ||
95
+ !Array.isArray(candidate.params) ||
96
+ typeof candidate.hasOnUpdate !== 'boolean' ||
97
+ typeof candidate.hasUpdateAfter !== 'boolean' ||
98
+ !Array.isArray(candidate.updateAfter) ||
99
+ candidate.updateAfter.some((target) => typeof target !== 'string') ||
100
+ (!candidate.hasUpdateAfter && candidate.updateAfter.length > 0)) {
57
101
  return undefined;
58
102
  }
103
+ const params = candidate.params.map(parseParam);
104
+ if (params.some((param) => param === undefined))
105
+ return undefined;
106
+ return {
107
+ name: candidate.name,
108
+ file: expectedFile,
109
+ params: params,
110
+ hasOnUpdate: candidate.hasOnUpdate,
111
+ hasUpdateAfter: candidate.hasUpdateAfter,
112
+ updateAfter: [...candidate.updateAfter],
113
+ };
59
114
  }
60
- function versioned(url, token, content) {
61
- url.searchParams.set(PROJECT_PARAM, token);
62
- url.searchParams.set(LOAD_PARAM, content);
63
- return url.href;
64
- }
65
- function projectToken(projectPath) {
66
- return createHash('sha1').update(path.resolve(projectPath)).digest('hex').slice(0, 16);
67
- }
68
- /** Content identity for cache-busting: stable across runs, changes only when the file's bytes do. */
69
- function contentToken(filePath) {
70
- try {
71
- return createHash('sha1').update(readFileSync(filePath)).digest('hex').slice(0, 16);
115
+ function parseTerminal(value, token, expectedFile) {
116
+ const candidate = record(value);
117
+ if (!candidate ||
118
+ candidate.kind !== 'project-entry-result' ||
119
+ candidate.version !== PROJECT_COMPONENT_PROTOCOL_VERSION ||
120
+ candidate.token !== token ||
121
+ typeof candidate.ok !== 'boolean') {
122
+ return undefined;
72
123
  }
73
- catch {
74
- // The subsequent import attempt reports the real read/import error; this
75
- // token only needs to exist so versioned() has something to stamp.
76
- return 'unreadable';
124
+ if (candidate.ok) {
125
+ if (!exactKeys(candidate, ['kind', 'version', 'token', 'ok', 'components'])) {
126
+ return undefined;
127
+ }
128
+ if (!Array.isArray(candidate.components))
129
+ return undefined;
130
+ const components = candidate.components.map((row) => parseComponent(row, expectedFile));
131
+ if (components.some((row) => row === undefined))
132
+ return undefined;
133
+ return { ok: true, components: components };
77
134
  }
135
+ if (!exactKeys(candidate, ['kind', 'version', 'token', 'ok', 'code', 'message']) ||
136
+ (candidate.code !== 'component-load-failed' &&
137
+ candidate.code !== 'component-load-unsupported') ||
138
+ typeof candidate.message !== 'string') {
139
+ return undefined;
140
+ }
141
+ return {
142
+ ok: false,
143
+ code: candidate.code,
144
+ message: candidate.message,
145
+ };
78
146
  }
79
- const RELATIVE_EXTENSIONS = ['.ts', '.tsx', '.js'];
80
- /** Mirrors the editor's resolveProjectImport (play-code.ts): direct, direct+ext, direct/index+ext. */
81
- function relativeCandidates(unresolvedPath) {
82
- const candidates = [
83
- ...RELATIVE_EXTENSIONS.map((extension) => `${unresolvedPath}${extension}`),
84
- ...RELATIVE_EXTENSIONS.map((extension) => path.join(unresolvedPath, `index${extension}`)),
85
- ];
86
- if (unresolvedPath.endsWith('.js')) {
87
- const withoutJs = unresolvedPath.slice(0, -'.js'.length);
88
- candidates.unshift(`${withoutJs}.ts`, `${withoutJs}.tsx`);
147
+ function appendTail(current, chunk) {
148
+ const next = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
149
+ if (next.length >= PROJECT_COMPONENT_DIAGNOSTIC_BYTES) {
150
+ return next.subarray(next.length - PROJECT_COMPONENT_DIAGNOSTIC_BYTES);
89
151
  }
90
- return candidates;
152
+ const combined = Buffer.concat([current, next]);
153
+ return combined.length <= PROJECT_COMPONENT_DIAGNOSTIC_BYTES
154
+ ? combined
155
+ : combined.subarray(combined.length - PROJECT_COMPONENT_DIAGNOSTIC_BYTES);
91
156
  }
92
- function resolveRelativeCandidate(unresolvedPath) {
93
- return relativeCandidates(unresolvedPath).find((candidate) => existsSync(candidate));
157
+ function diagnosticMessage(base, stdout, stderr) {
158
+ const diagnostics = [];
159
+ if (stdout.length > 0)
160
+ diagnostics.push(`stdout tail:\n${stdout.toString('utf8')}`);
161
+ if (stderr.length > 0)
162
+ diagnostics.push(`stderr tail:\n${stderr.toString('utf8')}`);
163
+ return diagnostics.length > 0 ? `${base}\n${diagnostics.join('\n')}` : base;
94
164
  }
95
- // The synchronous hook is process-wide, but it is inert for imports that were
96
- // not initiated by this loader. The PROJECT_PARAM token selects the project
97
- // resolver for a given run; nextResolve is always tried first so real Node
98
- // resolution (package "exports" maps, symlinks, literal existing paths) stays
99
- // authoritative, and our TypeScript-project conventions are only a fallback.
100
- if (hooksSupported) {
101
- nodeModule.registerHooks({
102
- resolve(specifier, context, nextResolve) {
103
- const run = runFromParent(context.parentURL);
104
- if (!run)
105
- return nextResolve(specifier, context);
106
- if (specifier.startsWith('@waica/')) {
107
- const bridge = run.context.packageBridges.get(specifier);
108
- if (bridge)
109
- return { url: bridge, shortCircuit: true };
110
- const resolved = run.context.projectRequire.resolve(specifier);
111
- const url = pathToFileURL(resolved);
112
- return {
113
- url: /\.tsx?$/.test(resolved)
114
- ? versioned(url, run.token, contentToken(resolved))
115
- : url.href,
116
- shortCircuit: true,
117
- };
118
- }
119
- if (specifier.startsWith('.')) {
120
- const unresolved = new URL(specifier, context.parentURL);
121
- let resolvedURL;
122
- try {
123
- resolvedURL = new URL(nextResolve(specifier, context).url);
124
- }
125
- catch (error) {
126
- // Directory imports (./lib -> ./lib/index.ts) and multi-dot
127
- // specifiers (./foo.helper -> ./foo.helper.ts) are valid under the
128
- // project's moduleResolution "bundler" and run fine in Vite, but
129
- // Node's default resolver rejects both — fall back to the same
130
- // candidates the editor tries before giving up.
131
- const fallback = resolveRelativeCandidate(fileURLToPath(unresolved));
132
- if (!fallback)
133
- throw error;
134
- resolvedURL = pathToFileURL(fallback);
135
- }
136
- const resolvedPath = fileURLToPath(resolvedURL);
137
- return {
138
- url: versioned(resolvedURL, run.token, contentToken(resolvedPath)),
139
- shortCircuit: true,
140
- };
141
- }
142
- return nextResolve(specifier, context);
143
- },
144
- });
145
- }
146
- function componentDescriptions(module, file) {
147
- const descriptions = {};
148
- for (const value of Object.values(module)) {
149
- if (typeof value !== 'function' || !Object.hasOwn(value, 'componentName'))
150
- continue;
151
- const Class = value;
152
- if (typeof Class.componentName !== 'string' || !Class.componentName)
153
- continue;
154
- descriptions[Class.componentName] = {
155
- file,
156
- params: objectRecord(Class.params),
157
- defaults: classDefaults(Class),
158
- };
165
+ function schedulingAdapter(row) {
166
+ class SchedulingAdapter {
159
167
  }
160
- return descriptions;
161
- }
162
- function errorChain(error) {
163
- const errors = [];
164
- let current = error;
165
- const seen = new Set();
166
- while (current instanceof Error && !seen.has(current)) {
167
- errors.push(current);
168
- seen.add(current);
169
- current = current.cause;
168
+ Object.defineProperty(SchedulingAdapter, 'componentName', { value: row.name });
169
+ if (row.hasUpdateAfter) {
170
+ Object.defineProperty(SchedulingAdapter, 'updateAfter', {
171
+ value: Object.freeze([...row.updateAfter]),
172
+ });
170
173
  }
171
- return errors;
172
- }
173
- function unsupportedByNode(error) {
174
- for (const candidate of errorChain(error)) {
175
- const code = candidate.code;
176
- if (code === 'ERR_UNKNOWN_FILE_EXTENSION' ||
177
- code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX') {
178
- return true;
179
- }
180
- if (/not supported in strip-only mode|unsupported TypeScript syntax|unknown file extension/i.test(candidate.message)) {
181
- return true;
182
- }
174
+ if (row.hasOnUpdate) {
175
+ Object.defineProperty(SchedulingAdapter.prototype, 'onUpdate', {
176
+ value: () => undefined,
177
+ });
183
178
  }
184
- return false;
179
+ return SchedulingAdapter;
185
180
  }
186
- function bridgeModule(token, specifier, module) {
187
- const key = `waica.project-component.${process.pid}.${token}.${specifier}`;
188
- Reflect.set(process, Symbol.for(key), module);
189
- const exports = Object.keys(module).filter((name) => /^[$A-Z_a-z][$\w]*$/.test(name));
190
- const lines = [
191
- `const value = process[Symbol.for(${JSON.stringify(key)})]`,
192
- `if (!value) throw new Error(${JSON.stringify(`Missing package bridge for ${specifier}`)})`,
193
- ];
194
- let index = 0;
195
- for (const name of exports) {
196
- if (name === 'default') {
197
- lines.push('export default value.default');
198
- continue;
199
- }
200
- const binding = `binding${index++}`;
201
- lines.push(`const ${binding} = value[${JSON.stringify(name)}]`, `export { ${binding} as ${name} }`);
181
+ function description(row) {
182
+ const params = {};
183
+ const defaults = {};
184
+ for (const param of row.params) {
185
+ params[param.name] = {
186
+ ref: param.ref,
187
+ ...(param.hasOptions ? { options: [] } : {}),
188
+ };
189
+ if (param.default !== undefined)
190
+ defaults[param.name] = param.default;
202
191
  }
203
192
  return {
204
- key,
205
- url: `data:text/javascript;charset=utf-8,${encodeURIComponent(lines.join('\n'))}`,
193
+ file: row.file,
194
+ Class: schedulingAdapter(row),
195
+ params,
196
+ defaults,
206
197
  };
207
198
  }
208
- const BRIDGED_SPECIFIERS = ['@waica/engine', '@waica/behaviors', '@waica/archetype-platformer'];
209
- async function packageBridges(projectPath, token,
210
- // Defaults to a fresh resolver for standalone callers (e.g. tests); the
211
- // real loadProjectComponents call site reuses validateProject's resolver
212
- // so these packages are not re-resolved from scratch a second time.
213
- resolver = new PackageResolver(projectPath)) {
214
- // @waica/archetype-platformer joins engine/behaviors here (not just the
215
- // separate projectRequire.resolve path below) so a freshly created,
216
- // not-yet-installed project still gets the MCP-bundled copy the same way
217
- // it already does for engine/behaviors, instead of a bare MODULE_NOT_FOUND.
218
- // The three packages are independent, so they load concurrently.
219
- const bridges = await Promise.all(BRIDGED_SPECIFIERS.map(async (specifier) => {
220
- try {
221
- const loaded = await resolver.load(specifier);
222
- // Keyed by the resolved package's own version rather than a per-run
223
- // counter, so re-validating without a dependency bump reuses the exact
224
- // same data: URL — and therefore Node's already-cached module for it —
225
- // instead of pinning a fresh, functionally identical one every run.
226
- const bridge = bridgeModule(`${token}:${loaded.provenance.version}`, specifier, loaded.module);
227
- return { specifier, bridge };
228
- }
229
- catch {
230
- // The importing project file receives the anchored resolver's real error.
231
- return undefined;
199
+ function runnerFromModule() {
200
+ const javascript = fileURLToPath(new URL('./project-component-runner.js', import.meta.url));
201
+ if (existsSync(javascript))
202
+ return javascript;
203
+ const typescript = fileURLToPath(new URL('./project-component-runner.ts', import.meta.url));
204
+ if (existsSync(typescript))
205
+ return typescript;
206
+ throw new ProjectComponentRunnerUnavailableError(`Project component runner is missing beside ${fileURLToPath(import.meta.url)}.`);
207
+ }
208
+ function checkoutPackagesRoot() {
209
+ const candidate = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
210
+ return FALLBACK_PACKAGE_DIRECTORIES.every(([, directory]) => existsSync(path.join(candidate, directory, 'package.json')))
211
+ ? candidate
212
+ : undefined;
213
+ }
214
+ async function compileSourceFallbacks(packagesRoot) {
215
+ const repositoryRoot = path.dirname(packagesRoot);
216
+ const requireFromLoader = nodeModule.createRequire(import.meta.url);
217
+ const typescriptRoot = path.dirname(requireFromLoader.resolve('typescript/package.json'));
218
+ const compiler = path.join(typescriptRoot, 'bin', 'tsc');
219
+ const outputRoot = await mkdtemp(path.join(tmpdir(), 'waica-mcp-fallback-'));
220
+ try {
221
+ await Promise.all(FALLBACK_PACKAGE_DIRECTORIES.map(async ([, directory]) => {
222
+ const sourceRoot = path.join(packagesRoot, directory);
223
+ const destinationRoot = path.join(outputRoot, directory);
224
+ await mkdir(destinationRoot, { recursive: true });
225
+ await Promise.all([
226
+ execFileAsync(process.execPath, [
227
+ compiler,
228
+ '-p',
229
+ path.join(sourceRoot, 'tsconfig.build.json'),
230
+ '--outDir',
231
+ path.join(destinationRoot, 'dist'),
232
+ '--declaration',
233
+ 'false',
234
+ ], { cwd: repositoryRoot }),
235
+ copyFile(path.join(sourceRoot, 'package.json'), path.join(destinationRoot, 'package.json')),
236
+ ...(existsSync(path.join(sourceRoot, 'assets'))
237
+ ? [
238
+ cp(path.join(sourceRoot, 'assets'), path.join(destinationRoot, 'assets'), {
239
+ recursive: true,
240
+ }),
241
+ ]
242
+ : []),
243
+ ]);
244
+ }));
245
+ const entries = Object.fromEntries(FALLBACK_PACKAGE_DIRECTORIES.map(([specifier, directory]) => [
246
+ specifier,
247
+ path.join(outputRoot, directory, 'dist', 'index.js'),
248
+ ]));
249
+ entries.three = nodeModule
250
+ .createRequire(path.join(packagesRoot, 'engine', 'package.json'))
251
+ .resolve('three');
252
+ if (Object.values(entries).some((entry) => !existsSync(entry))) {
253
+ throw new Error('TypeScript fallback compilation did not emit every package entry.');
232
254
  }
233
- }));
234
- const urls = new Map();
235
- const keys = [];
236
- for (const entry of bridges) {
237
- if (!entry)
238
- continue;
239
- urls.set(entry.specifier, entry.bridge.url);
240
- keys.push(entry.bridge.key);
255
+ process.once('exit', () => rmSync(outputRoot, { recursive: true, force: true }));
256
+ return entries;
257
+ }
258
+ catch (error) {
259
+ rmSync(outputRoot, { recursive: true, force: true });
260
+ throw error;
241
261
  }
242
- return { urls, keys };
262
+ }
263
+ async function fallbackEntriesFor(runnerPath) {
264
+ const packagesRoot = checkoutPackagesRoot();
265
+ if (!packagesRoot)
266
+ return {};
267
+ const built = Object.fromEntries(FALLBACK_PACKAGE_DIRECTORIES.map(([specifier, directory]) => [
268
+ specifier,
269
+ path.join(packagesRoot, directory, 'dist', 'index.js'),
270
+ ]));
271
+ built.three = nodeModule
272
+ .createRequire(path.join(packagesRoot, 'engine', 'package.json'))
273
+ .resolve('three');
274
+ if (Object.values(built).every((entry) => existsSync(entry)))
275
+ return built;
276
+ if (!runnerPath.endsWith('.ts'))
277
+ return {};
278
+ sourceFallbackEntries ??= compileSourceFallbacks(packagesRoot);
279
+ return sourceFallbackEntries;
243
280
  }
244
281
  async function projectModuleFiles(projectPath) {
245
282
  const groups = await Promise.all(['components', 'roles', 'states'].map(async (directory) => (await directFiles(path.join(projectPath, 'src', directory), '.ts')).map((file) => `src/${directory}/${file}`)));
246
283
  return groups.flat();
247
284
  }
248
- /**
249
- * Executes every project-owned component/role/state module independently.
250
- * Failures are returned as data so one bad file cannot abort validation.
251
- */
252
- export async function loadProjectComponents(projectPath, resolver) {
253
- if (!hooksSupported) {
254
- return { components: {}, failures: [unsupportedNodeFailure()] };
285
+ function cancellationReason(signal) {
286
+ return signal.reason ?? new DOMException('The operation was aborted.', 'AbortError');
287
+ }
288
+ function unavailable(message, cause) {
289
+ return new ProjectComponentRunnerUnavailableError(message, cause === undefined ? undefined : { cause });
290
+ }
291
+ export class ProjectComponentLoader {
292
+ active = new Set();
293
+ defaults;
294
+ closed = false;
295
+ constructor(options = {}) {
296
+ this.defaults = options;
255
297
  }
256
- const token = projectToken(projectPath);
257
- const bridges = await packageBridges(projectPath, token, resolver);
258
- hookContexts.set(token, {
259
- projectRequire: projectAnchoredRequire(projectPath),
260
- packageBridges: bridges.urls,
261
- });
262
- const components = {};
263
- const failures = [];
264
- try {
265
- for (const relative of await projectModuleFiles(projectPath)) {
266
- const file = path.join(projectPath, relative);
267
- try {
268
- const entryHref = versioned(pathToFileURL(file), token, contentToken(file));
269
- const module = await nativeImport(entryHref);
270
- Object.assign(components, componentDescriptions(module, relative));
298
+ async load(projectPath, _resolver, options = {}) {
299
+ this.assertOpen();
300
+ options.signal?.throwIfAborted();
301
+ const files = await projectModuleFiles(projectPath);
302
+ this.assertOpen();
303
+ options.signal?.throwIfAborted();
304
+ if (files.length === 0)
305
+ return { components: {}, failures: [] };
306
+ const runnerPath = options.runnerPath ?? this.defaults.runnerPath ?? runnerFromModule();
307
+ if (!existsSync(runnerPath)) {
308
+ throw unavailable(`Project component runner is missing: ${runnerPath}`);
309
+ }
310
+ const deadlineMs = options.deadlineMs ?? this.defaults.deadlineMs ?? PROJECT_COMPONENT_DEADLINE_MS;
311
+ let fallbackEntries;
312
+ try {
313
+ fallbackEntries = await fallbackEntriesFor(runnerPath);
314
+ }
315
+ catch (error) {
316
+ throw unavailable('Cannot prepare source fallback packages for the project component runner.', error);
317
+ }
318
+ this.assertOpen();
319
+ options.signal?.throwIfAborted();
320
+ const components = {};
321
+ const failures = [];
322
+ for (const relativeFile of files) {
323
+ this.assertOpen();
324
+ options.signal?.throwIfAborted();
325
+ const outcome = await this.runEntry({
326
+ projectPath,
327
+ relativeFile,
328
+ runnerPath,
329
+ deadlineMs,
330
+ fallbackEntries,
331
+ signal: options.signal,
332
+ });
333
+ if ('failure' in outcome) {
334
+ failures.push(outcome.failure);
271
335
  }
272
- catch (error) {
273
- failures.push({
274
- code: unsupportedByNode(error) ? 'component-load-unsupported' : 'component-load-failed',
275
- file: relative,
276
- message: causeText(error),
277
- });
336
+ else {
337
+ for (const row of outcome.components)
338
+ components[row.name] = description(row);
278
339
  }
279
340
  }
341
+ return { components, failures };
280
342
  }
281
- finally {
282
- hookContexts.delete(token);
283
- for (const key of bridges.keys)
284
- Reflect.deleteProperty(process, Symbol.for(key));
343
+ assertOpen() {
344
+ if (this.closed) {
345
+ throw unavailable('Project component runner is unavailable because its owner is closed.');
346
+ }
347
+ }
348
+ async close() {
349
+ if (this.closed && this.active.size === 0)
350
+ return;
351
+ this.closed = true;
352
+ const reason = new DOMException('The MCP server is closing.', 'AbortError');
353
+ await Promise.all([...this.active].map((execution) => execution.terminate(reason)));
285
354
  }
286
- return { components, failures };
355
+ runEntry(input) {
356
+ const token = randomUUID();
357
+ let child;
358
+ try {
359
+ child = (this.defaults.launcher ?? fork)(input.runnerPath, [], {
360
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
361
+ serialization: 'json',
362
+ execArgv: [],
363
+ });
364
+ }
365
+ catch (error) {
366
+ return Promise.reject(unavailable(`Cannot launch project component runner ${input.runnerPath}.`, error));
367
+ }
368
+ let stdout = Buffer.alloc(0);
369
+ let stderr = Buffer.alloc(0);
370
+ let ready = false;
371
+ let terminalCount = 0;
372
+ let terminal;
373
+ let protocolFailure;
374
+ let infrastructureFailure;
375
+ let timeout = false;
376
+ let aborted;
377
+ let terminationFailure;
378
+ let closeObservedResolve;
379
+ const closeObserved = new Promise((resolve) => {
380
+ closeObservedResolve = resolve;
381
+ });
382
+ const forceTerminate = () => {
383
+ if (child.exitCode !== null || child.signalCode !== null)
384
+ return;
385
+ try {
386
+ if (!child.kill('SIGKILL')) {
387
+ terminationFailure ??= new Error(`Could not force-terminate validation child ${child.pid ?? '(unknown pid)'}.`);
388
+ }
389
+ }
390
+ catch (error) {
391
+ terminationFailure ??= error instanceof Error ? error : new Error(String(error));
392
+ }
393
+ };
394
+ const execution = {
395
+ terminate: async (reason) => {
396
+ aborted ??= reason;
397
+ forceTerminate();
398
+ await closeObserved;
399
+ if (terminationFailure)
400
+ throw terminationFailure;
401
+ },
402
+ };
403
+ this.active.add(execution);
404
+ return new Promise((resolve, reject) => {
405
+ const clear = () => {
406
+ clearTimeout(timer);
407
+ input.signal?.removeEventListener('abort', onAbort);
408
+ this.active.delete(execution);
409
+ closeObservedResolve();
410
+ };
411
+ const onAbort = () => {
412
+ aborted ??= cancellationReason(input.signal);
413
+ forceTerminate();
414
+ };
415
+ if (input.signal)
416
+ input.signal.addEventListener('abort', onAbort, { once: true });
417
+ child.stdout?.on('data', (chunk) => {
418
+ stdout = appendTail(stdout, chunk);
419
+ });
420
+ child.stderr?.on('data', (chunk) => {
421
+ stderr = appendTail(stderr, chunk);
422
+ });
423
+ child.on('error', (error) => {
424
+ infrastructureFailure ??= unavailable(`Project component runner failed to launch for ${input.relativeFile}.`, error);
425
+ forceTerminate();
426
+ });
427
+ child.on('message', (message) => {
428
+ if (!ready) {
429
+ if (!validReady(message)) {
430
+ infrastructureFailure ??= unavailable(`Project component runner handshake is incompatible for ${input.relativeFile}.`);
431
+ forceTerminate();
432
+ return;
433
+ }
434
+ ready = true;
435
+ child.send({
436
+ kind: 'load-project-entry',
437
+ version: PROJECT_COMPONENT_PROTOCOL_VERSION,
438
+ token,
439
+ projectPath: input.projectPath,
440
+ entryFile: path.join(input.projectPath, input.relativeFile),
441
+ relativeFile: input.relativeFile,
442
+ fallbackEntries: input.fallbackEntries,
443
+ }, (error) => {
444
+ if (!error)
445
+ return;
446
+ infrastructureFailure ??= unavailable(`Cannot send a request to the project component runner for ${input.relativeFile}.`, error);
447
+ forceTerminate();
448
+ });
449
+ return;
450
+ }
451
+ const candidate = record(message);
452
+ if (candidate?.kind === 'project-entry-result')
453
+ terminalCount += 1;
454
+ if (terminalCount > 1) {
455
+ protocolFailure = 'Runner sent more than one terminal message.';
456
+ forceTerminate();
457
+ return;
458
+ }
459
+ const parsed = parseTerminal(message, token, input.relativeFile);
460
+ if (!parsed) {
461
+ protocolFailure = 'Runner returned a malformed or unbound terminal payload.';
462
+ forceTerminate();
463
+ return;
464
+ }
465
+ terminal = parsed;
466
+ });
467
+ child.once('close', (code, signal) => {
468
+ clear();
469
+ if (aborted !== undefined) {
470
+ reject(aborted);
471
+ return;
472
+ }
473
+ if (terminationFailure) {
474
+ reject(terminationFailure);
475
+ return;
476
+ }
477
+ if (infrastructureFailure) {
478
+ reject(infrastructureFailure);
479
+ return;
480
+ }
481
+ if (!ready) {
482
+ reject(unavailable(`Project component runner exited before its handshake for ${input.relativeFile}.`));
483
+ return;
484
+ }
485
+ if (timeout) {
486
+ resolve({
487
+ failure: {
488
+ code: 'component-load-failed',
489
+ file: input.relativeFile,
490
+ message: diagnosticMessage(`Timed out after ${input.deadlineMs} ms; the direct child was force-terminated and its close was observed.`, stdout, stderr),
491
+ },
492
+ });
493
+ return;
494
+ }
495
+ if (protocolFailure || terminalCount !== 1 || !terminal) {
496
+ const outcome = signal
497
+ ? `signal ${signal}`
498
+ : code === null
499
+ ? 'without an exit status'
500
+ : `exit ${code}`;
501
+ resolve({
502
+ failure: {
503
+ code: 'component-load-failed',
504
+ file: input.relativeFile,
505
+ message: diagnosticMessage(protocolFailure ?? `Child ended with ${outcome} before one terminal payload.`, stdout, stderr),
506
+ },
507
+ });
508
+ return;
509
+ }
510
+ if (signal || code !== 0) {
511
+ resolve({
512
+ failure: {
513
+ code: 'component-load-failed',
514
+ file: input.relativeFile,
515
+ message: diagnosticMessage(signal
516
+ ? `Child exited with signal ${signal} after returning metadata.`
517
+ : `Child exited with status ${code} after returning metadata.`, stdout, stderr),
518
+ },
519
+ });
520
+ return;
521
+ }
522
+ if (!terminal.ok) {
523
+ resolve({
524
+ failure: {
525
+ code: terminal.code,
526
+ file: input.relativeFile,
527
+ message: diagnosticMessage(terminal.message, stdout, stderr),
528
+ },
529
+ });
530
+ return;
531
+ }
532
+ resolve({ components: terminal.components });
533
+ });
534
+ const timer = setTimeout(() => {
535
+ if (!ready) {
536
+ infrastructureFailure ??= unavailable(`Project component runner did not complete its handshake within ${input.deadlineMs} ms for ${input.relativeFile}.`);
537
+ }
538
+ else {
539
+ timeout = true;
540
+ }
541
+ forceTerminate();
542
+ }, input.deadlineMs);
543
+ });
544
+ }
545
+ }
546
+ const defaultLoader = new ProjectComponentLoader();
547
+ /**
548
+ * Executes every direct Project component, role and state entry in its own
549
+ * short-lived child. File-attributable failures remain result data.
550
+ */
551
+ export function loadProjectComponents(projectPath, resolver, options) {
552
+ return defaultLoader.load(projectPath, resolver, options);
287
553
  }