@volter/editor-live 0.5.57
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/BUNDLED_NOTICES +434 -0
- package/LICENSE +202 -0
- package/README.md +20 -0
- package/dist/editor-document.d.ts +152 -0
- package/dist/editor.d.ts +399 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +2080 -0
- package/dist/index.js.map +7 -0
- package/dist/lazy-proxy.d.ts +26 -0
- package/dist/session.d.ts +32 -0
- package/dist/singleton.d.ts +18 -0
- package/dist/tools.d.ts +23 -0
- package/package.json +46 -0
- package/src/editor-document.ts +234 -0
- package/src/editor.ts +663 -0
- package/src/index.ts +38 -0
- package/src/lazy-proxy.ts +68 -0
- package/src/session.ts +267 -0
- package/src/singleton.ts +30 -0
- package/src/tools.ts +45 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2080 @@
|
|
|
1
|
+
// ../editor-sdk/src/http-transport.node.ts
|
|
2
|
+
import { Agent, fetch as nodeFetch } from "undici";
|
|
3
|
+
function createDispatcher(timeoutMs) {
|
|
4
|
+
return new Agent({ headersTimeout: timeoutMs, bodyTimeout: timeoutMs });
|
|
5
|
+
}
|
|
6
|
+
async function dispatchFetch(url, init, dispatcher) {
|
|
7
|
+
if (!dispatcher) return fetch(url, init);
|
|
8
|
+
return await nodeFetch(url, {
|
|
9
|
+
...init,
|
|
10
|
+
dispatcher
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ../editor-sdk/src/client.ts
|
|
15
|
+
var DEFAULT_URL = "http://127.0.0.1:20173";
|
|
16
|
+
var EditorCommandError = class extends Error {
|
|
17
|
+
code;
|
|
18
|
+
/**
|
|
19
|
+
* True when the RELAY ended the command itself rather than the editor
|
|
20
|
+
* answering it — the HTTP 504 that `server/server-utils.ts`'s
|
|
21
|
+
* `commandResponseFor` gives any `timedOut` result, or this client's own
|
|
22
|
+
* deadline below.
|
|
23
|
+
*
|
|
24
|
+
* Read it as "no answer", not as "the budget expired". `editor-server.ts`
|
|
25
|
+
* raises `timedOut` for five conditions and only one of them takes the full
|
|
26
|
+
* budget: the command's timer expiring, the controlling tab's socket dying,
|
|
27
|
+
* the receipt window closing unanswered, a beating-but-dead tab, and no tab
|
|
28
|
+
* present at all. The last four can fail in milliseconds.
|
|
29
|
+
*
|
|
30
|
+
* A caller that converges by retrying (`vgai restart`) needs the distinction
|
|
31
|
+
* because a refusal the editor ANSWERED may go differently next time, while
|
|
32
|
+
* a command the relay abandoned tells you nothing new on a second identical
|
|
33
|
+
* attempt — and when the abandonment was a 120s budget, re-running it three
|
|
34
|
+
* times is `restart-readiness.ts`'s 361-seconds-of-silence defect.
|
|
35
|
+
*/
|
|
36
|
+
timedOut;
|
|
37
|
+
constructor(message, code, timedOut = false) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "EditorCommandError";
|
|
40
|
+
this.code = code;
|
|
41
|
+
this.timedOut = timedOut;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
var COMMAND_DEADLINE_MS = 15e4;
|
|
45
|
+
var BLENDER_DEADLINE_MS = 30 * 6e4;
|
|
46
|
+
var UNDICI_DEFAULT_HEADERS_TIMEOUT_MS = 3e5;
|
|
47
|
+
var CONSOLE_DRAIN_TIMEOUT_MS = 1500;
|
|
48
|
+
function describeFetchFailure(error) {
|
|
49
|
+
const messages = [];
|
|
50
|
+
let current = error;
|
|
51
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
52
|
+
if (!(current instanceof Error)) break;
|
|
53
|
+
if (current.message) messages.push(current.message);
|
|
54
|
+
const code = current.code;
|
|
55
|
+
if (typeof code === "string" && code !== "") {
|
|
56
|
+
return { code, detail: messages.join(" <- ") };
|
|
57
|
+
}
|
|
58
|
+
const aggregate = current.errors;
|
|
59
|
+
if (Array.isArray(aggregate) && aggregate.length > 0) {
|
|
60
|
+
const inner = describeFetchFailure(aggregate[0]);
|
|
61
|
+
return { code: inner.code, detail: [...messages, inner.detail].join(" <- ") };
|
|
62
|
+
}
|
|
63
|
+
current = current.cause;
|
|
64
|
+
}
|
|
65
|
+
return { code: "UNKNOWN", detail: messages.join(" <- ") || String(error) };
|
|
66
|
+
}
|
|
67
|
+
var RETRYABLE_TRANSPORT_CODES = /* @__PURE__ */ new Set([
|
|
68
|
+
"ECONNREFUSED",
|
|
69
|
+
"ECONNRESET",
|
|
70
|
+
"EPIPE",
|
|
71
|
+
"ETIMEDOUT",
|
|
72
|
+
"EHOSTUNREACH",
|
|
73
|
+
"UND_ERR_SOCKET",
|
|
74
|
+
"UND_ERR_CONNECT_TIMEOUT"
|
|
75
|
+
]);
|
|
76
|
+
var TRANSPORT_RETRY_DELAY_MS = 400;
|
|
77
|
+
var EditorClient = class {
|
|
78
|
+
baseUrl;
|
|
79
|
+
/**
|
|
80
|
+
* The running game's debug plane — see {@link GameDebugDoor}. It rides the
|
|
81
|
+
* SAME `/__editor/command` relay every other method here uses (relay cases
|
|
82
|
+
* `inspect-gameplay-state` / `invoke-debug-command`, `command-listener.ts`),
|
|
83
|
+
* so a tool contribution reaches the game through the client it already has
|
|
84
|
+
* rather than a second channel of its own.
|
|
85
|
+
*/
|
|
86
|
+
game;
|
|
87
|
+
/**
|
|
88
|
+
* Called with the raw body of EVERY response this client receives — command
|
|
89
|
+
* envelopes and `/__editor/state` alike, on success AND on refusal.
|
|
90
|
+
*
|
|
91
|
+
* It exists for exactly one contract: the server stamps `unresolvedConsole`
|
|
92
|
+
* onto every envelope (`server-utils.ts`'s `commandResponseFor`), and the CLI
|
|
93
|
+
* has to see those counts to be loud about them. Routing that through a
|
|
94
|
+
* single observer here — rather than teaching each of the CLI's output sites
|
|
95
|
+
* to unpack a response — is what keeps the loudness contract ONE mechanism.
|
|
96
|
+
* The observer must not throw; anything it raises is swallowed, because a
|
|
97
|
+
* reporting hook may never break the command it is reporting on.
|
|
98
|
+
*/
|
|
99
|
+
transport;
|
|
100
|
+
onEnvelope;
|
|
101
|
+
constructor(opts) {
|
|
102
|
+
if (opts !== void 0 && (typeof opts !== "object" || opts === null || Array.isArray(opts))) {
|
|
103
|
+
throw new TypeError(
|
|
104
|
+
'EditorClient options must be an object. Use new EditorClient({ url: "http://127.0.0.1:20173" }), not new EditorClient("...").'
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const unknownOptions = Object.keys(opts ?? {}).filter(
|
|
108
|
+
(key) => key !== "url" && key !== "onEnvelope" && key !== "transport"
|
|
109
|
+
);
|
|
110
|
+
if (unknownOptions.length > 0) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`EditorClient: unknown option${unknownOptions.length === 1 ? "" : "s"} ${unknownOptions.map((key) => `"${key}"`).join(", ")}. Use { url: "http://127.0.0.1:<port>" } to target an editor.`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
this.baseUrl = (opts?.url ?? DEFAULT_URL).replace(/\/$/, "");
|
|
116
|
+
this.onEnvelope = opts?.onEnvelope ?? null;
|
|
117
|
+
this.transport = opts?.transport ?? null;
|
|
118
|
+
this.game = {
|
|
119
|
+
state: async (name) => {
|
|
120
|
+
const data = await this.command({
|
|
121
|
+
type: "inspect-gameplay-state",
|
|
122
|
+
keys: [name]
|
|
123
|
+
});
|
|
124
|
+
return data.state?.[name];
|
|
125
|
+
},
|
|
126
|
+
command: async (name, ...args) => (await this.command({ type: "invoke-debug-command", name, args })).result
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* `retryTransport` opts a command into ONE automatic retry after a transport
|
|
131
|
+
* failure (see {@link RETRYABLE_TRANSPORT_CODES}). It is deliberately
|
|
132
|
+
* OPT-IN and off by default: a socket that died after the request was written
|
|
133
|
+
* cannot prove the editor did not already run the command, so a blanket retry
|
|
134
|
+
* would risk playing/stopping/writing twice. Read-only relays — the captures —
|
|
135
|
+
* have no such hazard and turn it on.
|
|
136
|
+
*/
|
|
137
|
+
async command(body, options) {
|
|
138
|
+
const type = String(body["type"] ?? "command");
|
|
139
|
+
const deadlineMs = options?.deadlineMs ?? COMMAND_DEADLINE_MS;
|
|
140
|
+
if (this.transport) {
|
|
141
|
+
const answered = await this.transport(body);
|
|
142
|
+
if (!answered.ok) {
|
|
143
|
+
throw new EditorCommandError(
|
|
144
|
+
answered.error ?? `Editor command "${type}" failed`,
|
|
145
|
+
answered.code
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return answered;
|
|
149
|
+
}
|
|
150
|
+
const url = `${this.baseUrl}/__editor/command`;
|
|
151
|
+
const request = JSON.stringify(body);
|
|
152
|
+
let res;
|
|
153
|
+
let retried = false;
|
|
154
|
+
let commandDispatcher;
|
|
155
|
+
for (; ; ) {
|
|
156
|
+
try {
|
|
157
|
+
const init = {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers: { "Content-Type": "application/json" },
|
|
160
|
+
body: request,
|
|
161
|
+
signal: AbortSignal.timeout(deadlineMs)
|
|
162
|
+
};
|
|
163
|
+
commandDispatcher = deadlineMs > UNDICI_DEFAULT_HEADERS_TIMEOUT_MS ? createDispatcher(deadlineMs + 3e4) : void 0;
|
|
164
|
+
res = await dispatchFetch(url, init, commandDispatcher);
|
|
165
|
+
break;
|
|
166
|
+
} catch (error) {
|
|
167
|
+
await commandDispatcher?.destroy?.();
|
|
168
|
+
commandDispatcher = void 0;
|
|
169
|
+
if (error?.name === "TimeoutError") {
|
|
170
|
+
throw new EditorCommandError(
|
|
171
|
+
`The editor at ${this.baseUrl} never answered "${type}" within ${Math.round(deadlineMs / 1e3)}s \u2014 past every server-side budget, so the server itself is not answering. Check the terminal running \`volter-editor edit\`.`,
|
|
172
|
+
void 0,
|
|
173
|
+
true
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
const { code, detail } = describeFetchFailure(error);
|
|
177
|
+
if (options?.retryTransport === true && !retried && RETRYABLE_TRANSPORT_CODES.has(code)) {
|
|
178
|
+
retried = true;
|
|
179
|
+
await new Promise((resolve3) => setTimeout(resolve3, TRANSPORT_RETRY_DELAY_MS));
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
throw new EditorCommandError(
|
|
183
|
+
`POST ${url} ("${type}") never reached the editor: ${code}${detail ? ` (${detail})` : ""}.` + (retried ? ` Retried once after ${TRANSPORT_RETRY_DELAY_MS}ms; it failed the same way.` : "") + (options?.retryTransport === true ? "" : " Not retried automatically: this command can change editor state, and a socket that died after the request was written cannot prove the editor did not already run it.") + " A transport failure means the port stopped answering, not that the editor refused \u2014 the dev server restarts on any watched source edit, and it shuts itself down after an idle window. Check the terminal running `volter-editor edit` and confirm the port this client resolved.",
|
|
184
|
+
code,
|
|
185
|
+
false
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
let data;
|
|
190
|
+
try {
|
|
191
|
+
data = await this.readJson(res);
|
|
192
|
+
} finally {
|
|
193
|
+
await commandDispatcher?.destroy?.();
|
|
194
|
+
}
|
|
195
|
+
if (!data.ok) {
|
|
196
|
+
throw new EditorCommandError(
|
|
197
|
+
data.error ?? `Editor command failed: ${res.status}`,
|
|
198
|
+
data.code,
|
|
199
|
+
// 504 is every `timedOut` result (`commandResponseFor`) — the relay
|
|
200
|
+
// gave up, on any of its five grounds. A 200 body with `ok: false` is
|
|
201
|
+
// an ANSWER from the editor, however unwelcome. See `timedOut` above.
|
|
202
|
+
res.status === 504
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return data;
|
|
206
|
+
}
|
|
207
|
+
// --- Play control ---
|
|
208
|
+
/** `opts.seed` (D15/T-D15.6, objection-4 fix) — `vgai play --seed <n>`'s
|
|
209
|
+
* explicit config leg, relayed as `cmd['seed']`; `handleCommand`'s
|
|
210
|
+
* `'play'` case threads it into `enterPlayMode`'s highest-precedence seed
|
|
211
|
+
* argument (beats manifest.determinism.defaultSeed/?vgai-seed=). Omitted,
|
|
212
|
+
* boot seeding falls back to that precedence unchanged.
|
|
213
|
+
*
|
|
214
|
+
* `opts.name` (`vgai play --name <text>`) — an OPTIONAL label for this run,
|
|
215
|
+
* relayed as `cmd['name']` and slugified server-side into the run's
|
|
216
|
+
* `logs/play-*.jsonl` filename and its session-journal line. Findability
|
|
217
|
+
* only: no registry, no uniqueness, no lookup verb — grep and `ls` are the
|
|
218
|
+
* query engine. Omitted, the filename keeps its exact unnamed shape. */
|
|
219
|
+
/* `opts.record` (`vgai play --record <name>`) — NAMES this run's recording
|
|
220
|
+
* file. It does not ENABLE recording: every relayed play records, with no
|
|
221
|
+
* flag (see `@vgai/game`'s `src/play/play-recording.ts`). Omitted, the clip is named for
|
|
222
|
+
* the durable Gameplay Session; named, it becomes an explicit keepsake in
|
|
223
|
+
* `.vgai/recordings/<name>.webm`. */
|
|
224
|
+
async play(opts) {
|
|
225
|
+
return this.command({
|
|
226
|
+
type: "play",
|
|
227
|
+
...opts?.seed !== void 0 ? { seed: opts.seed } : {},
|
|
228
|
+
...opts?.name ? { name: opts.name } : {},
|
|
229
|
+
...opts?.record ? { record: opts.record } : {}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
/** Dispose the current play session and mount it again from fresh project entry source. */
|
|
233
|
+
async restart() {
|
|
234
|
+
await this.command({ type: "play" });
|
|
235
|
+
}
|
|
236
|
+
/** Stops play, and finalizes this run's recording before the surface it was
|
|
237
|
+
* photographing is torn down. The capture is absent when nothing recorded. */
|
|
238
|
+
async stop() {
|
|
239
|
+
return this.command({ type: "stop" });
|
|
240
|
+
}
|
|
241
|
+
async pause() {
|
|
242
|
+
await this.command({ type: "pause" });
|
|
243
|
+
}
|
|
244
|
+
async resume() {
|
|
245
|
+
await this.command({ type: "resume" });
|
|
246
|
+
}
|
|
247
|
+
async step() {
|
|
248
|
+
await this.command({ type: "step" });
|
|
249
|
+
}
|
|
250
|
+
// --- Selection ---
|
|
251
|
+
async select(id) {
|
|
252
|
+
await this.command({ type: "select", id });
|
|
253
|
+
}
|
|
254
|
+
async selectMultiple(ids) {
|
|
255
|
+
await this.command({ type: "select-multiple", ids });
|
|
256
|
+
}
|
|
257
|
+
async selectAll() {
|
|
258
|
+
await this.command({ type: "select-all" });
|
|
259
|
+
}
|
|
260
|
+
// --- Viewport ---
|
|
261
|
+
async focusEntity(id) {
|
|
262
|
+
await this.command({ type: "focus-entity", id });
|
|
263
|
+
}
|
|
264
|
+
async focusSelection() {
|
|
265
|
+
await this.command({ type: "focus-selection" });
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Frame the EDIT viewport camera on one entity — the strict sibling of
|
|
269
|
+
* {@link focusEntity}. Same framing; an id the scene does not know is a
|
|
270
|
+
* refusal naming the id (`EditorCommandError`, code `ENTITY_NOT_FOUND`)
|
|
271
|
+
* rather than `focusEntity`'s silent no-op, so a caller that frames an
|
|
272
|
+
* entity before capturing it cannot photograph the wrong thing.
|
|
273
|
+
*/
|
|
274
|
+
async frameEntity(id) {
|
|
275
|
+
await this.command({ type: "frame-entity", id });
|
|
276
|
+
}
|
|
277
|
+
async viewPreset(preset) {
|
|
278
|
+
await this.command({ type: "view-preset", preset });
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* LOOK AROUND THE OPEN MODEL, visibly. Swings the active Object3D
|
|
282
|
+
* document's camera — the one on the human's screen — by `azimuth`/
|
|
283
|
+
* `elevation` radians, animated over `duration` seconds, and resolves when
|
|
284
|
+
* the move ends. A human drag during the move cancels it where it stands
|
|
285
|
+
* (`cancelledBy: 'human'`); the promise still resolves.
|
|
286
|
+
*/
|
|
287
|
+
async orbitDocument(options) {
|
|
288
|
+
return this.command({ type: "document-orbit", ...options });
|
|
289
|
+
}
|
|
290
|
+
/** A slow full revolution around the open document's subject, at a constant rate. */
|
|
291
|
+
async turntableDocument(options) {
|
|
292
|
+
return this.command({ type: "document-turntable", ...options });
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Frame the open document's subject (its selection if it has one). `fit`
|
|
296
|
+
* scales the fitted distance: 1 is the toolbar Frame button's tight fit.
|
|
297
|
+
*/
|
|
298
|
+
async frameDocument(fit) {
|
|
299
|
+
return this.command({
|
|
300
|
+
type: "document-frame",
|
|
301
|
+
...fit === void 0 ? {} : { fit }
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
async setCamera(position, target, fov) {
|
|
305
|
+
await this.command({
|
|
306
|
+
type: "set-camera",
|
|
307
|
+
position,
|
|
308
|
+
target,
|
|
309
|
+
...fov === void 0 ? {} : { fov }
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
async captureViewport(size) {
|
|
313
|
+
const data = await this.command({
|
|
314
|
+
type: "capture-viewport",
|
|
315
|
+
...size === void 0 ? {} : { size }
|
|
316
|
+
});
|
|
317
|
+
return { base64: data.base64, mimeType: data.mimeType };
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Unit 4 (live-front-door wave) — capture the RUNNING GAME (`vgai
|
|
321
|
+
* screenshot`'s wire leg). Sends the SAME `bridge-screenshot` relay op
|
|
322
|
+
* `@vgai/live`'s `RelayTransport.screenshot` (and therefore
|
|
323
|
+
* `game.screenshot()` on the relay path) already sends, so all three
|
|
324
|
+
* surfaces composite the identical full game stack — canvas(es) plus the
|
|
325
|
+
* HUD/react DOM layers — rather than any of them inventing a second,
|
|
326
|
+
* subtly-different capture path. Contrast {@link captureViewport}, which
|
|
327
|
+
* captures the EDITOR viewport's canvas and would silently hand back an
|
|
328
|
+
* editor-only (HUD-less, possibly not-even-playing) image.
|
|
329
|
+
*
|
|
330
|
+
* Rejects — loudly, via `command`'s own `{ok:false}` unwrap — when play
|
|
331
|
+
* mode isn't running ("not in play mode — start play before using the
|
|
332
|
+
* debug seam") or no game canvas is mounted yet. Never returns a blank or
|
|
333
|
+
* editor-only frame as a stand-in.
|
|
334
|
+
*
|
|
335
|
+
* `opts.refreshStarvedFrame` is the loop-starvation leg: without recent rAF
|
|
336
|
+
* progress the canvas holds a provably stale frame and the relay
|
|
337
|
+
* refuses it with `BRIDGE_SCREENSHOT_STALE` rather than pass it off as
|
|
338
|
+
* current. Setting this asks the relay to render exactly ONE deterministic
|
|
339
|
+
* tick (`runTicks(1, {render:'last'})`) first — the same escape
|
|
340
|
+
* `@vgai/live`'s `RelayTransport.screenshot` has always used, which is why
|
|
341
|
+
* `vgai eval` could recover these frames while `vgai screenshot` could not.
|
|
342
|
+
* Off by default: a caller who does not ask must never be handed a frame
|
|
343
|
+
* that only exists because the capture drove the game.
|
|
344
|
+
*/
|
|
345
|
+
async captureGame(opts) {
|
|
346
|
+
const data = await this.command({
|
|
347
|
+
type: "bridge-screenshot",
|
|
348
|
+
...opts?.refreshStarvedFrame === true ? { refreshStarvedFrame: true } : {}
|
|
349
|
+
});
|
|
350
|
+
const layers = data.layers;
|
|
351
|
+
const flatness = data.flatness;
|
|
352
|
+
return {
|
|
353
|
+
base64: data.base64,
|
|
354
|
+
mimeType: data.mimeType,
|
|
355
|
+
composite: data.composite === true,
|
|
356
|
+
...layers && Number.isInteger(layers.canvases) && Number.isInteger(layers.domOverlays) ? { layers } : {},
|
|
357
|
+
// Pass the pixel-honesty fields through as the page reported them: the
|
|
358
|
+
// warning sentence is written where the pixels are, so nothing here
|
|
359
|
+
// re-derives (or softens) it.
|
|
360
|
+
...flatness && typeof flatness.dominantFraction === "number" ? { flatness } : {},
|
|
361
|
+
...data.loopRecoveryFrame === true ? { loopRecoveryFrame: true } : {},
|
|
362
|
+
// Same pass-through rule: the recorded-run notice is written where the
|
|
363
|
+
// pixels are, so nothing here re-derives or softens it.
|
|
364
|
+
...data.recording && typeof data.recording.notice === "string" ? { recording: data.recording } : {}
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
/** Start recording the same clean running-game composite `captureGame`
|
|
368
|
+
* photographs. Recording state lives in the editor page, so another process
|
|
369
|
+
* may stop it later through the same project session. */
|
|
370
|
+
async startGameplayRecording(options = {}) {
|
|
371
|
+
return this.command({
|
|
372
|
+
type: "bridge-recording-start",
|
|
373
|
+
...options.fps !== void 0 ? { fps: options.fps } : {},
|
|
374
|
+
...options.name !== void 0 ? { name: options.name } : {},
|
|
375
|
+
...options.format !== void 0 ? { format: options.format } : {}
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
/** Export a paused run as fixed-step video. Advances game state; maximum
|
|
379
|
+
* five minutes. `audio` describes the muxed track, or is `false` when the
|
|
380
|
+
* world implements no `AudioAdapter.renderOffline` and the file is
|
|
381
|
+
* genuinely silent — read it, never assume either. */
|
|
382
|
+
async exportGameplayVideo(options) {
|
|
383
|
+
return this.command(
|
|
384
|
+
{ type: "bridge-recording-export", ...options },
|
|
385
|
+
{ deadlineMs: 61e4, retryTransport: false }
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
/** Stop the page-owned recorder and return its WebM path and metadata. */
|
|
389
|
+
async stopGameplayRecording() {
|
|
390
|
+
return this.command({ type: "bridge-recording-stop" });
|
|
391
|
+
}
|
|
392
|
+
/** Read the active capture's monotonic media position. This is the only clock
|
|
393
|
+
* suitable for selecting intervals inside the finalized recording. */
|
|
394
|
+
async getGameplayRecordingTimeline() {
|
|
395
|
+
const timeline = await this.command({
|
|
396
|
+
type: "bridge-recording-timeline"
|
|
397
|
+
});
|
|
398
|
+
return { startedAt: timeline.startedAt, elapsedMs: timeline.elapsedMs };
|
|
399
|
+
}
|
|
400
|
+
/** Encode a recorded canvas/DOM interval into a normal composite WebM. */
|
|
401
|
+
async exportGameplayReplay(options) {
|
|
402
|
+
return this.command(
|
|
403
|
+
{ type: "bridge-recording-replay-export", ...options },
|
|
404
|
+
{ deadlineMs: 61e4, retryTransport: false }
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
async captureGameplayReplay(replayPath, positionMs) {
|
|
408
|
+
return this.command({
|
|
409
|
+
type: "bridge-recording-replay-capture",
|
|
410
|
+
replayPath,
|
|
411
|
+
positionMs
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Capture an isolated, deterministic four-view preview through the editor's
|
|
416
|
+
* native Asset Lab. The SDK delegates rendering to the editor; it never
|
|
417
|
+
* loads, clones, or interprets Three.js assets itself.
|
|
418
|
+
*/
|
|
419
|
+
async captureAssetPreview(source, options = {}) {
|
|
420
|
+
const data = await this.command(
|
|
421
|
+
{
|
|
422
|
+
type: "capture-asset-preview",
|
|
423
|
+
...source,
|
|
424
|
+
...options
|
|
425
|
+
},
|
|
426
|
+
// Photographing changes nothing, and this is the relay `vgai screenshot
|
|
427
|
+
// <module>` / `project.bake.preview` rides — the lane where a momentary
|
|
428
|
+
// transport failure cost a cold agent three probe modules.
|
|
429
|
+
{ retryTransport: true }
|
|
430
|
+
);
|
|
431
|
+
return {
|
|
432
|
+
width: data.width,
|
|
433
|
+
height: data.height,
|
|
434
|
+
// Absent from editors that predate orientation reporting.
|
|
435
|
+
...data.orientation ? { orientation: data.orientation } : {},
|
|
436
|
+
views: data.views,
|
|
437
|
+
contactSheet: data.contactSheet
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* A project-defined labeled shot set (`vgai screenshot <target> --shots <set>`):
|
|
442
|
+
* the DEFINITION travels with the command (project data — see
|
|
443
|
+
* `AssetPreviewShotSetDefinition`; the CLI resolves it from the registered
|
|
444
|
+
* `project.<set>.previewShots` tool), and the editor's generic
|
|
445
|
+
* capture engine renders it — see `packages/editor/src/asset-preview.ts`'s
|
|
446
|
+
* `captureShotSetAssetPreview`. Throws (via `command`'s `{ok:false}`
|
|
447
|
+
* unwrap) with a clear message naming the missing joint(s) when the asset
|
|
448
|
+
* lacks a bone the definition requires.
|
|
449
|
+
*/
|
|
450
|
+
async captureShotSetPreview(source, definition, options = {}) {
|
|
451
|
+
const data = await this.command(
|
|
452
|
+
{
|
|
453
|
+
type: "capture-asset-preview",
|
|
454
|
+
...source,
|
|
455
|
+
...options,
|
|
456
|
+
shotSet: definition
|
|
457
|
+
},
|
|
458
|
+
{ retryTransport: true }
|
|
459
|
+
);
|
|
460
|
+
return {
|
|
461
|
+
width: data.width,
|
|
462
|
+
height: data.height,
|
|
463
|
+
shots: data.shots,
|
|
464
|
+
// An older editor predates the empty-frame guard and sends none.
|
|
465
|
+
warnings: data.warnings ?? [],
|
|
466
|
+
contactSheet: data.contactSheet
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* B8.4 — score the asset against a reference GLB (`vgai screenshot
|
|
471
|
+
* <model.glb> --compare <ref.glb>`): matched orthographic front + side silhouettes
|
|
472
|
+
* (equal-height bounding-box framing, both yaw-normalized to face the
|
|
473
|
+
* camera), per-view IoU numbers, and overlay evidence images. The
|
|
474
|
+
* reference GLB's raw bytes travel base64 in the command; the editor
|
|
475
|
+
* renders and scores — the SDK never interprets Three.js assets itself.
|
|
476
|
+
*/
|
|
477
|
+
async captureAssetComparePreview(source, refGlbBase64, options = {}) {
|
|
478
|
+
const { refForward, ...dimensions } = options;
|
|
479
|
+
const data = await this.command({
|
|
480
|
+
type: "capture-asset-preview",
|
|
481
|
+
...source,
|
|
482
|
+
...dimensions,
|
|
483
|
+
compare: { glbBase64: refGlbBase64, ...refForward ? { forward: refForward } : {} }
|
|
484
|
+
});
|
|
485
|
+
return { width: data.width, height: data.height, views: data.views };
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* The STORY lane (`vgai screenshot <file>.stories.tsx`): every CSF export of
|
|
489
|
+
* one project story file rendered in the live session's DOM and captured
|
|
490
|
+
* through the same composite leg {@link captureGame} uses, returned as
|
|
491
|
+
* per-export images plus one variant sheet. `options.story` narrows to a
|
|
492
|
+
* single export.
|
|
493
|
+
*
|
|
494
|
+
* Rendering happens in the EDITOR — the SDK never imports, composes or
|
|
495
|
+
* mounts a CSF module itself; the session already owns that machinery for
|
|
496
|
+
* its Stories panel and this drives it.
|
|
497
|
+
*/
|
|
498
|
+
async captureStoryVariants(modulePath, options = {}) {
|
|
499
|
+
const data = await this.command({
|
|
500
|
+
type: "capture-story-variants",
|
|
501
|
+
modulePath,
|
|
502
|
+
...options
|
|
503
|
+
});
|
|
504
|
+
return {
|
|
505
|
+
modulePath: data.modulePath,
|
|
506
|
+
width: data.width,
|
|
507
|
+
height: data.height,
|
|
508
|
+
variants: data.variants,
|
|
509
|
+
contactSheet: data.contactSheet
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
// --- Panels ---
|
|
513
|
+
async showViewport(tab) {
|
|
514
|
+
await this.command({ type: "viewport-tab", tab });
|
|
515
|
+
}
|
|
516
|
+
/** Focus a static workspace panel by the key the editor's panel registry
|
|
517
|
+
* holds; an unknown key refuses naming the keys it does hold. */
|
|
518
|
+
async showPanel(panel) {
|
|
519
|
+
await this.command({ type: "show-panel", panel });
|
|
520
|
+
}
|
|
521
|
+
/** Show several instances of the running game split-screen — multiplayer
|
|
522
|
+
* authoring. Pass a total `count` (default "Player N" labels) or an array of
|
|
523
|
+
* `names` (its length is the count; index 0 is the primary). Requires a live
|
|
524
|
+
* play session. */
|
|
525
|
+
async setInstanceCount(countOrNames) {
|
|
526
|
+
await this.command(
|
|
527
|
+
Array.isArray(countOrNames) ? { type: "set-instance-count", names: countOrNames } : { type: "set-instance-count", count: countOrNames }
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
async openAsset(path, kind) {
|
|
531
|
+
await this.command({ type: "open-asset-tab", path, kind });
|
|
532
|
+
}
|
|
533
|
+
/** SELECT a project asset — the other half of the browser's
|
|
534
|
+
* selection-vs-open contract (single click selects and fills the
|
|
535
|
+
* Inspector; double click opens a document). */
|
|
536
|
+
async selectAsset(path) {
|
|
537
|
+
await this.command({ type: "select-asset", path });
|
|
538
|
+
}
|
|
539
|
+
async closeAsset(key) {
|
|
540
|
+
await this.command({ type: "close-asset-tab", key });
|
|
541
|
+
}
|
|
542
|
+
async toggleCommandPalette() {
|
|
543
|
+
await this.command({ type: "toggle-command-palette" });
|
|
544
|
+
}
|
|
545
|
+
async toggleConsole() {
|
|
546
|
+
await this.command({ type: "toggle-console" });
|
|
547
|
+
}
|
|
548
|
+
/** Switch the editor's NAMED WORKSPACE — the task-named layout memory
|
|
549
|
+
* (`game`/`model`/`sculpt`/`texture`/`animate`/`look`). Resolves once the
|
|
550
|
+
* dock has finished rebuilding, so a following capture photographs the
|
|
551
|
+
* arrangement that was asked for. */
|
|
552
|
+
async setWorkspace(workspace) {
|
|
553
|
+
await this.command({ type: "set-workspace", workspace });
|
|
554
|
+
}
|
|
555
|
+
/** Apply a STYLE BUNDLE — palette, material, icon set and region defaults
|
|
556
|
+
* in one gesture (`classic`/`glass`/`maya`/`substance`, or one a package
|
|
557
|
+
* the project declares carries, `blender`). */
|
|
558
|
+
async setStyle(style) {
|
|
559
|
+
await this.command({ type: "set-style", style });
|
|
560
|
+
}
|
|
561
|
+
/** Set the MATERIAL apart from the bundle that usually carries it.
|
|
562
|
+
* Answers with what the chrome wears afterwards. */
|
|
563
|
+
async setAppearance(appearance) {
|
|
564
|
+
return this.command({
|
|
565
|
+
type: "set-appearance",
|
|
566
|
+
...appearance
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
async showBuild() {
|
|
570
|
+
await this.command({ type: "show-build" });
|
|
571
|
+
}
|
|
572
|
+
/** Atomically present a durable editor view and return its shareable URL. */
|
|
573
|
+
async present(view) {
|
|
574
|
+
const presented = await this.command({ type: "present-view", view });
|
|
575
|
+
return { view: presented.view, url: presented.url, warnings: presented.warnings };
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* The INSPECTION SUBJECT the editor is showing right now, as data — the
|
|
579
|
+
* serialized projection of the inspection model (design:
|
|
580
|
+
* `docs/ARCHITECTURE-CORE.md` §Editor chrome, "The Inspection Model").
|
|
581
|
+
*
|
|
582
|
+
* The same subject a human reads in the inspector: identity, presentation,
|
|
583
|
+
* verbs, and the identified sections in display order — with a `fields`
|
|
584
|
+
* section's CURRENT VALUES read through the same io the field rows edit
|
|
585
|
+
* through. With nothing selected it answers the active surface's own
|
|
586
|
+
* no-selection subject when it has one, exactly as the panel does; it never
|
|
587
|
+
* reports another surface's, and when the panel itself is unmounted it
|
|
588
|
+
* answers `{none: true}` rather than a subject nobody is looking at. A
|
|
589
|
+
* `custom` section body is a named opaque (`{kind, id, title}`) — the editor
|
|
590
|
+
* renders those with React — plus its displayed values under `data` when it
|
|
591
|
+
* has any (the Transform section's position/rotation/scale).
|
|
592
|
+
*/
|
|
593
|
+
async inspect() {
|
|
594
|
+
const data = await this.command({ type: "inspect" });
|
|
595
|
+
return data.subject;
|
|
596
|
+
}
|
|
597
|
+
/** Run one verb exposed by the active Inspector subject, by its id. */
|
|
598
|
+
async runInspectionAction(actionId) {
|
|
599
|
+
const data = await this.command({
|
|
600
|
+
type: "run-inspection-action",
|
|
601
|
+
actionId
|
|
602
|
+
});
|
|
603
|
+
return data.subject;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Run ONE command by id — the door to everything the command palette lists.
|
|
607
|
+
*
|
|
608
|
+
* Under the Code-OSS frame this is the workbench's own `ICommandService`, so
|
|
609
|
+
* any command id works: a view's `vgai.<view>.<verb>`, an editor action's
|
|
610
|
+
* `vgai.action.<id>`, or one of VS Code's own. Standalone `vgai edit` has no
|
|
611
|
+
* command service and answers the `vgai.<view>.<verb>` shape directly off
|
|
612
|
+
* the views registry, refusing anything else BY NAME.
|
|
613
|
+
*
|
|
614
|
+
* The result is whatever the command answered — a view verb's state, or
|
|
615
|
+
* `null` for a command that returns nothing.
|
|
616
|
+
*/
|
|
617
|
+
async runCommand(commandId, args) {
|
|
618
|
+
const data = await this.command({
|
|
619
|
+
type: "run-command",
|
|
620
|
+
commandId,
|
|
621
|
+
...args === void 0 ? {} : { args }
|
|
622
|
+
});
|
|
623
|
+
return data.result;
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* One STRUCTURE op on the authored tree — the hierarchy context menu's own
|
|
627
|
+
* verbs, on the same helpers, for a caller with no pointer to right-click
|
|
628
|
+
* with. `id`/`ids` default to the current selection.
|
|
629
|
+
*/
|
|
630
|
+
async structureOp(op, options = {}) {
|
|
631
|
+
return this.command({ type: "structure-op", op, ...options });
|
|
632
|
+
}
|
|
633
|
+
/** "Extract Component…" — the hierarchy row's action, as a command. Answers
|
|
634
|
+
* the action's own sentence, which NAMES the files it created. */
|
|
635
|
+
async extractComponent(options = {}) {
|
|
636
|
+
return this.command({ type: "extract-component", ...options });
|
|
637
|
+
}
|
|
638
|
+
/** "Fork Component…" — extract's twin: one new file, one callsite retargeted. */
|
|
639
|
+
async forkComponent(options = {}) {
|
|
640
|
+
return this.command({ type: "fork-component", ...options });
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* The HIERARCHY PANEL's actual rendered row tree, as data.
|
|
644
|
+
*
|
|
645
|
+
* The same rows a human is looking at: the adapter's tree after the component
|
|
646
|
+
* marks fold implementation subtrees, after the internals reveal, after the
|
|
647
|
+
* document promotion, the child cap, the collapse state, the search filter
|
|
648
|
+
* and the selection scope. Works in play mode and edit mode alike — the
|
|
649
|
+
* answer reports which (`playState`, `activeViewportTab`), because a
|
|
650
|
+
* play-mode tree and an edit-mode tree come from different adapters.
|
|
651
|
+
*
|
|
652
|
+
* Deliberately NOT `status().entities`, which walks the raw adapter tree and
|
|
653
|
+
* therefore answers a different question: a panel defect is invisible in it.
|
|
654
|
+
*
|
|
655
|
+
* Each row carries `childCount` (what its caret opens), `internalChildCount`
|
|
656
|
+
* (what is folded behind "Reveal Internals") and `expandable` (whether the
|
|
657
|
+
* panel draws a caret at all) — so "this subtree exists but the UI offers no
|
|
658
|
+
* way to open it" is a readable fact rather than something only a human
|
|
659
|
+
* squinting at the panel can notice.
|
|
660
|
+
*
|
|
661
|
+
* Rejects, naming the panel, when no hierarchy panel is mounted: an empty
|
|
662
|
+
* tree would be a fabricated answer about a surface nobody is being shown.
|
|
663
|
+
*/
|
|
664
|
+
async hierarchy() {
|
|
665
|
+
const data = await this.command({ type: "hierarchy" });
|
|
666
|
+
return data.hierarchy;
|
|
667
|
+
}
|
|
668
|
+
/** Run the Hierarchy panel's own Expand All action. */
|
|
669
|
+
async expandHierarchyAll() {
|
|
670
|
+
await this.command({ type: "expand-hierarchy-all" });
|
|
671
|
+
}
|
|
672
|
+
/** Run the Hierarchy panel's own Collapse All action — Expand All's other
|
|
673
|
+
* half, and the only way back to the tree's rest state through the product
|
|
674
|
+
* (see `HierarchyPanelSnapshot.collapseAll`). */
|
|
675
|
+
async collapseHierarchyAll() {
|
|
676
|
+
await this.command({ type: "collapse-hierarchy-all" });
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Write one editable path through the active Inspector's own IO.
|
|
680
|
+
*
|
|
681
|
+
* The answer carries `write` as well as the subject, because an ack alone
|
|
682
|
+
* cannot be believed: a write with no persistence route open succeeds and
|
|
683
|
+
* changes no byte, and `write.persisted` is how the caller tells the two
|
|
684
|
+
* apart without diffing the tree (`InspectedWriteDestination` in `types.ts`).
|
|
685
|
+
*/
|
|
686
|
+
async setInspectionField(path, value) {
|
|
687
|
+
const data = await this.command({
|
|
688
|
+
type: "set-inspection-field",
|
|
689
|
+
path,
|
|
690
|
+
value
|
|
691
|
+
});
|
|
692
|
+
return { subject: data.subject, write: data.write };
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* REMOVE one editable path's authored override — the other half of the write
|
|
696
|
+
* door, and the only one that can express byte-ABSENCE.
|
|
697
|
+
*
|
|
698
|
+
* {@link setInspectionField} writes a VALUE, so reverting a property an
|
|
699
|
+
* authoring gesture ADDED puts the default back EXPLICITLY and leaves the
|
|
700
|
+
* source one attribute heavier than it started. This drops the property, so
|
|
701
|
+
* whatever governs it in its absence takes over — the same `io.remove` the
|
|
702
|
+
* Inspector's revert arrow calls, the same persistence pipe, the same awaited
|
|
703
|
+
* `{ destination, persisted }` ack.
|
|
704
|
+
*
|
|
705
|
+
* Rejects with `code: 'REMOVAL_UNAVAILABLE'` when the field does not declare
|
|
706
|
+
* itself removable or the lane implements no removal door. That refusal is a
|
|
707
|
+
* MISSING SEAM, not a failed removal, and it is coded rather than phrased
|
|
708
|
+
* precisely so a caller can grade the two differently.
|
|
709
|
+
*/
|
|
710
|
+
async removeInspectionField(path) {
|
|
711
|
+
const data = await this.command({
|
|
712
|
+
type: "remove-inspection-field",
|
|
713
|
+
path
|
|
714
|
+
});
|
|
715
|
+
return { subject: data.subject, write: data.write };
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* OPEN one piece of the adapter's SCENE TABLE by id — a scene, a prefab, or
|
|
719
|
+
* a story state, because the table makes them siblings (they differ only in
|
|
720
|
+
* instance site). The ids are exactly what `getState().adapter.scenes.entries`
|
|
721
|
+
* reports, so the table is both the menu and the address space.
|
|
722
|
+
*
|
|
723
|
+
* With a game LIVE in the session, opening a scene the adapter declares
|
|
724
|
+
* reachable through that game's own scenes contract NAVIGATES it — the same
|
|
725
|
+
* switch the editor's own scene picker makes — and the answer carries the
|
|
726
|
+
* game's own reading (`scene`).
|
|
727
|
+
*
|
|
728
|
+
* Rejects with a coded reason rather than prose: `SCENE_NOT_FOUND` (and it
|
|
729
|
+
* names the ids that DO exist), `SCENE_NOT_OPENABLE` carrying the adapter's
|
|
730
|
+
* own declared reason for a scene it says nothing can reach,
|
|
731
|
+
* `SCENE_NAVIGATION_NOT_RUNNING` for a live-only scene with no game running,
|
|
732
|
+
* `SCENE_CONTRACT_UNAVAILABLE` / `SCENE_NOT_IN_CONTRACT` (naming the ids the
|
|
733
|
+
* game itself publishes) / `SCENE_SWITCH_FAILED` when the running game's own
|
|
734
|
+
* navigation cannot take it, `SCENE_NOT_OPENABLE_LIVE` when this session has
|
|
735
|
+
* no remount for a native swap-slot scene,
|
|
736
|
+
* `SCENE_TABLE_UNAVAILABLE` before the adapter has loaded, and
|
|
737
|
+
* `SCENE_DOCUMENT_NOT_MOUNTED` when the host has no document for a piece the
|
|
738
|
+
* table says is openable — a host gap, not a table statement.
|
|
739
|
+
*/
|
|
740
|
+
async open(id) {
|
|
741
|
+
return this.command({ type: "open", id });
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Undo / redo one project transaction — the same queue the keyboard shortcut
|
|
745
|
+
* drives. `moved` is false when there was nothing left in that direction.
|
|
746
|
+
*/
|
|
747
|
+
async undo() {
|
|
748
|
+
return this.command({ type: "undo" });
|
|
749
|
+
}
|
|
750
|
+
async redo() {
|
|
751
|
+
return this.command({ type: "redo" });
|
|
752
|
+
}
|
|
753
|
+
/** Read the editor's actual current durable projection. */
|
|
754
|
+
async currentView() {
|
|
755
|
+
const data = await this.command({ type: "current-view" });
|
|
756
|
+
return data.view;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Capture the active center document exactly as presented to the user.
|
|
760
|
+
*
|
|
761
|
+
* A number is a SQUARE of that size (the default shape); `{width, height}`
|
|
762
|
+
* asks for a shaped frame — a video-aspect look that needs no crop. Both are
|
|
763
|
+
* bounded by the relay budget; see {@link CaptureDimensions}.
|
|
764
|
+
*/
|
|
765
|
+
/** Photograph the editor PAGE itself — every panel as the person sees it, at
|
|
766
|
+
* `scale` output pixels per CSS pixel (default `devicePixelRatio`), which is
|
|
767
|
+
* what a 1 px border or a glyph edge is judged through. */
|
|
768
|
+
async captureEditorChrome(options) {
|
|
769
|
+
return this.command({
|
|
770
|
+
type: "capture-editor-chrome",
|
|
771
|
+
...options?.scale === void 0 ? {} : { scale: options.scale }
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
/** With a view, present and capture it in one request so document discovery
|
|
775
|
+
* cannot retarget the capture between two client calls. */
|
|
776
|
+
async captureActiveDocument(size, view) {
|
|
777
|
+
return this.command({
|
|
778
|
+
type: "capture-active-document",
|
|
779
|
+
...view ? { view } : {},
|
|
780
|
+
...typeof size === "number" ? { size } : {},
|
|
781
|
+
...typeof size === "object" && size !== null ? { width: size.width, height: size.height } : {}
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Read or drive the ACTIVE center document's own DOM — the scoped
|
|
786
|
+
* editor-chrome door, and the read/gesture half of the same subject
|
|
787
|
+
* {@link captureActiveDocument} photographs. NOT play-mode gated, and NOT
|
|
788
|
+
* page automation: a target outside the active document's container is
|
|
789
|
+
* refused by name. Design and scope contract:
|
|
790
|
+
* `packages/editor/src/editor-document-probe.ts`.
|
|
791
|
+
*/
|
|
792
|
+
async documentProbe(step) {
|
|
793
|
+
return this.command({ type: "document-probe", step });
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Run a wire-carried step against the ACTIVE document's published context
|
|
797
|
+
* (`packages/editor/src/document-context-registry.ts`) — the REPL door over
|
|
798
|
+
* an open document, in Edit mode. `src` is the step's own `toString()`;
|
|
799
|
+
* same serialization contract as `page-script` (no closures survive).
|
|
800
|
+
*/
|
|
801
|
+
/**
|
|
802
|
+
* The Blender lane's doors (`blender-execute`, `blender-scene-info`,
|
|
803
|
+
* `blender-object-info`, `blender-screenshot-view`, `blender-read-file`,
|
|
804
|
+
* `blender-write-file`, `blender-list-files`, `blender-start`,
|
|
805
|
+
* `blender-status`): Blender runs in the editor tab's worker, and
|
|
806
|
+
* `vgai blender-mcp` is transport onto these. `blender-status` is the only
|
|
807
|
+
* one that creates nothing — it answers whether this tab already has a
|
|
808
|
+
* session, which is how a caller survives an editor restart.
|
|
809
|
+
*/
|
|
810
|
+
async blender(type, fields = {}) {
|
|
811
|
+
return this.command({ type, ...fields }, { deadlineMs: BLENDER_DEADLINE_MS });
|
|
812
|
+
}
|
|
813
|
+
async documentScript(src) {
|
|
814
|
+
const outcome = await this.command({ type: "document-script", src });
|
|
815
|
+
return outcome.result;
|
|
816
|
+
}
|
|
817
|
+
// --- Display (set semantics) ---
|
|
818
|
+
async setGrid(enabled) {
|
|
819
|
+
await this.command({ type: "set-grid", enabled });
|
|
820
|
+
}
|
|
821
|
+
async setHelpers(enabled) {
|
|
822
|
+
await this.command({ type: "set-helpers", enabled });
|
|
823
|
+
}
|
|
824
|
+
async setStats(enabled) {
|
|
825
|
+
await this.command({ type: "set-stats", enabled });
|
|
826
|
+
}
|
|
827
|
+
async setShadingMode(mode) {
|
|
828
|
+
await this.command({ type: "set-shading-mode", mode });
|
|
829
|
+
}
|
|
830
|
+
async setHelperType(helperType, enabled) {
|
|
831
|
+
await this.command({ type: "set-helper-type", helperType, enabled });
|
|
832
|
+
}
|
|
833
|
+
// --- Transform tools (set semantics) ---
|
|
834
|
+
async setTransformMode(mode) {
|
|
835
|
+
await this.command({ type: "set-transform-mode", mode });
|
|
836
|
+
}
|
|
837
|
+
async setTransformSpace(space) {
|
|
838
|
+
await this.command({ type: "set-transform-space", space });
|
|
839
|
+
}
|
|
840
|
+
async setSnap(enabled) {
|
|
841
|
+
await this.command({ type: "set-snap", enabled });
|
|
842
|
+
}
|
|
843
|
+
// --- Project management ---
|
|
844
|
+
async createProject(name, location, template = "default", exampleId) {
|
|
845
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/create-project`, {
|
|
846
|
+
method: "POST",
|
|
847
|
+
headers: { "Content-Type": "application/json" },
|
|
848
|
+
body: JSON.stringify({ name, location, template, ...exampleId ? { exampleId } : {} })
|
|
849
|
+
});
|
|
850
|
+
const data = await this.readJson(res);
|
|
851
|
+
if (!res.ok) {
|
|
852
|
+
throw new Error(data.error ?? `Create project failed: ${res.status}`);
|
|
853
|
+
}
|
|
854
|
+
return { path: data.path, config: data.config };
|
|
855
|
+
}
|
|
856
|
+
async openProject(path) {
|
|
857
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/open-project`, {
|
|
858
|
+
method: "POST",
|
|
859
|
+
headers: { "Content-Type": "application/json" },
|
|
860
|
+
body: JSON.stringify({ path })
|
|
861
|
+
});
|
|
862
|
+
const body = await this.readJson(res);
|
|
863
|
+
if (!res.ok) {
|
|
864
|
+
throw new Error(body.error ?? `Open project failed: ${res.status}`);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
async getProject() {
|
|
868
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/project`);
|
|
869
|
+
const data = await this.readJson(res);
|
|
870
|
+
if (!res.ok) throw new Error(data.error ?? `Failed to get project: ${res.status}`);
|
|
871
|
+
return data.project;
|
|
872
|
+
}
|
|
873
|
+
async listRecentProjects() {
|
|
874
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/recent-projects`);
|
|
875
|
+
const data = await this.readJson(res);
|
|
876
|
+
if (!res.ok) throw new Error(data.error ?? `Failed to list projects: ${res.status}`);
|
|
877
|
+
return data.projects;
|
|
878
|
+
}
|
|
879
|
+
// --- Registered project tools ---
|
|
880
|
+
/** List tools explicitly registered in `package.json#vgai.tools`.
|
|
881
|
+
* The editor server loads callable metadata in Node; modules never enter the
|
|
882
|
+
* editor browser merely because they were listed. */
|
|
883
|
+
async listProjectTools() {
|
|
884
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/project-tools`);
|
|
885
|
+
const body = await this.readJson(res);
|
|
886
|
+
if (!res.ok) throw new Error(`Failed to list project tools: ${res.status}`);
|
|
887
|
+
return body;
|
|
888
|
+
}
|
|
889
|
+
/** Execute one Node-hosted project tool through the shared validated
|
|
890
|
+
* dispatcher. Write/destructive tools require `confirm:true`. */
|
|
891
|
+
async runProjectTool(name, input = {}, options = {}) {
|
|
892
|
+
const dispatcher = createDispatcher(0);
|
|
893
|
+
try {
|
|
894
|
+
const res = await this.httpFetch(
|
|
895
|
+
`${this.baseUrl}/__editor/project-tools/run`,
|
|
896
|
+
{
|
|
897
|
+
method: "POST",
|
|
898
|
+
headers: { "Content-Type": "application/json" },
|
|
899
|
+
body: JSON.stringify({
|
|
900
|
+
name,
|
|
901
|
+
input,
|
|
902
|
+
confirm: options.confirm === true,
|
|
903
|
+
// Omitted (not null) when unset — the wire body is JSON and the tool
|
|
904
|
+
// host reads absence as "the sole instance", same convention as the
|
|
905
|
+
// relay's `instance`.
|
|
906
|
+
...options.instance !== void 0 ? { instance: options.instance } : {}
|
|
907
|
+
})
|
|
908
|
+
},
|
|
909
|
+
dispatcher
|
|
910
|
+
);
|
|
911
|
+
const body = await this.readJson(res);
|
|
912
|
+
if (!body || typeof body !== "object" || typeof body.ok !== "boolean") {
|
|
913
|
+
throw new Error(`Project tool returned an invalid response (${res.status}).`);
|
|
914
|
+
}
|
|
915
|
+
return body;
|
|
916
|
+
} finally {
|
|
917
|
+
await dispatcher?.destroy?.();
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
// --- First-party generation job activity ---
|
|
921
|
+
/** Read the one project-local generation job ledger. Provider-native
|
|
922
|
+
* request/result shapes remain on their registered operations. */
|
|
923
|
+
async listGenerationJobs() {
|
|
924
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/generations`);
|
|
925
|
+
const body = await this.readJson(res);
|
|
926
|
+
if (!res.ok) throw new Error(`Failed to list generation jobs: ${res.status}`);
|
|
927
|
+
return body;
|
|
928
|
+
}
|
|
929
|
+
/** Forget operational job state. Accepted provenance and project assets
|
|
930
|
+
* are deliberately unaffected. */
|
|
931
|
+
async forgetGenerationJob(id) {
|
|
932
|
+
const res = await this.httpFetch(
|
|
933
|
+
`${this.baseUrl}/__editor/generations/${encodeURIComponent(id)}`,
|
|
934
|
+
{
|
|
935
|
+
method: "DELETE"
|
|
936
|
+
}
|
|
937
|
+
);
|
|
938
|
+
const body = await this.readJson(res);
|
|
939
|
+
if (!res.ok) throw new Error(body.error ?? `Failed to forget generation job: ${res.status}`);
|
|
940
|
+
return body.removed === true;
|
|
941
|
+
}
|
|
942
|
+
// --- Logs ---
|
|
943
|
+
async getLogEntries() {
|
|
944
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/log-entries`);
|
|
945
|
+
const data = await this.readJson(res);
|
|
946
|
+
if (!res.ok) return [];
|
|
947
|
+
return data.entries;
|
|
948
|
+
}
|
|
949
|
+
// --- State ---
|
|
950
|
+
/**
|
|
951
|
+
* The document table the host resolved — every scene, prefab, page, model,
|
|
952
|
+
* shot, take … the project's finders produced (`getState().adapter.scenes`
|
|
953
|
+
* is the same projection). A command, so it answers wherever the control
|
|
954
|
+
* channel reaches, not only where `/__editor/state` is served.
|
|
955
|
+
*/
|
|
956
|
+
async documentTable() {
|
|
957
|
+
return this.command({ type: "document-table" });
|
|
958
|
+
}
|
|
959
|
+
async getState() {
|
|
960
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/state`);
|
|
961
|
+
const state = await this.readJson(res);
|
|
962
|
+
if (!res.ok) throw new Error(`Failed to get editor state: ${res.status} ${res.statusText}`);
|
|
963
|
+
return state;
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* The complete unresolved console set the session is holding right now.
|
|
967
|
+
*
|
|
968
|
+
* Command envelopes only carry COUNTS (`unresolvedConsole` on
|
|
969
|
+
* `commandResponseFor`). The named conditions live on GET `/__editor/console`.
|
|
970
|
+
* This is the method that turns "a command that exits before an envelope
|
|
971
|
+
* arrives" into a real reading: the CLI calls it at start and at exit
|
|
972
|
+
* through the same {@link onEnvelope} observer every other response uses.
|
|
973
|
+
* A session that does not answer within {@link CONSOLE_DRAIN_TIMEOUT_MS} is
|
|
974
|
+
* a thrown error the caller treats as "nothing learned", never a hang.
|
|
975
|
+
*/
|
|
976
|
+
async getUnresolvedConsole(opts) {
|
|
977
|
+
const res = await this.httpFetch(
|
|
978
|
+
`${this.baseUrl}/__editor/console${opts?.all === true ? "?all=1" : ""}`,
|
|
979
|
+
{ signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS) }
|
|
980
|
+
);
|
|
981
|
+
if (!res.ok) {
|
|
982
|
+
throw new Error(`Failed to read unresolved console: ${res.status}`);
|
|
983
|
+
}
|
|
984
|
+
return await this.readJson(res, true);
|
|
985
|
+
}
|
|
986
|
+
/** Acknowledge one named console condition. The response is observed and
|
|
987
|
+
* hydrated through the same path as every other client response. */
|
|
988
|
+
async acknowledgeConsole(input) {
|
|
989
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/console/ack`, {
|
|
990
|
+
method: "POST",
|
|
991
|
+
headers: { "Content-Type": "application/json" },
|
|
992
|
+
body: JSON.stringify(input),
|
|
993
|
+
signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS * 4)
|
|
994
|
+
});
|
|
995
|
+
const body = await this.readJson(res);
|
|
996
|
+
return body;
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* `fetch` for this client's plain routes, with the ONE thing Node's `fetch`
|
|
1000
|
+
* will not do: name why it failed.
|
|
1001
|
+
*
|
|
1002
|
+
* `readJson` below already owns "the server answered the wrong thing"; this
|
|
1003
|
+
* owns "nothing answered at all", which used to reach the caller as the bare
|
|
1004
|
+
* `TypeError: fetch failed` with the real code buried on `.cause`. No retry
|
|
1005
|
+
* here — these routes create projects, run tools and acknowledge console
|
|
1006
|
+
* conditions, so repeating one is the caller's decision. The relayed
|
|
1007
|
+
* `command` path above has its own opt-in retry for the read-only captures.
|
|
1008
|
+
*/
|
|
1009
|
+
async httpFetch(url, init, dispatcher) {
|
|
1010
|
+
try {
|
|
1011
|
+
return await dispatchFetch(url, init, dispatcher);
|
|
1012
|
+
} catch (error) {
|
|
1013
|
+
if (error?.name === "TimeoutError") throw error;
|
|
1014
|
+
const { code, detail } = describeFetchFailure(error);
|
|
1015
|
+
throw new EditorCommandError(
|
|
1016
|
+
`${init?.method ?? "GET"} ${url} never reached the editor: ${code}${detail ? ` (${detail})` : ""}. Nothing answered on that port \u2014 check the terminal running \`volter-editor edit\` and confirm the port this client resolved.`,
|
|
1017
|
+
code,
|
|
1018
|
+
false
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
/** Parse one JSON body and hand it to {@link onEnvelope}.
|
|
1023
|
+
*
|
|
1024
|
+
* A command/state envelope carries current counts but not the named set. If
|
|
1025
|
+
* an observer is installed, do the bounded console GET before resolving the
|
|
1026
|
+
* original request. That makes a subsequent `process.exit()` safe: the
|
|
1027
|
+
* observer has already received every condition and occurrence count. */
|
|
1028
|
+
async readJson(res, consoleComplete = false) {
|
|
1029
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
1030
|
+
if (!contentType.includes("application/json")) {
|
|
1031
|
+
throw new Error(
|
|
1032
|
+
`The editor at ${this.baseUrl} answered with its page fallback (${contentType || "no content-type"}) rather than JSON, so no editor server handled the request. Check that this URL is a running \`volter-editor edit\` session.`
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
const body = await res.json();
|
|
1036
|
+
this.observe(body, { unresolvedConsoleComplete: consoleComplete });
|
|
1037
|
+
if (this.onEnvelope !== null && !consoleComplete && body !== null && typeof body === "object" && "unresolvedConsole" in body) {
|
|
1038
|
+
try {
|
|
1039
|
+
const consoleRes = await this.httpFetch(`${this.baseUrl}/__editor/console`, {
|
|
1040
|
+
signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS)
|
|
1041
|
+
});
|
|
1042
|
+
if (consoleRes.ok) {
|
|
1043
|
+
const consoleBody = await consoleRes.json();
|
|
1044
|
+
this.observe(consoleBody, { unresolvedConsoleComplete: true });
|
|
1045
|
+
}
|
|
1046
|
+
} catch {
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
return body;
|
|
1050
|
+
}
|
|
1051
|
+
/** Hand one response body to {@link onEnvelope}, never letting it throw. */
|
|
1052
|
+
observe(body, observation) {
|
|
1053
|
+
if (this.onEnvelope === null) return;
|
|
1054
|
+
try {
|
|
1055
|
+
this.onEnvelope(body, observation);
|
|
1056
|
+
} catch {
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Whether an editor browser tab is connected to the server *right now*.
|
|
1061
|
+
* Unlike {@link getState}, this reflects live SSE connections, not cached
|
|
1062
|
+
* state — use it to check whether commands will actually reach an editor.
|
|
1063
|
+
*/
|
|
1064
|
+
async isConnected() {
|
|
1065
|
+
const state = await this.getState();
|
|
1066
|
+
return state.connected === true;
|
|
1067
|
+
}
|
|
1068
|
+
async waitForState(predicate, timeoutMs = 1e4) {
|
|
1069
|
+
const start = Date.now();
|
|
1070
|
+
while (Date.now() - start < timeoutMs) {
|
|
1071
|
+
const state = await this.getState();
|
|
1072
|
+
if (predicate(state)) return state;
|
|
1073
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1074
|
+
}
|
|
1075
|
+
throw new Error(`waitForState timed out after ${timeoutMs}ms`);
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
|
|
1079
|
+
// src/editor-document.ts
|
|
1080
|
+
var LiveEditorDocument = class {
|
|
1081
|
+
#client;
|
|
1082
|
+
constructor(client) {
|
|
1083
|
+
this.#client = client;
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Read matching elements inside the active document: tag, text, attributes,
|
|
1087
|
+
* value/checked/disabled and rect. `matched` is the total before `limit`.
|
|
1088
|
+
*
|
|
1089
|
+
* `styles` additionally resolves named properties per match — and resolving
|
|
1090
|
+
* is the point, because a theme token is an expression until an element
|
|
1091
|
+
* paints it. Ask for the standard property to learn the colour a person
|
|
1092
|
+
* sees; ask for a `--vgai-…` custom property to learn what a rule WOULD
|
|
1093
|
+
* paint, which is the only way to measure a `:hover` colour (`:hover` is a
|
|
1094
|
+
* browser state no synthetic event can enter, so there is deliberately no
|
|
1095
|
+
* hover verb on this door).
|
|
1096
|
+
*
|
|
1097
|
+
* await editor.document.query('.vgai-tree-row', {
|
|
1098
|
+
* styles: ['backgroundColor', '--vgai-widget-regular-hover'],
|
|
1099
|
+
* });
|
|
1100
|
+
*/
|
|
1101
|
+
async query(selector, options) {
|
|
1102
|
+
return this.#probe({
|
|
1103
|
+
action: "query",
|
|
1104
|
+
selector,
|
|
1105
|
+
...options?.scope === void 0 ? {} : { scope: options.scope },
|
|
1106
|
+
...options?.limit === void 0 ? {} : { limit: options.limit },
|
|
1107
|
+
...options?.styles === void 0 ? {} : { styles: [...options.styles] }
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
/** A REAL pointer gesture (pointerdown/mousedown/focus/pointerup/mouseup/click)
|
|
1111
|
+
* — not `element.click()`, which a `pointerdown` listener never sees. */
|
|
1112
|
+
async click(selector, options) {
|
|
1113
|
+
return this.#probe({
|
|
1114
|
+
action: "click",
|
|
1115
|
+
selector,
|
|
1116
|
+
...options?.scope === void 0 ? {} : { scope: options.scope },
|
|
1117
|
+
...options?.index === void 0 ? {} : { index: options.index },
|
|
1118
|
+
...options?.clicks === void 0 ? {} : { clicks: options.clicks }
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* TYPE into a field and commit with Enter, the way a person does — one
|
|
1123
|
+
* character at a time through the prototype's value setter, between real
|
|
1124
|
+
* `keydown`/`keyup`.
|
|
1125
|
+
*
|
|
1126
|
+
* `paste` is not a substitute: an untrusted `ClipboardEvent` performs no
|
|
1127
|
+
* default action, so a plain `<input>` with no paste handler keeps its old
|
|
1128
|
+
* value. Omit `selector` to type into whatever inside the scope has focus —
|
|
1129
|
+
* which is what a rename field is, one gesture after
|
|
1130
|
+
* `click(row, { clicks: 2 })`.
|
|
1131
|
+
*/
|
|
1132
|
+
async type(text, options) {
|
|
1133
|
+
return this.#probe({ action: "type", text, ...options ?? {} });
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* A real pointer DRAG across one matched element — press at `from`, move,
|
|
1137
|
+
* release at `to`. The gesture a direct-manipulation canvas needs; a
|
|
1138
|
+
* zero-length drag is a click at that fraction, which `click` (always the
|
|
1139
|
+
* center) cannot place.
|
|
1140
|
+
*
|
|
1141
|
+
* `from`, `to` and every point in `via` are `[x, y]` FRACTIONS OF THE
|
|
1142
|
+
* MATCHED ELEMENT'S BOX, 0..1 from its top-left — NEVER pixels and never
|
|
1143
|
+
* page coordinates. `[0.5, 0.5]` is its center, `[1, 0]` its top-right.
|
|
1144
|
+
* Compute a pixel target by measuring the element first: `query` answers
|
|
1145
|
+
* its `rect`, and `(px - rect.x) / rect.width` is the fraction to pass.
|
|
1146
|
+
*/
|
|
1147
|
+
async drag(selector, options) {
|
|
1148
|
+
return this.#probe({
|
|
1149
|
+
action: "drag",
|
|
1150
|
+
selector,
|
|
1151
|
+
...options.scope === void 0 ? {} : { scope: options.scope },
|
|
1152
|
+
from: options.from,
|
|
1153
|
+
to: options.to,
|
|
1154
|
+
...options.via === void 0 ? {} : { via: options.via },
|
|
1155
|
+
...options.steps === void 0 ? {} : { steps: options.steps },
|
|
1156
|
+
...options.index === void 0 ? {} : { index: options.index },
|
|
1157
|
+
...options.altKey === void 0 ? {} : { altKey: options.altKey },
|
|
1158
|
+
...options.ctrlKey === void 0 ? {} : { ctrlKey: options.ctrlKey },
|
|
1159
|
+
...options.metaKey === void 0 ? {} : { metaKey: options.metaKey },
|
|
1160
|
+
...options.shiftKey === void 0 ? {} : { shiftKey: options.shiftKey }
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
/** A real keydown/keyup on the target, or on whatever inside the document has focus. */
|
|
1164
|
+
async key(key, options) {
|
|
1165
|
+
return this.#probe({ action: "key", key, ...options ?? {} });
|
|
1166
|
+
}
|
|
1167
|
+
/** A real `ClipboardEvent` carrying `text/plain` — the gesture nothing else
|
|
1168
|
+
* in the product can produce. */
|
|
1169
|
+
async paste(text, options) {
|
|
1170
|
+
return this.#probe({ action: "paste", text, ...options ?? {} });
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Choose `value` on a `<select>` — a native dropdown's options are drawn by
|
|
1174
|
+
* the OS, so `click` has nothing in the document to resolve, and a plain
|
|
1175
|
+
* `element.value =` is invisible to React. Set through the prototype's own
|
|
1176
|
+
* value setter plus `input`/`change`; `value` is the option's `value`, not
|
|
1177
|
+
* its label. An unknown value is refused with the options it does offer.
|
|
1178
|
+
*/
|
|
1179
|
+
async select(selector, value, options) {
|
|
1180
|
+
return this.#probe({
|
|
1181
|
+
action: "select",
|
|
1182
|
+
selector,
|
|
1183
|
+
value,
|
|
1184
|
+
...options?.scope === void 0 ? {} : { scope: options.scope },
|
|
1185
|
+
...options?.index === void 0 ? {} : { index: options.index }
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* THE REPL over the open document: run `step` in the editor page against the
|
|
1190
|
+
* object the ACTIVE document published as its context (the mesh document
|
|
1191
|
+
* publishes its `MeshEditSession`, whose `ctx` is the bpy-shaped edit
|
|
1192
|
+
* context — `ctx.ops.mesh.bevel({ offset: 0.1 })`, `ctx.selection`,
|
|
1193
|
+
* `ctx.history`, `session.commit()`). Edit mode, no play. Serialized like
|
|
1194
|
+
* `game.page`: the step's own source travels, so inline every value it
|
|
1195
|
+
* needs and return plain data.
|
|
1196
|
+
*/
|
|
1197
|
+
async run(step) {
|
|
1198
|
+
return this.#client.documentScript(step.toString());
|
|
1199
|
+
}
|
|
1200
|
+
#probe(step) {
|
|
1201
|
+
return this.#client.documentProbe(step);
|
|
1202
|
+
}
|
|
1203
|
+
};
|
|
1204
|
+
|
|
1205
|
+
// src/editor.ts
|
|
1206
|
+
var EXTENSION_KIND = {
|
|
1207
|
+
".glb": "model",
|
|
1208
|
+
".gltf": "model",
|
|
1209
|
+
".png": "image",
|
|
1210
|
+
".jpg": "image",
|
|
1211
|
+
".jpeg": "image",
|
|
1212
|
+
".webp": "image",
|
|
1213
|
+
".gif": "image",
|
|
1214
|
+
".svg": "image",
|
|
1215
|
+
".hdr": "image",
|
|
1216
|
+
".exr": "image",
|
|
1217
|
+
".mp4": "video",
|
|
1218
|
+
".webm": "video",
|
|
1219
|
+
".mp3": "audio",
|
|
1220
|
+
".ogg": "audio",
|
|
1221
|
+
".wav": "audio",
|
|
1222
|
+
".flac": "audio",
|
|
1223
|
+
".glsl": "source",
|
|
1224
|
+
".vert": "source",
|
|
1225
|
+
".frag": "source",
|
|
1226
|
+
// PROJECT SCRIPTS ARE SOURCE. Without these the guess below falls through to
|
|
1227
|
+
// `'json'`, the asset-document router sends the file to the generic JSON
|
|
1228
|
+
// viewer (`asset-documents.tsx#assetDocumentViewerRoute`: `spec.kind ===
|
|
1229
|
+
// 'json'` is decided before any content routing), and the LIVE MODELING
|
|
1230
|
+
// DOCUMENT never mounts — `editor.openAsset('src/lib/fox/fox.model.ts')`
|
|
1231
|
+
// silently shows a text pane instead of the model. Only `kind: 'source'`
|
|
1232
|
+
// reaches `SourceAssetViewer`, which is what content-routes a project script
|
|
1233
|
+
// to `LiveModuleDocument`. The set matches that viewer's own
|
|
1234
|
+
// `isProjectScriptPath` regex, `/\.(?:[cm]?[jt]sx?)$/`.
|
|
1235
|
+
".ts": "source",
|
|
1236
|
+
".tsx": "source",
|
|
1237
|
+
".mts": "source",
|
|
1238
|
+
".cts": "source",
|
|
1239
|
+
".js": "source",
|
|
1240
|
+
".jsx": "source",
|
|
1241
|
+
".mjs": "source",
|
|
1242
|
+
".cjs": "source"
|
|
1243
|
+
};
|
|
1244
|
+
function inferAssetKind(path) {
|
|
1245
|
+
if (path.endsWith(".prefab.json")) return "prefab";
|
|
1246
|
+
const dot = path.lastIndexOf(".");
|
|
1247
|
+
const ext = dot >= 0 ? path.slice(dot).toLowerCase() : "";
|
|
1248
|
+
return EXTENSION_KIND[ext] ?? "json";
|
|
1249
|
+
}
|
|
1250
|
+
var LiveEditor = class {
|
|
1251
|
+
/** `#`-private, not `private`: `volter-editor eval --list` enumerates this object's
|
|
1252
|
+
* real runtime members, and TypeScript's erased `private` would leave the
|
|
1253
|
+
* raw `EditorClient` advertised beside them. */
|
|
1254
|
+
#client;
|
|
1255
|
+
/**
|
|
1256
|
+
* The ACTIVE center document's own DOM: read it, click it, key it, paste
|
|
1257
|
+
* into it. The one door onto editor chrome that is not play-mode gated, and
|
|
1258
|
+
* deliberately scoped to that document alone —
|
|
1259
|
+
* `packages/editor/src/editor-document-probe.ts` carries the design and the
|
|
1260
|
+
* refusal contract. Screenshotting the same subject is
|
|
1261
|
+
* {@link LiveEditor.captureActiveDocument}, not a fifth verb here.
|
|
1262
|
+
*/
|
|
1263
|
+
document;
|
|
1264
|
+
constructor(client) {
|
|
1265
|
+
this.#client = client;
|
|
1266
|
+
this.document = new LiveEditorDocument(client);
|
|
1267
|
+
}
|
|
1268
|
+
/**
|
|
1269
|
+
* THE BLENDER LANE'S VERBS, from `volter-editor eval`.
|
|
1270
|
+
*
|
|
1271
|
+
* Blender runs headless in the editor tab's worker (ARCHITECTURE-CORE, "THE
|
|
1272
|
+
* BLENDER IN THE TAB IS BLENDER") and answers `blender-start`,
|
|
1273
|
+
* `blender-execute`, `blender-scene-info`, `blender-object-info`,
|
|
1274
|
+
* `blender-screenshot-view`, `blender-read-file`, `blender-write-file`,
|
|
1275
|
+
* `blender-list-files`, `blender-stop` and `blender-status`. They were
|
|
1276
|
+
* reachable from `@volter/editor-sdk` and through `vgai blender-mcp` but from
|
|
1277
|
+
* no GENERAL door, so driving a session meant writing an MCP client script
|
|
1278
|
+
* per question — the same discovery failure `eval-surface.ts`'s header
|
|
1279
|
+
* records, in a lane that had not noticed it yet.
|
|
1280
|
+
*
|
|
1281
|
+
* volter-editor eval "await editor.blender('blender-execute', { code: 'import bpy; print(len(bpy.data.objects))' })"
|
|
1282
|
+
*
|
|
1283
|
+
* `blender-status` is the only verb that creates nothing: it answers whether
|
|
1284
|
+
* this tab already has a session without starting one.
|
|
1285
|
+
*/
|
|
1286
|
+
async blender(type, fields = {}) {
|
|
1287
|
+
return this.#client.blender(type, fields);
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* The active authoring adapter's persistence destination — where a save would
|
|
1291
|
+
* land (`status().savePath`). A read only: a three root has no scene document
|
|
1292
|
+
* to open, and its root is activated instead.
|
|
1293
|
+
*/
|
|
1294
|
+
async scene() {
|
|
1295
|
+
const state = await this.#client.getState();
|
|
1296
|
+
return state.savePath;
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Make the connected human editor show the same subject/view as the agent.
|
|
1300
|
+
* The returned URL is a compact, shareable projection — not a serialized
|
|
1301
|
+
* workspace or document payload.
|
|
1302
|
+
*/
|
|
1303
|
+
async present(view) {
|
|
1304
|
+
return this.#client.present(view);
|
|
1305
|
+
}
|
|
1306
|
+
/** The human editor's actual active document, selection, camera and utility. */
|
|
1307
|
+
async currentView() {
|
|
1308
|
+
return this.#client.currentView();
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Capture the same center document the human is currently looking at.
|
|
1312
|
+
*
|
|
1313
|
+
* A number is a SQUARE of that size — the default, and the right shape for
|
|
1314
|
+
* an unstaged look at a model. `{width, height}` asks for a shaped frame, so
|
|
1315
|
+
* a video-aspect look needs no crop afterwards. Both are bounded by the
|
|
1316
|
+
* relay budget (64-1024 per side, total no larger than a 1024 square); see
|
|
1317
|
+
* `@volter/editor-sdk`'s `CaptureDimensions`.
|
|
1318
|
+
* Supply a view to present and photograph it in one editor request.
|
|
1319
|
+
*/
|
|
1320
|
+
async captureActiveDocument(size, view) {
|
|
1321
|
+
return this.#client.captureActiveDocument(size, view);
|
|
1322
|
+
}
|
|
1323
|
+
/**
|
|
1324
|
+
* Photograph the editor PAGE — every panel, tab strip and viewport as the
|
|
1325
|
+
* person sees it. `vgai screenshot editor` is this verb from the shell. The
|
|
1326
|
+
* one door for judging chrome sighted: a skin, a workspace arrangement or a
|
|
1327
|
+
* contributed panel is looked at through this, never guessed at from DOM
|
|
1328
|
+
* probes. The page at its own layout, `scale` output pixels per CSS pixel
|
|
1329
|
+
* (default `devicePixelRatio`) — a 1 px border or a glyph stroke is only
|
|
1330
|
+
* judgeable at the scale the reference it is compared against was captured
|
|
1331
|
+
* at, and the result reports its own `size` and `scale`.
|
|
1332
|
+
*/
|
|
1333
|
+
async captureEditorChrome(options) {
|
|
1334
|
+
return this.#client.captureEditorChrome(options);
|
|
1335
|
+
}
|
|
1336
|
+
/** `'all'` -> `EditorClient.selectAll()` (mirrors `vgai select --all`); otherwise `EditorClient.select(id)` (mirrors `vgai select <entityId>`). */
|
|
1337
|
+
async select(id) {
|
|
1338
|
+
if (id === "all") {
|
|
1339
|
+
await this.#client.selectAll();
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
await this.#client.select(id);
|
|
1343
|
+
}
|
|
1344
|
+
/** Mirrors `vgai deselect`. */
|
|
1345
|
+
async deselect() {
|
|
1346
|
+
await this.#client.select(null);
|
|
1347
|
+
}
|
|
1348
|
+
/** No `id` -> focus the current selection (mirrors bare `vgai focus`); `id` given -> focus that entity. */
|
|
1349
|
+
async focus(id) {
|
|
1350
|
+
if (id !== void 0) {
|
|
1351
|
+
await this.#client.focusEntity(id);
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
await this.#client.focusSelection();
|
|
1355
|
+
}
|
|
1356
|
+
async frame(target) {
|
|
1357
|
+
if (typeof target === "string") {
|
|
1358
|
+
await this.#client.frameEntity(target);
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
await this.#client.frameDocument(target?.fit);
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* WATCH THE AGENT LOOK AROUND THE MODEL.
|
|
1365
|
+
*
|
|
1366
|
+
* Swings the open Object3D document's camera — the camera the human's tab is
|
|
1367
|
+
* showing — around the framed subject by `azimuth`/`elevation` RADIANS,
|
|
1368
|
+
* animated over `duration` seconds (default 0.6), and resolves when the move
|
|
1369
|
+
* ends. This is deliberately not a jump cut: the point of the verb is that a
|
|
1370
|
+
* person watching sees the agent walk around the thing it is working on.
|
|
1371
|
+
*
|
|
1372
|
+
* `await editor.orbit({ azimuth: Math.PI / 2 })` — a quarter turn to the right.
|
|
1373
|
+
*
|
|
1374
|
+
* There is ONE camera, and the human owns it: a drag during the move cancels
|
|
1375
|
+
* it exactly where it is, and the resolved outcome says `cancelledBy:
|
|
1376
|
+
* 'human'` rather than throwing. A second look verb supersedes the first.
|
|
1377
|
+
* The move is drawn by the document's own frame loop, so a document that
|
|
1378
|
+
* isn't being drawn (background tab, inactive panel) doesn't orbit.
|
|
1379
|
+
*/
|
|
1380
|
+
async orbit(options) {
|
|
1381
|
+
const known = ["azimuth", "elevation", "duration"];
|
|
1382
|
+
const unknown = Object.keys(options ?? {}).filter((key) => !known.includes(key));
|
|
1383
|
+
if (unknown.length > 0)
|
|
1384
|
+
throw new Error(
|
|
1385
|
+
`editor.orbit: ${unknown.join(", ")} ${unknown.length === 1 ? "is not a key" : "are not keys"} this verb takes. It takes azimuth and elevation in RADIANS (relative to where the camera is now) and duration in SECONDS.`
|
|
1386
|
+
);
|
|
1387
|
+
return this.#client.orbitDocument(options);
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* A slow full revolution of the open document's subject — {@link orbit} with
|
|
1391
|
+
* the turns spelled out and a constant angular rate. Resolves at the end of
|
|
1392
|
+
* the last revolution.
|
|
1393
|
+
*/
|
|
1394
|
+
async turntable(options) {
|
|
1395
|
+
return this.#client.turntableDocument(options);
|
|
1396
|
+
}
|
|
1397
|
+
async view(preset) {
|
|
1398
|
+
await this.#client.viewPreset(preset);
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Switch the editor's NAMED WORKSPACE — `await editor.workspace('model')`.
|
|
1402
|
+
*
|
|
1403
|
+
* A workspace is a task-named LAYOUT MEMORY over the one dock
|
|
1404
|
+
* (ARCHITECTURE-CORE §Editor chrome): `game` (the default, the editor's
|
|
1405
|
+
* standing arrangement), `model`, `sculpt`, `texture`, `animate`, `look`.
|
|
1406
|
+
* Switching is an EXPLICIT act — nothing in the editor moves chrome on its
|
|
1407
|
+
* own, opening a document included — and this is the session door to it,
|
|
1408
|
+
* beside `Window → Workspace` and the registered actions.
|
|
1409
|
+
*
|
|
1410
|
+
* Resolves once the dock has finished rebuilding, so a capture taken
|
|
1411
|
+
* immediately after photographs the arrangement that was asked for. Each
|
|
1412
|
+
* workspace remembers the user's own hand-tuning per project, so switching
|
|
1413
|
+
* away and back is lossless.
|
|
1414
|
+
*/
|
|
1415
|
+
async workspace(id) {
|
|
1416
|
+
await this.#client.setWorkspace(id);
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Apply a STYLE BUNDLE by id — the chrome's palette, material, icon set and
|
|
1420
|
+
* region defaults in one gesture, the session door beside
|
|
1421
|
+
* `View → <Style> Style`. A bundle the open project does not offer refuses
|
|
1422
|
+
* and names the vocabulary; `currentView().style` reports the one worn.
|
|
1423
|
+
*/
|
|
1424
|
+
async style(id) {
|
|
1425
|
+
await this.#client.setStyle(id);
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* Set the MATERIAL apart from the bundle that usually carries it.
|
|
1429
|
+
* Appearance is palette × material, independent axes by ruling, so
|
|
1430
|
+
* `style()` alone can never say whether a cost belongs to the blur or to
|
|
1431
|
+
* the palette. This is the door that measures them apart; it answers with
|
|
1432
|
+
* what the chrome wears afterwards (`style` is `null` when the mix matches
|
|
1433
|
+
* no registered bundle).
|
|
1434
|
+
*/
|
|
1435
|
+
async appearance(appearance) {
|
|
1436
|
+
return this.#client.setAppearance(appearance);
|
|
1437
|
+
}
|
|
1438
|
+
/** Focus an editor panel: a viewport tab, the console, the build surface, or
|
|
1439
|
+
* any key the editor's static-panel registry holds — an unknown key refuses
|
|
1440
|
+
* naming the ones it does. */
|
|
1441
|
+
async showPanel(name) {
|
|
1442
|
+
switch (name) {
|
|
1443
|
+
case "viewport-edit":
|
|
1444
|
+
await this.#client.showViewport("edit");
|
|
1445
|
+
return;
|
|
1446
|
+
case "console":
|
|
1447
|
+
await this.#client.toggleConsole();
|
|
1448
|
+
return;
|
|
1449
|
+
default:
|
|
1450
|
+
await this.#client.showPanel(name);
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
/** `kind` inferred from `path`'s extension when omitted (`inferAssetKind`) — pass it explicitly to override. */
|
|
1455
|
+
async openAsset(path, kind) {
|
|
1456
|
+
await this.#client.openAsset(path, kind ?? inferAssetKind(path));
|
|
1457
|
+
}
|
|
1458
|
+
/**
|
|
1459
|
+
* SELECT a project asset — the browser's single click, which fills the
|
|
1460
|
+
* Inspector without opening a document. `openAsset` is the double click.
|
|
1461
|
+
*
|
|
1462
|
+
* This is how a project's own `asset.inspector` section is reached: select
|
|
1463
|
+
* the file it matches, then `inspect()` lists the verbs that section
|
|
1464
|
+
* declares and `runAction(id)` runs one. Selecting a path nothing matches
|
|
1465
|
+
* is not an error — the Inspector shows what it has, exactly as it does
|
|
1466
|
+
* for a human.
|
|
1467
|
+
*/
|
|
1468
|
+
async selectAsset(path) {
|
|
1469
|
+
await this.#client.selectAsset(path);
|
|
1470
|
+
}
|
|
1471
|
+
/**
|
|
1472
|
+
* Captures the editor's native four-view preview. A bare string is a
|
|
1473
|
+
* project-relative asset path (the common case); an explicit source object
|
|
1474
|
+
* targets a path, a LIVE SCENE ENTITY (`assetPreview({ entityId }, …)`) —
|
|
1475
|
+
* which is what makes `options.stage: 'scene'`, the entity photographed
|
|
1476
|
+
* where it stands under the scene's own lighting, reachable from here — or
|
|
1477
|
+
* RAW GLB BYTES (`assetPreview({ glbBase64 }, …)`), for a model that exists
|
|
1478
|
+
* only in the calling Node process's memory and has never been written to
|
|
1479
|
+
* disk. `stage` defaults to `'lab'`, the neutral Asset Lab staging this has
|
|
1480
|
+
* always produced, and the bytes form is lab-only.
|
|
1481
|
+
*/
|
|
1482
|
+
async assetPreview(source, options) {
|
|
1483
|
+
return this.#client.captureAssetPreview(
|
|
1484
|
+
typeof source === "string" ? { assetPath: source } : source,
|
|
1485
|
+
options
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* The same subject photographed as a LABELED SHOT SET instead of the four
|
|
1490
|
+
* views — a caller-supplied definition of turntable yaws and bone-anchored
|
|
1491
|
+
* crops, rendered against the asset's own skeleton, with a contact sheet.
|
|
1492
|
+
* Every source {@link assetPreview} takes works here, GLB bytes included:
|
|
1493
|
+
* a shot set stages its own subject, so it needs no place to stand.
|
|
1494
|
+
*
|
|
1495
|
+
* Sole in-repo caller today: `project.bake.preview`'s `--orbit` lane.
|
|
1496
|
+
*/
|
|
1497
|
+
async assetPreviewShots(source, definition, options) {
|
|
1498
|
+
return this.#client.captureShotSetPreview(
|
|
1499
|
+
typeof source === "string" ? { assetPath: source } : source,
|
|
1500
|
+
definition,
|
|
1501
|
+
options
|
|
1502
|
+
);
|
|
1503
|
+
}
|
|
1504
|
+
async grid(on) {
|
|
1505
|
+
await this.#client.setGrid(on);
|
|
1506
|
+
}
|
|
1507
|
+
async helpers(on) {
|
|
1508
|
+
await this.#client.setHelpers(on);
|
|
1509
|
+
}
|
|
1510
|
+
async stats(on) {
|
|
1511
|
+
await this.#client.setStats(on);
|
|
1512
|
+
}
|
|
1513
|
+
async shading(mode) {
|
|
1514
|
+
await this.#client.setShadingMode(mode);
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* READ the inspector, as data — the serialized inspection subject
|
|
1518
|
+
* (`editor.inspect()`; design: `docs/ARCHITECTURE-CORE.md` §Editor chrome,
|
|
1519
|
+
* "The Inspection Model"). This is the Figma-Inspect analog: whatever a
|
|
1520
|
+
* human would see in the inspector right now — the subject's identity, its
|
|
1521
|
+
* verbs, and every identified section in display order, with a `fields`
|
|
1522
|
+
* section's CURRENT VALUES at their scriptable `path`s.
|
|
1523
|
+
*
|
|
1524
|
+
* Reach for it whenever the next step depends on what an object actually
|
|
1525
|
+
* IS: `await editor.select(id)` then `await editor.inspect()` answers "what
|
|
1526
|
+
* properties does this thing have, and what are they set to" in one call,
|
|
1527
|
+
* against the same model the panel renders — no scene-graph reads, no
|
|
1528
|
+
* guessing at property names.
|
|
1529
|
+
*
|
|
1530
|
+
* When the inspector is showing NOTHING — nothing selected on a surface
|
|
1531
|
+
* with no empty-state subject of its own, which is most of them — the answer
|
|
1532
|
+
* is `{none: true}`, so "the human sees no inspector" and "the read failed"
|
|
1533
|
+
* are never the same value. A surface whose empty space IS a real thing (an
|
|
1534
|
+
* open Asset Lab document) still answers with that subject, and never with
|
|
1535
|
+
* another surface's.
|
|
1536
|
+
*
|
|
1537
|
+
* A `custom` section body is a named opaque: the editor renders it with
|
|
1538
|
+
* React, so the wire reports its identity rather than pretending to describe
|
|
1539
|
+
* its rendering — plus, when the section can say what it DISPLAYS, a `data`
|
|
1540
|
+
* payload in its own vocabulary (`transform` carries
|
|
1541
|
+
* `{position, rotation, scale}`, rotation in Euler XYZ degrees).
|
|
1542
|
+
*/
|
|
1543
|
+
async inspect() {
|
|
1544
|
+
return this.#client.inspect();
|
|
1545
|
+
}
|
|
1546
|
+
/** Run one verb listed by `inspect().quickActions`, through the same action
|
|
1547
|
+
* the human Inspector button invokes. */
|
|
1548
|
+
async runAction(actionId) {
|
|
1549
|
+
return this.#client.runInspectionAction(actionId);
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* Run ONE command by id — the door to everything the command palette lists.
|
|
1553
|
+
*
|
|
1554
|
+
* ONE NAME (orchestrator ruling 2026-09-19). There were briefly TWO doors
|
|
1555
|
+
* onto the one view-verb table — this one and `editor.viewVerb(view, verb)`,
|
|
1556
|
+
* which addressed the same registry by its two halves. A second addressing
|
|
1557
|
+
* of one table is a second name for one thing, and an agent reading
|
|
1558
|
+
* `--list` had to choose between them with nothing to choose on. This door
|
|
1559
|
+
* stays because it is strictly wider: it addresses a COMMAND ID, so under
|
|
1560
|
+
* the frame it reaches everything the workbench knows — a `vgai.action.<id>`
|
|
1561
|
+
* editor action, one of VS Code's own — and not only a view. A VIEW is
|
|
1562
|
+
* reached by spelling its verb's command id:
|
|
1563
|
+
*
|
|
1564
|
+
* await editor.command('vgai.blender-uv-view.state')
|
|
1565
|
+
* await editor.command('vgai.blender-uv-view.zoom', { to: 600 })
|
|
1566
|
+
*
|
|
1567
|
+
* await editor.command('vgai.blender-node-view.view-all')
|
|
1568
|
+
* await editor.command('vgai.blender-node-view.look', { node: 'Principled BSDF' })
|
|
1569
|
+
*
|
|
1570
|
+
* Under the Code-OSS frame this is the workbench's own command service, so
|
|
1571
|
+
* any command id works — ours and VS Code's alike. Standalone `vgai edit`
|
|
1572
|
+
* has no command service and answers the `vgai.<view>.<verb>` shape off the
|
|
1573
|
+
* SAME verb table the frame's commands call, refusing any other id by name.
|
|
1574
|
+
* One table, two doors, exactly like the keymap's.
|
|
1575
|
+
*
|
|
1576
|
+
* Answers with whatever the command returned — a view verb's own state, or
|
|
1577
|
+
* `null` for a command that returns nothing.
|
|
1578
|
+
*/
|
|
1579
|
+
async command(commandId, args) {
|
|
1580
|
+
return this.#client.runCommand(commandId, args);
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1583
|
+
* RESTRUCTURE the authored tree — the hierarchy context menu's own verbs.
|
|
1584
|
+
*
|
|
1585
|
+
* `create`, `delete`, `duplicate`, `reparent`, `reorder`, `wrap`, `unwrap`,
|
|
1586
|
+
* `group`, `ungroup`, `copy`, `cut`, `paste`; `extractComponent` and
|
|
1587
|
+
* `forkComponent` are the two that write whole new files and have their own
|
|
1588
|
+
* doors below. All of them run the SAME `authoring/consumer-actions.ts`
|
|
1589
|
+
* helpers the menu items call, so there is one implementation of each op and
|
|
1590
|
+
* not a second that can disagree with what a human gets.
|
|
1591
|
+
*
|
|
1592
|
+
* It exists because the menu is a POINTER surface: every one of these ops was
|
|
1593
|
+
* reachable only by right-clicking a hierarchy row, which is nothing an agent
|
|
1594
|
+
* can do — so for an ingest root, whose only authoring surface IS the editor,
|
|
1595
|
+
* structure was closed entirely.
|
|
1596
|
+
*
|
|
1597
|
+
* `id`/`ids` default to the current selection. The answer carries the same
|
|
1598
|
+
* per-edit `write` ack `setField` does, so `write.persisted` tells a saved
|
|
1599
|
+
* restructure from a live-only one. An op the active adapter does not provide
|
|
1600
|
+
* REJECTS by name — never a silent no-op.
|
|
1601
|
+
*/
|
|
1602
|
+
async structure(op, options) {
|
|
1603
|
+
return this.#client.structureOp(op, options ?? {});
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* "Extract Component…" — lift the selected native subtree into its own
|
|
1607
|
+
* component file (plus a story) and replace the callsite with it.
|
|
1608
|
+
*
|
|
1609
|
+
* Answers the action's own sentence, which NAMES both new files, because
|
|
1610
|
+
* undo owns the callsite edit and will not remove them.
|
|
1611
|
+
*/
|
|
1612
|
+
async extractComponent(options) {
|
|
1613
|
+
return (await this.#client.extractComponent(options ?? {})).hint;
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* "Fork Component…" — copy the selected instance's component definition to a
|
|
1617
|
+
* new file and retarget THIS CALLSITE at it.
|
|
1618
|
+
*
|
|
1619
|
+
* One callsite is the unit of the edit; when that callsite sits inside a
|
|
1620
|
+
* component rendered many times, every one of those renders now renders the
|
|
1621
|
+
* fork.
|
|
1622
|
+
*/
|
|
1623
|
+
async forkComponent(options) {
|
|
1624
|
+
return (await this.#client.forkComponent(options ?? {})).hint;
|
|
1625
|
+
}
|
|
1626
|
+
/**
|
|
1627
|
+
* READ the hierarchy panel, as data — the rows a human is looking at right
|
|
1628
|
+
* now, nested exactly as the panel nests them.
|
|
1629
|
+
*
|
|
1630
|
+
* The companion to {@link inspect}: that one answers "what IS the selected
|
|
1631
|
+
* thing", this one answers "what does the tree LOOK LIKE". It is the panel's
|
|
1632
|
+
* own output, not a fresh walk of the scene — the adapter's tree after the
|
|
1633
|
+
* component marks fold implementation subtrees (bones, particle renderers,
|
|
1634
|
+
* instanced pools), after the internals reveal, the document promotion, the
|
|
1635
|
+
* child cap, the collapse state, the search filter and the selection scope.
|
|
1636
|
+
*
|
|
1637
|
+
* Works in play mode and edit mode; the answer says which (`playState`,
|
|
1638
|
+
* `activeViewportTab`), because the two are different adapters and a tree
|
|
1639
|
+
* that looks wrong is very often the wrong adapter's tree.
|
|
1640
|
+
*
|
|
1641
|
+
* Prefer this over `status().entities`, which is deliberately a different
|
|
1642
|
+
* question — the RAW adapter tree, unprojected. A panel that renders the
|
|
1643
|
+
* wrong rows looks perfectly healthy in that facet.
|
|
1644
|
+
*
|
|
1645
|
+
* Each row carries `childCount` (what its caret opens), `internalChildCount`
|
|
1646
|
+
* (what is folded behind "Reveal Internals") and `expandable` (whether the
|
|
1647
|
+
* panel draws a caret at all), so "this subtree exists but nothing in the UI
|
|
1648
|
+
* opens it" is a fact you can read rather than one you have to notice.
|
|
1649
|
+
*
|
|
1650
|
+
* Rejects, naming the panel, when no hierarchy panel is mounted — an empty
|
|
1651
|
+
* tree would be a fabricated answer about a surface nobody is being shown.
|
|
1652
|
+
*/
|
|
1653
|
+
async hierarchy() {
|
|
1654
|
+
return this.#client.hierarchy();
|
|
1655
|
+
}
|
|
1656
|
+
/** Expand every branch through the Hierarchy panel's own action. */
|
|
1657
|
+
async expandHierarchyAll() {
|
|
1658
|
+
await this.#client.expandHierarchyAll();
|
|
1659
|
+
}
|
|
1660
|
+
/** Collapse every branch through the same panel action. Expanding is
|
|
1661
|
+
* persisted per project, so without this the tree's REST STATE — what a
|
|
1662
|
+
* person sees on opening the project — is unreachable once any reader has
|
|
1663
|
+
* expanded it. */
|
|
1664
|
+
async collapseHierarchyAll() {
|
|
1665
|
+
await this.#client.collapseHierarchyAll();
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* Write one editable field from `inspect()` by its stable path, through the
|
|
1669
|
+
* same Inspector IO and persistence boundary the human control uses.
|
|
1670
|
+
*
|
|
1671
|
+
* The answer is `{ subject, write }`, and `write` is the half worth reading
|
|
1672
|
+
* first: a write with no persistence route open still succeeds — it lands on
|
|
1673
|
+
* the live object and journals live-only — so `write.persisted` is how you
|
|
1674
|
+
* tell a saved edit from one that will not survive the session, without
|
|
1675
|
+
* diffing the tree. `write.destination` is the adapter's own words for where
|
|
1676
|
+
* it went ("live-only (not saved)" is a destination, never silence).
|
|
1677
|
+
*/
|
|
1678
|
+
async setField(path, value) {
|
|
1679
|
+
return this.#client.setInspectionField(path, value);
|
|
1680
|
+
}
|
|
1681
|
+
/**
|
|
1682
|
+
* REMOVE one field's authored override — the revert arrow, as a command.
|
|
1683
|
+
*
|
|
1684
|
+
* Reach for this instead of `setField` whenever you are UNDOING an edit that
|
|
1685
|
+
* added a property the source did not carry: `setField` can only write a
|
|
1686
|
+
* value, so setting the default back leaves `position={[0, 0, 0]}` in the
|
|
1687
|
+
* file where there was nothing before. Only this door restores the bytes.
|
|
1688
|
+
*
|
|
1689
|
+
* The answer is the same `{ subject, write }` shape, awaited past the bytes.
|
|
1690
|
+
* It rejects with `code: 'REMOVAL_UNAVAILABLE'` when the field is not
|
|
1691
|
+
* declared removable or the lane has no removal door — which is a missing
|
|
1692
|
+
* seam to report, not a removal that failed.
|
|
1693
|
+
*/
|
|
1694
|
+
async removeField(path) {
|
|
1695
|
+
return this.#client.removeInspectionField(path);
|
|
1696
|
+
}
|
|
1697
|
+
/** Open a document by its adapter-declared id, through its registered owner. */
|
|
1698
|
+
async open(id) {
|
|
1699
|
+
return this.#client.open(id);
|
|
1700
|
+
}
|
|
1701
|
+
/** Undo / redo one project transaction, through the session's own history
|
|
1702
|
+
* queue — the same one the keyboard shortcut drives. */
|
|
1703
|
+
async undo() {
|
|
1704
|
+
return this.#client.undo();
|
|
1705
|
+
}
|
|
1706
|
+
async redo() {
|
|
1707
|
+
return this.#client.redo();
|
|
1708
|
+
}
|
|
1709
|
+
/** Mirrors `vgai status` — the full live editor state as JSON. */
|
|
1710
|
+
async status() {
|
|
1711
|
+
return this.#client.getState();
|
|
1712
|
+
}
|
|
1713
|
+
/** A live viewport PNG (`EditorClient.captureViewport`) — no direct CLI verb exists; this is the closest wire read. */
|
|
1714
|
+
async screenshot(size) {
|
|
1715
|
+
return this.#client.captureViewport(size);
|
|
1716
|
+
}
|
|
1717
|
+
};
|
|
1718
|
+
|
|
1719
|
+
// src/tools.ts
|
|
1720
|
+
var LiveTools = class {
|
|
1721
|
+
/** `#`-private for the same reason `LiveEditor.#client` is. */
|
|
1722
|
+
#client;
|
|
1723
|
+
constructor(client) {
|
|
1724
|
+
this.#client = client;
|
|
1725
|
+
}
|
|
1726
|
+
/** Enumerate the exact `package.json#vgai.tools` catalog without executing it. */
|
|
1727
|
+
async list() {
|
|
1728
|
+
return this.#client.listProjectTools();
|
|
1729
|
+
}
|
|
1730
|
+
/** Return one tool's discoverable metadata, or `null` when it is not registered. */
|
|
1731
|
+
async describe(name) {
|
|
1732
|
+
const catalog = await this.list();
|
|
1733
|
+
return catalog.tools.find((tool) => tool.name === name) ?? null;
|
|
1734
|
+
}
|
|
1735
|
+
/**
|
|
1736
|
+
* Invoke the same validated callable used by the editor and CLI.
|
|
1737
|
+
*
|
|
1738
|
+
* `instance` names WHICH mounted instance the tool should drive when several
|
|
1739
|
+
* are live (multiplayer authoring) — it reaches the tool as `ctx.instance`,
|
|
1740
|
+
* and a tool that drives the game binds
|
|
1741
|
+
* `game.instance(ctx.instance)` from it. Omitted is the single-instance case;
|
|
1742
|
+
* the tool then targets the sole live instance, exactly as before.
|
|
1743
|
+
*/
|
|
1744
|
+
async run(name, input = {}, options = {}) {
|
|
1745
|
+
return this.#client.runProjectTool(name, input, options);
|
|
1746
|
+
}
|
|
1747
|
+
};
|
|
1748
|
+
|
|
1749
|
+
// src/lazy-proxy.ts
|
|
1750
|
+
function isFunction(value) {
|
|
1751
|
+
return typeof value === "function";
|
|
1752
|
+
}
|
|
1753
|
+
function walk(root, path) {
|
|
1754
|
+
if (path.length === 0) return { thisArg: void 0, fn: root };
|
|
1755
|
+
let obj = root;
|
|
1756
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
1757
|
+
const key = path[i];
|
|
1758
|
+
obj = obj[key];
|
|
1759
|
+
}
|
|
1760
|
+
const lastKey = path[path.length - 1];
|
|
1761
|
+
return { thisArg: obj, fn: obj[lastKey] };
|
|
1762
|
+
}
|
|
1763
|
+
function lazyChainProxy(resolveRoot, path = []) {
|
|
1764
|
+
const callableTarget = (() => {
|
|
1765
|
+
});
|
|
1766
|
+
return new Proxy(callableTarget, {
|
|
1767
|
+
get(_target, prop) {
|
|
1768
|
+
if (prop === "then" || prop === "catch" || prop === "finally") return void 0;
|
|
1769
|
+
return lazyChainProxy(resolveRoot, [...path, prop]);
|
|
1770
|
+
},
|
|
1771
|
+
apply(_target, _thisArg, args) {
|
|
1772
|
+
return resolveRoot().then((root) => {
|
|
1773
|
+
const { thisArg, fn } = walk(root, path);
|
|
1774
|
+
if (!isFunction(fn)) {
|
|
1775
|
+
const label = path.length > 0 ? path.map(String).join(".") : "(the connected value)";
|
|
1776
|
+
throw new TypeError(`@volter/editor-live: ${label} is not a function on the connected session.`);
|
|
1777
|
+
}
|
|
1778
|
+
return fn.apply(thisArg, args);
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// src/session.ts
|
|
1785
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync as realpathSync2 } from "node:fs";
|
|
1786
|
+
import { dirname, join as join3, resolve as resolve2 } from "node:path";
|
|
1787
|
+
|
|
1788
|
+
// ../editor-project/src/manifest/locate.ts
|
|
1789
|
+
import { existsSync } from "node:fs";
|
|
1790
|
+
import { join } from "node:path";
|
|
1791
|
+
|
|
1792
|
+
// ../editor-project/src/manifest/filename.ts
|
|
1793
|
+
var MANIFEST_FILENAME = "vgai.project.json";
|
|
1794
|
+
var REMOVED_MANIFEST_FILENAME = "vgai.game.json";
|
|
1795
|
+
function removedManifestFilenameMessage(where) {
|
|
1796
|
+
return `${where}: found \`${REMOVED_MANIFEST_FILENAME}\` and no \`${MANIFEST_FILENAME}\`. \`${REMOVED_MANIFEST_FILENAME}\` was REMOVED (the legacy-removal doctrine, docs/ARCHITECTURE-CORE.md \xA7Vocabulary) \u2014 nothing reads it any more, and it is deliberately NOT read as a fallback, because a second accepted name is the defect.
|
|
1797
|
+
Fix: rename it \u2014 \`git mv ${REMOVED_MANIFEST_FILENAME} ${MANIFEST_FILENAME}\`. The contents are unchanged; only the filename moved.`;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// ../editor-project/src/manifest/locate.ts
|
|
1801
|
+
function assertNoRemovedManifestFilename(dir) {
|
|
1802
|
+
if (existsSync(join(dir, MANIFEST_FILENAME))) return;
|
|
1803
|
+
if (!existsSync(join(dir, REMOVED_MANIFEST_FILENAME))) return;
|
|
1804
|
+
throw new Error(removedManifestFilenameMessage(join(dir, REMOVED_MANIFEST_FILENAME)));
|
|
1805
|
+
}
|
|
1806
|
+
function resolveManifestPath(dir) {
|
|
1807
|
+
assertNoRemovedManifestFilename(dir);
|
|
1808
|
+
return join(dir, MANIFEST_FILENAME);
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
// ../editor-sdk/src/session/registry-format.ts
|
|
1812
|
+
import {
|
|
1813
|
+
mkdirSync,
|
|
1814
|
+
readdirSync,
|
|
1815
|
+
readFileSync,
|
|
1816
|
+
realpathSync,
|
|
1817
|
+
unlinkSync,
|
|
1818
|
+
writeFileSync
|
|
1819
|
+
} from "node:fs";
|
|
1820
|
+
import { homedir } from "node:os";
|
|
1821
|
+
import { join as join2, resolve } from "node:path";
|
|
1822
|
+
var EDITOR_SESSIONS_REGISTRY_FILE = join2(homedir(), ".vgai", "editor-sessions.json");
|
|
1823
|
+
function isEditorSessionEntry(v) {
|
|
1824
|
+
if (typeof v !== "object" || v === null) return false;
|
|
1825
|
+
const s = v;
|
|
1826
|
+
const optionalIdentity = (value) => value === void 0 || value === null || typeof value === "string";
|
|
1827
|
+
return (typeof s["project"] === "string" || s["project"] === null) && typeof s["port"] === "number" && typeof s["pid"] === "number" && typeof s["startedAt"] === "string" && optionalIdentity(s["sessionId"]) && optionalIdentity(s["controlSecret"]) && optionalIdentity(s["repositoryId"]) && optionalIdentity(s["worktreeId"]) && optionalIdentity(s["worktreeRoot"]) && optionalIdentity(s["projectRelativePath"]) && optionalIdentity(s["branch"]) && optionalIdentity(s["headCommit"]) && optionalIdentity(s["baseCommit"]);
|
|
1828
|
+
}
|
|
1829
|
+
function normalizeEditorSessionEntry(session) {
|
|
1830
|
+
return {
|
|
1831
|
+
...session,
|
|
1832
|
+
sessionId: session.sessionId ?? null,
|
|
1833
|
+
controlSecret: session.controlSecret ?? null,
|
|
1834
|
+
repositoryId: session.repositoryId ?? null,
|
|
1835
|
+
worktreeId: session.worktreeId ?? null,
|
|
1836
|
+
worktreeRoot: session.worktreeRoot ?? null,
|
|
1837
|
+
projectRelativePath: session.projectRelativePath ?? null,
|
|
1838
|
+
branch: session.branch ?? null,
|
|
1839
|
+
headCommit: session.headCommit ?? null,
|
|
1840
|
+
baseCommit: session.baseCommit ?? null
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
function pidAlive(pid) {
|
|
1844
|
+
try {
|
|
1845
|
+
process.kill(pid, 0);
|
|
1846
|
+
return true;
|
|
1847
|
+
} catch {
|
|
1848
|
+
return false;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
function readLiveRegisteredSessions() {
|
|
1852
|
+
try {
|
|
1853
|
+
const raw = JSON.parse(readFileSync(EDITOR_SESSIONS_REGISTRY_FILE, "utf8"));
|
|
1854
|
+
return Array.isArray(raw) ? raw.filter(isEditorSessionEntry).map(normalizeEditorSessionEntry).filter((s) => pidAlive(s.pid)) : [];
|
|
1855
|
+
} catch {
|
|
1856
|
+
return [];
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
function servedProjectAnswer(body) {
|
|
1860
|
+
const b = body;
|
|
1861
|
+
return {
|
|
1862
|
+
path: b.project?.path ?? b.serving?.path ?? null,
|
|
1863
|
+
manifestError: b.project ? null : b.serving?.error ?? null
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
// ../editor-sdk/src/session/discovery.ts
|
|
1868
|
+
var EDITOR_SESSION_DISCOVERY_TIMEOUT_MS = 2e3;
|
|
1869
|
+
var EDITOR_PROBE_TIMEOUT_MS = 1500;
|
|
1870
|
+
var EditorTimeoutError = class extends Error {
|
|
1871
|
+
constructor(label, ms) {
|
|
1872
|
+
super(`${label} timed out after ${ms}ms`);
|
|
1873
|
+
this.name = "EditorTimeoutError";
|
|
1874
|
+
}
|
|
1875
|
+
};
|
|
1876
|
+
function withTimeout(promise, ms, label) {
|
|
1877
|
+
return new Promise((resolvePromise, reject) => {
|
|
1878
|
+
const timer = setTimeout(() => reject(new EditorTimeoutError(label, ms)), ms);
|
|
1879
|
+
promise.then(
|
|
1880
|
+
(v) => {
|
|
1881
|
+
clearTimeout(timer);
|
|
1882
|
+
resolvePromise(v);
|
|
1883
|
+
},
|
|
1884
|
+
(err) => {
|
|
1885
|
+
clearTimeout(timer);
|
|
1886
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
1887
|
+
}
|
|
1888
|
+
);
|
|
1889
|
+
});
|
|
1890
|
+
}
|
|
1891
|
+
async function fetchJson(url, timeoutMs) {
|
|
1892
|
+
try {
|
|
1893
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
1894
|
+
if (!res.ok) return void 0;
|
|
1895
|
+
return await res.json();
|
|
1896
|
+
} catch {
|
|
1897
|
+
return void 0;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
function baseUrl(session) {
|
|
1901
|
+
return session.url ?? `http://127.0.0.1:${session.port}`;
|
|
1902
|
+
}
|
|
1903
|
+
var HttpSessionDiscovery = class {
|
|
1904
|
+
async listSessions(timeoutMs) {
|
|
1905
|
+
const registered = readLiveRegisteredSessions();
|
|
1906
|
+
const perProbeTimeout = Math.min(EDITOR_PROBE_TIMEOUT_MS, Math.max(200, timeoutMs));
|
|
1907
|
+
const probes = await Promise.all(
|
|
1908
|
+
registered.map(async (s) => {
|
|
1909
|
+
const body = await fetchJson(
|
|
1910
|
+
`${baseUrl({ port: s.port, project: null, pid: s.pid })}/__editor/project`,
|
|
1911
|
+
perProbeTimeout
|
|
1912
|
+
);
|
|
1913
|
+
if (body === void 0) return void 0;
|
|
1914
|
+
const served = servedProjectAnswer(body);
|
|
1915
|
+
const info = {
|
|
1916
|
+
port: s.port,
|
|
1917
|
+
project: served.path,
|
|
1918
|
+
pid: s.pid,
|
|
1919
|
+
manifestError: served.manifestError
|
|
1920
|
+
};
|
|
1921
|
+
return info;
|
|
1922
|
+
})
|
|
1923
|
+
);
|
|
1924
|
+
return probes.filter((p) => p !== void 0);
|
|
1925
|
+
}
|
|
1926
|
+
};
|
|
1927
|
+
|
|
1928
|
+
// src/session.ts
|
|
1929
|
+
function findProjectRootFrom(dir) {
|
|
1930
|
+
let cur = resolve2(dir);
|
|
1931
|
+
for (; ; ) {
|
|
1932
|
+
if (existsSync2(resolveManifestPath(cur))) return cur;
|
|
1933
|
+
const parent = dirname(cur);
|
|
1934
|
+
if (parent === cur) return null;
|
|
1935
|
+
cur = parent;
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
function canonicalPath(path) {
|
|
1939
|
+
try {
|
|
1940
|
+
return realpathSync2(path);
|
|
1941
|
+
} catch {
|
|
1942
|
+
return resolve2(path);
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
function readProjectSession(projectRoot) {
|
|
1946
|
+
try {
|
|
1947
|
+
const value = JSON.parse(
|
|
1948
|
+
readFileSync2(join3(projectRoot, ".vgai", "session.json"), "utf8")
|
|
1949
|
+
);
|
|
1950
|
+
if (typeof value !== "object" || value === null) return null;
|
|
1951
|
+
const hint = value;
|
|
1952
|
+
if (typeof hint["port"] !== "number" || !Number.isInteger(hint["port"]) || hint["port"] <= 0 || typeof hint["pid"] !== "number" || typeof hint["url"] !== "string" || typeof hint["startedAt"] !== "string") {
|
|
1953
|
+
return null;
|
|
1954
|
+
}
|
|
1955
|
+
return hint;
|
|
1956
|
+
} catch {
|
|
1957
|
+
return null;
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
async function probeServedProject(port) {
|
|
1961
|
+
try {
|
|
1962
|
+
const response = await fetch(`http://127.0.0.1:${port}/__editor/project`, {
|
|
1963
|
+
signal: AbortSignal.timeout(EDITOR_SESSION_DISCOVERY_TIMEOUT_MS)
|
|
1964
|
+
});
|
|
1965
|
+
if (!response.ok) return void 0;
|
|
1966
|
+
return servedProjectAnswer(await response.json());
|
|
1967
|
+
} catch {
|
|
1968
|
+
return void 0;
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
function manifestRefusal(projectRoot, manifestError) {
|
|
1972
|
+
return new Error(
|
|
1973
|
+
`@volter/editor-live: the editor session for ${projectRoot} is live, but its vgai.project.json does not load, so there is no editor or game to drive \u2014 the editor page is showing this same error. Fix the manifest and retry; the session recovers on save, no restart needed.
|
|
1974
|
+
${manifestError}`
|
|
1975
|
+
);
|
|
1976
|
+
}
|
|
1977
|
+
function noMatchingSessionRefusal(projectRoot, canon, sessions, discoveryFailure) {
|
|
1978
|
+
if (discoveryFailure !== null) {
|
|
1979
|
+
return new Error(
|
|
1980
|
+
`@volter/editor-live: could not READ the editor session registry while looking for ${projectRoot} \u2014 ${discoveryFailure}. This is not the answer "no editor is running": the question went unanswered, so nothing is known about what is live. Retry (a probe can time out while the box is loaded); if it keeps failing, \`vgai sessions\` asks the same question directly.`
|
|
1981
|
+
);
|
|
1982
|
+
}
|
|
1983
|
+
if (sessions.length === 0) {
|
|
1984
|
+
return new Error(
|
|
1985
|
+
`@volter/editor-live: the editor session registry is readable and lists NO live sessions, so none covers ${projectRoot}. @volter/editor-live only attaches to an already-running session \u2014 it never starts one \u2014 so run \`volter-editor edit\` in that project first, then retry.`
|
|
1986
|
+
);
|
|
1987
|
+
}
|
|
1988
|
+
const listed = sessions.map((s) => ` port ${s.port} \u2192 ${s.project === null ? "(no project)" : s.project}`).join("\n");
|
|
1989
|
+
return new Error(
|
|
1990
|
+
`@volter/editor-live: ${sessions.length} live editor session(s) are running, but none of them opens ${projectRoot}. @volter/editor-live never silently attaches to a different project.
|
|
1991
|
+
looking for (resolved): ${canon}
|
|
1992
|
+
live sessions:
|
|
1993
|
+
${listed}
|
|
1994
|
+
If one of those is meant to be this project, the two paths differ after resolution \u2014 the usual cause is a git worktree or a symlink, where the session was opened through a different path to the same files. Run \`volter-editor edit\` from THIS path, or use the path the session lists.`
|
|
1995
|
+
);
|
|
1996
|
+
}
|
|
1997
|
+
async function resolveSession(projectDir = process.cwd(), deps = {}) {
|
|
1998
|
+
const findRoot = deps.findProjectRootFrom ?? findProjectRootFrom;
|
|
1999
|
+
const transport = deps.transport ?? new HttpSessionDiscovery();
|
|
2000
|
+
const projectRoot = findRoot(projectDir);
|
|
2001
|
+
if (projectRoot === null) {
|
|
2002
|
+
throw new Error(
|
|
2003
|
+
`@volter/editor-live: no vgai.project.json found in ${projectDir} or any parent directory \u2014 is this a vgai project?`
|
|
2004
|
+
);
|
|
2005
|
+
}
|
|
2006
|
+
const localHint = (deps.readProjectSession ?? readProjectSession)(projectRoot);
|
|
2007
|
+
if (localHint) {
|
|
2008
|
+
if (deps.verifyProjectSession) {
|
|
2009
|
+
if (await deps.verifyProjectSession(localHint, projectRoot)) {
|
|
2010
|
+
return { port: localHint.port, projectRoot };
|
|
2011
|
+
}
|
|
2012
|
+
} else {
|
|
2013
|
+
const served = await probeServedProject(localHint.port);
|
|
2014
|
+
if (served?.path != null && canonicalPath(served.path) === canonicalPath(projectRoot)) {
|
|
2015
|
+
if (served.manifestError !== null) throw manifestRefusal(projectRoot, served.manifestError);
|
|
2016
|
+
return { port: localHint.port, projectRoot };
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
let sessions;
|
|
2021
|
+
let discoveryFailure = null;
|
|
2022
|
+
try {
|
|
2023
|
+
sessions = await withTimeout(
|
|
2024
|
+
transport.listSessions(EDITOR_SESSION_DISCOVERY_TIMEOUT_MS),
|
|
2025
|
+
EDITOR_SESSION_DISCOVERY_TIMEOUT_MS,
|
|
2026
|
+
"editor session discovery"
|
|
2027
|
+
);
|
|
2028
|
+
} catch (err) {
|
|
2029
|
+
sessions = [];
|
|
2030
|
+
discoveryFailure = err instanceof Error ? err.message : String(err);
|
|
2031
|
+
}
|
|
2032
|
+
const canon = canonicalPath(projectRoot);
|
|
2033
|
+
const match = sessions.find((s) => s.project !== null && canonicalPath(s.project) === canon);
|
|
2034
|
+
if (!match) throw noMatchingSessionRefusal(projectRoot, canon, sessions, discoveryFailure);
|
|
2035
|
+
if (match.manifestError != null) throw manifestRefusal(projectRoot, match.manifestError);
|
|
2036
|
+
return { port: match.port, projectRoot };
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// src/singleton.ts
|
|
2040
|
+
function createLazySession(factory) {
|
|
2041
|
+
let promise = null;
|
|
2042
|
+
return {
|
|
2043
|
+
ensure() {
|
|
2044
|
+
promise ??= factory();
|
|
2045
|
+
return promise;
|
|
2046
|
+
},
|
|
2047
|
+
reset() {
|
|
2048
|
+
promise = null;
|
|
2049
|
+
}
|
|
2050
|
+
};
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
// src/index.ts
|
|
2054
|
+
function bindTo(port) {
|
|
2055
|
+
const client = new EditorClient({ url: `http://127.0.0.1:${port}` });
|
|
2056
|
+
return { editor: new LiveEditor(client), tools: new LiveTools(client) };
|
|
2057
|
+
}
|
|
2058
|
+
function unconnectedBindings() {
|
|
2059
|
+
return bindTo(0);
|
|
2060
|
+
}
|
|
2061
|
+
async function connect(projectDir, deps) {
|
|
2062
|
+
const session = await resolveSession(projectDir, deps);
|
|
2063
|
+
return { ...bindTo(session.port), session };
|
|
2064
|
+
}
|
|
2065
|
+
var lazySession = createLazySession(() => connect());
|
|
2066
|
+
var editor = lazyChainProxy(() => lazySession.ensure().then((s) => s.editor));
|
|
2067
|
+
var tools = lazyChainProxy(() => lazySession.ensure().then((s) => s.tools));
|
|
2068
|
+
export {
|
|
2069
|
+
LiveEditor,
|
|
2070
|
+
LiveEditorDocument,
|
|
2071
|
+
LiveTools,
|
|
2072
|
+
connect,
|
|
2073
|
+
editor,
|
|
2074
|
+
findProjectRootFrom,
|
|
2075
|
+
inferAssetKind,
|
|
2076
|
+
resolveSession,
|
|
2077
|
+
tools,
|
|
2078
|
+
unconnectedBindings
|
|
2079
|
+
};
|
|
2080
|
+
//# sourceMappingURL=index.js.map
|