@volter/live 0.5.210 → 0.5.212

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/BUNDLED_NOTICES +13 -13
  2. package/README.md +7 -11
  3. package/dist/chunk-7SZ3C4CC.js +768 -0
  4. package/dist/chunk-7SZ3C4CC.js.map +7 -0
  5. package/dist/chunk-7WKVJGHZ.js +15 -0
  6. package/dist/chunk-7WKVJGHZ.js.map +7 -0
  7. package/dist/chunk-SDIWRX5X.js +189 -0
  8. package/dist/chunk-SDIWRX5X.js.map +7 -0
  9. package/dist/chunk-USXOIBVX.js +81 -0
  10. package/dist/chunk-USXOIBVX.js.map +7 -0
  11. package/dist/chunk-Y5W77HYT.js +123 -0
  12. package/dist/chunk-Y5W77HYT.js.map +7 -0
  13. package/dist/chunk-YPAOTPIF.js +202 -0
  14. package/dist/chunk-YPAOTPIF.js.map +7 -0
  15. package/dist/client.d.ts +370 -0
  16. package/dist/client.js +9 -0
  17. package/dist/client.js.map +7 -0
  18. package/dist/discovery.d.ts +32 -0
  19. package/dist/discovery.js +19 -0
  20. package/dist/discovery.js.map +7 -0
  21. package/dist/editor-document.d.ts +12 -47
  22. package/dist/editor.d.ts +34 -181
  23. package/dist/http-transport.d.ts +6 -0
  24. package/dist/index.d.ts +1 -5
  25. package/dist/index.js +76 -1722
  26. package/dist/index.js.map +4 -4
  27. package/dist/installed-package.d.ts +6 -0
  28. package/dist/installed-package.js +7 -0
  29. package/dist/installed-package.js.map +7 -0
  30. package/dist/lazy-proxy.d.ts +11 -11
  31. package/dist/member-hints.d.ts +6 -9
  32. package/dist/product-create.d.ts +29 -0
  33. package/dist/product-create.js +13 -0
  34. package/dist/product-create.js.map +7 -0
  35. package/dist/product-locator.d.ts +100 -0
  36. package/dist/product-locator.js +25 -0
  37. package/dist/product-locator.js.map +7 -0
  38. package/dist/registry-format.d.ts +44 -0
  39. package/dist/registry-format.js +21 -0
  40. package/dist/registry-format.js.map +7 -0
  41. package/dist/session.d.ts +10 -3
  42. package/dist/singleton.d.ts +1 -13
  43. package/dist/volter-manifest.d.ts +55 -0
  44. package/dist/volter-manifest.js +9 -0
  45. package/dist/volter-manifest.js.map +7 -0
  46. package/dist/workbench-locator.d.ts +84 -0
  47. package/dist/workbench-locator.js +416 -0
  48. package/dist/workbench-locator.js.map +7 -0
  49. package/package.json +35 -3
  50. package/src/client.ts +1052 -0
  51. package/src/discovery.ts +105 -0
  52. package/src/editor-document.ts +12 -49
  53. package/src/editor.ts +45 -212
  54. package/src/http-transport.ts +19 -0
  55. package/src/index.ts +3 -7
  56. package/src/installed-package.ts +15 -0
  57. package/src/lazy-proxy.ts +11 -11
  58. package/src/member-hints.ts +8 -12
  59. package/src/product-create.ts +38 -0
  60. package/src/product-locator.ts +327 -0
  61. package/src/registry-format.ts +185 -0
  62. package/src/session.ts +38 -66
  63. package/src/singleton.ts +1 -16
  64. package/src/volter-manifest.ts +97 -0
  65. package/src/workbench-locator.ts +625 -0
  66. package/dist/tools.d.ts +0 -23
  67. package/src/tools.ts +0 -45
