@vgai/live 0.4.0-canary.20260715.0

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/src/session.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Session resolution for `connect()` (`index.ts`) — turns a project
3
+ * directory into `{ port, projectRoot }` for an ALREADY-RUNNING `vgai edit`
4
+ * session. `@vgai/live` never starts or stops a session (see the module doc
5
+ * on `index.ts`); this only finds one.
6
+ *
7
+ * Two pieces, from two different sources:
8
+ *
9
+ * 1. Live-session DISCOVERY + PROBING (`HttpEditorTransport.listSessions`,
10
+ * `withTimeout`, `EDITOR_SESSION_DISCOVERY_TIMEOUT_MS`) — reused
11
+ * directly from `@vgai/sdk`'s `editor/transport.ts` (B3,
12
+ * `resolveEditorSession`'s own sibling machinery). That module is a
13
+ * real, tested, PUBLICLY EXPORTED part of `@vgai/sdk` — reading
14
+ * `~/.vgai/editor-sessions.json`, PID-liveness-filtering it, and
15
+ * verifying each survivor with a live `GET /__editor/project` probe is
16
+ * exactly the hard, easy-to-get-subtly-wrong part worth reusing rather
17
+ * than re-deriving.
18
+ *
19
+ * 2. Project-root discovery (`findProjectRootFrom` below) — LIFTED (not
20
+ * imported) from `packages/vgai-cli/src/project-root.ts`. `@vgai/sdk`'s
21
+ * `resolveEditorSession` takes an already-known `ctx.projectRoot`; it has
22
+ * no "walk up from an arbitrary cwd to find the nearest vgai.game.json"
23
+ * step of its own, which `connect(projectDir?)` needs. `@vgai/cli` is
24
+ * `"private": true` with no `exports` field to import through (and
25
+ * depending on it from here would invert the intended CLI -> SDK ->
26
+ * engine direction anyway — see `@vgai/sdk`'s own architecture note),
27
+ * so this ~10-line pure function is duplicated verbatim instead. Keep in
28
+ * sync if that logic ever changes.
29
+ *
30
+ * DELIBERATE DIVERGENCE from `resolveEditorSession` itself: that function's
31
+ * documented precedence (see its own doc comment) falls back to "the
32
+ * lowest-numbered port among ALL live sessions" when `ctx.projectRoot` is
33
+ * given but matches none of them — a reasonable default for a single-agent,
34
+ * usually-one-session tool context. `@vgai/live` explicitly does NOT reuse
35
+ * that fallback: CLAUDE.md's editor-first workflow is emphatic that a
36
+ * project must never be silently retargeted to a DIFFERENT project's editor
37
+ * session (a real hazard in a multi-project/fleet setup — several unrelated
38
+ * `vgai edit` sessions can easily be running at once). `resolveSession`
39
+ * below matches the CLI's own stricter `getClient()` behavior instead: only
40
+ * a session whose OWN project matches `projectRoot` is ever returned: no
41
+ * match means a clear, actionable error, never a guess.
42
+ */
43
+
44
+ import { existsSync } from 'node:fs';
45
+ import { dirname, join, resolve } from 'node:path';
46
+ import {
47
+ EDITOR_SESSION_DISCOVERY_TIMEOUT_MS,
48
+ type EditorSessionInfo,
49
+ type EditorTransport,
50
+ HttpEditorTransport,
51
+ withTimeout,
52
+ } from '@vgai/sdk';
53
+
54
+ /**
55
+ * Lifted verbatim from `packages/vgai-cli/src/project-root.ts` (see this
56
+ * module's doc comment above for why it's duplicated rather than imported).
57
+ * Nearest ancestor of `dir` (inclusive) containing a `vgai.game.json`, or
58
+ * `null` if none.
59
+ */
60
+ export function findProjectRootFrom(dir: string): string | null {
61
+ let cur = resolve(dir);
62
+ for (;;) {
63
+ if (existsSync(join(cur, 'vgai.game.json'))) return cur;
64
+ const parent = dirname(cur);
65
+ if (parent === cur) return null;
66
+ cur = parent;
67
+ }
68
+ }
69
+
70
+ /** The minimal slice of `EditorTransport` `resolveSession` actually needs — narrower than the full interface so a test double only has to implement one method. */
71
+ export type SessionListingTransport = Pick<EditorTransport, 'listSessions'>;
72
+
73
+ export interface ResolvedSession {
74
+ /** Editor dev-server port the resolved session is listening on. */
75
+ port: number;
76
+ /** Absolute project root — the nearest ancestor of the requested directory containing `vgai.game.json`. */
77
+ projectRoot: string;
78
+ }
79
+
80
+ export interface SessionResolutionDeps {
81
+ /** Session listing + liveness verification — defaults to a real `HttpEditorTransport` (`@vgai/sdk`). Overridable for tests. */
82
+ transport?: SessionListingTransport;
83
+ /** Project-root discovery — defaults to the real fs walk (`findProjectRootFrom` above). Overridable for tests. */
84
+ findProjectRootFrom?: (dir: string) => string | null;
85
+ }
86
+
87
+ /**
88
+ * Resolve `projectDir` (default `process.cwd()`) to the port of its already-
89
+ * running `vgai edit` session. Throws a descriptive error (never hangs
90
+ * indefinitely — bounded by `EDITOR_SESSION_DISCOVERY_TIMEOUT_MS`, and never
91
+ * silently attaches to an unrelated project's session — see the module doc
92
+ * above) when no vgai.game.json is found, or no live session covers it.
93
+ */
94
+ export async function resolveSession(
95
+ projectDir: string = process.cwd(),
96
+ deps: SessionResolutionDeps = {},
97
+ ): Promise<ResolvedSession> {
98
+ const findRoot = deps.findProjectRootFrom ?? findProjectRootFrom;
99
+ const transport = deps.transport ?? new HttpEditorTransport();
100
+
101
+ const projectRoot = findRoot(projectDir);
102
+ if (projectRoot === null) {
103
+ throw new Error(
104
+ `@vgai/live: no vgai.game.json found in ${projectDir} or any parent directory — is this a vgai project?`,
105
+ );
106
+ }
107
+
108
+ let sessions: EditorSessionInfo[];
109
+ try {
110
+ sessions = await withTimeout(
111
+ transport.listSessions(EDITOR_SESSION_DISCOVERY_TIMEOUT_MS),
112
+ EDITOR_SESSION_DISCOVERY_TIMEOUT_MS,
113
+ 'editor session discovery',
114
+ );
115
+ } catch {
116
+ sessions = [];
117
+ }
118
+
119
+ const canon = resolve(projectRoot);
120
+ const match = sessions.find((s) => s.project !== null && resolve(s.project) === canon);
121
+ if (!match) {
122
+ const otherCount = sessions.length;
123
+ throw new Error(
124
+ `@vgai/live: no live editor session found for ${projectRoot}. @vgai/live only attaches to ` +
125
+ 'an already-running session — it never starts one — so run `vgai edit` in that project ' +
126
+ 'first, then retry.' +
127
+ (otherCount > 0
128
+ ? ` (${otherCount} other live session(s) found, but none open this project — @vgai/live ` +
129
+ 'never silently attaches to a different project.)'
130
+ : ''),
131
+ );
132
+ }
133
+
134
+ return { port: match.port, projectRoot };
135
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * A tiny memoized-async-factory primitive — the machinery behind
3
+ * `index.ts`'s lazy top-level `editor`/`game`/`page` singletons. Pulled into
4
+ * its own module (rather than inlined) so it's independently unit-testable
5
+ * with a fake factory, with no need to exercise a real `connect()` (session
6
+ * discovery, network) just to prove "two accesses, one connect".
7
+ *
8
+ * Caches the in-flight PROMISE, not just the resolved value — two callers
9
+ * racing `ensure()` before the first resolves still share the SAME
10
+ * connection attempt, not two independent ones.
11
+ */
12
+ export interface LazySession<T> {
13
+ /** Returns the memoized promise, creating it via `factory()` on first call. */
14
+ ensure(): Promise<T>;
15
+ /** Clears the memo — the NEXT `ensure()` calls `factory()` again. Exposed for tests (and for a caller that deliberately wants to reconnect); not needed in ordinary use. */
16
+ reset(): void;
17
+ }
18
+
19
+ export function createLazySession<T>(factory: () => Promise<T>): LazySession<T> {
20
+ let promise: Promise<T> | null = null;
21
+ return {
22
+ ensure(): Promise<T> {
23
+ promise ??= factory();
24
+ return promise;
25
+ },
26
+ reset(): void {
27
+ promise = null;
28
+ },
29
+ };
30
+ }