@staix/agent-console 0.0.0-stage → 0.1.1

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 (43) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +354 -2
  4. package/bin/acon.js +8 -0
  5. package/package.json +38 -4
  6. package/shell/asset-viewer.css +5 -0
  7. package/shell/asset-viewer.html +11 -0
  8. package/shell/asset-viewer.js +23 -0
  9. package/shell/icon.svg +1 -0
  10. package/shell/index.html +53 -0
  11. package/shell/shell.css +534 -0
  12. package/shell/shell.js +2214 -0
  13. package/shell/space-view.js +164 -0
  14. package/shell/space.js +241 -0
  15. package/shell/space3d.js +364 -0
  16. package/shell/static.js +111 -0
  17. package/shell/vendor/BufferGeometryUtils.js +1523 -0
  18. package/shell/vendor/GLTFLoader.js +5006 -0
  19. package/shell/vendor/NOTICE +30 -0
  20. package/shell/vendor/SkeletonUtils.js +496 -0
  21. package/shell/vendor/manifest.json +42 -0
  22. package/shell/vendor/mermaid/NOTICE +25 -0
  23. package/shell/vendor/mermaid/manifest.json +19 -0
  24. package/shell/vendor/mermaid/mermaid.js +3580 -0
  25. package/shell/vendor/three.core.js +61162 -0
  26. package/shell/vendor/three.module.js +19537 -0
  27. package/shell/visual-frame.css +5 -0
  28. package/shell/visual-frame.html +14 -0
  29. package/shell/visual-frame.js +54 -0
  30. package/src/assets.ts +200 -0
  31. package/src/canonical.ts +16 -0
  32. package/src/cli.ts +246 -0
  33. package/src/conformance.ts +299 -0
  34. package/src/contract.ts +276 -0
  35. package/src/install.ts +248 -0
  36. package/src/pets.ts +63 -0
  37. package/src/plugins.ts +357 -0
  38. package/src/serve.ts +184 -0
  39. package/src/server.ts +462 -0
  40. package/src/setup.ts +74 -0
  41. package/src/space.ts +106 -0
  42. package/src/static.ts +102 -0
  43. package/test/fixtures/review/cases.json +726 -0
