@vgai/sdk 0.5.53 → 0.5.56

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@vgai/sdk",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.5.53",
5
+ "version": "0.5.56",
6
6
  "type": "module",
7
7
  "repository": {
8
8
  "type": "git",
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@modelcontextprotocol/sdk": "^1.30.0",
36
- "@vgai/engine": "0.5.53",
36
+ "@vgai/engine": "0.5.56",
37
37
  "playwright": "^1.58.2",
38
38
  "zod": "^4.3.6"
39
39
  }
@@ -14,9 +14,17 @@
14
14
  * project.
15
15
  */
16
16
 
17
- import { readFileSync, realpathSync } from 'node:fs';
17
+ import {
18
+ mkdirSync,
19
+ readdirSync,
20
+ readFileSync,
21
+ realpathSync,
22
+ unlinkSync,
23
+ writeFileSync,
24
+ } from 'node:fs';
18
25
  import { homedir } from 'node:os';
19
26
  import { join, resolve } from 'node:path';
27
+ import { setTimeout as delay } from 'node:timers/promises';
20
28
 
21
29
  /** One registry entry, exactly as the server's write half records it. */
22
30
  export interface EditorSessionEntry {
@@ -103,6 +111,61 @@ export function readLiveRegisteredSessions(): EditorSessionEntry[] {
103
111
  }
104
112
  }
105
113
 