@@ -0,0 +1,105 @@
1
+ /** Registered-session discovery; never selects another project's session. */
2
+ import { readLiveRegisteredSessions, servedProjectAnswer } from './registry-format.js';
3
+ export { servedProjectAnswer } from './registry-format.js';
4
+
5
+ export const EDITOR_SESSION_DISCOVERY_TIMEOUT_MS = 2000;
6
+
7
+ export const EDITOR_PROBE_TIMEOUT_MS = 1500;
8
+
9
+ export class EditorTimeoutError extends Error {
10
+ constructor(label: string, ms: number) {
11
+ super(`${label} timed out after ${ms}ms`);
12
+ this.name = 'EditorTimeoutError';
13
+ }
14
+ }
15
+
16
+ export function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
17
+ return new Promise<T>((resolvePromise, reject) => {
18
+ const timer = setTimeout(() => reject(new EditorTimeoutError(label, ms)), ms);
19
+ promise.then(
20
+ (v) => {
21
+ clearTimeout(timer);
22
+ resolvePromise(v);
23
+ },
24
+ (err) => {
25
+ clearTimeout(timer);
26
+ reject(err instanceof Error ? err : new Error(String(err)));
27
+ },
28
+ );
29
+ });
30
+ }
31
+
32
+ export interface EditorSessionInfo {
33
+ /** The port this session's dev server is bound to. */
34
+ port: number;
35
+ /** Canonical project root path currently open, or null when none. */
36
+ project: string | null;
37
+ /** Dev-server process id, when known from the local session registry (null for an unregistered server the caller only probed by port). */
38
+ pid: number | null;
39
+ /** Exact explicitly targeted editor origin/base URL, including protocol and host. */
40
+ url?: string;
41
+ /**
42
+ * Why this server cannot DESCRIBE the `project` it is serving — its
43
+ * manifest's own parse/validation failure, naming the failing key. `null`
44
+ * when the project reads fine, and `undefined` from a probe that did not ask
45
+ * See {@link servedProjectAnswer}: a session in
46
+ * this state is still THIS project's session, and callers must report the
47
+ * reason rather than treat it as no session at all.
48
+ */
49
+ manifestError?: string | null;
50
+ }
51
+
52
+ async function fetchJson(url: string, timeoutMs: number): Promise<unknown> {
53
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
54
+ if (!res.ok) throw new Error(`GET /__editor/project returned HTTP ${res.status}`);
55
+ return await res.json();
56
+ }
57
+
58
+ function baseUrl(session: EditorSessionInfo): string {
59
+ return session.url ?? `http://127.0.0.1:${session.port}`;
60
+ }
61
+
62
+ export interface SessionListingTransport { listSessions(timeoutMs: number): Promise<EditorSessionInfo[]>; }
63
+ export class HttpSessionDiscovery implements SessionListingTransport {
64
+ async listSessions(timeoutMs: number): Promise<EditorSessionInfo[]> {
65
+ const registered = readLiveRegisteredSessions();
66
+ const perProbeTimeout = Math.min(EDITOR_PROBE_TIMEOUT_MS, Math.max(200, timeoutMs));
67
+ const failures: string[] = [];
68
+ const probes = await Promise.all(
69
+ registered.map(async (s) => {
70
+ try {
71
+ const body = await fetchJson(
72
+ `${baseUrl({ port: s.port, project: null, pid: s.pid })}/__editor/project`,
73
+ perProbeTimeout,
74
+ );
75
+ const served = servedProjectAnswer(body);
76
+ const info: EditorSessionInfo = {
77
+ port: s.port,
78
+ project: served.path,
79
+ pid: s.pid,
80
+ manifestError: served.manifestError,
81
+ };
82
+ return info;
83
+ } catch (error) {
84
+ // Name the step that did not answer, not a guess at why: an aborted fetch here means
85
+ // the server took the request to /__editor/project and sent nothing back in time.
86
+ const timedOut = (error as { name?: string } | null)?.name === 'TimeoutError';
87
+ const detail = timedOut
88
+ ? `GET /__editor/project sent no answer within ${perProbeTimeout} ms (pid ${s.pid})`
89
+ : error instanceof Error ? `${error.name}: ${error.message}` : String(error);
90
+ failures.push(`port ${s.port}: ${detail}`);
91
+ return undefined;
92
+ }
93
+ }),
94
+ );
95
+ const sessions = probes.filter((p): p is EditorSessionInfo => p !== undefined);
96
+ // A readable registry with registered live processes is not an empty
97
+ // registry just because every HTTP discovery request failed. Keep any
98
+ // verified sessions usable, but refuse an entirely unanswered inventory
99
+ // rather than telling the caller to launch a second editor.
100
+ if (registered.length > 0 && sessions.length === 0) {
101
+ throw new Error(`Registered editor sessions could not be inspected: ${failures.join('; ')}`);
102
+ }
103
+ return sessions;
104
+ }
105
+ }
@@ -1,46 +1,15 @@
1
- /**
2
- * `editor.document` — the session binding for the scoped editor-chrome door.
3
- *
4
- * WHY IT IS A SEPARATE OBJECT, and why the verbs are these, is recorded once
5
- * in the implementation's header
6
- * (`packages/sdk/src/kit/editor-document-probe.ts`); the short version is that
7
- * `game.page()` is play-mode-gated and rooted at the GAME container, so an
8
- * editor surface that is not a running game could be neither read nor driven
9
- * through the product.
10
- *
11
- * WHAT IT ADDRESSES is a closed VOCABULARY of named surfaces, passed as
12
- * `{ scope }` on every verb:
13
- *
14
- * `document` (default) the active centre document's whole box
15
- * `header` / `shelf` that document's toolbar strip / tool rail
16
- * `rail` the PROPERTIES view — its tabs, sections and fields
17
- * `outliner` the OUTLINER view — its rows and their controls
18
- * `content` the CONTENT view — its categories and asset rows
19
- * `menubar` the APPLICATION MENUS (Edit, View, …) and the menus they open
20
- *
21
- * await editor.document.click('[data-testid=app-menu-edit]', { scope: 'menubar' });
22
- * await editor.document.query('[role=tab]', { scope: 'rail' });
23
- * await editor.document.click('[data-testid=properties-tab-modifiers]', { scope: 'rail' });
24
- * await editor.document.click('[data-ingest-name=Cube]', { scope: 'outliner', clicks: 2 });
25
- * await editor.document.type('Wheel', { scope: 'outliner' });
26
- *
27
- * It is not page automation: a selector resolving outside the named surface is
28
- * refused by a message naming the scope and listing the others. `rail` and
29
- * `outliner`, and `content` are editor chrome and stay readable while the Game document is
30
- * active; `editor.hierarchy()` / `editor.inspect()` keep answering what those
31
- * panels RESOLVED, where this door answers what they DREW.
32
- *
33
- * A field on `LiveEditor` rather than methods on it, so the editor's `eval --list` command
34
- * shows the verbs as one named surface — the same reason `game.input`
35
- * and `game.events` are instance fields.
36
- */
1
+ /** Session binding for reading and driving the editor's named surfaces.
2
+ * Each verb accepts a document-probe scope: document, header, shelf, rail,
3
+ * outliner, utility or area. Targets outside that scope are refused. Chrome
4
+ * scopes remain available without an open document. The eval member listing
5
+ * groups these verbs under editor.document. */
37
6
 