@@ -0,0 +1,364 @@
1
+ // Loaded only after the browser's user chooses 3D. All assets stay on this origin.
2
+ import * as THREE from "./vendor/three.module.js";
3
+ import { GLTFLoader } from "./vendor/GLTFLoader.js";
4
+
5
+ const UNIT = 100;
6
+ const rows = { idle: [0, 6], working: [7, 6], waiting: [6, 6], blocked: [5, 8], away: [0, 6] };
7
+ const hash = (text) => [...text].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 0);
8
+ const radians = (degrees) => degrees * Math.PI / 180;
9
+
10
+ /** The stock loader fetches blob URLs for embedded images. Decode bytes directly under the shell CSP. */
11
+ function embeddedImages(parser) {
12
+ const cache = new Map();
13
+ return { name: "ACON_embedded_images", beforeRoot() {
14
+ parser.loadImageSource = (index) => {
15
+ if (!cache.has(index)) cache.set(index, (async () => {
16
+ const image = parser.json.images[index];
17
+ let blob;
18
+ if (image.bufferView !== undefined) {
19
+ blob = new Blob([await parser.getDependency("bufferView", image.bufferView)], { type: image.mimeType });
20
+ } else if (typeof image.uri === "string" && /^data:image\/(png|jpeg|webp);base64,/.test(image.uri)) {
21
+ const [header, body] = image.uri.split(",", 2);
22
+ const bytes = Uint8Array.from(atob(body), (c) => c.charCodeAt(0));
23
+ blob = new Blob([bytes], { type: header.slice(5, header.indexOf(";")) });
24
+ } else throw new Error("Model images must be embedded");
25
+ const bitmap = await createImageBitmap(blob, { imageOrientation: "none", premultiplyAlpha: "none" });
26
+ const texture = new THREE.Texture(bitmap);
27
+ texture.needsUpdate = true;
28
+ return texture;
29
+ })());
30
+ return cache.get(index).then((texture) => texture.clone());
31
+ };
32
+ } };
33
+ }
34
+
35
+ /** One scheduled draw per invalidation, with only a bounded factual-state sprite burst. */
36
+ export async function createSpace3D({ mount, onFallback, onProjection, onCamera }) {
37
+ const canvas = document.createElement("canvas");
38
+ canvas.className = "space-canvas";
39
+ canvas.setAttribute("aria-hidden", "true");
40
+ const context = canvas.getContext("webgl2", { alpha: false, antialias: true });
41
+ if (!context) throw new Error("WebGL2 is unavailable");
42
+ const renderer = new THREE.WebGLRenderer({ canvas, context, antialias: true });
43
+ renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2));
44
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
45
+ mount.append(canvas);
46
+ const scene = new THREE.Scene();
47
+ const camera = new THREE.OrthographicCamera(-5, 5, 5, -5, 0.01, 1000);
48
+ const loader = new GLTFLoader();
49
+ loader.register(embeddedImages);
50
+ const aborter = new AbortController();
51
+ const textures = new Map(), models = new Map(), globalResources = new Set();
52
+ let transientResources = [], contents = new THREE.Group();
53
+ scene.add(contents);
54
+ let state = null, generation = 0, dead = false, frame = 0, count = 0, signature = "";
55
+ let readiness = Promise.resolve(), burstUntil = 0, spriteFrames = [], projection = { actors: [], cards: [] };
56
+ let modelCount = 0, boxCount = 0, modelBoxes = [], spriteBounds = [], measured = { modelMinPx: 0, spritePx: 0 };
57
+ let backingTexture = null;
58
+ /** A soft disc behind each pet: enough contrast on dark floors without a square sticker. */
59
+ function backing() {
60
+ if (backingTexture) return backingTexture;
61
+ const disc = document.createElement("canvas"); disc.width = disc.height = 64;
62
+ const g = disc.getContext("2d"), gradient = g.createRadialGradient(32, 32, 8, 32, 32, 31);
63
+ gradient.addColorStop(0, "rgba(255,255,255,0.85)"); gradient.addColorStop(0.7, "rgba(255,255,255,0.55)"); gradient.addColorStop(1, "rgba(255,255,255,0)");
64
+ g.fillStyle = gradient; g.beginPath(); g.arc(32, 32, 31, 0, Math.PI * 2); g.fill();
65
+ backingTexture = shared(new THREE.CanvasTexture(disc));
66
+ return backingTexture;
67
+ }
68
+ let cameraState = { yaw: 45, pitch: 60, zoom: 1 };
69
+ const reduced = matchMedia("(prefers-reduced-motion: reduce)");
70
+ const anchors = { actors: [], cards: [] };
71
+ const own = (resource) => { transientResources.push(resource); return resource; };
72
+ const shared = (resource) => { globalResources.add(resource); return resource; };
73
+ function instrumentation() {
74
+ mount.dataset.renderCount = String(count); mount.dataset.pendingFrames = String(frame ? 1 : 0);
75
+ mount.dataset.modelCount = String(modelCount); mount.dataset.boxCount = String(boxCount);
76
+ mount.dataset.modelMinPx = String(Math.round(measured.modelMinPx)); mount.dataset.spritePx = String(Math.round(measured.spritePx));
77
+ }
78
+ const request = () => { if (!dead && !frame) { frame = requestAnimationFrame(draw); instrumentation(); } };
79
+ const fail = () => {
80
+ if (dead) return;
81
+ burstUntil = 0;
82
+ if (frame) cancelAnimationFrame(frame);
83
+ frame = 0; instrumentation();
84
+ onFallback?.("3D is unavailable. Showing Room instead.");
85
+ };
86
+ const lost = (event) => { event.preventDefault(); fail(); };
87
+ canvas.addEventListener("webglcontextlost", lost);
88
+
89
+ async function bytes(url) {
90
+ const response = await fetch(url, { signal: aborter.signal });
91
+ if (!response.ok) throw new Error("A space asset could not load");
92
+ return response;
93
+ }
94
+ function texture(url) {
95
+ if (!textures.has(url)) textures.set(url, (async () => {
96
+ const response = await bytes(url);
97
+ const bitmap = await createImageBitmap(await response.blob(), { imageOrientation: "flipY" });
98
+ if (dead) { bitmap.close(); return null; }
99
+ const value = shared(new THREE.Texture(bitmap));
100
+ value.colorSpace = THREE.SRGBColorSpace;
101
+ value.needsUpdate = true;
102
+ return value;
103
+ })().catch(() => null));
104
+ return textures.get(url);
105
+ }
106
+ function model(name, pack) {
107
+ const url = `/models/${pack.id}/${name}`;
108
+ if (!models.has(url)) models.set(url, (async () => {
109
+ const response = await bytes(url);
110
+ const gltf = await loader.parseAsync(await response.arrayBuffer(), "");
111
+ gltf.scene.traverse((node) => {
112
+ if (node.geometry) shared(node.geometry);
113
+ for (const material of node.material ? (Array.isArray(node.material) ? node.material : [node.material]) : []) {
114
+ shared(material);
115
+ for (const value of Object.values(material)) if (value?.isTexture) shared(value);
116
+ }
117
+ });
118
+ if (dead) { disposeShared(); return null; }
119
+ return gltf.scene;
120
+ })().catch(() => null));
121
+ return models.get(url);
122
+ }
123
+ function disposeShared() {
124
+ const bitmaps = new Set();
125
+ for (const resource of globalResources) {
126
+ if (resource.isTexture && resource.image?.close) bitmaps.add(resource.image);
127
+ resource.dispose();
128
+ }
129
+ for (const bitmap of bitmaps) bitmap.close();
130
+ globalResources.clear();
131
+ }
132
+ function reset() {
133
+ scene.remove(contents);
134
+ for (const resource of transientResources) resource.dispose();
135
+ transientResources = [];
136
+ contents = new THREE.Group(); scene.add(contents);
137
+ spriteFrames = []; anchors.actors = []; anchors.cards = []; modelBoxes = []; spriteBounds = [];
138
+ modelCount = 0; boxCount = 0;
139
+ }
140
+ function material(parameters) { return own(new THREE.MeshStandardMaterial(parameters)); }
141
+ function box(width, height, depth, color, map) {
142
+ const mesh = new THREE.Mesh(own(new THREE.BoxGeometry(width, height, depth)), material({ color, ...(map ? { map } : {}), roughness: 0.85 }));
143
+ contents.add(mesh); return mesh;
144
+ }
145
+ function world(position, width, height, elevation = 0) {
146
+ return new THREE.Vector3((position.x + width / 2 - state.layout.width / 2) / UNIT, elevation, (position.y + height / 2 - state.layout.height / 2) / UNIT);
147
+ }
148
+ function furniture(name, location, dimensions, resources) {
149
+ const asset = resources.models.get(name);
150
+ if (asset) {
151
+ const group = asset.clone(true);
152
+ const bounds = new THREE.Box3().setFromObject(group), size = bounds.getSize(new THREE.Vector3()), center = bounds.getCenter(new THREE.Vector3());
153
+ // Fit by width, then cap the height: fitting all three axes (or a thin board's depth) shrank models to a few pixels.
154
+ let scale = dimensions[0] / (size.x || 1);
155
+ if (size.z * scale > dimensions[2] * 1.5) scale = dimensions[2] * 1.5 / (size.z || 1);
156
+ if (size.y * scale > dimensions[1]) scale = dimensions[1] / (size.y || 1);
157
+ const wrapper = new THREE.Group();
158
+ group.position.set(-center.x, -bounds.min.y, -center.z); group.scale.setScalar(1);
159
+ wrapper.add(group); wrapper.scale.setScalar(scale); wrapper.position.copy(location);
160
+ contents.add(wrapper); modelCount += 1;
161
+ wrapper.updateMatrixWorld(true);
162
+ modelBoxes.push({ name, box: new THREE.Box3().setFromObject(wrapper) });
163
+ return size.y * scale;
164
+ }
165
+ const mesh = box(...dimensions, 0x9f8064, resources.images.get(name));
166
+ mesh.position.copy(location); mesh.position.y += dimensions[1] / 2; boxCount += 1;
167
+ return dimensions[1];
168
+ }
169
+ function rebuild(resources) {
170
+ reset();
171
+ const rgb = String(state.theme).match(/[0-9.]+/g)?.slice(0, 3).map(Number);
172
+ const dark = state.theme === "dark" || (rgb?.length === 3 && rgb.reduce((sum, n) => sum + n, 0) > 420);
173
+ scene.background = new THREE.Color(dark ? 0x20242b : 0xe9ece9);
174
+ contents.add(new THREE.HemisphereLight(0xffffff, dark ? 0x7b8296 : 0xa39a87, 2.4));
175
+ const light = new THREE.DirectionalLight(0xffffff, 2.5); light.position.set(-4, 12, 7); contents.add(light);
176
+ const width = state.layout.width / UNIT, depth = state.layout.height / UNIT;
177
+ const floorMap = resources.images.get("floor")?.clone();
178
+ if (floorMap) {
179
+ own(floorMap); floorMap.wrapS = floorMap.wrapT = THREE.RepeatWrapping;
180
+ floorMap.repeat.set(state.layout.width / (state.pack.tile?.[0] || 64), state.layout.height / (state.pack.tile?.[1] || 64));
181
+ }
182
+ const floor = new THREE.Mesh(own(new THREE.PlaneGeometry(width + 0.8, depth + 0.8)), material({ color: floorMap ? 0xffffff : dark ? 0x707a79 : 0xd4d5c1, ...(floorMap ? { map: floorMap, emissive: dark ? 0x353a42 : 0x8a8e93 } : {}), side: THREE.DoubleSide }));
183
+ floor.rotation.x = -Math.PI / 2; floor.position.y = -0.025; contents.add(floor);
184
+ const wallMap = resources.images.get("wall");
185
+ const wall = box(width + 0.8, 1.3, 0.08, dark ? 0x8a9397 : 0xe0ddd0, wallMap); wall.position.set(0, 0.65, -depth / 2 - 0.4);
186
+ for (const zone of state.model.zones) {
187
+ const p = state.layout.zones.get(zone.id);
188
+ if (!p) continue;
189
+ const name = zone.kind === "done" ? "shelf" : "board";
190
+ furniture(name, world(p, 234, 0), [2.2, 2.0, 0.7], resources);
191
+ }
192
+ for (const actor of state.model.actors) {
193
+ const p = state.layout.actors.get(actor.id), metric = state.actors.find((a) => a.id === actor.id);
194
+ if (!p || !metric) continue;
195
+ const at = world(p, metric.width, metric.height);
196
+ const deskTop = furniture("desk", at, [1.6, 1.3, 1.2], resources);
197
+ const pet = state.pets.find((item) => item.id === actor.pet) ?? (state.pets.length ? state.pets[hash(actor.id) % state.pets.length] : null);
198
+ const sheet = pet && resources.pets.get(pet.id);
199
+ if (sheet) {
200
+ const map = own(sheet.clone());
201
+ const [row, frames] = rows[actor.stale ? "idle" : actor.state] ?? rows.idle;
202
+ map.repeat.set(1 / 8, 1 / 11); map.offset.set(0, 1 - (row + 1) / 11);
203
+ map.magFilter = THREE.NearestFilter; map.minFilter = THREE.LinearFilter;
204
+ const sprite = new THREE.Sprite(own(new THREE.SpriteMaterial({ map, transparent: true, alphaTest: 0.02, depthWrite: false })));
205
+ sprite.position.copy(at); sprite.position.y = Math.max(1.15, deskTop + 0.6); sprite.scale.set(1.2, 1.3, 1);
206
+ const backer = new THREE.Sprite(own(new THREE.SpriteMaterial({ map: backing(), transparent: true, depthWrite: false })));
207
+ backer.position.copy(sprite.position); backer.scale.set(1.35, 1.35, 1);
208
+ backer.renderOrder = 1; sprite.renderOrder = 2;
209
+ contents.add(backer, sprite);
210
+ spriteBounds.push({ position: sprite.position, height: 1.3 });
211
+ if (!actor.stale && actor.state !== "idle" && actor.state !== "away") spriteFrames.push({ map, frames, row });
212
+ anchors.actors.push({ id: actor.id, position: sprite.position });
213
+ } else {
214
+ const token = box(0.5, 0.55, 0.35, actor.state === "blocked" ? 0xc9534c : 0x668cba);
215
+ token.position.copy(at); token.position.y = Math.max(1.05, deskTop + 0.3);
216
+ anchors.actors.push({ id: actor.id, position: token.position });
217
+ }
218
+ }
219
+ for (const card of state.model.cards) {
220
+ const p = state.layout.cards.get(card.id), metric = state.cards.find((item) => item.id === card.id);
221
+ if (!p || !metric) continue;
222
+ const mesh = box(Math.max(0.6, metric.width / UNIT * 0.75), 0.12, Math.max(0.3, metric.height / UNIT * 0.7), card.tone === "err" ? 0xb95d56 : card.tone === "ok" ? 0x6d967b : card.tone === "warn" ? 0xe8c981 : 0xd4cdbb);
223
+ mesh.position.copy(world(p, metric.width, metric.height, 0.24));
224
+ anchors.cards.push({ id: card.id, position: mesh.position });
225
+ }
226
+ // Message lifetimes belong to the existing DOM renderer; never leave a stale message in a frozen canvas.
227
+ for (const link of state.model.links.filter((item) => item.kind !== "message")) {
228
+ const from = anchors.actors.find((item) => item.id === link.from), to = anchors.actors.find((item) => item.id === link.to);
229
+ if (!from || !to) continue;
230
+ const points = [from.position.clone(), to.position.clone()];
231
+ const line = new THREE.Line(own(new THREE.BufferGeometry().setFromPoints(points)), own(new THREE.LineBasicMaterial({ color: link.kind === "message" ? 0xb78c36 : 0x7793a6, transparent: true, opacity: 0.65 })));
232
+ contents.add(line);
233
+ }
234
+ contentBounds = new THREE.Box3();
235
+ for (const child of contents.children) if (!child.isLight) contentBounds.union(new THREE.Box3().setFromObject(child));
236
+ for (const item of anchors.actors) contentBounds.expandByPoint(item.position.clone().add(new THREE.Vector3(0, 0.7, 0)));
237
+ aim();
238
+ }
239
+ let contentBounds = null;
240
+ function aim() {
241
+ if (!state) return;
242
+ const width = Math.max(1, state.width), height = Math.max(1, state.height);
243
+ renderer.setSize(width, height, false);
244
+ const aspect = width / height;
245
+ const halfW = state.layout.width / UNIT / 2 + 0.4, halfD = state.layout.height / UNIT / 2 + 0.4;
246
+ const yaw = radians(cameraState.yaw), pitch = radians(cameraState.pitch), distance = Math.hypot(halfW, halfD) * 3 + 10;
247
+ camera.position.set(Math.sin(yaw) * Math.cos(pitch) * distance, Math.sin(pitch) * distance, Math.cos(yaw) * Math.cos(pitch) * distance);
248
+ camera.lookAt(0, 0, 0); camera.updateMatrixWorld();
249
+ // Frame the scene's actual box at this yaw and pitch, not a bounding sphere: a sphere wasted half the view
250
+ // and left furniture and pets a few pixels tall. The DOM overlay and canvas share this viewport.
251
+ const inverse = camera.matrixWorldInverse;
252
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
253
+ const content = contentBounds && !contentBounds.isEmpty() ? contentBounds : new THREE.Box3(new THREE.Vector3(-halfW, 0, -halfD), new THREE.Vector3(halfW, 2.2, halfD));
254
+ for (const x of [content.min.x, content.max.x]) for (const y of [content.min.y, content.max.y]) for (const z of [content.min.z, content.max.z]) {
255
+ const p = new THREE.Vector3(x, y, z).applyMatrix4(inverse);
256
+ minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x); minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y);
257
+ }
258
+ const margin = 1.03 / Math.max(0.4, cameraState.zoom);
259
+ let spanX = (maxX - minX) / 2 * margin, spanY = (maxY - minY) / 2 * margin;
260
+ if (spanX / spanY > aspect) spanY = spanX / aspect; else spanX = spanY * aspect;
261
+ const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
262
+ camera.left = cx - spanX; camera.right = cx + spanX; camera.top = cy + spanY; camera.bottom = cy - spanY;
263
+ camera.updateProjectionMatrix();
264
+ }
265
+ /** Screen size evidence for the checks: the smallest model's longer projected side and a pet sprite's height. */
266
+ function measure() {
267
+ const toScreen = (v) => { const p = v.clone().project(camera); return [(p.x + 1) * state.width / 2, (1 - p.y) * state.height / 2]; };
268
+ let modelMinPx = 0;
269
+ const sides = {};
270
+ for (const { name, box: bounds } of modelBoxes) {
271
+ const xs = [], ys = [];
272
+ for (const x of [bounds.min.x, bounds.max.x]) for (const y of [bounds.min.y, bounds.max.y]) for (const z of [bounds.min.z, bounds.max.z]) {
273
+ const [sx, sy] = toScreen(new THREE.Vector3(x, y, z)); xs.push(sx); ys.push(sy);
274
+ }
275
+ const side = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
276
+ sides[name] = Math.round(Math.min(sides[name] ?? Infinity, side));
277
+ modelMinPx = modelMinPx ? Math.min(modelMinPx, side) : side;
278
+ }
279
+ mount.dataset.modelSides = JSON.stringify(sides);
280
+ const sprite = spriteBounds[0];
281
+ let spritePx = 0;
282
+ if (sprite) {
283
+ const top = toScreen(sprite.position.clone().add(new THREE.Vector3(0, sprite.height / 2, 0)));
284
+ const bottom = toScreen(sprite.position.clone().add(new THREE.Vector3(0, -sprite.height / 2, 0)));
285
+ spritePx = Math.abs(bottom[1] - top[1]);
286
+ }
287
+ return { modelMinPx, spritePx };
288
+ }
289
+ function draw(now) {
290
+ frame = 0;
291
+ if (dead || !state || context.isContextLost()) { instrumentation(); return; }
292
+ const animating = now < burstUntil && !reduced.matches;
293
+ for (const item of spriteFrames) item.map.offset.x = (animating ? Math.floor(now / 150) % item.frames : 0) / 8;
294
+ try { renderer.render(scene, camera); } catch { fail(); return; }
295
+ count += 1; instrumentation();
296
+ const point = (entry) => {
297
+ const p = entry.position.clone().project(camera);
298
+ return { id: entry.id, x: (p.x + 1) * state.width / 2, y: (1 - p.y) * state.height / 2, depth: p.z };
299
+ };
300
+ projection = { actors: anchors.actors.map(point), cards: anchors.cards.map(point) };
301
+ measured = measure();
302
+ instrumentation();
303
+ onProjection?.(projection);
304
+ if (animating) request();
305
+ }
306
+ async function update(next) {
307
+ const { sampledAt, samples, ...facts } = next.model;
308
+ const nextSignature = JSON.stringify([facts, [...next.layout.actors], [...next.layout.cards], [...next.layout.zones], next.layout.width, next.layout.height, next.pack, next.pets, next.actors.map(({ id, width, height }) => [id, width, height]), next.cards.map(({ id, width, height }) => [id, width, height]), next.width, next.height, next.theme]);
309
+ state = next;
310
+ if (nextSignature === signature) return readiness;
311
+ signature = nextSignature;
312
+ const turn = ++generation;
313
+ const pack = state.pack;
314
+ const images = new Map(), pets = new Map(), templates = new Map();
315
+ const jobs = [];
316
+ for (const name of (pack?.images ?? []).filter((name) => ["floor", "wall", "desk", "shelf", "board"].includes(name))) jobs.push(texture(`/assets/${pack.id}/${name}`).then((value) => { if (value) images.set(name, value); }));
317
+ for (const pet of state.pets) jobs.push(texture(`/pets/${pet.id}/sheet`).then((value) => { if (value) pets.set(pet.id, value); }));
318
+ for (const name of (pack?.models ?? []).filter((name) => ["desk", "shelf", "board"].includes(name))) jobs.push(model(name, pack).then((value) => { if (value) templates.set(name, value); }));
319
+ readiness = Promise.all(jobs).then(() => {
320
+ if (dead || turn !== generation) return;
321
+ rebuild({ images, pets, models: templates });
322
+ burstUntil = reduced.matches || state.layout.settle ? 0 : performance.now() + 800;
323
+ request();
324
+ }).catch(() => { if (!dead && turn === generation) fail(); });
325
+ return readiness;
326
+ }
327
+ function setCamera(next) {
328
+ const value = { yaw: Number.isFinite(next.yaw) ? next.yaw : cameraState.yaw, pitch: Number.isFinite(next.pitch) ? Math.max(20, Math.min(80, next.pitch)) : cameraState.pitch, zoom: Number.isFinite(next.zoom) ? Math.max(0.4, Math.min(2, next.zoom)) : cameraState.zoom };
329
+ if (value.yaw === cameraState.yaw && value.pitch === cameraState.pitch && value.zoom === cameraState.zoom) return;
330
+ cameraState = value;
331
+ aim(); request();
332
+ }
333
+ let drag = null;
334
+ const pointerDown = (event) => {
335
+ if (event.button !== 0) return;
336
+ drag = { id: event.pointerId, x: event.clientX, y: event.clientY, yaw: cameraState.yaw, pitch: cameraState.pitch };
337
+ canvas.setPointerCapture(event.pointerId);
338
+ };
339
+ const pointerMove = (event) => {
340
+ if (!drag || drag.id !== event.pointerId) return;
341
+ setCamera({ ...cameraState, yaw: drag.yaw - (event.clientX - drag.x) * 0.35, pitch: drag.pitch + (event.clientY - drag.y) * 0.2 });
342
+ onCamera?.({ ...cameraState });
343
+ };
344
+ const pointerEnd = (event) => { if (drag?.id === event.pointerId) drag = null; };
345
+ canvas.addEventListener("pointerdown", pointerDown); canvas.addEventListener("pointermove", pointerMove);
346
+ canvas.addEventListener("pointerup", pointerEnd); canvas.addEventListener("pointercancel", pointerEnd);
347
+ const motionChanged = () => { if (reduced.matches) { burstUntil = 0; request(); } };
348
+ reduced.addEventListener("change", motionChanged);
349
+ return { update, setCamera,
350
+ get renderCount() { return count; }, get ready() { return readiness; }, get pendingFrames() { return frame ? 1 : 0; },
351
+ get modelCount() { return modelCount; }, get boxCount() { return boxCount; }, get projection() { return projection; },
352
+ destroy() {
353
+ if (dead) return;
354
+ dead = true; generation += 1; aborter.abort();
355
+ if (frame) cancelAnimationFrame(frame);
356
+ frame = 0; instrumentation();
357
+ canvas.removeEventListener("pointerdown", pointerDown); canvas.removeEventListener("pointermove", pointerMove);
358
+ canvas.removeEventListener("pointerup", pointerEnd); canvas.removeEventListener("pointercancel", pointerEnd);
359
+ canvas.removeEventListener("webglcontextlost", lost); reduced.removeEventListener("change", motionChanged);
360
+ for (const resource of transientResources) resource.dispose(); transientResources = [];
361
+ disposeShared(); renderer.dispose(); canvas.remove();
362
+ }
363
+ };
364
+ }
@@ -0,0 +1,111 @@
1
+ // H2 transport. Credentials live only in this tab; the static host serves no node data.
2
+ const NODE_ID = /^[a-z][a-z0-9-]{0,31}$/;
3
+ const TOOL_ID = /^[a-z][a-z0-9-]{1,15}$/;
4
+ const loopback = (host) => host === 'localhost' || host === '[::1]' || /^127\.\d+\.\d+\.\d+$/.test(host);
5
+
6
+ export function createStaticTransport(config, onStatus = () => {}) {
7
+ if (config?.api !== 1 || !Array.isArray(config.nodes) || !config.nodes.length || config.nodes.length > 32) throw new Error('Invalid static config; rebuild with acon build --static --nodes id=https://node.example');
8
+ const ids = new Set();
9
+ const nodes = config.nodes.map((input) => {
10
+ let url;
11
+ try { url = new URL(input?.url); } catch { throw new Error("Invalid node URL in config.json; rebuild with acon build --static --nodes id=https://node.example"); }
12
+ if (!NODE_ID.test(input.id) || ids.has(input.id) || url.origin !== input.url || (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback(url.hostname)))) throw new Error('Invalid node id or origin in config.json; rebuild with unique ids and exact HTTPS origins');
13
+ ids.add(input.id);
14
+ const key = `acon.auth:${input.id}:${url.origin}`;
15
+ let token = '';
16
+ try { token = sessionStorage.getItem(key) ?? ''; } catch { /* this tab may disallow storage */ }
17
+ return { id: input.id, url: url.origin, key, token, status: 'connecting', detail: 'Connecting to node', grant: '', grantUntil: 0, data: null, busy: null };
18
+ });
19
+ const notify = (node, status, detail) => { node.status = status; node.detail = detail; onStatus(); };
20
+ const headersFor = (node, json = true) => ({ ...(json ? { 'Content-Type': 'application/json' } : {}), ...(node.token ? { Authorization: `Bearer ${node.token}` } : {}) });
21
+ const failed = async (node) => {
22
+ // A failed CORS fetch and a stopped node look identical to JavaScript. An opaque,
23
+ // bounded health probe establishes reachability without reading cross-origin data.
24
+ try {
25
+ await fetch(`${node.url}/healthz`, { mode: 'no-cors', credentials: 'omit', cache: 'no-store', signal: AbortSignal.timeout(3000) });
26
+ notify(node, 'refused (origin)', 'Node reachable but browser access refused; add this shell origin to acon serve --origin and check the Access CORS settings.');
27
+ } catch { notify(node, 'offline', 'Node is unreachable; start acon serve and check the node URL and network.'); }
28
+ return { status: 0, data: { ok: false, error: node.detail } };
29
+ };
30
+ const request = async (node, path, body) => {
31
+ const action = body !== undefined && /^\/p\/[^/]+\/action$/.test(path);
32
+ const signal = AbortSignal.timeout(action ? 35000 : 8000);
33
+ const uncertain = () => ({ status: 0, data: { ok: false, uncertain: true, error: 'The action outcome is unknown because its response was interrupted or invalid; check the node System log and current result before repeating the action.' } });
34
+ let response;
35
+ let data = null;
36
+ try {
37
+ response = await fetch(`${node.url}${path}`, { method: body === undefined ? 'GET' : 'POST', headers: headersFor(node, body !== undefined), ...(body === undefined ? {} : { body: JSON.stringify(body) }), credentials: 'include', cache: 'no-store', signal, redirect: 'error' });
38
+ try { data = await response.json(); }
39
+ catch { if (action && response.status !== 401 && response.status !== 403) return uncertain(); }
40
+ } catch { return action ? uncertain() : failed(node); }
41
+ if (response.status === 401 || (response.status === 403 && data?.readOnly !== true)) {
42
+ node.grant = '';
43
+ notify(node, 'unauthorized', 'Authentication refused; enter this node\'s bearer token or sign in to its Access application, then reconnect.');
44
+ } else notify(node, 'online', 'Connected');
45
+ return { status: response.status, data: data ?? { ok: false, error: `Node answered HTTP ${response.status} without JSON; check its System log.` } };
46
+ };
47
+ const connect = async (node) => {
48
+ if (!node.grant || Date.now() >= node.grantUntil) {
49
+ const session = await request(node, '/session', { resources: true });
50
+ if (session.status !== 200 || !/^[0-9a-f]{64}$/.test(session.data.resourceToken ?? '')) return null;
51
+ node.grant = session.data.resourceToken;
52
+ node.grantUntil = Date.now() + Math.min(Number(session.data.expiresIn) || 0, 3600000) - 10000;
53
+ }
54
+ const result = await request(node, '/tools', {});
55
+ node.data = result.status === 200 ? result.data : null;
56
+ return node.data;
57
+ };
58
+ const owner = (key) => nodes.find((node) => key.startsWith(`${node.id}:`));
59
+ const resolve = (path, refs = new Map()) => {
60
+ const match = /^\/p\/([^/]+)\/(.*)$/.exec(path);
61
+ if (!match) return null;
62
+ const node = owner(match[1]);
63
+ if (!node) return null;
64
+ const raw = match[1].slice(node.id.length + 1);
65
+ if (!TOOL_ID.test(raw)) return null;
66
+ const ref = refs.get(match[1]) ?? raw;
67
+ if (!new RegExp(`^${raw}(?:@[0-9a-f]{12})?$`).test(ref)) return null;
68
+ return { node, path: `/p/${ref}/${match[2]}` };
69
+ };
70
+ return {
71
+ nodes,
72
+ async tools() {
73
+ // One bounded refresh per node, with no nested retry loop or repeated auth prompt.
74
+ await Promise.all(nodes.map((node) => node.busy ??= connect(node).finally(() => { node.busy = null; })));
75
+ const tools = nodes.flatMap((node) => (node.data?.tools ?? []).filter((tool) => TOOL_ID.test(tool?.tool)).map((tool) => ({ ...tool, tool: `${node.id}:${tool.tool}`, rawTool: tool.tool, node: node.id, title: `${node.id}: ${tool.title ?? tool.tool}`, readOnly: node.data.readOnly === true, ...(node.status === 'online' ? {} : { status: 'offline', reason: node.detail }) })));
76
+ return { status: 200, data: { tools, pets: [], assetPacks: [], node: { id: 'static', version: 'H2' }, errors: [], readOnly: false } };
77
+ },
78
+ async post(path, body, refs) {
79
+ if (path === '/tools') return this.tools();
80
+ if (path === '/ping') return { status: 200, data: { ok: true } };
81
+ const route = resolve(path, refs);
82
+ if (!route) return { status: 404, data: { ok: false, error: 'Unknown node/tool route; reload the static console.' } };
83
+ if (route.node.status !== "online") return { status: 0, data: { ok: false, error: route.node.detail } };
84
+ return request(route.node, route.path, body);
85
+ },
86
+ resource(path, refs) {
87
+ const route = resolve(path, refs);
88
+ if (!route) return path;
89
+ const url = new URL(route.path, route.node.url);
90
+ url.searchParams.set('acon_resource', route.node.grant);
91
+ return url.href;
92
+ },
93
+ async loadStyle(path, refs) {
94
+ const route = resolve(path, refs);
95
+ if (!route) throw new Error('Unknown plugin stylesheet node; reload the console.');
96
+ const response = await fetch(this.resource(path, refs), { headers: headersFor(route.node, false), credentials: 'include', signal: AbortSignal.timeout(8000), redirect: 'error' });
97
+ if (!response.ok) throw new Error('Plugin stylesheet could not load; check this node\'s authentication in System.');
98
+ // Constructed sheets preserve style-src self: no remote link or inline style element.
99
+ const sheet = new CSSStyleSheet();
100
+ sheet.replaceSync(await response.text());
101
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
102
+ },
103
+ setToken(id, token) {
104
+ const node = nodes.find((item) => item.id === id);
105
+ if (!node) return;
106
+ node.token = token.trim(); node.grant = ''; node.data = null;
107
+ try { if (node.token) sessionStorage.setItem(node.key, node.token); else sessionStorage.removeItem(node.key); } catch { /* memory-only tab */ }
108
+ notify(node, 'connecting', 'Reconnecting');
109
+ },
110
+ };
111
+ }