114
+ /** Announce a launch before the server can register. Each launcher owns its file. */
115
+ export function announceEditorLaunch(project: string): () => void {
116
+ const directory = join(project, '.vgai', 'editor-launches');
117
+ mkdirSync(directory, { recursive: true });
118
+ const file = join(directory, `${process.pid}.json`);
119
+ writeFileSync(file, JSON.stringify({ pid: process.pid }));
120
+ const clear = () => {
121
+ process.removeListener('exit', clear);
122
+ try {
123
+ unlinkSync(file);
124
+ } catch {
125
+ // Already cleared when the server became ready.
126
+ }
127
+ };
128
+ process.once('exit', clear);
129
+ return clear;
130
+ }
131
+
132
+ function hasPendingEditorLaunch(project: string): boolean {
133
+ const directory = join(project, '.vgai', 'editor-launches');
134
+ try {
135
+ return readdirSync(directory).some((name) => {
136
+ if (!/^\d+\.json$/.test(name)) return false;
137
+ const pid = Number(name.slice(0, -5));
138
+ return pid !== process.pid && pidAlive(pid);
139
+ });
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ /** Wait only for an observed live launcher, never for a missing editor. */
146
+ export async function waitForPendingEditorLaunch(project: string): Promise<void> {
147
+ const canon = canonicalizeProjectPath(project);
148
+ const ready = () =>
149
+ readLiveRegisteredSessions().some(
150
+ (session) => session.project !== null && canonicalizeProjectPath(session.project) === canon,
151
+ );
152
+ if (ready() || !hasPendingEditorLaunch(canon)) return;
153
+ const deadline = Date.now() + 180_000;
154
+ while (!ready()) {
155
+ if (!hasPendingEditorLaunch(canon)) {
156
+ // The launcher clears its announcement only after registration.
157
+ if (ready()) return;
158
+ throw new Error(`The editor launch for ${canon} ended before registering a session.`);
159
+ }
160
+ if (Date.now() >= deadline) {
161
+ throw new Error(
162
+ `The editor launch for ${canon} is still running but has not registered within 180s.`,
163
+ );
164
+ }
165
+ await delay(100);
166
+ }
167
+ }
168
+
106
169
  /**
107
170
  * WHICH PROJECT a `/__editor/project` body says its server is serving.
108
171
  *
@@ -186,6 +249,10 @@ export async function resolveRegisteredSession<
186
249
  return options.notRunning();
187
250
  }
188
251
 
252
+ if (editorUrl === undefined && ctx.projectRoot !== undefined) {
253
+ await waitForPendingEditorLaunch(ctx.projectRoot);
254
+ }
255
+
189
256
  let sessions: T[];
190
257
  try {
191
258
  sessions = await options.withTimeout(
@@ -35,7 +35,7 @@
35
35
  import { appendFileSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
36
36
  import { join } from 'node:path';
37
37
  import type { TripwireTier } from './build-discipline';
38
- import type { RecordedTabCensus } from './tab-census';
38
+ import type { BlenderTabMetrics, RecordedTabCensus } from './tab-census';
39
39
 
40
40
  /** `editor-` + an ISO instant with `:`/`.` flattened + `.jsonl` — the same
41
41
  * lexicographic-order-is-chronological-order shape `play-*.jsonl` uses. */
@@ -709,6 +709,35 @@ function tabDeathProfileBody(line: SessionJournalEvent & { kind: 'tab-death-prof
709
709
  * phrasings of the same numbers is how a reader ends up believing there are
710
710
  * two measurements. Renderer counts appear only when the page could read them
711
711
  * (no mounted render-debug adapter = the words are absent, never a zero). */
712
+ /**
713
+ * The Blender block's words: what the twin's worker and this tab's main thread
714
+ * have been doing. Absent entirely in a tab with no Blender session.
715
+ *
716
+ * `in flight` is the field that speaks during a wedge — the 2026-09-16 call
717
+ * that held the worker 1,800s produced no other number anywhere in the product.
718
+ * A field the page could not measure is OMITTED rather than printed as 0: no
719
+ * call yet, or no Long Tasks API (Safari/Firefox), is not "never stalled".
720
+ */
721
+ function formatBlenderMetrics(blender: BlenderTabMetrics): string {
722
+ const s = (ms: number): string => `${Math.round(ms / 100) / 10}s`;
723
+ const parts = [
724
+ blender.inFlightMs === null ? 'idle' : `IN FLIGHT ${s(blender.inFlightMs)}`,
725
+ ...(blender.lastCallMs === null ? [] : [`last call ${s(blender.lastCallMs)}`]),
726
+ ...(blender.maxCallMs === null ? [] : [`max ${s(blender.maxCallMs)}`]),
727
+ `over 5s ${blender.callsOver5s}`,
728
+ `over 30s ${blender.callsOver30s}`,
729
+ ...(blender.longestTaskMs === null
730
+ ? []
731
+ : [
732
+ `longest main-thread task ${s(blender.longestTaskMs)} (${blender.tasksOver100ms} over 100ms)`,
733
+ ]),
734
+ ...(blender.lastCallLongestTaskMs === null
735
+ ? []
736
+ : [`${s(blender.lastCallLongestTaskMs)} of that during the last call`]),
737
+ ];
738
+ return `blender: ${parts.join(', ')}`;
739
+ }
740
+
712
741
  export function formatTabCensus(census: RecordedTabCensus | null): string {
713
742
  if (census === null) return 'no resource census';
714
743
  const parts = [
@@ -721,7 +750,13 @@ export function formatTabCensus(census: RecordedTabCensus | null): string {
721
750
  if (census.textures !== undefined) parts.push(`tex ${census.textures}`);
722
751
  if (census.geometries !== undefined) parts.push(`geo ${census.geometries}`);
723
752
  if (census.programs !== undefined) parts.push(`prog ${census.programs}`);
724
- return parts.join(', ');
753
+ const line = parts.join(', ');
754
+ // On its own line: the Blender block answers a different question from the
755
+ // resource profile (is the tab ANSWERING, not what is it holding), and
756
+ // running the two together is how a reader stops seeing either.
757
+ return census.blender === undefined
758
+ ? line
759
+ : `${line}\n ${formatBlenderMetrics(census.blender)}`;
725
760
  }
726
761
 
727
762
  /**
@@ -25,6 +25,12 @@
25
25
  * Deliberately import-free so a browser bundle can take it: the census's other
26
26
  * `@vgai/sdk` home, `project/session-journal.ts`, reads `node:fs`.
27
27
  *
28
+ * WHAT ELSE RIDES IT. The beat is the one channel that still moves when the
29
+ * page's main thread or the Blender worker is blocked, so `blender` — how long
30
+ * the twin's calls are taking and how long the main thread has been stalled —
31
+ * is carried here too (absent unless a Blender session exists in the tab). Same
32
+ * rule as the rest of this file: measurement only, no budget anywhere.
33
+ *
28
34
  * ABSENT IS NOT ZERO. `heapUsedMB`/`heapLimitMB` are null off Chromium
29
35
  * (`performance.memory` is non-standard). The renderer counts are ABSENT rather
30
36
  * than zero when no game has registered a render-debug adapter, and `programs`
@@ -32,6 +38,47 @@
32
38
  * a different claim from "nobody measured".
33
39
  */
34
40
 
41
+ /**
42
+ * THE BLENDER TWIN'S STALLS, as numbers.
43
+ *
44
+ * WHY (measured 2026-09-16): one `blender-execute` held the twin's worker for
45
+ * over 1,800s and wedged the tab, and a 0.86s-per-call scene stretched into
46
+ * heartbeat timeouts that read as "tab present, did not respond". The product
47
+ * had no number for either. Owner ruling: a tab that stops answering is the
48
+ * product's defect regardless of what the machine is doing, and the product has
49
+ * to surface it — so these ride the census to `vgai status`.
50
+ *
51
+ * MEASURED BY THE PAGE, because neither blocked party can report on itself: the
52
+ * worker's own loop is what is stuck, and a stalled main thread cannot send.
53
+ * The call half comes from `BlenderRuntime.metrics()`
54
+ * (`packages/bpy-twin/browser/runtime.ts`), the stall half from a `longtask`
55
+ * PerformanceObserver (`packages/editor/src/blender-tab-metrics.ts`).
56
+ *
57
+ * MEASUREMENT ONLY. No threshold here cancels, kills or budgets a call.
58
+ */
59
+ export interface BlenderTabMetrics {
60
+ /** Age of the oldest OUTSTANDING worker call, or null when the twin is idle.
61
+ * The only field with a number during a wedge. */
62
+ readonly inFlightMs: number | null;
63
+ /** Duration of the newest completed call; null before the first one. */
64
+ readonly lastCallMs: number | null;
65
+ /** The longest call this page has seen, counting an outstanding one. */
66
+ readonly maxCallMs: number | null;
67
+ /** Calls past 5s, and past 30s, since this page loaded. */
68
+ readonly callsOver5s: number;
69
+ readonly callsOver30s: number;
70
+ /** Longest `longtask` entry since page load, in ms; null off Chromium (the
71
+ * Long Tasks API is not implemented everywhere) — never 0, which would read
72
+ * as "the main thread never stalled". */
73
+ readonly longestTaskMs: number | null;
74
+ /** How many long tasks ran past 100ms since page load. */
75
+ readonly tasksOver100ms: number;
76
+ /** The longest long task that overlapped the newest call's window — what the
77
+ * MAIN thread was doing while the worker was busy. Null when there has been
78
+ * no call yet, or no long task during it. */
79
+ readonly lastCallLongestTaskMs: number | null;
80
+ }
81
+
35
82
  /**
36
83
  * A census as a CURRENT page produces it and the server files it.
37
84
  *
@@ -57,6 +104,8 @@ export interface TabCensus {
57
104
  readonly geometries?: number;
58
105
  /** Compiled programs — absent with no adapter, or before the first render. */
59
106
  readonly programs?: number;
107
+ /** {@link BlenderTabMetrics} — absent unless this tab has a Blender session. */
108
+ readonly blender?: BlenderTabMetrics;
60
109
  }
61
110
 
62
111
  /**
@@ -4,7 +4,8 @@ export type ToolContributionPoint =
4
4
  | 'asset.inspector'
5
5
  | 'generation.result'
6
6
  | 'workspace.utility'
7
- | 'workspace.analytics';
7
+ | 'workspace.analytics'
8
+ | 'workspace.status';
8
9
 
9
10
  /**
10
11
  * One contribution module, FOUND BY SCANNING — never listed anywhere.