@volter/editor-sdk 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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +19 -0
- package/package.json +90 -0
- package/src/account.ts +210 -0
- package/src/chrome.ts +83 -0
- package/src/client.ts +1547 -0
- package/src/commands.ts +66 -0
- package/src/contributions.ts +985 -0
- package/src/document-probe.ts +237 -0
- package/src/editor-view.ts +220 -0
- package/src/extension.ts +40 -0
- package/src/generations.ts +178 -0
- package/src/host.ts +1167 -0
- package/src/http-transport.browser.ts +14 -0
- package/src/http-transport.node.ts +19 -0
- package/src/index.ts +128 -0
- package/src/layout-arrangements.ts +5 -0
- package/src/layouts.tsx +108 -0
- package/src/looks.ts +14 -0
- package/src/project/output-roots.ts +73 -0
- package/src/project/tab-census.ts +149 -0
- package/src/project-tool-catalog.ts +96 -0
- package/src/selection.tsx +108 -0
- package/src/services.ts +18 -0
- package/src/session/build-report.ts +19 -0
- package/src/session/collaboration-types.ts +262 -0
- package/src/session/command-table.ts +333 -0
- package/src/session/discovery.ts +90 -0
- package/src/session/editor-brand.ts +73 -0
- package/src/session/editor-compatibility.ts +248 -0
- package/src/session/editor-control-lifecycle.ts +68 -0
- package/src/session/editor-control-protocol.ts +5 -0
- package/src/session/entrypoint-selection-readers.ts +66 -0
- package/src/session/entrypoint-selection-source.ts +120 -0
- package/src/session/game-css-scope.ts +30 -0
- package/src/session/product-create.ts +24 -0
- package/src/session/product-locator.ts +389 -0
- package/src/session/project-module-url.ts +245 -0
- package/src/session/registry-format.ts +203 -0
- package/src/session/relative-path-guard.ts +56 -0
- package/src/session/source-glob.ts +15 -0
- package/src/session/tool-contribution-convention.ts +116 -0
- package/src/session/workbench-locator.ts +650 -0
- package/src/session.ts +41 -0
- package/src/share.ts +160 -0
- package/src/tools/errors.ts +91 -0
- package/src/tools/provider-execution.ts +70 -0
- package/src/tools/registry.ts +341 -0
- package/src/tools/types.ts +159 -0
- package/src/transport.ts +97 -0
- package/src/types.ts +1581 -0
- package/src/views.ts +164 -0
- package/src/widgets/design-system.ts +93 -0
- package/src/widgets/editor-appearance.ts +149 -0
- package/src/widgets/editor-material.ts +83 -0
- package/src/widgets/icon-set-registry.ts +105 -0
- package/src/widgets/index.ts +71 -0
- package/src/widgets/inspector-widgets/AlignmentGrid.tsx +182 -0
- package/src/widgets/inspector-widgets/AssetSlotPicker.tsx +123 -0
- package/src/widgets/inspector-widgets/BorderEditor.tsx +309 -0
- package/src/widgets/inspector-widgets/ColorPicker.tsx +549 -0
- package/src/widgets/inspector-widgets/CurveEditor.tsx +359 -0
- package/src/widgets/inspector-widgets/FilterEditor.tsx +108 -0
- package/src/widgets/inspector-widgets/FontPicker.tsx +191 -0
- package/src/widgets/inspector-widgets/GradientEditor.tsx +623 -0
- package/src/widgets/inspector-widgets/ScrubbableInput.tsx +180 -0
- package/src/widgets/inspector-widgets/ShadowEditor.tsx +319 -0
- package/src/widgets/inspector-widgets/color-utils.ts +201 -0
- package/src/widgets/inspector-widgets/curve-utils.ts +212 -0
- package/src/widgets/inspector-widgets/index.ts +24 -0
- package/src/widgets/inspector-widgets/shared.tsx +140 -0
- package/src/widgets/interactive-edit-scope.ts +33 -0
- package/src/widgets/patterns/Dialog.tsx +129 -0
- package/src/widgets/patterns/Fields.tsx +44 -0
- package/src/widgets/patterns/List.tsx +25 -0
- package/src/widgets/patterns/StateSurface.tsx +40 -0
- package/src/widgets/patterns/Surfaces.tsx +122 -0
- package/src/widgets/patterns/Tabs.tsx +80 -0
- package/src/widgets/patterns/Toolbar.tsx +72 -0
- package/src/widgets/patterns/Tree.tsx +72 -0
- package/src/widgets/primitives/AnchoredMenu.tsx +260 -0
- package/src/widgets/primitives/Button.tsx +62 -0
- package/src/widgets/primitives/ColorInput.tsx +78 -0
- package/src/widgets/primitives/DraftTextInput.tsx +63 -0
- package/src/widgets/primitives/EditorIcon.tsx +157 -0
- package/src/widgets/primitives/FormControls.tsx +88 -0
- package/src/widgets/primitives/HoverPreview.tsx +96 -0
- package/src/widgets/primitives/JsonInput.tsx +113 -0
- package/src/widgets/primitives/Layout.tsx +100 -0
- package/src/widgets/primitives/Menu.tsx +140 -0
- package/src/widgets/primitives/NumberInput.tsx +169 -0
- package/src/widgets/primitives/Panel.tsx +80 -0
- package/src/widgets/primitives/SectionHeader.tsx +77 -0
- package/src/widgets/primitives/Text.tsx +54 -0
- package/src/widgets/primitives/ThemeRootPortal.tsx +52 -0
- package/src/widgets/primitives/Tooltip.tsx +204 -0
- package/src/widgets/primitives/Vec3Input.tsx +70 -0
- package/src/widgets/primitives/banner-tones.ts +32 -0
- package/src/widgets/primitives/clamp-to-viewport.ts +44 -0
- package/src/widgets/primitives/editor-icons.ts +245 -0
- package/src/widgets/primitives/panel-header-styles.ts +42 -0
- package/src/widgets/theme.ts +2633 -0
- package/src/widgets/z-index.ts +25 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,1547 @@
|
|
|
1
|
+
import type { GenerationJobsDocument } from '@volter/editor-sdk/generations';
|
|
2
|
+
import type { Dispatcher } from 'undici';
|
|
3
|
+
import { createDispatcher, dispatchFetch } from '#http-transport';
|
|
4
|
+
import type { DocumentProbeResult, DocumentProbeStep } from './document-probe.js';
|
|
5
|
+
import type {
|
|
6
|
+
ActiveDocumentCapture,
|
|
7
|
+
AssetCompareCapture,
|
|
8
|
+
AssetCompareOptions,
|
|
9
|
+
AssetKind,
|
|
10
|
+
AssetPreviewCapture,
|
|
11
|
+
AssetPreviewOptions,
|
|
12
|
+
AssetPreviewShotSetDefinition,
|
|
13
|
+
AssetPreviewSource,
|
|
14
|
+
CaptureDimensions,
|
|
15
|
+
DocumentCameraPose,
|
|
16
|
+
DocumentLookOutcome,
|
|
17
|
+
DocumentTableProjection,
|
|
18
|
+
EditorChromeCapture,
|
|
19
|
+
EditorChromeCaptureOptions,
|
|
20
|
+
EditorState,
|
|
21
|
+
EditorView,
|
|
22
|
+
EditorWorkspaceName,
|
|
23
|
+
GameCapture,
|
|
24
|
+
GameplayRecordingCapture,
|
|
25
|
+
GameplayRecordingOptions,
|
|
26
|
+
GameplayRecordingStarted,
|
|
27
|
+
GameplayRecordingTimeline,
|
|
28
|
+
GameplayReplayCapture,
|
|
29
|
+
HelperVisibility,
|
|
30
|
+
InspectedFieldWrite,
|
|
31
|
+
InspectedHierarchy,
|
|
32
|
+
InspectedInspection,
|
|
33
|
+
LabeledShotSetCapture,
|
|
34
|
+
PlayStarted,
|
|
35
|
+
PresentedEditorView,
|
|
36
|
+
ProjectInfo,
|
|
37
|
+
ProjectTemplate,
|
|
38
|
+
ProjectToolCatalog,
|
|
39
|
+
ProjectToolOutcome,
|
|
40
|
+
RecentProject,
|
|
41
|
+
ShadingMode,
|
|
42
|
+
StoryCaptureOptions,
|
|
43
|
+
StoryVariantCapture,
|
|
44
|
+
StructureOp,
|
|
45
|
+
StructureOpOptions,
|
|
46
|
+
StructureOpResult,
|
|
47
|
+
TransformMode,
|
|
48
|
+
TransformSpace,
|
|
49
|
+
Vec3Value,
|
|
50
|
+
ViewPreset,
|
|
51
|
+
ViewportCapture,
|
|
52
|
+
ViewportTab,
|
|
53
|
+
} from './types.js';
|
|
54
|
+
|
|
55
|
+
// The editor's default origin, spelled out because this package deliberately
|
|
56
|
+
// does not depend on `@volter/editor-project`. `DEFAULT_EDITOR_PORT` in
|
|
57
|
+
// `packages/project/src/manifest/editor-port.ts` is the owner of the number and
|
|
58
|
+
// of the never-`localhost` rule; keep this in step with it.
|
|
59
|
+
const DEFAULT_URL = 'http://127.0.0.1:20173';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A refused editor command, carrying the relay's own STRUCTURED failure code
|
|
63
|
+
* alongside the prose.
|
|
64
|
+
*
|
|
65
|
+
* `/__editor/command` has always answered `{ ok: false, error, code }` and
|
|
66
|
+
* this client has always dropped the `code` on the floor, so every caller that
|
|
67
|
+
* wanted to react to a specific refusal had to substring-match an English
|
|
68
|
+
* sentence. `vgai screenshot`'s loop-recovery fallback is the first caller that
|
|
69
|
+
* genuinely must branch (`BRIDGE_SCREENSHOT_STALE` has a working recovery;
|
|
70
|
+
* "not in play mode" does not), and a fallback keyed on prose would fire on
|
|
71
|
+
* the wrong failure the first time someone rewords the message.
|
|
72
|
+
*/
|
|
73
|
+
export class EditorCommandError extends Error {
|
|
74
|
+
readonly code: string | undefined;
|
|
75
|
+
/**
|
|
76
|
+
* True when the RELAY ended the command itself rather than the editor
|
|
77
|
+
* answering it — the HTTP 504 that `server/server-utils.ts`'s
|
|
78
|
+
* `commandResponseFor` gives any `timedOut` result, or this client's own
|
|
79
|
+
* deadline below.
|
|
80
|
+
*
|
|
81
|
+
* Read it as "no answer", not as "the budget expired". `editor-server.ts`
|
|
82
|
+
* raises `timedOut` for five conditions and only one of them takes the full
|
|
83
|
+
* budget: the command's timer expiring, the controlling tab's socket dying,
|
|
84
|
+
* the receipt window closing unanswered, a beating-but-dead tab, and no tab
|
|
85
|
+
* present at all. The last four can fail in milliseconds.
|
|
86
|
+
*
|
|
87
|
+
* A caller that converges by retrying (`vgai restart`) needs the distinction
|
|
88
|
+
* because a refusal the editor ANSWERED may go differently next time, while
|
|
89
|
+
* a command the relay abandoned tells you nothing new on a second identical
|
|
90
|
+
* attempt — and when the abandonment was a 120s budget, re-running it three
|
|
91
|
+
* times is `restart-readiness.ts`'s 361-seconds-of-silence defect.
|
|
92
|
+
*/
|
|
93
|
+
readonly timedOut: boolean;
|
|
94
|
+
constructor(message: string, code?: string | undefined, timedOut = false) {
|
|
95
|
+
super(message);
|
|
96
|
+
this.name = 'EditorCommandError';
|
|
97
|
+
this.code = code;
|
|
98
|
+
this.timedOut = timedOut;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The client's own ceiling on ONE relayed command.
|
|
104
|
+
*
|
|
105
|
+
* A backstop for a LOST server, not a per-command budget: the server already
|
|
106
|
+
* owns per-type budgets (`server/server-utils.ts`'s `relayCommandTimeoutMs`)
|
|
107
|
+
* and its timer must always be the one that fires, because its message names
|
|
108
|
+
* the tab and the remedy while this one can only say "no answer". So this is
|
|
109
|
+
* deliberately ONE number, comfortably above the longest server budget
|
|
110
|
+
* (120s, `play`/`capture-story-variants`) rather than a mirror of that table —
|
|
111
|
+
* a second copy of it would drift silently, and the drift would show up as
|
|
112
|
+
* this timer winning a race it must always lose.
|
|
113
|
+
*
|
|
114
|
+
* Without it a `fetch` with no `AbortSignal` waits on the OS: a dev server
|
|
115
|
+
* that stops answering mid-command holds the CLI open indefinitely, with no
|
|
116
|
+
* output and nothing to read.
|
|
117
|
+
*/
|
|
118
|
+
const COMMAND_DEADLINE_MS = 150_000;
|
|
119
|
+
/** The Blender lane's own ceiling: a chunk is a whole modeling step, not a tick. */
|
|
120
|
+
const BLENDER_DEADLINE_MS = 30 * 60_000;
|
|
121
|
+
/** undici's default `headersTimeout`; a command deadline beyond it needs its own dispatcher (see `command`). */
|
|
122
|
+
const UNDICI_DEFAULT_HEADERS_TIMEOUT_MS = 300_000;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Ceiling on {@link EditorClient.getUnresolvedConsole}. The CLI drains this
|
|
126
|
+
* on every verb, including ones that never wait for a command envelope, so
|
|
127
|
+
* a silent hang here would become a silent hang on `vgai sessions`. The
|
|
128
|
+
* server route is a plain in-process GET; 1.5s is already longer than it
|
|
129
|
+
* should ever take.
|
|
130
|
+
*/
|
|
131
|
+
const CONSOLE_DRAIN_TIMEOUT_MS = 1_500;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Node's `fetch` collapses EVERY network-layer failure into one two-word
|
|
135
|
+
* `TypeError: fetch failed`. The real reason — `ECONNREFUSED`, `ECONNRESET`,
|
|
136
|
+
* `EPIPE`, a DNS miss — lives only on `error.cause` (sometimes two links down,
|
|
137
|
+
* or inside an `AggregateError`), and nothing prints it unless something walks
|
|
138
|
+
* the chain. That is the whole reason `project.bake.preview` was observed
|
|
139
|
+
* failing with a bare "fetch failed" and no way to tell a dead editor from a
|
|
140
|
+
* momentary one (WORK.md, cold barrel 2026-08-29): the code below used to
|
|
141
|
+
* rethrow the `TypeError` untouched, on the belief — stated in a comment right
|
|
142
|
+
* where it happened — that "a connection refused / DNS failure still surfaces
|
|
143
|
+
* as itself". It does not. This walks the chain so the message can say which.
|
|
144
|
+
*/
|
|
145
|
+
function describeFetchFailure(error: unknown): { code: string; detail: string } {
|
|
146
|
+
const messages: string[] = [];
|
|
147
|
+
let current: unknown = error;
|
|
148
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
149
|
+
if (!(current instanceof Error)) break;
|
|
150
|
+
if (current.message) messages.push(current.message);
|
|
151
|
+
const code = (current as { code?: unknown }).code;
|
|
152
|
+
if (typeof code === 'string' && code !== '') {
|
|
153
|
+
return { code, detail: messages.join(' <- ') };
|
|
154
|
+
}
|
|
155
|
+
const aggregate = (current as { errors?: unknown }).errors;
|
|
156
|
+
if (Array.isArray(aggregate) && aggregate.length > 0) {
|
|
157
|
+
const inner = describeFetchFailure(aggregate[0]);
|
|
158
|
+
return { code: inner.code, detail: [...messages, inner.detail].join(' <- ') };
|
|
159
|
+
}
|
|
160
|
+
current = (current as { cause?: unknown }).cause;
|
|
161
|
+
}
|
|
162
|
+
return { code: 'UNKNOWN', detail: messages.join(' <- ') || String(error) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Transport failures where a second attempt is worth making: the connection
|
|
167
|
+
* itself failed or died, rather than the editor answering something unwelcome.
|
|
168
|
+
* A dev server that is restarting (any watched source edit restarts it) is
|
|
169
|
+
* unreachable for a fraction of a second and reachable again after — which is
|
|
170
|
+
* exactly the "fails, then succeeds unchanged" shape that was reported.
|
|
171
|
+
*/
|
|
172
|
+
const RETRYABLE_TRANSPORT_CODES = new Set([
|
|
173
|
+
'ECONNREFUSED',
|
|
174
|
+
'ECONNRESET',
|
|
175
|
+
'EPIPE',
|
|
176
|
+
'ETIMEDOUT',
|
|
177
|
+
'EHOSTUNREACH',
|
|
178
|
+
'UND_ERR_SOCKET',
|
|
179
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
/** Gap before the one automatic retry — long enough for a dev-server restart's
|
|
183
|
+
* listen socket to come back, short enough to stay invisible. */
|
|
184
|
+
const TRANSPORT_RETRY_DELAY_MS = 400;
|
|
185
|
+
|
|
186
|
+
/** Context accompanying one {@link EditorClient} response observation. */
|
|
187
|
+
export interface EditorEnvelopeObservation {
|
|
188
|
+
/** True only when this body came from the console-ledger endpoint and its
|
|
189
|
+
* `entries` field is therefore the complete named console set. Command
|
|
190
|
+
* payloads may also own an unrelated `entries` field. */
|
|
191
|
+
readonly unresolvedConsoleComplete: boolean;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The GAME DEBUG PLANE, as a contribution's client sees it.
|
|
196
|
+
*
|
|
197
|
+
* Deliberately the same two words `@vgai/live`'s session binding uses
|
|
198
|
+
* (`game.state(name)` / `game.command(name, ...args)`), because it is the same
|
|
199
|
+
* plane: whatever the running game registered through `ctx.debug` — a provider
|
|
200
|
+
* read by name, a command invoked by name. A tool contribution that wants the
|
|
201
|
+
* game's own vitals in its panel has this door and no other; there is
|
|
202
|
+
* deliberately no per-capability method, because a game names its own
|
|
203
|
+
* providers and commands.
|
|
204
|
+
*
|
|
205
|
+
* Both legs reject LOUDLY (`EditorCommandError`) rather than answer with a
|
|
206
|
+
* placeholder: play not running is `'not in play mode — start play before
|
|
207
|
+
* using the debug seam'`, and an unregistered command carries
|
|
208
|
+
* `code: 'DEBUG_COMMAND_NOT_REGISTERED'`. A panel decides what to show for
|
|
209
|
+
* those; the client never invents one.
|
|
210
|
+
*/
|
|
211
|
+
/** What one undo/redo step reports back — `moved` is false when there was
|
|
212
|
+
* nothing left in that direction, which is an answer, not an error. */
|
|
213
|
+
export interface HistoryStep {
|
|
214
|
+
readonly moved: boolean;
|
|
215
|
+
readonly canUndo: boolean;
|
|
216
|
+
readonly canRedo: boolean;
|
|
217
|
+
readonly undoLabel: string | null;
|
|
218
|
+
readonly redoLabel: string | null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** What `open()` acknowledges: the workspace document the scene-table entry
|
|
222
|
+
* resolved to, and the title its tab now carries — the game's own word for
|
|
223
|
+
* that composition, not a filename. */
|
|
224
|
+
export interface OpenedDocument {
|
|
225
|
+
readonly documentId: string;
|
|
226
|
+
readonly title: string;
|
|
227
|
+
/**
|
|
228
|
+
* The GAME's own answer, present only when opening navigated a running game
|
|
229
|
+
* (a scene the adapter declares reachable through the game's scenes
|
|
230
|
+
* contract, or a native swap-slot remount): what was asked for, and which
|
|
231
|
+
* scene the game reports it is in once its own navigation settled. `current`
|
|
232
|
+
* can differ from `requested` — that is the game's reading, not a host claim.
|
|
233
|
+
*/
|
|
234
|
+
readonly scene?: { readonly requested: string; readonly current: string | null };
|
|
235
|
+
/**
|
|
236
|
+
* Present when opening restarted play at a native swap-slot key rather than
|
|
237
|
+
* navigating a live contract — the slot is a module-level const.
|
|
238
|
+
*/
|
|
239
|
+
readonly restart?: true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface GameDebugDoor {
|
|
243
|
+
/**
|
|
244
|
+
* Read ONE registered state provider by name (`'bot.tester'`). `undefined`
|
|
245
|
+
* when the running game registered no such provider — or no debug adapter at
|
|
246
|
+
* all, which is an honest answer rather than a refusal.
|
|
247
|
+
*/
|
|
248
|
+
state(name: string): Promise<unknown>;
|
|
249
|
+
/** Invoke ONE registered debug command by name, with its own arguments. */
|
|
250
|
+
command(name: string, ...args: unknown[]): Promise<unknown>;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export class EditorClient {
|
|
254
|
+
private readonly baseUrl: string;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The running game's debug plane — see {@link GameDebugDoor}. It rides the
|
|
258
|
+
* SAME `/__editor/command` relay every other method here uses (relay cases
|
|
259
|
+
* `inspect-gameplay-state` / `invoke-debug-command`, `command-listener.ts`),
|
|
260
|
+
* so a tool contribution reaches the game through the client it already has
|
|
261
|
+
* rather than a second channel of its own.
|
|
262
|
+
*/
|
|
263
|
+
readonly game: GameDebugDoor;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Called with the raw body of EVERY response this client receives — command
|
|
267
|
+
* envelopes and `/__editor/state` alike, on success AND on refusal.
|
|
268
|
+
*
|
|
269
|
+
* It exists for exactly one contract: the server stamps `unresolvedConsole`
|
|
270
|
+
* onto every envelope (`server-utils.ts`'s `commandResponseFor`), and the CLI
|
|
271
|
+
* has to see those counts to be loud about them. Routing that through a
|
|
272
|
+
* single observer here — rather than teaching each of the CLI's output sites
|
|
273
|
+
* to unpack a response — is what keeps the loudness contract ONE mechanism.
|
|
274
|
+
* The observer must not throw; anything it raises is swallowed, because a
|
|
275
|
+
* reporting hook may never break the command it is reporting on.
|
|
276
|
+
*/
|
|
277
|
+
private readonly transport:
|
|
278
|
+
| ((body: Record<string, unknown>) => Promise<Record<string, unknown>>)
|
|
279
|
+
| null;
|
|
280
|
+
private readonly onEnvelope:
|
|
281
|
+
| ((body: unknown, observation: EditorEnvelopeObservation) => void)
|
|
282
|
+
| null;
|
|
283
|
+
|
|
284
|
+
constructor(opts?: {
|
|
285
|
+
url?: string;
|
|
286
|
+
onEnvelope?: (body: unknown, observation: EditorEnvelopeObservation) => void;
|
|
287
|
+
/**
|
|
288
|
+
* An IN-PAGE command channel, for a client that lives inside the editor
|
|
289
|
+
* page itself and has no editor server to reach. Given, every command goes through it instead of
|
|
290
|
+
* `POST /__editor/command`, and answers in the route's own body shape
|
|
291
|
+
* (`{ ok: true, ...data }` / `{ ok: false, error, code? }`).
|
|
292
|
+
*/
|
|
293
|
+
transport?: (body: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
294
|
+
}) {
|
|
295
|
+
if (opts !== undefined && (typeof opts !== 'object' || opts === null || Array.isArray(opts))) {
|
|
296
|
+
throw new TypeError(
|
|
297
|
+
'EditorClient options must be an object. Use new EditorClient({ url: "http://127.0.0.1:20173" }), not new EditorClient("...").',
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
const unknownOptions = Object.keys(opts ?? {}).filter(
|
|
301
|
+
(key) => key !== 'url' && key !== 'onEnvelope' && key !== 'transport',
|
|
302
|
+
);
|
|
303
|
+
if (unknownOptions.length > 0) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`EditorClient: unknown option${unknownOptions.length === 1 ? '' : 's'} ${unknownOptions.map((key) => `"${key}"`).join(', ')}. Use { url: "http://127.0.0.1:<port>" } to target an editor.`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
this.baseUrl = (opts?.url ?? DEFAULT_URL).replace(/\/$/, '');
|
|
309
|
+
this.onEnvelope = opts?.onEnvelope ?? null;
|
|
310
|
+
this.transport = opts?.transport ?? null;
|
|
311
|
+
this.game = {
|
|
312
|
+
state: async (name: string): Promise<unknown> => {
|
|
313
|
+
// `keys` narrows the relay to the one provider asked for, so a panel
|
|
314
|
+
// polling one vital never drags the whole plane's `stateAll()` across
|
|
315
|
+
// the wire. A game with no debug adapter answers `{ state: null }`.
|
|
316
|
+
const data = await this.command<{ state: Record<string, unknown> | null }>({
|
|
317
|
+
type: 'inspect-gameplay-state',
|
|
318
|
+
keys: [name],
|
|
319
|
+
});
|
|
320
|
+
return data.state?.[name];
|
|
321
|
+
},
|
|
322
|
+
command: async (name: string, ...args: unknown[]): Promise<unknown> =>
|
|
323
|
+
(await this.command<{ result: unknown }>({ type: 'invoke-debug-command', name, args }))
|
|
324
|
+
.result,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* `retryTransport` opts a command into ONE automatic retry after a transport
|
|
330
|
+
* failure (see {@link RETRYABLE_TRANSPORT_CODES}). It is deliberately
|
|
331
|
+
* OPT-IN and off by default: a socket that died after the request was written
|
|
332
|
+
* cannot prove the editor did not already run the command, so a blanket retry
|
|
333
|
+
* would risk playing/stopping/writing twice. Read-only relays — the captures —
|
|
334
|
+
* have no such hazard and turn it on.
|
|
335
|
+
*/
|
|
336
|
+
private async command<T extends object = Record<string, never>>(
|
|
337
|
+
body: Record<string, unknown>,
|
|
338
|
+
options?: { retryTransport?: boolean; deadlineMs?: number },
|
|
339
|
+
): Promise<T> {
|
|
340
|
+
const type = String(body['type'] ?? 'command');
|
|
341
|
+
const deadlineMs = options?.deadlineMs ?? COMMAND_DEADLINE_MS;
|
|
342
|
+
if (this.transport) {
|
|
343
|
+
const answered = (await this.transport(body)) as {
|
|
344
|
+
ok: boolean;
|
|
345
|
+
error?: string;
|
|
346
|
+
code?: string;
|
|
347
|
+
} & T;
|
|
348
|
+
if (!answered.ok) {
|
|
349
|
+
throw new EditorCommandError(
|
|
350
|
+
answered.error ?? `Editor command "${type}" failed`,
|
|
351
|
+
answered.code,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
return answered;
|
|
355
|
+
}
|
|
356
|
+
const url = `${this.baseUrl}/__editor/command`;
|
|
357
|
+
const request = JSON.stringify(body);
|
|
358
|
+
let res: Response | undefined;
|
|
359
|
+
let retried = false;
|
|
360
|
+
let commandDispatcher: Dispatcher | undefined;
|
|
361
|
+
for (;;) {
|
|
362
|
+
try {
|
|
363
|
+
const init: RequestInit = {
|
|
364
|
+
method: 'POST',
|
|
365
|
+
headers: { 'Content-Type': 'application/json' },
|
|
366
|
+
body: request,
|
|
367
|
+
signal: AbortSignal.timeout(deadlineMs),
|
|
368
|
+
};
|
|
369
|
+
// The command route answers only when the command completes, and
|
|
370
|
+
// Node's fetch (undici) gives a server 300 s to send response HEADERS
|
|
371
|
+
// regardless of the abort signal: a modeling step measured at eleven
|
|
372
|
+
// minutes in the tab died as UND_ERR_HEADERS_TIMEOUT well inside its
|
|
373
|
+
// half-hour budget. A deadline past that default carries its own
|
|
374
|
+
// dispatcher, through undici's own fetch so the two agree.
|
|
375
|
+
commandDispatcher = deadlineMs > UNDICI_DEFAULT_HEADERS_TIMEOUT_MS
|
|
376
|
+
? createDispatcher(deadlineMs + 30_000)
|
|
377
|
+
: undefined;
|
|
378
|
+
res = await dispatchFetch(url, init, commandDispatcher);
|
|
379
|
+
break;
|
|
380
|
+
} catch (error) {
|
|
381
|
+
await commandDispatcher?.destroy?.();
|
|
382
|
+
commandDispatcher = undefined;
|
|
383
|
+
// Only the deadline is reshaped into a "no answer" verdict; everything
|
|
384
|
+
// else is a TRANSPORT failure, and Node hides its reason behind a bare
|
|
385
|
+
// `fetch failed` (see `describeFetchFailure`).
|
|
386
|
+
if ((error as { name?: string } | null)?.name === 'TimeoutError') {
|
|
387
|
+
throw new EditorCommandError(
|
|
388
|
+
`The editor at ${this.baseUrl} never answered "${type}" ` +
|
|
389
|
+
`within ${Math.round(deadlineMs / 1000)}s — past every server-side budget, so ` +
|
|
390
|
+
'the server itself is not answering. Check the terminal running `volter-editor edit`.',
|
|
391
|
+
undefined,
|
|
392
|
+
true,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
const { code, detail } = describeFetchFailure(error);
|
|
396
|
+
if (options?.retryTransport === true && !retried && RETRYABLE_TRANSPORT_CODES.has(code)) {
|
|
397
|
+
retried = true;
|
|
398
|
+
await new Promise((resolve) => setTimeout(resolve, TRANSPORT_RETRY_DELAY_MS));
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
throw new EditorCommandError(
|
|
402
|
+
`POST ${url} ("${type}") never reached the editor: ${code}${detail ? ` (${detail})` : ''}.` +
|
|
403
|
+
(retried
|
|
404
|
+
? ` Retried once after ${TRANSPORT_RETRY_DELAY_MS}ms; it failed the same way.`
|
|
405
|
+
: '') +
|
|
406
|
+
(options?.retryTransport === true
|
|
407
|
+
? ''
|
|
408
|
+
: ' Not retried automatically: this command can change editor state, and a socket that' +
|
|
409
|
+
' died after the request was written cannot prove the editor did not already run it.') +
|
|
410
|
+
' A transport failure means the port stopped answering, not that the editor refused —' +
|
|
411
|
+
' the dev server restarts on any watched source edit, and it shuts itself down after an' +
|
|
412
|
+
' idle window. Check the terminal running `volter-editor edit` and confirm the port' +
|
|
413
|
+
' this client resolved.',
|
|
414
|
+
code,
|
|
415
|
+
false,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
let data: { ok: boolean; error?: string; code?: string } & T;
|
|
420
|
+
try {
|
|
421
|
+
data = (await this.readJson(res)) as typeof data;
|
|
422
|
+
} finally {
|
|
423
|
+
await commandDispatcher?.destroy?.();
|
|
424
|
+
}
|
|
425
|
+
if (!data.ok) {
|
|
426
|
+
throw new EditorCommandError(
|
|
427
|
+
data.error ?? `Editor command failed: ${res.status}`,
|
|
428
|
+
data.code,
|
|
429
|
+
// 504 is every `timedOut` result (`commandResponseFor`) — the relay
|
|
430
|
+
// gave up, on any of its five grounds. A 200 body with `ok: false` is
|
|
431
|
+
// an ANSWER from the editor, however unwelcome. See `timedOut` above.
|
|
432
|
+
res.status === 504,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
return data;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// --- Play control ---
|
|
439
|
+
|
|
440
|
+
/** `opts.seed` (D15/T-D15.6, objection-4 fix) — `vgai play --seed <n>`'s
|
|
441
|
+
* explicit config leg, relayed as `cmd['seed']`; `handleCommand`'s
|
|
442
|
+
* `'play'` case threads it into `enterPlayMode`'s highest-precedence seed
|
|
443
|
+
* argument (beats manifest.determinism.defaultSeed/?vgai-seed=). Omitted,
|
|
444
|
+
* boot seeding falls back to that precedence unchanged.
|
|
445
|
+
*
|
|
446
|
+
* `opts.name` (`vgai play --name <text>`) — an OPTIONAL label for this run,
|
|
447
|
+
* relayed as `cmd['name']` and slugified server-side into the run's
|
|
448
|
+
* `logs/play-*.jsonl` filename and its session-journal line. Findability
|
|
449
|
+
* only: no registry, no uniqueness, no lookup verb — grep and `ls` are the
|
|
450
|
+
* query engine. Omitted, the filename keeps its exact unnamed shape. */
|
|
451
|
+
/* `opts.record` (`vgai play --record <name>`) — NAMES this run's recording
|
|
452
|
+
* file. It does not ENABLE recording: every relayed play records, with no
|
|
453
|
+
* flag (see `@vgai/game`'s `src/play/play-recording.ts`). Omitted, the clip is named for
|
|
454
|
+
* the durable Gameplay Session; named, it becomes an explicit keepsake in
|
|
455
|
+
* `.vgai/recordings/<name>.webm`. */
|
|
456
|
+
async play(opts?: {
|
|
457
|
+
seed?: number;
|
|
458
|
+
name?: string | null;
|
|
459
|
+
record?: string | null;
|
|
460
|
+
}): Promise<PlayStarted> {
|
|
461
|
+
return this.command<PlayStarted>({
|
|
462
|
+
type: 'play',
|
|
463
|
+
...(opts?.seed !== undefined ? { seed: opts.seed } : {}),
|
|
464
|
+
...(opts?.name ? { name: opts.name } : {}),
|
|
465
|
+
...(opts?.record ? { record: opts.record } : {}),
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Dispose the current play session and mount it again from fresh project entry source. */
|
|
470
|
+
async restart(): Promise<void> {
|
|
471
|
+
await this.command({ type: 'play' });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Stops play, and finalizes this run's recording before the surface it was
|
|
475
|
+
* photographing is torn down. The capture is absent when nothing recorded. */
|
|
476
|
+
async stop(): Promise<{ recording?: GameplayRecordingCapture }> {
|
|
477
|
+
return this.command<{ recording?: GameplayRecordingCapture }>({ type: 'stop' });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async pause(): Promise<void> {
|
|
481
|
+
await this.command({ type: 'pause' });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async resume(): Promise<void> {
|
|
485
|
+
await this.command({ type: 'resume' });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async step(): Promise<void> {
|
|
489
|
+
await this.command({ type: 'step' });
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// --- Selection ---
|
|
493
|
+
|
|
494
|
+
async select(id: string | null): Promise<void> {
|
|
495
|
+
await this.command({ type: 'select', id });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async selectMultiple(ids: string[]): Promise<void> {
|
|
499
|
+
await this.command({ type: 'select-multiple', ids });
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async selectAll(): Promise<void> {
|
|
503
|
+
await this.command({ type: 'select-all' });
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// --- Viewport ---
|
|
507
|
+
|
|
508
|
+
async focusEntity(id: string): Promise<void> {
|
|
509
|
+
await this.command({ type: 'focus-entity', id });
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async focusSelection(): Promise<void> {
|
|
513
|
+
await this.command({ type: 'focus-selection' });
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Frame the EDIT viewport camera on one entity — the strict sibling of
|
|
518
|
+
* {@link focusEntity}. Same framing; an id the scene does not know is a
|
|
519
|
+
* refusal naming the id (`EditorCommandError`, code `ENTITY_NOT_FOUND`)
|
|
520
|
+
* rather than `focusEntity`'s silent no-op, so a caller that frames an
|
|
521
|
+
* entity before capturing it cannot photograph the wrong thing.
|
|
522
|
+
*/
|
|
523
|
+
async frameEntity(id: string): Promise<void> {
|
|
524
|
+
await this.command({ type: 'frame-entity', id });
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async viewPreset(preset: ViewPreset): Promise<void> {
|
|
528
|
+
await this.command({ type: 'view-preset', preset });
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* LOOK AROUND THE OPEN MODEL, visibly. Swings the active Object3D
|
|
533
|
+
* document's camera — the one on the human's screen — by `azimuth`/
|
|
534
|
+
* `elevation` radians, animated over `duration` seconds, and resolves when
|
|
535
|
+
* the move ends. A human drag during the move cancels it where it stands
|
|
536
|
+
* (`cancelledBy: 'human'`); the promise still resolves.
|
|
537
|
+
*/
|
|
538
|
+
async orbitDocument(options: {
|
|
539
|
+
azimuth?: number;
|
|
540
|
+
elevation?: number;
|
|
541
|
+
duration?: number;
|
|
542
|
+
}): Promise<DocumentLookOutcome> {
|
|
543
|
+
return this.command<DocumentLookOutcome>({ type: 'document-orbit', ...options });
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** A slow full revolution around the open document's subject, at a constant rate. */
|
|
547
|
+
async turntableDocument(options?: {
|
|
548
|
+
seconds?: number;
|
|
549
|
+
revolutions?: number;
|
|
550
|
+
}): Promise<DocumentLookOutcome> {
|
|
551
|
+
return this.command<DocumentLookOutcome>({ type: 'document-turntable', ...options });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Frame the open document's subject (its selection if it has one). `fit`
|
|
556
|
+
* scales the fitted distance: 1 is the toolbar Frame button's tight fit.
|
|
557
|
+
*/
|
|
558
|
+
async frameDocument(fit?: number): Promise<DocumentCameraPose> {
|
|
559
|
+
return this.command<DocumentCameraPose>({
|
|
560
|
+
type: 'document-frame',
|
|
561
|
+
...(fit === undefined ? {} : { fit }),
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async setCamera(position: Vec3Value, target: Vec3Value, fov?: number): Promise<void> {
|
|
566
|
+
await this.command({
|
|
567
|
+
type: 'set-camera',
|
|
568
|
+
position,
|
|
569
|
+
target,
|
|
570
|
+
...(fov === undefined ? {} : { fov }),
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async captureViewport(size?: number): Promise<ViewportCapture> {
|
|
575
|
+
const data = await this.command<ViewportCapture>({
|
|
576
|
+
type: 'capture-viewport',
|
|
577
|
+
...(size === undefined ? {} : { size }),
|
|
578
|
+
});
|
|
579
|
+
return { base64: data.base64, mimeType: data.mimeType };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Unit 4 (live-front-door wave) — capture the RUNNING GAME (`vgai
|
|
584
|
+
* screenshot`'s wire leg). Sends the SAME `bridge-screenshot` relay op
|
|
585
|
+
* `@vgai/live`'s `RelayTransport.screenshot` (and therefore
|
|
586
|
+
* `game.screenshot()` on the relay path) already sends, so all three
|
|
587
|
+
* surfaces composite the identical full game stack — canvas(es) plus the
|
|
588
|
+
* HUD/react DOM layers — rather than any of them inventing a second,
|
|
589
|
+
* subtly-different capture path. Contrast {@link captureViewport}, which
|
|
590
|
+
* captures the EDITOR viewport's canvas and would silently hand back an
|
|
591
|
+
* editor-only (HUD-less, possibly not-even-playing) image.
|
|
592
|
+
*
|
|
593
|
+
* Rejects — loudly, via `command`'s own `{ok:false}` unwrap — when play
|
|
594
|
+
* mode isn't running ("not in play mode — start play before using the
|
|
595
|
+
* debug seam") or no game canvas is mounted yet. Never returns a blank or
|
|
596
|
+
* editor-only frame as a stand-in.
|
|
597
|
+
*
|
|
598
|
+
* `opts.refreshStarvedFrame` is the loop-starvation leg: without recent rAF
|
|
599
|
+
* progress the canvas holds a provably stale frame and the relay
|
|
600
|
+
* refuses it with `BRIDGE_SCREENSHOT_STALE` rather than pass it off as
|
|
601
|
+
* current. Setting this asks the relay to render exactly ONE deterministic
|
|
602
|
+
* tick (`runTicks(1, {render:'last'})`) first — the same escape
|
|
603
|
+
* `@vgai/live`'s `RelayTransport.screenshot` has always used, which is why
|
|
604
|
+
* `vgai eval` could recover these frames while `vgai screenshot` could not.
|
|
605
|
+
* Off by default: a caller who does not ask must never be handed a frame
|
|
606
|
+
* that only exists because the capture drove the game.
|
|
607
|
+
*/
|
|
608
|
+
async captureGame(opts?: { refreshStarvedFrame?: boolean }): Promise<GameCapture> {
|
|
609
|
+
const data = await this.command<GameCapture>({
|
|
610
|
+
type: 'bridge-screenshot',
|
|
611
|
+
...(opts?.refreshStarvedFrame === true ? { refreshStarvedFrame: true } : {}),
|
|
612
|
+
});
|
|
613
|
+
const layers = data.layers;
|
|
614
|
+
const flatness = data.flatness;
|
|
615
|
+
return {
|
|
616
|
+
base64: data.base64,
|
|
617
|
+
mimeType: data.mimeType,
|
|
618
|
+
composite: data.composite === true,
|
|
619
|
+
...(layers && Number.isInteger(layers.canvases) && Number.isInteger(layers.domOverlays)
|
|
620
|
+
? { layers }
|
|
621
|
+
: {}),
|
|
622
|
+
// Pass the pixel-honesty fields through as the page reported them: the
|
|
623
|
+
// warning sentence is written where the pixels are, so nothing here
|
|
624
|
+
// re-derives (or softens) it.
|
|
625
|
+
...(flatness && typeof flatness.dominantFraction === 'number' ? { flatness } : {}),
|
|
626
|
+
...(data.loopRecoveryFrame === true ? { loopRecoveryFrame: true } : {}),
|
|
627
|
+
// Same pass-through rule: the recorded-run notice is written where the
|
|
628
|
+
// pixels are, so nothing here re-derives or softens it.
|
|
629
|
+
...(data.recording && typeof data.recording.notice === 'string'
|
|
630
|
+
? { recording: data.recording }
|
|
631
|
+
: {}),
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Start recording the same clean running-game composite `captureGame`
|
|
636
|
+
* photographs. Recording state lives in the editor page, so another process
|
|
637
|
+
* may stop it later through the same project session. */
|
|
638
|
+
async startGameplayRecording(
|
|
639
|
+
options: GameplayRecordingOptions = {},
|
|
640
|
+
): Promise<GameplayRecordingStarted> {
|
|
641
|
+
return this.command<GameplayRecordingStarted>({
|
|
642
|
+
type: 'bridge-recording-start',
|
|
643
|
+
...(options.fps !== undefined ? { fps: options.fps } : {}),
|
|
644
|
+
...(options.name !== undefined ? { name: options.name } : {}),
|
|
645
|
+
...(options.format !== undefined ? { format: options.format } : {}),
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Export a paused run as fixed-step video. Advances game state; maximum
|
|
650
|
+
* five minutes. `audio` describes the muxed track, or is `false` when the
|
|
651
|
+
* world implements no `AudioAdapter.renderOffline` and the file is
|
|
652
|
+
* genuinely silent — read it, never assume either. */
|
|
653
|
+
async exportGameplayVideo(options: { frames: number; fps?: number; name?: string }): Promise<{
|
|
654
|
+
path: string;
|
|
655
|
+
frames: number;
|
|
656
|
+
fps: number;
|
|
657
|
+
durationMs: number;
|
|
658
|
+
wallMs: number;
|
|
659
|
+
width: number;
|
|
660
|
+
height: number;
|
|
661
|
+
audio:
|
|
662
|
+
| false
|
|
663
|
+
| {
|
|
664
|
+
codec: 'opus';
|
|
665
|
+
sampleRate: number;
|
|
666
|
+
channels: number;
|
|
667
|
+
durationSeconds: number;
|
|
668
|
+
rms: number;
|
|
669
|
+
peak: number;
|
|
670
|
+
};
|
|
671
|
+
}> {
|
|
672
|
+
return this.command(
|
|
673
|
+
{ type: 'bridge-recording-export', ...options },
|
|
674
|
+
{ deadlineMs: 610_000, retryTransport: false },
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Stop the page-owned recorder and return its WebM path and metadata. */
|
|
679
|
+
async stopGameplayRecording(): Promise<GameplayRecordingCapture> {
|
|
680
|
+
return this.command<GameplayRecordingCapture>({ type: 'bridge-recording-stop' });
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Read the active capture's monotonic media position. This is the only clock
|
|
684
|
+
* suitable for selecting intervals inside the finalized recording. */
|
|
685
|
+
async getGameplayRecordingTimeline(): Promise<GameplayRecordingTimeline> {
|
|
686
|
+
const timeline = await this.command<GameplayRecordingTimeline>({
|
|
687
|
+
type: 'bridge-recording-timeline',
|
|
688
|
+
});
|
|
689
|
+
return { startedAt: timeline.startedAt, elapsedMs: timeline.elapsedMs };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Encode a recorded canvas/DOM interval into a normal composite WebM. */
|
|
693
|
+
async exportGameplayReplay(options: {
|
|
694
|
+
replayPath: string;
|
|
695
|
+
fps?: number;
|
|
696
|
+
startMs?: number;
|
|
697
|
+
endMs?: number;
|
|
698
|
+
name?: string;
|
|
699
|
+
}): Promise<{
|
|
700
|
+
path: string;
|
|
701
|
+
frames: number;
|
|
702
|
+
fps: number;
|
|
703
|
+
durationMs: number;
|
|
704
|
+
width: number;
|
|
705
|
+
height: number;
|
|
706
|
+
audio: boolean;
|
|
707
|
+
}> {
|
|
708
|
+
return this.command(
|
|
709
|
+
{ type: 'bridge-recording-replay-export', ...options },
|
|
710
|
+
{ deadlineMs: 610_000, retryTransport: false },
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async captureGameplayReplay(
|
|
715
|
+
replayPath: string,
|
|
716
|
+
positionMs: number,
|
|
717
|
+
): Promise<GameplayReplayCapture> {
|
|
718
|
+
return this.command<GameplayReplayCapture>({
|
|
719
|
+
type: 'bridge-recording-replay-capture',
|
|
720
|
+
replayPath,
|
|
721
|
+
positionMs,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Capture an isolated, deterministic four-view preview through the editor's
|
|
727
|
+
* native Asset Lab. The SDK delegates rendering to the editor; it never
|
|
728
|
+
* loads, clones, or interprets Three.js assets itself.
|
|
729
|
+
*/
|
|
730
|
+
async captureAssetPreview(
|
|
731
|
+
source: AssetPreviewSource,
|
|
732
|
+
options: AssetPreviewOptions = {},
|
|
733
|
+
): Promise<AssetPreviewCapture> {
|
|
734
|
+
const data = await this.command<AssetPreviewCapture>(
|
|
735
|
+
{
|
|
736
|
+
type: 'capture-asset-preview',
|
|
737
|
+
...source,
|
|
738
|
+
...options,
|
|
739
|
+
},
|
|
740
|
+
// Photographing changes nothing, and this is the relay `vgai screenshot
|
|
741
|
+
// <module>` / `project.bake.preview` rides — the lane where a momentary
|
|
742
|
+
// transport failure cost a cold agent three probe modules.
|
|
743
|
+
{ retryTransport: true },
|
|
744
|
+
);
|
|
745
|
+
return {
|
|
746
|
+
width: data.width,
|
|
747
|
+
height: data.height,
|
|
748
|
+
// Absent from editors that predate orientation reporting.
|
|
749
|
+
...(data.orientation ? { orientation: data.orientation } : {}),
|
|
750
|
+
views: data.views,
|
|
751
|
+
contactSheet: data.contactSheet,
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* A project-defined labeled shot set (`vgai screenshot <target> --shots <set>`):
|
|
757
|
+
* the DEFINITION travels with the command (project data — see
|
|
758
|
+
* `AssetPreviewShotSetDefinition`; the CLI resolves it from the registered
|
|
759
|
+
* `project.<set>.previewShots` tool), and the editor's generic
|
|
760
|
+
* capture engine renders it — see `packages/editor/src/asset-preview.ts`'s
|
|
761
|
+
* `captureShotSetAssetPreview`. Throws (via `command`'s `{ok:false}`
|
|
762
|
+
* unwrap) with a clear message naming the missing joint(s) when the asset
|
|
763
|
+
* lacks a bone the definition requires.
|
|
764
|
+
*/
|
|
765
|
+
async captureShotSetPreview(
|
|
766
|
+
source: AssetPreviewSource,
|
|
767
|
+
definition: AssetPreviewShotSetDefinition,
|
|
768
|
+
options: AssetPreviewOptions = {},
|
|
769
|
+
): Promise<LabeledShotSetCapture> {
|
|
770
|
+
const data = await this.command<LabeledShotSetCapture>(
|
|
771
|
+
{
|
|
772
|
+
type: 'capture-asset-preview',
|
|
773
|
+
...source,
|
|
774
|
+
...options,
|
|
775
|
+
shotSet: definition,
|
|
776
|
+
},
|
|
777
|
+
{ retryTransport: true },
|
|
778
|
+
);
|
|
779
|
+
return {
|
|
780
|
+
width: data.width,
|
|
781
|
+
height: data.height,
|
|
782
|
+
shots: data.shots,
|
|
783
|
+
// An older editor predates the empty-frame guard and sends none.
|
|
784
|
+
warnings: data.warnings ?? [],
|
|
785
|
+
contactSheet: data.contactSheet,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* B8.4 — score the asset against a reference GLB (`vgai screenshot
|
|
791
|
+
* <model.glb> --compare <ref.glb>`): matched orthographic front + side silhouettes
|
|
792
|
+
* (equal-height bounding-box framing, both yaw-normalized to face the
|
|
793
|
+
* camera), per-view IoU numbers, and overlay evidence images. The
|
|
794
|
+
* reference GLB's raw bytes travel base64 in the command; the editor
|
|
795
|
+
* renders and scores — the SDK never interprets Three.js assets itself.
|
|
796
|
+
*/
|
|
797
|
+
async captureAssetComparePreview(
|
|
798
|
+
source: AssetPreviewSource,
|
|
799
|
+
refGlbBase64: string,
|
|
800
|
+
options: AssetCompareOptions = {},
|
|
801
|
+
): Promise<AssetCompareCapture> {
|
|
802
|
+
const { refForward, ...dimensions } = options;
|
|
803
|
+
const data = await this.command<AssetCompareCapture>({
|
|
804
|
+
type: 'capture-asset-preview',
|
|
805
|
+
...source,
|
|
806
|
+
...dimensions,
|
|
807
|
+
compare: { glbBase64: refGlbBase64, ...(refForward ? { forward: refForward } : {}) },
|
|
808
|
+
});
|
|
809
|
+
return { width: data.width, height: data.height, views: data.views };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* The STORY lane (`vgai screenshot <file>.stories.tsx`): every CSF export of
|
|
814
|
+
* one project story file rendered in the live session's DOM and captured
|
|
815
|
+
* through the same composite leg {@link captureGame} uses, returned as
|
|
816
|
+
* per-export images plus one variant sheet. `options.story` narrows to a
|
|
817
|
+
* single export.
|
|
818
|
+
*
|
|
819
|
+
* Rendering happens in the EDITOR — the SDK never imports, composes or
|
|
820
|
+
* mounts a CSF module itself; the session already owns that machinery for
|
|
821
|
+
* its Stories panel and this drives it.
|
|
822
|
+
*/
|
|
823
|
+
async captureStoryVariants(
|
|
824
|
+
modulePath: string,
|
|
825
|
+
options: StoryCaptureOptions = {},
|
|
826
|
+
): Promise<StoryVariantCapture> {
|
|
827
|
+
const data = await this.command<StoryVariantCapture>({
|
|
828
|
+
type: 'capture-story-variants',
|
|
829
|
+
modulePath,
|
|
830
|
+
...options,
|
|
831
|
+
});
|
|
832
|
+
return {
|
|
833
|
+
modulePath: data.modulePath,
|
|
834
|
+
width: data.width,
|
|
835
|
+
height: data.height,
|
|
836
|
+
variants: data.variants,
|
|
837
|
+
contactSheet: data.contactSheet,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// --- Panels ---
|
|
842
|
+
|
|
843
|
+
async showViewport(tab: ViewportTab): Promise<void> {
|
|
844
|
+
await this.command({ type: 'viewport-tab', tab });
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** Focus a static workspace panel by the key the editor's panel registry
|
|
848
|
+
* holds; an unknown key refuses naming the keys it does hold. */
|
|
849
|
+
async showPanel(panel: string): Promise<void> {
|
|
850
|
+
await this.command({ type: 'show-panel', panel });
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Show several instances of the running game split-screen — multiplayer
|
|
854
|
+
* authoring. Pass a total `count` (default "Player N" labels) or an array of
|
|
855
|
+
* `names` (its length is the count; index 0 is the primary). Requires a live
|
|
856
|
+
* play session. */
|
|
857
|
+
async setInstanceCount(countOrNames: number | string[]): Promise<void> {
|
|
858
|
+
await this.command(
|
|
859
|
+
Array.isArray(countOrNames)
|
|
860
|
+
? { type: 'set-instance-count', names: countOrNames }
|
|
861
|
+
: { type: 'set-instance-count', count: countOrNames },
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
async openAsset(path: string, kind: AssetKind): Promise<void> {
|
|
866
|
+
await this.command({ type: 'open-asset-tab', path, kind });
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** SELECT a project asset — the other half of the browser's
|
|
870
|
+
* selection-vs-open contract (single click selects and fills the
|
|
871
|
+
* Inspector; double click opens a document). */
|
|
872
|
+
async selectAsset(path: string): Promise<void> {
|
|
873
|
+
await this.command({ type: 'select-asset', path });
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
async closeAsset(key: string): Promise<void> {
|
|
877
|
+
await this.command({ type: 'close-asset-tab', key });
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
async toggleCommandPalette(): Promise<void> {
|
|
881
|
+
await this.command({ type: 'toggle-command-palette' });
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async toggleConsole(): Promise<void> {
|
|
885
|
+
await this.command({ type: 'toggle-console' });
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** Switch the editor's NAMED WORKSPACE — the task-named layout memory
|
|
889
|
+
* (`game`/`model`/`sculpt`/`texture`/`animate`/`look`). Resolves once the
|
|
890
|
+
* dock has finished rebuilding, so a following capture photographs the
|
|
891
|
+
* arrangement that was asked for. */
|
|
892
|
+
async setWorkspace(workspace: EditorWorkspaceName): Promise<void> {
|
|
893
|
+
await this.command({ type: 'set-workspace', workspace });
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** Apply a STYLE BUNDLE — palette, material, icon set and region defaults
|
|
897
|
+
* in one gesture (`classic`/`glass`/`maya`/`substance`, or one a package
|
|
898
|
+
* the project declares carries, `blender`). */
|
|
899
|
+
async setStyle(style: string): Promise<void> {
|
|
900
|
+
await this.command({ type: 'set-style', style });
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** Set the MATERIAL apart from the bundle that usually carries it.
|
|
904
|
+
* Answers with what the chrome wears afterwards. */
|
|
905
|
+
async setAppearance(appearance: {
|
|
906
|
+
readonly material?: string;
|
|
907
|
+
}): Promise<{ material: string; style: string | null }> {
|
|
908
|
+
return this.command<{ material: string; style: string | null }>({
|
|
909
|
+
type: 'set-appearance',
|
|
910
|
+
...appearance,
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
async showBuild(): Promise<void> {
|
|
915
|
+
await this.command({ type: 'show-build' });
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/** Atomically present a durable editor view and return its shareable URL. */
|
|
919
|
+
async present(view: EditorView): Promise<PresentedEditorView> {
|
|
920
|
+
const presented = await this.command<PresentedEditorView>({ type: 'present-view', view });
|
|
921
|
+
return { view: presented.view, url: presented.url, warnings: presented.warnings };
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* The INSPECTION SUBJECT the editor is showing right now, as data — the
|
|
926
|
+
* serialized projection of the inspection model (design:
|
|
927
|
+
* `docs/ARCHITECTURE-CORE.md` §Editor chrome, "The Inspection Model").
|
|
928
|
+
*
|
|
929
|
+
* The same subject a human reads in the inspector: identity, presentation,
|
|
930
|
+
* verbs, and the identified sections in display order — with a `fields`
|
|
931
|
+
* section's CURRENT VALUES read through the same io the field rows edit
|
|
932
|
+
* through. With nothing selected it answers the active surface's own
|
|
933
|
+
* no-selection subject when it has one, exactly as the panel does; it never
|
|
934
|
+
* reports another surface's, and when the panel itself is unmounted it
|
|
935
|
+
* answers `{none: true}` rather than a subject nobody is looking at. A
|
|
936
|
+
* `custom` section body is a named opaque (`{kind, id, title}`) — the editor
|
|
937
|
+
* renders those with React — plus its displayed values under `data` when it
|
|
938
|
+
* has any (the Transform section's position/rotation/scale).
|
|
939
|
+
*/
|
|
940
|
+
async inspect(): Promise<InspectedInspection> {
|
|
941
|
+
const data = await this.command<{ subject: InspectedInspection }>({ type: 'inspect' });
|
|
942
|
+
return data.subject;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** Run one verb exposed by the active Inspector subject, by its id. */
|
|
946
|
+
async runInspectionAction(actionId: string): Promise<InspectedInspection> {
|
|
947
|
+
const data = await this.command<{ subject: InspectedInspection }>({
|
|
948
|
+
type: 'run-inspection-action',
|
|
949
|
+
actionId,
|
|
950
|
+
});
|
|
951
|
+
return data.subject;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Run ONE command by id — the door to everything the command palette lists.
|
|
956
|
+
*
|
|
957
|
+
* Under the Code-OSS frame this is the workbench's own `ICommandService`, so
|
|
958
|
+
* any command id works: a view's `vgai.<view>.<verb>`, an editor action's
|
|
959
|
+
* `vgai.action.<id>`, or one of VS Code's own. Standalone `vgai edit` has no
|
|
960
|
+
* command service and answers the `vgai.<view>.<verb>` shape directly off
|
|
961
|
+
* the views registry, refusing anything else BY NAME.
|
|
962
|
+
*
|
|
963
|
+
* The result is whatever the command answered — a view verb's state, or
|
|
964
|
+
* `null` for a command that returns nothing.
|
|
965
|
+
*/
|
|
966
|
+
async runCommand(commandId: string, args?: unknown): Promise<unknown> {
|
|
967
|
+
const data = await this.command<{ result: unknown }>({
|
|
968
|
+
type: 'run-command',
|
|
969
|
+
commandId,
|
|
970
|
+
...(args === undefined ? {} : { args }),
|
|
971
|
+
});
|
|
972
|
+
return data.result;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* One STRUCTURE op on the authored tree — the hierarchy context menu's own
|
|
977
|
+
* verbs, on the same helpers, for a caller with no pointer to right-click
|
|
978
|
+
* with. `id`/`ids` default to the current selection.
|
|
979
|
+
*/
|
|
980
|
+
async structureOp(op: StructureOp, options: StructureOpOptions = {}): Promise<StructureOpResult> {
|
|
981
|
+
return this.command<StructureOpResult>({ type: 'structure-op', op, ...options });
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/** "Extract Component…" — the hierarchy row's action, as a command. Answers
|
|
985
|
+
* the action's own sentence, which NAMES the files it created. */
|
|
986
|
+
async extractComponent(options: { id?: string; name?: string } = {}): Promise<{ hint: string }> {
|
|
987
|
+
return this.command<{ hint: string }>({ type: 'extract-component', ...options });
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/** "Fork Component…" — extract's twin: one new file, one callsite retargeted. */
|
|
991
|
+
async forkComponent(options: { id?: string } = {}): Promise<{ hint: string }> {
|
|
992
|
+
return this.command<{ hint: string }>({ type: 'fork-component', ...options });
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* The HIERARCHY PANEL's actual rendered row tree, as data.
|
|
997
|
+
*
|
|
998
|
+
* The same rows a human is looking at: the adapter's tree after the component
|
|
999
|
+
* marks fold implementation subtrees, after the internals reveal, after the
|
|
1000
|
+
* document promotion, the child cap, the collapse state, the search filter
|
|
1001
|
+
* and the selection scope. Works in play mode and edit mode alike — the
|
|
1002
|
+
* answer reports which (`playState`, `activeViewportTab`), because a
|
|
1003
|
+
* play-mode tree and an edit-mode tree come from different adapters.
|
|
1004
|
+
*
|
|
1005
|
+
* Deliberately NOT `status().entities`, which walks the raw adapter tree and
|
|
1006
|
+
* therefore answers a different question: a panel defect is invisible in it.
|
|
1007
|
+
*
|
|
1008
|
+
* Each row carries `childCount` (what its caret opens), `internalChildCount`
|
|
1009
|
+
* (what is folded behind "Reveal Internals") and `expandable` (whether the
|
|
1010
|
+
* panel draws a caret at all) — so "this subtree exists but the UI offers no
|
|
1011
|
+
* way to open it" is a readable fact rather than something only a human
|
|
1012
|
+
* squinting at the panel can notice.
|
|
1013
|
+
*
|
|
1014
|
+
* Rejects, naming the panel, when no hierarchy panel is mounted: an empty
|
|
1015
|
+
* tree would be a fabricated answer about a surface nobody is being shown.
|
|
1016
|
+
*/
|
|
1017
|
+
async hierarchy(): Promise<InspectedHierarchy> {
|
|
1018
|
+
const data = await this.command<{ hierarchy: InspectedHierarchy }>({ type: 'hierarchy' });
|
|
1019
|
+
return data.hierarchy;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** Run the Hierarchy panel's own Expand All action. */
|
|
1023
|
+
async expandHierarchyAll(): Promise<void> {
|
|
1024
|
+
await this.command<Record<string, never>>({ type: 'expand-hierarchy-all' });
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** Run the Hierarchy panel's own Collapse All action — Expand All's other
|
|
1028
|
+
* half, and the only way back to the tree's rest state through the product
|
|
1029
|
+
* (see `HierarchyPanelSnapshot.collapseAll`). */
|
|
1030
|
+
async collapseHierarchyAll(): Promise<void> {
|
|
1031
|
+
await this.command<Record<string, never>>({ type: 'collapse-hierarchy-all' });
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Write one editable path through the active Inspector's own IO.
|
|
1036
|
+
*
|
|
1037
|
+
* The answer carries `write` as well as the subject, because an ack alone
|
|
1038
|
+
* cannot be believed: a write with no persistence route open succeeds and
|
|
1039
|
+
* changes no byte, and `write.persisted` is how the caller tells the two
|
|
1040
|
+
* apart without diffing the tree (`InspectedWriteDestination` in `types.ts`).
|
|
1041
|
+
*/
|
|
1042
|
+
async setInspectionField(path: string, value: unknown): Promise<InspectedFieldWrite> {
|
|
1043
|
+
const data = await this.command<InspectedFieldWrite>({
|
|
1044
|
+
type: 'set-inspection-field',
|
|
1045
|
+
path,
|
|
1046
|
+
value,
|
|
1047
|
+
});
|
|
1048
|
+
return { subject: data.subject, write: data.write };
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* REMOVE one editable path's authored override — the other half of the write
|
|
1053
|
+
* door, and the only one that can express byte-ABSENCE.
|
|
1054
|
+
*
|
|
1055
|
+
* {@link setInspectionField} writes a VALUE, so reverting a property an
|
|
1056
|
+
* authoring gesture ADDED puts the default back EXPLICITLY and leaves the
|
|
1057
|
+
* source one attribute heavier than it started. This drops the property, so
|
|
1058
|
+
* whatever governs it in its absence takes over — the same `io.remove` the
|
|
1059
|
+
* Inspector's revert arrow calls, the same persistence pipe, the same awaited
|
|
1060
|
+
* `{ destination, persisted }` ack.
|
|
1061
|
+
*
|
|
1062
|
+
* Rejects with `code: 'REMOVAL_UNAVAILABLE'` when the field does not declare
|
|
1063
|
+
* itself removable or the lane implements no removal door. That refusal is a
|
|
1064
|
+
* MISSING SEAM, not a failed removal, and it is coded rather than phrased
|
|
1065
|
+
* precisely so a caller can grade the two differently.
|
|
1066
|
+
*/
|
|
1067
|
+
async removeInspectionField(path: string): Promise<InspectedFieldWrite> {
|
|
1068
|
+
const data = await this.command<InspectedFieldWrite>({
|
|
1069
|
+
type: 'remove-inspection-field',
|
|
1070
|
+
path,
|
|
1071
|
+
});
|
|
1072
|
+
return { subject: data.subject, write: data.write };
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* OPEN one piece of the adapter's SCENE TABLE by id — a scene, a prefab, or
|
|
1077
|
+
* a story state, because the table makes them siblings (they differ only in
|
|
1078
|
+
* instance site). The ids are exactly what `getState().adapter.scenes.entries`
|
|
1079
|
+
* reports, so the table is both the menu and the address space.
|
|
1080
|
+
*
|
|
1081
|
+
* With a game LIVE in the session, opening a scene the adapter declares
|
|
1082
|
+
* reachable through that game's own scenes contract NAVIGATES it — the same
|
|
1083
|
+
* switch the editor's own scene picker makes — and the answer carries the
|
|
1084
|
+
* game's own reading (`scene`).
|
|
1085
|
+
*
|
|
1086
|
+
* Rejects with a coded reason rather than prose: `SCENE_NOT_FOUND` (and it
|
|
1087
|
+
* names the ids that DO exist), `SCENE_NOT_OPENABLE` carrying the adapter's
|
|
1088
|
+
* own declared reason for a scene it says nothing can reach,
|
|
1089
|
+
* `SCENE_NAVIGATION_NOT_RUNNING` for a live-only scene with no game running,
|
|
1090
|
+
* `SCENE_CONTRACT_UNAVAILABLE` / `SCENE_NOT_IN_CONTRACT` (naming the ids the
|
|
1091
|
+
* game itself publishes) / `SCENE_SWITCH_FAILED` when the running game's own
|
|
1092
|
+
* navigation cannot take it, `SCENE_NOT_OPENABLE_LIVE` when this session has
|
|
1093
|
+
* no remount for a native swap-slot scene,
|
|
1094
|
+
* `SCENE_TABLE_UNAVAILABLE` before the adapter has loaded, and
|
|
1095
|
+
* `SCENE_DOCUMENT_NOT_MOUNTED` when the host has no document for a piece the
|
|
1096
|
+
* table says is openable — a host gap, not a table statement.
|
|
1097
|
+
*/
|
|
1098
|
+
async open(id: string): Promise<OpenedDocument> {
|
|
1099
|
+
return this.command<OpenedDocument>({ type: 'open', id });
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Undo / redo one project transaction — the same queue the keyboard shortcut
|
|
1104
|
+
* drives. `moved` is false when there was nothing left in that direction.
|
|
1105
|
+
*/
|
|
1106
|
+
async undo(): Promise<HistoryStep> {
|
|
1107
|
+
return this.command<HistoryStep>({ type: 'undo' });
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
async redo(): Promise<HistoryStep> {
|
|
1111
|
+
return this.command<HistoryStep>({ type: 'redo' });
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** Read the editor's actual current durable projection. */
|
|
1115
|
+
async currentView(): Promise<EditorView> {
|
|
1116
|
+
const data = await this.command<{ view: EditorView }>({ type: 'current-view' });
|
|
1117
|
+
return data.view;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Capture the active center document exactly as presented to the user.
|
|
1122
|
+
*
|
|
1123
|
+
* A number is a SQUARE of that size (the default shape); `{width, height}`
|
|
1124
|
+
* asks for a shaped frame — a video-aspect look that needs no crop. Both are
|
|
1125
|
+
* bounded by the relay budget; see {@link CaptureDimensions}.
|
|
1126
|
+
*/
|
|
1127
|
+
/** Photograph the editor PAGE itself — every panel as the person sees it, at
|
|
1128
|
+
* `scale` output pixels per CSS pixel (default `devicePixelRatio`), which is
|
|
1129
|
+
* what a 1 px border or a glyph edge is judged through. */
|
|
1130
|
+
async captureEditorChrome(options?: EditorChromeCaptureOptions): Promise<EditorChromeCapture> {
|
|
1131
|
+
return this.command<EditorChromeCapture>({
|
|
1132
|
+
type: 'capture-editor-chrome',
|
|
1133
|
+
...(options?.scale === undefined ? {} : { scale: options.scale }),
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** With a view, present and capture it in one request so document discovery
|
|
1138
|
+
* cannot retarget the capture between two client calls. */
|
|
1139
|
+
async captureActiveDocument(
|
|
1140
|
+
size?: CaptureDimensions,
|
|
1141
|
+
view?: EditorView,
|
|
1142
|
+
): Promise<ActiveDocumentCapture> {
|
|
1143
|
+
return this.command<ActiveDocumentCapture>({
|
|
1144
|
+
type: 'capture-active-document',
|
|
1145
|
+
...(view ? { view } : {}),
|
|
1146
|
+
...(typeof size === 'number' ? { size } : {}),
|
|
1147
|
+
...(typeof size === 'object' && size !== null
|
|
1148
|
+
? { width: size.width, height: size.height }
|
|
1149
|
+
: {}),
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Read or drive the ACTIVE center document's own DOM — the scoped
|
|
1155
|
+
* editor-chrome door, and the read/gesture half of the same subject
|
|
1156
|
+
* {@link captureActiveDocument} photographs. NOT play-mode gated, and NOT
|
|
1157
|
+
* page automation: a target outside the active document's container is
|
|
1158
|
+
* refused by name. Design and scope contract:
|
|
1159
|
+
* `packages/editor/src/editor-document-probe.ts`.
|
|
1160
|
+
*/
|
|
1161
|
+
async documentProbe(step: DocumentProbeStep): Promise<DocumentProbeResult> {
|
|
1162
|
+
return this.command<DocumentProbeResult>({ type: 'document-probe', step });
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* Run a wire-carried step against the ACTIVE document's published context
|
|
1167
|
+
* (`packages/editor/src/document-context-registry.ts`) — the REPL door over
|
|
1168
|
+
* an open document, in Edit mode. `src` is the step's own `toString()`;
|
|
1169
|
+
* same serialization contract as `page-script` (no closures survive).
|
|
1170
|
+
*/
|
|
1171
|
+
/**
|
|
1172
|
+
* The Blender lane's doors (`blender-execute`, `blender-scene-info`,
|
|
1173
|
+
* `blender-object-info`, `blender-screenshot-view`, `blender-read-file`,
|
|
1174
|
+
* `blender-write-file`, `blender-list-files`, `blender-start`,
|
|
1175
|
+
* `blender-status`): Blender runs in the editor tab's worker, and
|
|
1176
|
+
* `vgai blender-mcp` is transport onto these. `blender-status` is the only
|
|
1177
|
+
* one that creates nothing — it answers whether this tab already has a
|
|
1178
|
+
* session, which is how a caller survives an editor restart.
|
|
1179
|
+
*/
|
|
1180
|
+
async blender<T extends object = Record<string, unknown>>(
|
|
1181
|
+
type: `blender-${string}`,
|
|
1182
|
+
fields: Record<string, unknown> = {},
|
|
1183
|
+
): Promise<T> {
|
|
1184
|
+
// One modeling chunk can run for minutes in the tab (an exact boolean
|
|
1185
|
+
// over a dense mesh measured 80-90 s under Wasm); the relay's server-side
|
|
1186
|
+
// budget for blender-execute is the same half hour.
|
|
1187
|
+
return this.command<T>({ type, ...fields }, { deadlineMs: BLENDER_DEADLINE_MS });
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
async documentScript<T = unknown>(src: string): Promise<T> {
|
|
1191
|
+
const outcome = await this.command<{ result: T }>({ type: 'document-script', src });
|
|
1192
|
+
return outcome.result;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// --- Display (set semantics) ---
|
|
1196
|
+
|
|
1197
|
+
async setGrid(enabled: boolean): Promise<void> {
|
|
1198
|
+
await this.command({ type: 'set-grid', enabled });
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
async setHelpers(enabled: boolean): Promise<void> {
|
|
1202
|
+
await this.command({ type: 'set-helpers', enabled });
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
async setStats(enabled: boolean): Promise<void> {
|
|
1206
|
+
await this.command({ type: 'set-stats', enabled });
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
async setShadingMode(mode: ShadingMode): Promise<void> {
|
|
1210
|
+
await this.command({ type: 'set-shading-mode', mode });
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
async setHelperType(helperType: keyof HelperVisibility, enabled: boolean): Promise<void> {
|
|
1214
|
+
await this.command({ type: 'set-helper-type', helperType, enabled });
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// --- Transform tools (set semantics) ---
|
|
1218
|
+
|
|
1219
|
+
async setTransformMode(mode: TransformMode): Promise<void> {
|
|
1220
|
+
await this.command({ type: 'set-transform-mode', mode });
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
async setTransformSpace(space: TransformSpace): Promise<void> {
|
|
1224
|
+
await this.command({ type: 'set-transform-space', space });
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
async setSnap(enabled: boolean): Promise<void> {
|
|
1228
|
+
await this.command({ type: 'set-snap', enabled });
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// --- Project management ---
|
|
1232
|
+
|
|
1233
|
+
async createProject(
|
|
1234
|
+
name: string,
|
|
1235
|
+
location: string,
|
|
1236
|
+
template: ProjectTemplate = 'default',
|
|
1237
|
+
exampleId?: string,
|
|
1238
|
+
): Promise<ProjectInfo> {
|
|
1239
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/create-project`, {
|
|
1240
|
+
method: 'POST',
|
|
1241
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1242
|
+
body: JSON.stringify({ name, location, template, ...(exampleId ? { exampleId } : {}) }),
|
|
1243
|
+
});
|
|
1244
|
+
const data = (await this.readJson(res)) as {
|
|
1245
|
+
ok?: boolean;
|
|
1246
|
+
error?: string;
|
|
1247
|
+
path?: string;
|
|
1248
|
+
config?: ProjectInfo['config'];
|
|
1249
|
+
};
|
|
1250
|
+
if (!res.ok) {
|
|
1251
|
+
throw new Error(data.error ?? `Create project failed: ${res.status}`);
|
|
1252
|
+
}
|
|
1253
|
+
return { path: data.path as string, config: data.config as ProjectInfo['config'] };
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
async openProject(path: string): Promise<void> {
|
|
1257
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/open-project`, {
|
|
1258
|
+
method: 'POST',
|
|
1259
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1260
|
+
body: JSON.stringify({ path }),
|
|
1261
|
+
});
|
|
1262
|
+
const body = (await this.readJson(res)) as { error?: string };
|
|
1263
|
+
if (!res.ok) {
|
|
1264
|
+
throw new Error(body.error ?? `Open project failed: ${res.status}`);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
async getProject(): Promise<ProjectInfo | null> {
|
|
1269
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/project`);
|
|
1270
|
+
const data = (await this.readJson(res)) as { project: ProjectInfo | null; error?: string };
|
|
1271
|
+
if (!res.ok) throw new Error(data.error ?? `Failed to get project: ${res.status}`);
|
|
1272
|
+
return data.project;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
async listRecentProjects(): Promise<RecentProject[]> {
|
|
1276
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/recent-projects`);
|
|
1277
|
+
const data = (await this.readJson(res)) as { projects: RecentProject[]; error?: string };
|
|
1278
|
+
if (!res.ok) throw new Error(data.error ?? `Failed to list projects: ${res.status}`);
|
|
1279
|
+
return data.projects;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// --- Registered project tools ---
|
|
1283
|
+
|
|
1284
|
+
/** List tools explicitly registered in `package.json#vgai.tools`.
|
|
1285
|
+
* The editor server loads callable metadata in Node; modules never enter the
|
|
1286
|
+
* editor browser merely because they were listed. */
|
|
1287
|
+
async listProjectTools(): Promise<ProjectToolCatalog> {
|
|
1288
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/project-tools`);
|
|
1289
|
+
const body = await this.readJson(res);
|
|
1290
|
+
if (!res.ok) throw new Error(`Failed to list project tools: ${res.status}`);
|
|
1291
|
+
return body as ProjectToolCatalog;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/** Execute one Node-hosted project tool through the shared validated
|
|
1295
|
+
* dispatcher. Write/destructive tools require `confirm:true`. */
|
|
1296
|
+
async runProjectTool(
|
|
1297
|
+
name: string,
|
|
1298
|
+
input: unknown = {},
|
|
1299
|
+
options: { confirm?: boolean; instance?: string } = {},
|
|
1300
|
+
): Promise<ProjectToolOutcome> {
|
|
1301
|
+
// Provider subscriptions can outlive Node fetch's five-minute header limit.
|
|
1302
|
+
// Match browser fetch for this operation; dispose its sockets after reading the result.
|
|
1303
|
+
const dispatcher = createDispatcher(0);
|
|
1304
|
+
try {
|
|
1305
|
+
const res = await this.httpFetch(
|
|
1306
|
+
`${this.baseUrl}/__editor/project-tools/run`,
|
|
1307
|
+
{
|
|
1308
|
+
method: 'POST',
|
|
1309
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1310
|
+
body: JSON.stringify({
|
|
1311
|
+
name,
|
|
1312
|
+
input,
|
|
1313
|
+
confirm: options.confirm === true,
|
|
1314
|
+
// Omitted (not null) when unset — the wire body is JSON and the tool
|
|
1315
|
+
// host reads absence as "the sole instance", same convention as the
|
|
1316
|
+
// relay's `instance`.
|
|
1317
|
+
...(options.instance !== undefined ? { instance: options.instance } : {}),
|
|
1318
|
+
}),
|
|
1319
|
+
},
|
|
1320
|
+
dispatcher,
|
|
1321
|
+
);
|
|
1322
|
+
const body = (await this.readJson(res)) as ProjectToolOutcome;
|
|
1323
|
+
if (!body || typeof body !== 'object' || typeof body.ok !== 'boolean') {
|
|
1324
|
+
throw new Error(`Project tool returned an invalid response (${res.status}).`);
|
|
1325
|
+
}
|
|
1326
|
+
return body;
|
|
1327
|
+
} finally {
|
|
1328
|
+
await dispatcher?.destroy?.();
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// --- First-party generation job activity ---
|
|
1333
|
+
|
|
1334
|
+
/** Read the one project-local generation job ledger. Provider-native
|
|
1335
|
+
* request/result shapes remain on their registered operations. */
|
|
1336
|
+
async listGenerationJobs(): Promise<GenerationJobsDocument> {
|
|
1337
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/generations`);
|
|
1338
|
+
const body = await this.readJson(res);
|
|
1339
|
+
if (!res.ok) throw new Error(`Failed to list generation jobs: ${res.status}`);
|
|
1340
|
+
return body as GenerationJobsDocument;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** Forget operational job state. Accepted provenance and project assets
|
|
1344
|
+
* are deliberately unaffected. */
|
|
1345
|
+
async forgetGenerationJob(id: string): Promise<boolean> {
|
|
1346
|
+
const res = await this.httpFetch(
|
|
1347
|
+
`${this.baseUrl}/__editor/generations/${encodeURIComponent(id)}`,
|
|
1348
|
+
{
|
|
1349
|
+
method: 'DELETE',
|
|
1350
|
+
},
|
|
1351
|
+
);
|
|
1352
|
+
const body = (await this.readJson(res)) as { removed?: boolean; error?: string };
|
|
1353
|
+
if (!res.ok) throw new Error(body.error ?? `Failed to forget generation job: ${res.status}`);
|
|
1354
|
+
return body.removed === true;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// --- Logs ---
|
|
1358
|
+
|
|
1359
|
+
async getLogEntries(): Promise<
|
|
1360
|
+
Array<{ t: number; level: string; msg: string; source?: string }>
|
|
1361
|
+
> {
|
|
1362
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/log-entries`);
|
|
1363
|
+
const data = (await this.readJson(res)) as {
|
|
1364
|
+
entries: Array<{ t: number; level: string; msg: string; source?: string }>;
|
|
1365
|
+
};
|
|
1366
|
+
if (!res.ok) return [];
|
|
1367
|
+
return data.entries;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// --- State ---
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* The document table the host resolved — every scene, prefab, page, model,
|
|
1374
|
+
* shot, take … the project's finders produced (`getState().adapter.scenes`
|
|
1375
|
+
* is the same projection). A command, so it answers wherever the control
|
|
1376
|
+
* channel reaches, not only where `/__editor/state` is served.
|
|
1377
|
+
*/
|
|
1378
|
+
async documentTable(): Promise<DocumentTableProjection> {
|
|
1379
|
+
return this.command<DocumentTableProjection>({ type: 'document-table' });
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
async getState(): Promise<EditorState> {
|
|
1383
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/state`);
|
|
1384
|
+
const state = (await this.readJson(res)) as EditorState;
|
|
1385
|
+
if (!res.ok) throw new Error(`Failed to get editor state: ${res.status} ${res.statusText}`);
|
|
1386
|
+
return state;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* The complete unresolved console set the session is holding right now.
|
|
1391
|
+
*
|
|
1392
|
+
* Command envelopes only carry COUNTS (`unresolvedConsole` on
|
|
1393
|
+
* `commandResponseFor`). The named conditions live on GET `/__editor/console`.
|
|
1394
|
+
* This is the method that turns "a command that exits before an envelope
|
|
1395
|
+
* arrives" into a real reading: the CLI calls it at start and at exit
|
|
1396
|
+
* through the same {@link onEnvelope} observer every other response uses.
|
|
1397
|
+
* A session that does not answer within {@link CONSOLE_DRAIN_TIMEOUT_MS} is
|
|
1398
|
+
* a thrown error the caller treats as "nothing learned", never a hang.
|
|
1399
|
+
*/
|
|
1400
|
+
async getUnresolvedConsole(opts?: { all?: boolean }): Promise<unknown> {
|
|
1401
|
+
const res = await this.httpFetch(
|
|
1402
|
+
`${this.baseUrl}/__editor/console${opts?.all === true ? '?all=1' : ''}`,
|
|
1403
|
+
{ signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS) },
|
|
1404
|
+
);
|
|
1405
|
+
// Status first: a 404's empty body would otherwise die inside readJson
|
|
1406
|
+
// with a parse error that hides the one fact the caller classifies on
|
|
1407
|
+
// (does this server SERVE the console route at all?).
|
|
1408
|
+
if (!res.ok) {
|
|
1409
|
+
throw new Error(`Failed to read unresolved console: ${res.status}`);
|
|
1410
|
+
}
|
|
1411
|
+
return await this.readJson(res, true);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/** Acknowledge one named console condition. The response is observed and
|
|
1415
|
+
* hydrated through the same path as every other client response. */
|
|
1416
|
+
async acknowledgeConsole(input: {
|
|
1417
|
+
readonly id: string;
|
|
1418
|
+
readonly reason: string;
|
|
1419
|
+
readonly by: string;
|
|
1420
|
+
}): Promise<unknown> {
|
|
1421
|
+
const res = await this.httpFetch(`${this.baseUrl}/__editor/console/ack`, {
|
|
1422
|
+
method: 'POST',
|
|
1423
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1424
|
+
body: JSON.stringify(input),
|
|
1425
|
+
signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS * 4),
|
|
1426
|
+
});
|
|
1427
|
+
const body = await this.readJson(res);
|
|
1428
|
+
return body;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* `fetch` for this client's plain routes, with the ONE thing Node's `fetch`
|
|
1433
|
+
* will not do: name why it failed.
|
|
1434
|
+
*
|
|
1435
|
+
* `readJson` below already owns "the server answered the wrong thing"; this
|
|
1436
|
+
* owns "nothing answered at all", which used to reach the caller as the bare
|
|
1437
|
+
* `TypeError: fetch failed` with the real code buried on `.cause`. No retry
|
|
1438
|
+
* here — these routes create projects, run tools and acknowledge console
|
|
1439
|
+
* conditions, so repeating one is the caller's decision. The relayed
|
|
1440
|
+
* `command` path above has its own opt-in retry for the read-only captures.
|
|
1441
|
+
*/
|
|
1442
|
+
private async httpFetch(
|
|
1443
|
+
url: string,
|
|
1444
|
+
init?: RequestInit,
|
|
1445
|
+
dispatcher?: Dispatcher,
|
|
1446
|
+
): Promise<Response> {
|
|
1447
|
+
try {
|
|
1448
|
+
return await dispatchFetch(url, init, dispatcher);
|
|
1449
|
+
} catch (error) {
|
|
1450
|
+
if ((error as { name?: string } | null)?.name === 'TimeoutError') throw error;
|
|
1451
|
+
const { code, detail } = describeFetchFailure(error);
|
|
1452
|
+
throw new EditorCommandError(
|
|
1453
|
+
`${init?.method ?? 'GET'} ${url} never reached the editor: ${code}` +
|
|
1454
|
+
`${detail ? ` (${detail})` : ''}. Nothing answered on that port — check the terminal ` +
|
|
1455
|
+
'running `volter-editor edit` and confirm the port this client resolved.',
|
|
1456
|
+
code,
|
|
1457
|
+
false,
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/** Parse one JSON body and hand it to {@link onEnvelope}.
|
|
1463
|
+
*
|
|
1464
|
+
* A command/state envelope carries current counts but not the named set. If
|
|
1465
|
+
* an observer is installed, do the bounded console GET before resolving the
|
|
1466
|
+
* original request. That makes a subsequent `process.exit()` safe: the
|
|
1467
|
+
* observer has already received every condition and occurrence count. */
|
|
1468
|
+
private async readJson(res: Response, consoleComplete = false): Promise<unknown> {
|
|
1469
|
+
// Every one of this client's twelve routes funnels through here, so this is
|
|
1470
|
+
// where "did the editor server answer?" is asked — the same question, and
|
|
1471
|
+
// the same JSON-content-type rule, that
|
|
1472
|
+
// `packages/editor/src/editor-server-response.ts` owns on the browser side.
|
|
1473
|
+
// It is asked again rather than imported because THIS package is published
|
|
1474
|
+
// and depends on neither `@volter/editor-project` nor the editor bundle (see
|
|
1475
|
+
// `DEFAULT_URL` above for that policy).
|
|
1476
|
+
//
|
|
1477
|
+
// Not theoretical here: `baseUrl` is whatever `--url`/`VGAI_EDITOR_URL`
|
|
1478
|
+
// says, so the CLI is routinely pointed at a SHARE TUNNEL or a static host
|
|
1479
|
+
// — both of which answer `200 text/html` for a route nothing serves, and
|
|
1480
|
+
// `res.json()` then died as `Unexpected token '<'`, naming neither the URL
|
|
1481
|
+
// nor the cause.
|
|
1482
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
1483
|
+
if (!contentType.includes('application/json')) {
|
|
1484
|
+
throw new Error(
|
|
1485
|
+
`The editor at ${this.baseUrl} answered with its page fallback ` +
|
|
1486
|
+
`(${contentType || 'no content-type'}) rather than JSON, so no editor server handled ` +
|
|
1487
|
+
'the request. Check that this URL is a running `volter-editor edit` session.',
|
|
1488
|
+
);
|
|
1489
|
+
}
|
|
1490
|
+
const body: unknown = await res.json();
|
|
1491
|
+
this.observe(body, { unresolvedConsoleComplete: consoleComplete });
|
|
1492
|
+
if (
|
|
1493
|
+
this.onEnvelope !== null &&
|
|
1494
|
+
!consoleComplete &&
|
|
1495
|
+
body !== null &&
|
|
1496
|
+
typeof body === 'object' &&
|
|
1497
|
+
'unresolvedConsole' in body
|
|
1498
|
+
) {
|
|
1499
|
+
try {
|
|
1500
|
+
const consoleRes = await this.httpFetch(`${this.baseUrl}/__editor/console`, {
|
|
1501
|
+
signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS),
|
|
1502
|
+
});
|
|
1503
|
+
if (consoleRes.ok) {
|
|
1504
|
+
const consoleBody: unknown = await consoleRes.json();
|
|
1505
|
+
this.observe(consoleBody, { unresolvedConsoleComplete: true });
|
|
1506
|
+
}
|
|
1507
|
+
} catch {
|
|
1508
|
+
// The original response remains authoritative. A reporting follow-up
|
|
1509
|
+
// may degrade to its count-only envelope, never break the command.
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
return body;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
/** Hand one response body to {@link onEnvelope}, never letting it throw. */
|
|
1516
|
+
private observe(body: unknown, observation: EditorEnvelopeObservation): void {
|
|
1517
|
+
if (this.onEnvelope === null) return;
|
|
1518
|
+
try {
|
|
1519
|
+
this.onEnvelope(body, observation);
|
|
1520
|
+
} catch {
|
|
1521
|
+
// A reporting hook may never break the command it is reporting on.
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* Whether an editor browser tab is connected to the server *right now*.
|
|
1527
|
+
* Unlike {@link getState}, this reflects live SSE connections, not cached
|
|
1528
|
+
* state — use it to check whether commands will actually reach an editor.
|
|
1529
|
+
*/
|
|
1530
|
+
async isConnected(): Promise<boolean> {
|
|
1531
|
+
const state = await this.getState();
|
|
1532
|
+
return state.connected === true;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
async waitForState(
|
|
1536
|
+
predicate: (s: EditorState) => boolean,
|
|
1537
|
+
timeoutMs = 10_000,
|
|
1538
|
+
): Promise<EditorState> {
|
|
1539
|
+
const start = Date.now();
|
|
1540
|
+
while (Date.now() - start < timeoutMs) {
|
|
1541
|
+
const state = await this.getState();
|
|
1542
|
+
if (predicate(state)) return state;
|
|
1543
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1544
|
+
}
|
|
1545
|
+
throw new Error(`waitForState timed out after ${timeoutMs}ms`);
|
|
1546
|
+
}
|
|
1547
|
+
}
|