38
7
  import type {
39
8
  DocumentProbeResult,
40
9
  DocumentProbeScope,
41
10
  DocumentProbeStep,
42
- EditorClient,
43
11
  } from '@volter/sdk';
12
+ import type { EditorClient } from './client.js';
44
13
 
45
14
  /** Which named surface a verb runs against; `document` when omitted. */
46
15
  export interface DocumentScopeOption {
@@ -128,8 +97,7 @@ export class LiveEditorDocument {
128
97
  shiftKey?: boolean;
129
98
  },
130
99
  ): Promise<DocumentProbeResult> {
131
- // An option this door does not know is refused, never dropped: a dropped `position` clicked
132
- // the element's centre and answered as if it had clicked where it was asked.
100
+ // Refuse unknown options before dispatching the gesture.
133
101
  const known = ['scope', 'index', 'clicks', 'at', 'altKey', 'ctrlKey', 'metaKey', 'shiftKey'];
134
102
  const unknown = Object.keys(options ?? {}).filter((key) => !known.includes(key));
135
103
  if (unknown.length > 0) throw new Error(`click has no option ${unknown.map((key) => `\`${key}\``).join(', ')}; its options are scope, index, clicks, at ([x, y] fractions of the element's box) and the modifier keys altKey, ctrlKey, metaKey and shiftKey.`);
@@ -164,9 +132,7 @@ export class LiveEditorDocument {
164
132
 
165
133
  /**
166
134
  * A real pointer DRAG across one matched element — press at `from`, move,
167
- * release at `to`. The gesture a direct-manipulation canvas needs; a
168
- * zero-length drag is a click at that fraction, which `click` (always the
169
- * center) cannot place.
135
+ * release at `to`. A zero-length drag presses and releases at one fraction.
170
136
  *
171
137
  * `from`, `to` and every point in `via` are `[x, y]` FRACTIONS OF THE
172
138
  * MATCHED ELEMENT'S BOX, 0..1 from its top-left — NEVER pixels and never
@@ -250,13 +216,10 @@ export class LiveEditorDocument {
250
216
  }
251
217
 
252
218
  /**
253
- * THE REPL over the open document: run `step` in the editor page against the
254
- * object the ACTIVE document published as its context (the mesh document
255
- * publishes its `MeshEditSession`, whose `ctx` is the bpy-shaped edit
256
- * context — `ctx.ops.mesh.bevel({ offset: 0.1 })`, `ctx.selection`,
257
- * `ctx.history`, `session.commit()`). Edit mode, no play. Serialized like
258
- * `game.page`: the step's own source travels, so inline every value it
259
- * needs and return plain data.
219
+ * Run `step` in the editor page against the active document's published
220
+ * context, with its document id in `info`. The document owns the context's
221
+ * API. The step's source is serialized, so inline every value it needs and
222
+ * return plain data.
260
223
  */
261
224
  async run<T = unknown>(
262
225
  step: (ctx: unknown, info: { documentId: string }) => T | Promise<T>,
package/src/editor.ts CHANGED
@@ -13,17 +13,13 @@ import type {
13
13
  StageFrameCostReading,
14
14
  EditorChromeCapture,
15
15
  EditorChromeCaptureOptions,
16
- EditorClient,
17
16
  EditorState,
18
17
  EditorView,
19
18
  EditorWorkspaceName,
20
- HistoryStep,
21
19
  InspectedFieldWrite,
22
20
  InspectedHierarchy,
23
21
  InspectedInspection,
24
22
  LabeledShotSetCapture,
25
- ModelPlayLogReading,
26
- OpenedDocument,
27
23
  PresentedEditorView,
28
24
  ShadingMode,
29
25
  StructureOp,
@@ -32,14 +28,14 @@ import type {
32
28
  ViewPreset,
33
29
  ViewportCapture,
34
30
  } from '@volter/sdk';
31
+ import type { EditorClient, HistoryStep, OpenedDocument } from './client.js';
35
32
  import { LiveEditorDocument } from './editor-document.js';
36
33
 
37
- /** A panel `showPanel` can focus: the viewport tabs, the console, the build
38
- * surface, or any key the editor's own static-panel registry holds
39
- * (`hierarchy`, `assets`, `asset-library`, `inspector`, `history`, …) — which
40
- * is why this is open: the registry, not this union, is the vocabulary. */
34
+ /** A panel `showPanel` can focus: the console, the build surface, or any key
35
+ * the editor's own static-panel registry holds (`hierarchy`, `asset-library`,
36
+ * `inspector`, `history`, …) — which is why this is open: the registry, not
37
+ * this union, is the vocabulary. */
41
38
  export type PanelName =
42
- | 'viewport-edit'
43
39
  | 'console'
44
40
  // Keeps the literals above in autocomplete while admitting every key the
45
41
  // editor's registry holds — the registry answers, this union only hints.
@@ -65,15 +61,8 @@ const EXTENSION_KIND: Record<string, AssetKind> = {
65
61
  '.glsl': 'source',
66
62
  '.vert': 'source',
67
63
  '.frag': 'source',
68
- // PROJECT SCRIPTS ARE SOURCE. Without these the guess below falls through to
69
- // `'json'`, the asset-document router sends the file to the generic JSON
70
- // viewer (`asset-documents.tsx#assetDocumentViewerRoute`: `spec.kind ===
71
- // 'json'` is decided before any content routing), and the LIVE MODELING
72
- // DOCUMENT never mounts — `editor.openAsset('src/lib/fox/fox.model.ts')`
73
- // silently shows a text pane instead of the model. Only `kind: 'source'`
74
- // reaches `SourceAssetViewer`, which is what content-routes a project script
75
- // to `LiveModuleDocument`. The set matches that viewer's own
76
- // `isProjectScriptPath` regex, `/\.(?:[cm]?[jt]sx?)$/`.
64
+ // Source kinds select SourceAssetViewer, which routes project scripts by content to
65
+ // LiveModuleDocument. Keep these extensions aligned with its isProjectScriptPath predicate.
77
66
  '.ts': 'source',
78
67
  '.tsx': 'source',
79
68
  '.mts': 'source',
@@ -86,14 +75,11 @@ const EXTENSION_KIND: Record<string, AssetKind> = {
86
75
 
87
76
  /**
88
77
  * Lightweight extension-based `AssetKind` guess for `openAsset`'s optional
89
- * `kind` argument. Deliberately independent of (not shared with) the
90
- * editor's own `AssetBrowser.tsx#getAssetKind` — that function is a private,
91
- * React-component-local helper of a package `@volter/live` has no dependency
92
- * on. Callers with an unusual extension can always pass `kind` explicitly.
78
+ * `kind` argument. Callers with an unusual extension can always pass `kind`
79
+ * explicitly.
93
80
  */
94
81
  export function inferAssetKind(path: string): AssetKind {
95
- // `.prefab.json` is retired (PRs #578/#581/#589) but the `'prefab'`
96
- // AssetKind itself remains in `@volter/sdk` for view-link compatibility.
82
+ // `'prefab'` is an `@volter/sdk` AssetKind that view links name.
97
83
  if (path.endsWith('.prefab.json')) return 'prefab';
98
84
  const dot = path.lastIndexOf('.');
99
85
  const ext = dot >= 0 ? path.slice(dot).toLowerCase() : '';
@@ -101,19 +87,14 @@ export function inferAssetKind(path: string): AssetKind {
101
87
  }
102
88
 
103
89
  export class LiveEditor {
104
- /** `#`-private, not `private`: `cyclotron eval --list` enumerates this object's
90
+ /** `#`-private, not `private`: `<command> eval --list` enumerates this object's
105
91
  * real runtime members, and TypeScript's erased `private` would leave the
106
92
  * raw `EditorClient` advertised beside them. */
107
93
  readonly #client: EditorClient;
108
94
 
109
- /**
110
- * The ACTIVE center document's own DOM: read it, click it, key it, paste
111
- * into it. The one door onto editor chrome that is not play-mode gated, and
112
- * deliberately scoped to that document alone —
113
- * `packages/sdk/src/kit/editor-document-probe.ts` carries the design and the
114
- * refusal contract. Screenshotting the same subject is
115
- * {@link LiveEditor.captureActiveDocument}, not a fifth verb here.
116
- */
95
+ /** Read and drive DOM within a named scope: the active document by default, or editor chrome
96
+ * such as header, shelf, rail, outliner, utility and area. Targets outside the scope are refused.
97
+ * See `@volter/sdk`'s editor-document-probe for scope and gesture contracts. */
117
98
  readonly document: LiveEditorDocument;
118
99
 
119
100
  constructor(client: EditorClient) {
@@ -121,49 +102,17 @@ export class LiveEditor {
121
102
  this.document = new LiveEditorDocument(client);
122
103
  }
123
104
 
124
- /**
125
- * THE BLENDER LANE'S VERBS, from `cyclotron eval`.
126
- *
127
- * Blender runs headless in the editor tab's worker (ARCHITECTURE-CORE, "THE
128
- * BLENDER IN THE TAB IS BLENDER") and answers `blender-start`,
129
- * `blender-execute`, `blender-scene-info`, `blender-object-info`,
130
- * `blender-screenshot-view`, `blender-read-file`, `blender-write-file`,
131
- * `blender-list-files`, `blender-stop` and `blender-status`. They were
132
- * reachable from `@volter/sdk` and through `volter blender-mcp` but from
133
- * no GENERAL door, so driving a session meant writing an MCP client script
134
- * per question — the same discovery failure `eval-surface.ts`'s header
135
- * records, in a lane that had not noticed it yet.
136
- *
137
- * cyclotron eval "await editor.blender('blender-execute', { code: 'import bpy; print(len(bpy.data.objects))' })"
138
- *
139
- * `blender-status` is the only verb that creates nothing: it answers whether
140
- * this tab already has a session without starting one.
141
- */
142
- async blender<T extends object = Record<string, unknown>>(
143
- type: `blender-${string}`,
105
+ /** A verb a composed package contributes (`<package>-<verb>`), waited on for its own row's budget. */
106
+ async contributedVerb<T extends object = Record<string, unknown>>(
107
+ type: string,
144
108
  fields: Record<string, unknown> = {},
145
109
  ): Promise<T> {
146
- return this.#client.blender<T>(type, fields);
147
- }
148
-
149
- /**
150
- * MODEL PLAY'S LOG, from `cyclotron eval` — the same read as
151
- * `cyclotron play-log`. A play script writes it with
152
- * `play.log(kind, facts)`; the runner adds `play-start`, `script-reload`,
153
- * `script-error` and `play-stop`. Every entry carries `simT` (simulation
154
- * seconds) and `tick` (the frame). Each model document keeps its own: the
155
- * current run, or the last one after Stop; Play from a fresh copy empties it.
156
- * `documentId` picks one (`documents` lists them); the active Play's by default.
157
- *
158
- * cyclotron eval "(await editor.modelPlayLog({ kind: 'death' })).entries"
159
- */
160
- async modelPlayLog(query?: { readonly documentId?: string; readonly since?: number; readonly kind?: string }): Promise<ModelPlayLogReading> {
161
- return this.#client.modelPlayLog(query);
110
+ return this.#client.contributedVerb<T>(type, fields);
162
111
  }
163
112
 
164
113
  /**
165
114
  * The active authoring adapter's persistence destination — where a save would
166
- * land (`status().savePath`). A read only: a three root has no scene document
115
+ * land (`status().savePath`). A read only: a stage root has no scene document
167
116
  * to open, and its root is activated instead.
168
117
  */
169
118
  async scene(): Promise<string | null> {
@@ -185,19 +134,10 @@ export class LiveEditor {
185
134
  return this.#client.currentView();
186
135
  }
187
136
 
188
- /**
189
- * Capture the same center document the human is currently looking at.
190
- *
191
- * A number is a SQUARE of that size — the default, and the right shape for
192
- * an unstaged look at a model. `{width, height}` asks for a shaped frame, so
193
- * a video-aspect look needs no crop afterwards. Both are bounded by the
194
- * relay budget (64-1024 per side, total no larger than a 1024 square); see
195
- * `@volter/sdk`'s `CaptureDimensions`.
196
- * Supply a view to present and photograph it in one editor request.
197
- */
198
- /** The active document's RENDER alone, at the size asked for: what the document draws,
199
- * without what the viewport draws over it (its navigation gizmo, its readouts). To see a
200
- * stage as the person does, use `captureEditorChrome({ region: 'document' })`. */
137
+ /** Capture the active document's render without viewport navigation gizmos or readouts.
138
+ * A number requests a square; `{width, height}` requests a shaped frame, bounded by
139
+ * `CaptureDimensions`. Supply a view to present and capture it in one request.
140
+ * Use `captureEditorChrome({ region: 'document' })` to include viewport chrome. */
201
141
  async captureActiveDocument(
202
142
  size?: CaptureDimensions,
203
143
  view?: EditorView,
@@ -252,7 +192,7 @@ export class LiveEditor {
252
192
  */
253
193
  async frame(entityId: string): Promise<void>;
254
194
  /**
255
- * Bare `frame()` frames the OPEN Object3D document's subject instead — its
195
+ * Bare `frame()` frames the OPEN asset document's subject instead — its
256
196
  * selection if it has one, else the whole model: the toolbar's own Frame
257
197
  * button, reachable from a script. `fit` scales the fitted distance (1 is
258
198
  * that button's tight fit, 1.5 stands back a little for a shot).
@@ -269,7 +209,7 @@ export class LiveEditor {
269
209
  /**
270
210
  * WATCH THE AGENT LOOK AROUND THE MODEL.
271
211
  *
272
- * Swings the open Object3D document's camera — the camera the human's tab is
212
+ * Swings the open asset document's camera — the camera the human's tab is
273
213
  * showing — around the framed subject by `azimuth`/`elevation` RADIANS,
274
214
  * animated over `duration` seconds (default 0.6), and resolves when the move
275
215
  * ends. This is deliberately not a jump cut: the point of the verb is that a
@@ -292,12 +232,7 @@ export class LiveEditor {
292
232
  /** SECONDS the move takes (default 0.6). */
293
233
  readonly duration?: number;
294
234
  }): Promise<DocumentLookOutcome> {
295
- // AN UNKNOWN KEY IS REFUSED BY NAME. Every member here is optional, so a
296
- // misspelling — `yaw` for `azimuth`, `pitch` for `elevation` — used to
297
- // orbit by nothing at all while the outcome still reported a plausible
298
- // ABSOLUTE azimuth, which reads exactly like a move that happened
299
- // (measured 2026-09-21, and it cost a round). The keys and their unit are
300
- // in the refusal because that is the moment the caller needs them.
235
+ // Refuse unknown keys by name and include the supported keys and units.
301
236
  const known = ['azimuth', 'elevation', 'duration'];
302
237
  const unknown = Object.keys(options ?? {}).filter((key) => !known.includes(key));
303
238
  if (unknown.length > 0)
@@ -336,21 +271,7 @@ export class LiveEditor {
336
271
  await this.#client.viewPreset(preset);
337
272
  }
338
273
 
339
- /**
340
- * Switch the editor's NAMED WORKSPACE — `await editor.workspace('model')`.
341
- *
342
- * A workspace is a task-named LAYOUT MEMORY over the one dock
343
- * (ARCHITECTURE-CORE §Editor chrome): `game` (the default, the editor's
344
- * standing arrangement), `model`, `sculpt`, `texture`, `animate`, `look`.
345
- * Switching is an EXPLICIT act — nothing in the editor moves chrome on its
346
- * own, opening a document included — and this is the session door to it,
347
- * beside `Window → Workspace` and the registered actions.
348
- *
349
- * Resolves once the dock has finished rebuilding, so a capture taken
350
- * immediately after photographs the arrangement that was asked for. Each
351
- * workspace remembers the user's own hand-tuning per project, so switching
352
- * away and back is lossless.
353
- */
274
+
354
275
  async workspace(id: EditorWorkspaceName): Promise<void> {
355
276
  await this.#client.setWorkspace(id);
356
277
  }
@@ -367,11 +288,9 @@ export class LiveEditor {
367
288
 
368
289
  /**
369
290
  * A document stage's viewport PRESENTATION — its draw mode, lighting, backdrop and overlays
370
- * (`@volter/sdk/kit/viewport-presentation`) — resolved. With `layer`, that choice is
291
+ * (`@volter/sdk/viewport-presentation`) — resolved. With `layer`, that choice is
371
292
  * recorded for the view first, as a person's toolbar change would be, e.g.
372
293
  * `presentation('model:src/models/cube.blend', { all: { lighting: { studioPreset: 'kit' } } })`.
373
- * With a STRING, the named view of that id (`*.view.ts`, e.g. `'godot'`) is put on the view
374
- * whole; the answer's `viewPresets` lists the ones registered.
375
294
  */
376
295
  async presentation(
377
296
  documentId: string,
@@ -380,28 +299,11 @@ export class LiveEditor {
380
299
  return this.#client.viewportPresentation(documentId, layer);
381
300
  }
382
301
 
383
- /**
384
- * Set the MATERIAL apart from the bundle that usually carries it.
385
- * Appearance is palette × material, independent axes by ruling, so
386
- * `style()` alone can never say whether a cost belongs to the blur or to
387
- * the palette. This is the door that measures them apart; it answers with
388
- * what the chrome wears afterwards (`style` is `null` when the mix matches
389
- * no registered bundle).
390
- */
391
- async appearance(appearance: {
392
- readonly material?: string;
393
- }): Promise<{ material: string; style: string | null }> {
394
- return this.#client.setAppearance(appearance);
395
- }
396
-
397
- /** Focus an editor panel: a viewport tab, the console, the build surface, or
302
+ /** Focus an editor panel: the console, the build surface, or
398
303
  * any key the editor's static-panel registry holds — an unknown key refuses
399
304
  * naming the ones it does. */
400
305
  async showPanel(name: PanelName): Promise<void> {
401
306
  switch (name) {
402
- case 'viewport-edit':
403
- await this.#client.showViewport('edit');
404
- return;
405
307
  case 'console':
406
308
  await this.#client.toggleConsole();
407
309
  return;
@@ -415,24 +317,18 @@ export class LiveEditor {
415
317
 
416
318
  /** `kind` inferred from `path`'s extension when omitted (`inferAssetKind`) — pass it explicitly to override. */
417
319
  async openAsset(path: string, kind?: AssetKind): Promise<void> {
418
- // A `.blend` is not an asset but a Model document the project's finder lists; opened as an
419
- // asset it fell to the extension guess's `json` and showed its bytes.
420
- if (path.toLowerCase().endsWith('.blend'))
421
- throw new Error(
422
- `${path} is a Blender Model document, not an asset: open it with editor.open("model:${path}").`,
423
- );
320
+ // Finder-listed documents use their document opener rather than asset-kind inference.
321
+ const document = (await this.#client.documentTable()).entries.find((entry) => entry.source?.path === path);
322
+ if (document)
323
+ throw new Error(`${path} is a ${document.kind} document, not an asset: open it with editor.open("${document.id}").`);
424
324
  await this.#client.openAsset(path, kind ?? inferAssetKind(path));
425
325
  }
426
326
 
427
327
  /**
428
328
  * SELECT a project asset — the browser's single click, which fills the
429
329
  * Inspector without opening a document. `openAsset` is the double click.
430
- *
431
- * This is how a project's own `asset.inspector` section is reached: select
432
- * the file it matches, then `inspect()` lists the verbs that section
433
- * declares and `runAction(id)` runs one. Selecting a path nothing matches
434
- * is not an error — the Inspector shows what it has, exactly as it does
435
- * for a human.
330
+ * Selecting a path nothing matches is not an error — the Inspector shows
331
+ * what it has, exactly as it does for a human.
436
332
  */
437
333
  async selectAsset(path: string): Promise<void> {
438
334
  await this.#client.selectAsset(path);
@@ -446,8 +342,8 @@ export class LiveEditor {
446
342
  * where it stands under the scene's own lighting, reachable from here — or
447
343
  * RAW GLB BYTES (`assetPreview({ glbBase64 }, …)`), for a model that exists
448
344
  * only in the calling Node process's memory and has never been written to
449
- * disk. `stage` defaults to `'lab'`, the neutral Asset Lab staging this has
450
- * always produced, and the bytes form is lab-only.
345
+ * disk. `stage` defaults to `'lab'`, the neutral Asset Lab staging, and the
346
+ * bytes form is lab-only.
451
347
  */
452
348
  async assetPreview(
453
349
  source: string | AssetPreviewSource,
@@ -465,8 +361,6 @@ export class LiveEditor {
465
361
  * crops, rendered against the asset's own skeleton, with a contact sheet.
466
362
  * Every source {@link assetPreview} takes works here, GLB bytes included:
467
363
  * a shot set stages its own subject, so it needs no place to stand.
468
- *
469
- * Sole in-repo caller today: `project.bake.preview`'s `--orbit` lane.
470
364
  */
471
365
  async assetPreviewShots(
472
366
  source: string | AssetPreviewSource,
@@ -496,33 +390,7 @@ export class LiveEditor {
496
390
  await this.#client.setShadingMode(mode);
497
391
  }
498
392
 
499
- /**
500
- * READ the inspector, as data — the serialized inspection subject
501
- * (`editor.inspect()`; design: `docs/ARCHITECTURE-CORE.md` §Editor chrome,
502
- * "The Inspection Model"). This is the Figma-Inspect analog: whatever a
503
- * human would see in the inspector right now — the subject's identity, its
504
- * verbs, and every identified section in display order, with a `fields`
505
- * section's CURRENT VALUES at their scriptable `path`s.
506
- *
507
- * Reach for it whenever the next step depends on what an object actually
508
- * IS: `await editor.select(id)` then `await editor.inspect()` answers "what
509
- * properties does this thing have, and what are they set to" in one call,
510
- * against the same model the panel renders — no scene-graph reads, no
511
- * guessing at property names.
512
- *
513
- * When the inspector is showing NOTHING — nothing selected on a surface
514
- * with no empty-state subject of its own, which is most of them — the answer
515
- * is `{none: true}`, so "the human sees no inspector" and "the read failed"
516
- * are never the same value. A surface whose empty space IS a real thing (an
517
- * open Asset Lab document) still answers with that subject, and never with
518
- * another surface's.
519
- *
520
- * A `custom` section body is a named opaque: the editor renders it with
521
- * React, so the wire reports its identity rather than pretending to describe
522
- * its rendering — plus, when the section can say what it DISPLAYS, a `data`
523
- * payload in its own vocabulary (`transform` carries
524
- * `{position, rotation, scale}`, rotation in Euler XYZ degrees).
525
- */
393
+
526
394
  async inspect(): Promise<InspectedInspection> {
527
395
  return this.#client.inspect();
528
396
  }
@@ -534,32 +402,11 @@ export class LiveEditor {
534
402
  }
535
403
 
536
404
  /**
537
- * Run ONE command by id — the door to everything the command palette lists.
538
- *
539
- * ONE NAME (orchestrator ruling 2026-09-19). There were briefly TWO doors
540
- * onto the one view-verb table — this one and `editor.viewVerb(view, verb)`,
541
- * which addressed the same registry by its two halves. A second addressing
542
- * of one table is a second name for one thing, and an agent reading
543
- * `--list` had to choose between them with nothing to choose on. This door
544
- * stays because it is strictly wider: it addresses a COMMAND ID, so under
545
- * the frame it reaches everything the workbench knows — a `volter.action.<id>`
546
- * editor action, one of VS Code's own — and not only a view. A VIEW is
547
- * reached by spelling its verb's command id:
405
+ * Run a command by id. Under the native frame this uses ICommandService and
406
+ * can invoke editor or VS Code commands. Standalone sessions resolve registered
407
+ * view verbs of the form `volter.<view>.<verb>` and refuse other ids.
548
408
  *
549
- * await editor.command('volter.blender-uv-view.state')
550
- * await editor.command('volter.blender-uv-view.zoom', { to: 600 })
551
- *
552
- * await editor.command('volter.blender-node-view.view-all')
553
- * await editor.command('volter.blender-node-view.look', { node: 'Principled BSDF' })
554
- *
555
- * Under the Code-OSS frame this is the workbench's own command service, so
556
- * any command id works — ours and VS Code's alike. Standalone the editor's `edit` command
557
- * has no command service and answers the `volter.<view>.<verb>` shape off the
558
- * SAME verb table the frame's commands call, refusing any other id by name.
559
- * One table, two doors, exactly like the keymap's.
560
- *
561
- * Answers with whatever the command returned — a view verb's own state, or
562
- * `null` for a command that returns nothing.
409
+ * Returns the command’s result, or `null` when it returns nothing.
563
410
  */
564
411
  async command(commandId: string, args?: unknown): Promise<unknown> {
565
412
  return this.#client.runCommand(commandId, args);
@@ -575,11 +422,6 @@ export class LiveEditor {
575
422
  * helpers the menu items call, so there is one implementation of each op and
576
423
  * not a second that can disagree with what a human gets.
577
424
  *
578
- * It exists because the menu is a POINTER surface: every one of these ops was
579
- * reachable only by right-clicking a hierarchy row, which is nothing an agent
580
- * can do — so for an ingest root, whose only authoring surface IS the editor,
581
- * structure was closed entirely.
582
- *
583
425
  * `id`/`ids` default to the current selection. The answer carries the same
584
426
  * per-edit `write` ack `setField` does, so `write.persisted` tells a saved
585
427
  * restructure from a live-only one. An op the active adapter does not provide
@@ -618,23 +460,14 @@ export class LiveEditor {
618
460
  *
619
461
  * The companion to {@link inspect}: that one answers "what IS the selected
620
462
  * thing", this one answers "what does the tree LOOK LIKE". It is the panel's
621
- * own output, not a fresh walk of the scene — the adapter's tree after the
622
- * component marks fold implementation subtrees (bones, particle renderers,
623
- * instanced pools), after the internals reveal, the document promotion, the
624
- * child cap, the collapse state, the search filter and the selection scope.
625
- *
626
- * Works in play mode and edit mode; the answer says which (`playState`,
627
- * `activeViewportTab`), because the two are different adapters and a tree
628
- * that looks wrong is very often the wrong adapter's tree.
463
+ * own projection of component marks, document promotion, child caps,
464
+ * collapse state, search and selection scope onto the adapter tree.
629
465
  *
630
466
  * Prefer this over `status().entities`, which is deliberately a different
631
467
  * question — the RAW adapter tree, unprojected. A panel that renders the
632
468
  * wrong rows looks perfectly healthy in that facet.
633
469
  *
634
- * Each row carries `childCount` (what its caret opens), `internalChildCount`
635
- * (what is folded behind "Reveal Internals") and `expandable` (whether the
636
- * panel draws a caret at all), so "this subtree exists but nothing in the UI
637
- * opens it" is a fact you can read rather than one you have to notice.
470
+ * Each row reports the projected child count and whether the panel draws a disclosure control.
638
471
  *
639
472
  * Rejects, naming the panel, when no hierarchy panel is mounted — an empty
640
473
  * tree would be a fabricated answer about a surface nobody is being shown.
@@ -0,0 +1,19 @@
1
+ /** Node session clients need explicit header deadlines for long modeling calls.
2
+ * Some runtimes supply an undici-compatible Agent without destroy(); callers
3
+ * close explicitly when that lifecycle method exists. */
4
+ import { Agent, fetch as nodeFetch, type Dispatcher } from 'undici';
5
+
6
+ export function createDispatcher(timeoutMs: number): Dispatcher {
7
+ return new Agent({ headersTimeout: timeoutMs, bodyTimeout: timeoutMs });
8
+ }
9
+
10
+ export async function dispatchFetch(
11
+ url: string,
12
+ init?: RequestInit,
13
+ dispatcher?: Dispatcher,
14
+ ): Promise<Response> {
15
+ if (!dispatcher) return fetch(url, init);
16
+ return await nodeFetch(url, {
17
+ ...(init as object), dispatcher,
18
+ }) as unknown as Response;
19
+ }