@nudge-ui/mcp 0.1.3 → 0.2.1

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.
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Public, host-neutral contracts shared by the Nudge browser inspector and
3
+ * its local MCP companion.
4
+ *
5
+ * This module deliberately has no Node, browser, MCP SDK, or framework
6
+ * imports. Hosts can use the same validation and message shapes without
7
+ * pulling the companion into a browser bundle.
8
+ */
9
+ export const AGENT_PROTOCOL_VERSION = 2;
10
+ export const AGENT_PROTOCOL_LIMITS = {
11
+ projectId: 256,
12
+ workspaceRoot: 4096,
13
+ origin: 2048,
14
+ sessionToken: 256,
15
+ requestId: 256,
16
+ commandId: 256,
17
+ clientDispatchId: 256,
18
+ prompt: 32_000,
19
+ sketchId: 256,
20
+ sketchRevision: 2_147_483_647,
21
+ sketchFilename: 256,
22
+ sketchDescription: 2_000,
23
+ sketchCount: 4,
24
+ sketchImageBytes: 2 * 1024 * 1024,
25
+ sketchTotalBytes: 8 * 1024 * 1024,
26
+ sketchEncodedBytes: 12 * 1024 * 1024,
27
+ sketchAnnotationCount: 50,
28
+ sketchUrl: 2_048,
29
+ sketchTitle: 512,
30
+ sketchFramework: 128,
31
+ sketchHost: 128,
32
+ groupId: 256,
33
+ label: 160,
34
+ routeUrl: 2048,
35
+ routeTitle: 512,
36
+ routeCount: 64,
37
+ canvasCardCount: 128,
38
+ };
39
+ /**
40
+ * Returns the default project-specific loopback port used by the CLI.
41
+ *
42
+ * The value is deterministic so a browser can probe the companion without a
43
+ * port file, while the range stays high enough to avoid common dev servers.
44
+ * Callers may still override it with an explicit port when a collision occurs.
45
+ */
46
+ export function defaultBridgePort(projectId) {
47
+ if (!nonEmptyString(projectId, AGENT_PROTOCOL_LIMITS.projectId)) {
48
+ throw new TypeError("projectId must be a bounded non-empty string");
49
+ }
50
+ let hash = 2_166_136_261;
51
+ for (let index = 0; index < projectId.length; index += 1) {
52
+ hash ^= projectId.charCodeAt(index);
53
+ hash = Math.imul(hash, 16_777_619);
54
+ }
55
+ return 30_000 + ((hash >>> 0) % 20_000);
56
+ }
57
+ export const CANVAS_COMMAND_TYPES = [
58
+ "read-state",
59
+ "present-routes",
60
+ "focus-group",
61
+ "fit-all",
62
+ "remove-group",
63
+ ];
64
+ const ORIGIN_PROTOCOLS = new Set(["http:", "https:"]);
65
+ function isRecord(value) {
66
+ return typeof value === "object" && value !== null && !Array.isArray(value);
67
+ }
68
+ function hasOnlyKeys(value, keys) {
69
+ const allowed = new Set(keys);
70
+ return Object.keys(value).every((key) => allowed.has(key));
71
+ }
72
+ function nonEmptyString(value, maximum) {
73
+ return typeof value === "string" && value.length > 0 && value.length <= maximum;
74
+ }
75
+ function optionalString(value, maximum) {
76
+ return value === undefined || (typeof value === "string" && value.length <= maximum);
77
+ }
78
+ function optionalRevision(value) {
79
+ return value === undefined || (typeof value === "number" && Number.isSafeInteger(value) && value >= 0);
80
+ }
81
+ function boundedNumber(value, maximum) {
82
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= maximum;
83
+ }
84
+ function boundedFiniteNumber(value, maximum) {
85
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= maximum;
86
+ }
87
+ function boundedString(value, maximum) {
88
+ return typeof value === "string" && value.length <= maximum;
89
+ }
90
+ function validBase64(value, maximum) {
91
+ return typeof value === "string"
92
+ && value.length > 0
93
+ && value.length <= maximum
94
+ && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
95
+ }
96
+ function encodedByteLength(value) {
97
+ const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
98
+ return Math.max(0, Math.floor(value.length * 3 / 4) - padding);
99
+ }
100
+ /**
101
+ * Returns a canonical origin or null. Credentials, paths, and fragments are
102
+ * rejected so the browser cannot broaden the pairing boundary accidentally.
103
+ */
104
+ export function canonicalOrigin(value) {
105
+ if (!nonEmptyString(value, AGENT_PROTOCOL_LIMITS.origin))
106
+ return null;
107
+ let parsed;
108
+ try {
109
+ parsed = new URL(value);
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ if (!ORIGIN_PROTOCOLS.has(parsed.protocol) || parsed.username || parsed.password)
115
+ return null;
116
+ if (parsed.pathname !== "/" || parsed.search || parsed.hash)
117
+ return null;
118
+ return parsed.origin;
119
+ }
120
+ /** Returns true only for exact, canonical HTTP(S) origins. */
121
+ export function isCanonicalOrigin(value) {
122
+ return canonicalOrigin(value) === value;
123
+ }
124
+ /**
125
+ * Checks an origin against an explicit allow-list. An empty allow-list does
126
+ * not mean wildcard access; callers must choose an origin before pairing.
127
+ */
128
+ export function isAllowedOrigin(value, allowedOrigins) {
129
+ const origin = canonicalOrigin(value);
130
+ if (!origin || allowedOrigins.length === 0)
131
+ return false;
132
+ return allowedOrigins.some((candidate) => canonicalOrigin(candidate) === origin);
133
+ }
134
+ /**
135
+ * Validates an agent route and ensures it belongs to the paired origin.
136
+ * Fragments are retained because they are useful initial Canvas locations.
137
+ */
138
+ export function isSameOriginRoute(value, origin) {
139
+ if (!isRecord(value) || !hasOnlyKeys(value, ["url", "title", "label"]))
140
+ return false;
141
+ if (!nonEmptyString(value.url, AGENT_PROTOCOL_LIMITS.routeUrl))
142
+ return false;
143
+ if (!optionalString(value.title, AGENT_PROTOCOL_LIMITS.routeTitle))
144
+ return false;
145
+ if (!optionalString(value.label, AGENT_PROTOCOL_LIMITS.label))
146
+ return false;
147
+ const expectedOrigin = canonicalOrigin(origin);
148
+ if (!expectedOrigin)
149
+ return false;
150
+ let route;
151
+ try {
152
+ route = new URL(value.url, expectedOrigin);
153
+ }
154
+ catch {
155
+ return false;
156
+ }
157
+ if (route.origin !== expectedOrigin || !ORIGIN_PROTOCOLS.has(route.protocol))
158
+ return false;
159
+ return true;
160
+ }
161
+ /** Validates and normalises route values for a presentation command. */
162
+ export function validateRoutes(value, origin) {
163
+ if (!Array.isArray(value) || value.length === 0 || value.length > AGENT_PROTOCOL_LIMITS.routeCount) {
164
+ throw new TypeError(`routes must contain between 1 and ${AGENT_PROTOCOL_LIMITS.routeCount} items`);
165
+ }
166
+ const expectedOrigin = canonicalOrigin(origin);
167
+ if (!expectedOrigin)
168
+ throw new TypeError("origin must be a canonical HTTP(S) origin");
169
+ return value.map((route, index) => {
170
+ if (!isSameOriginRoute(route, expectedOrigin)) {
171
+ throw new TypeError(`routes[${index}] must be a same-origin route`);
172
+ }
173
+ const input = route;
174
+ const parsed = new URL(input.url, expectedOrigin);
175
+ return {
176
+ url: parsed.href,
177
+ ...(input.title === undefined ? {} : { title: input.title }),
178
+ ...(input.label === undefined ? {} : { label: input.label }),
179
+ };
180
+ });
181
+ }
182
+ /** Validates the fields shared by every companion message. */
183
+ export function isProtocolVersion(value) {
184
+ return value === AGENT_PROTOCOL_VERSION;
185
+ }
186
+ export function isProjectIdentity(value) {
187
+ if (!isRecord(value) || !hasOnlyKeys(value, ["projectId", "origin", "workspaceRoot"]))
188
+ return false;
189
+ return nonEmptyString(value.projectId, AGENT_PROTOCOL_LIMITS.projectId)
190
+ && (value.origin === undefined || isCanonicalOrigin(value.origin))
191
+ && optionalString(value.workspaceRoot, AGENT_PROTOCOL_LIMITS.workspaceRoot);
192
+ }
193
+ export function isAgentSketchCaptureMetadata(value) {
194
+ if (!isRecord(value) || !hasOnlyKeys(value, [
195
+ "url",
196
+ "title",
197
+ "timestamp",
198
+ "viewportWidth",
199
+ "viewportHeight",
200
+ "scrollX",
201
+ "scrollY",
202
+ "devicePixelRatio",
203
+ "host",
204
+ "framework",
205
+ ]))
206
+ return false;
207
+ if (!nonEmptyString(value.url, AGENT_PROTOCOL_LIMITS.sketchUrl)
208
+ || !boundedString(value.title, AGENT_PROTOCOL_LIMITS.sketchTitle)
209
+ || !boundedNumber(value.timestamp, Number.MAX_SAFE_INTEGER)
210
+ || !boundedNumber(value.viewportWidth, 100_000)
211
+ || !boundedNumber(value.viewportHeight, 100_000)
212
+ || !boundedFiniteNumber(value.scrollX, Number.MAX_SAFE_INTEGER)
213
+ || !boundedFiniteNumber(value.scrollY, Number.MAX_SAFE_INTEGER)
214
+ || !boundedFiniteNumber(value.devicePixelRatio, 100)
215
+ || value.devicePixelRatio <= 0
216
+ || !nonEmptyString(value.host, AGENT_PROTOCOL_LIMITS.sketchHost)
217
+ || !nonEmptyString(value.framework, AGENT_PROTOCOL_LIMITS.sketchFramework)) {
218
+ return false;
219
+ }
220
+ try {
221
+ const url = new URL(value.url);
222
+ if (url.protocol !== "http:" && url.protocol !== "https:")
223
+ return false;
224
+ }
225
+ catch {
226
+ return false;
227
+ }
228
+ return true;
229
+ }
230
+ export function isAgentSketchAnnotation(value) {
231
+ if (!isRecord(value) || !hasOnlyKeys(value, ["number", "description"]))
232
+ return false;
233
+ return boundedNumber(value.number, AGENT_PROTOCOL_LIMITS.sketchAnnotationCount)
234
+ && value.number >= 1
235
+ && nonEmptyString(value.description, AGENT_PROTOCOL_LIMITS.sketchDescription);
236
+ }
237
+ function validSketchAnnotationList(value) {
238
+ if (!Array.isArray(value)
239
+ || value.length > AGENT_PROTOCOL_LIMITS.sketchAnnotationCount
240
+ || !value.every(isAgentSketchAnnotation))
241
+ return false;
242
+ return new Set(value.map((annotation) => annotation.number)).size === value.length;
243
+ }
244
+ export function isAgentSketchMetadata(value) {
245
+ if (!isRecord(value) || !hasOnlyKeys(value, [
246
+ "id",
247
+ "revision",
248
+ "filename",
249
+ "mimeType",
250
+ "width",
251
+ "height",
252
+ "byteSize",
253
+ "capture",
254
+ "description",
255
+ "annotations",
256
+ ]))
257
+ return false;
258
+ return nonEmptyString(value.id, AGENT_PROTOCOL_LIMITS.sketchId)
259
+ && boundedNumber(value.revision, AGENT_PROTOCOL_LIMITS.sketchRevision)
260
+ && value.revision > 0
261
+ && nonEmptyString(value.filename, AGENT_PROTOCOL_LIMITS.sketchFilename)
262
+ && value.mimeType === "image/png"
263
+ && boundedNumber(value.width, 2_048)
264
+ && value.width > 0
265
+ && boundedNumber(value.height, 2_048)
266
+ && value.height > 0
267
+ && boundedNumber(value.byteSize, AGENT_PROTOCOL_LIMITS.sketchImageBytes)
268
+ && value.byteSize > 0
269
+ && isAgentSketchCaptureMetadata(value.capture)
270
+ && (value.description === undefined
271
+ || boundedString(value.description, AGENT_PROTOCOL_LIMITS.sketchDescription))
272
+ && (value.annotations === undefined || validSketchAnnotationList(value.annotations));
273
+ }
274
+ export function isAgentSketchAttachment(value) {
275
+ if (!isRecord(value) || !hasOnlyKeys(value, [
276
+ "id",
277
+ "revision",
278
+ "filename",
279
+ "mimeType",
280
+ "width",
281
+ "height",
282
+ "byteSize",
283
+ "capture",
284
+ "description",
285
+ "annotations",
286
+ "data",
287
+ ]) || !isAgentSketchMetadata({
288
+ id: value.id,
289
+ revision: value.revision,
290
+ filename: value.filename,
291
+ mimeType: value.mimeType,
292
+ width: value.width,
293
+ height: value.height,
294
+ byteSize: value.byteSize,
295
+ capture: value.capture,
296
+ ...(value.description === undefined ? {} : { description: value.description }),
297
+ ...(value.annotations === undefined ? {} : { annotations: value.annotations }),
298
+ }))
299
+ return false;
300
+ return validBase64(value.data, AGENT_PROTOCOL_LIMITS.sketchEncodedBytes)
301
+ && encodedByteLength(value.data) === value.byteSize;
302
+ }
303
+ export function validateSketchAttachments(value) {
304
+ if (value === undefined)
305
+ return [];
306
+ if (!Array.isArray(value) || value.length > AGENT_PROTOCOL_LIMITS.sketchCount) {
307
+ throw new TypeError(`attachments must contain at most ${AGENT_PROTOCOL_LIMITS.sketchCount} items`);
308
+ }
309
+ const attachments = value.map((candidate, index) => {
310
+ if (!isAgentSketchAttachment(candidate)) {
311
+ throw new TypeError(`attachments[${index}] is not a valid PNG attachment`);
312
+ }
313
+ return { ...candidate, capture: { ...candidate.capture } };
314
+ });
315
+ if (new Set(attachments.map((attachment) => `${attachment.id}\u0000${attachment.revision}`)).size
316
+ !== attachments.length) {
317
+ throw new TypeError("attachments must not contain duplicate sketch revisions");
318
+ }
319
+ const totalBytes = attachments.reduce((total, attachment) => total + attachment.byteSize, 0);
320
+ if (totalBytes > AGENT_PROTOCOL_LIMITS.sketchTotalBytes) {
321
+ throw new TypeError(`attachments must contain at most ${AGENT_PROTOCOL_LIMITS.sketchTotalBytes} decoded bytes`);
322
+ }
323
+ return attachments;
324
+ }
325
+ function validSketchMetadataList(value) {
326
+ if (!Array.isArray(value) || value.length > AGENT_PROTOCOL_LIMITS.sketchCount
327
+ || !value.every(isAgentSketchMetadata))
328
+ return false;
329
+ return new Set(value.map((sketch) => `${sketch.id}\u0000${sketch.revision}`)).size === value.length;
330
+ }
331
+ export function isAgentPromptRequest(value) {
332
+ if (!isRecord(value) || !hasOnlyKeys(value, [
333
+ "requestId",
334
+ "projectId",
335
+ "prompt",
336
+ "changeRevision",
337
+ "clientDispatchId",
338
+ "sketches",
339
+ ]))
340
+ return false;
341
+ return nonEmptyString(value.requestId, AGENT_PROTOCOL_LIMITS.requestId)
342
+ && nonEmptyString(value.projectId, AGENT_PROTOCOL_LIMITS.projectId)
343
+ && nonEmptyString(value.prompt, AGENT_PROTOCOL_LIMITS.prompt)
344
+ && optionalRevision(value.changeRevision)
345
+ && (value.clientDispatchId === undefined || nonEmptyString(value.clientDispatchId, AGENT_PROTOCOL_LIMITS.clientDispatchId))
346
+ && (value.sketches === undefined || validSketchMetadataList(value.sketches));
347
+ }
348
+ export function isAgentStatusUpdate(value) {
349
+ if (!isRecord(value) || !hasOnlyKeys(value, ["requestId", "status", "summary", "error"]))
350
+ return false;
351
+ return nonEmptyString(value.requestId, AGENT_PROTOCOL_LIMITS.requestId)
352
+ && (value.status === "working" || value.status === "completed"
353
+ || value.status === "failed" || value.status === "interrupted")
354
+ && optionalString(value.summary, AGENT_PROTOCOL_LIMITS.prompt)
355
+ && optionalString(value.error, AGENT_PROTOCOL_LIMITS.prompt);
356
+ }
357
+ export function isCanvasState(value) {
358
+ if (!isRecord(value) || !hasOnlyKeys(value, ["mode", "groups", "focusedGroupId", "focusedRouteUrl"]))
359
+ return false;
360
+ if (value.mode !== "inspect" && value.mode !== "canvas")
361
+ return false;
362
+ if (!Array.isArray(value.groups) || value.groups.length > AGENT_PROTOCOL_LIMITS.canvasCardCount)
363
+ return false;
364
+ if (!(value.focusedGroupId === undefined || value.focusedGroupId === null
365
+ || nonEmptyString(value.focusedGroupId, AGENT_PROTOCOL_LIMITS.groupId)))
366
+ return false;
367
+ if (!(value.focusedRouteUrl === undefined || value.focusedRouteUrl === null
368
+ || nonEmptyString(value.focusedRouteUrl, AGENT_PROTOCOL_LIMITS.routeUrl)))
369
+ return false;
370
+ return value.groups.every(isCanvasGroup);
371
+ }
372
+ export function isCanvasGroup(value) {
373
+ if (!isRecord(value) || !hasOnlyKeys(value, ["id", "label", "owner", "routes"]))
374
+ return false;
375
+ return nonEmptyString(value.id, AGENT_PROTOCOL_LIMITS.groupId)
376
+ && nonEmptyString(value.label, AGENT_PROTOCOL_LIMITS.label)
377
+ && (value.owner === "agent" || value.owner === "user")
378
+ && Array.isArray(value.routes)
379
+ && value.routes.length <= AGENT_PROTOCOL_LIMITS.routeCount
380
+ && value.routes.every((route) => isRecord(route)
381
+ && nonEmptyString(route.url, AGENT_PROTOCOL_LIMITS.routeUrl)
382
+ && optionalString(route.title, AGENT_PROTOCOL_LIMITS.routeTitle)
383
+ && optionalString(route.label, AGENT_PROTOCOL_LIMITS.label));
384
+ }
385
+ export function isCanvasCommand(value) {
386
+ if (!isRecord(value) || !hasOnlyKeys(value, ["type", "commandId", "groupId", "label", "routes"]))
387
+ return false;
388
+ if (!nonEmptyString(value.commandId, AGENT_PROTOCOL_LIMITS.commandId))
389
+ return false;
390
+ switch (value.type) {
391
+ case "read-state":
392
+ case "fit-all":
393
+ return true;
394
+ case "focus-group":
395
+ case "remove-group":
396
+ return nonEmptyString(value.groupId, AGENT_PROTOCOL_LIMITS.groupId);
397
+ case "present-routes":
398
+ return nonEmptyString(value.groupId, AGENT_PROTOCOL_LIMITS.groupId)
399
+ && nonEmptyString(value.label, AGENT_PROTOCOL_LIMITS.label)
400
+ && Array.isArray(value.routes)
401
+ && value.routes.length > 0
402
+ && value.routes.length <= AGENT_PROTOCOL_LIMITS.routeCount
403
+ && value.routes.every((route) => isRecord(route)
404
+ && nonEmptyString(route.url, AGENT_PROTOCOL_LIMITS.routeUrl)
405
+ && optionalString(route.title, AGENT_PROTOCOL_LIMITS.routeTitle)
406
+ && optionalString(route.label, AGENT_PROTOCOL_LIMITS.label));
407
+ default:
408
+ return false;
409
+ }
410
+ }
411
+ /** Creates a stable protocol error shape without exposing thrown values. */
412
+ export function protocolError(code, message) {
413
+ if (!nonEmptyString(code, 128) || !nonEmptyString(message, AGENT_PROTOCOL_LIMITS.prompt)) {
414
+ throw new TypeError("Protocol errors require bounded code and message strings");
415
+ }
416
+ return { code, message };
417
+ }
@@ -0,0 +1,54 @@
1
+ import { AGENT_PROTOCOL_VERSION } from "./internal/agent-protocol.js";
2
+ import { type BridgeAddress, type BrowserBridge } from "./bridge.js";
3
+ declare const REGISTRY_SCHEMA_VERSION: 1;
4
+ export interface ProjectSession {
5
+ readonly schemaVersion: typeof REGISTRY_SCHEMA_VERSION;
6
+ readonly protocolVersion: typeof AGENT_PROTOCOL_VERSION;
7
+ readonly sessionId: string;
8
+ readonly projectId: string;
9
+ readonly workspaceRoot: string;
10
+ readonly appRoot: string;
11
+ readonly gitCommonDir?: string;
12
+ readonly branch?: string;
13
+ readonly origin: string;
14
+ readonly endpoint: string;
15
+ readonly startedAt: string;
16
+ }
17
+ export interface ProjectBridgeOptions {
18
+ readonly appRoot: string;
19
+ readonly workspaceRoot?: string;
20
+ readonly projectId?: string;
21
+ readonly origin: string;
22
+ /** Other explicit browser origins, such as loopback aliases on the same port. */
23
+ readonly allowedOrigins?: readonly string[];
24
+ readonly host?: "127.0.0.1" | "::1" | "localhost";
25
+ readonly port?: number;
26
+ readonly commandTimeoutMs?: number;
27
+ /** Test/embedding override. Production uses a private per-user directory. */
28
+ readonly registryRoot?: string;
29
+ }
30
+ export interface ProjectBridgeRuntime {
31
+ readonly bridge: BrowserBridge;
32
+ readonly address: BridgeAddress;
33
+ readonly session: ProjectSession;
34
+ readonly browser: {
35
+ readonly projectId: string;
36
+ readonly origin: string;
37
+ readonly bridgeUrl: string;
38
+ };
39
+ close(): Promise<void>;
40
+ }
41
+ export interface StoredProjectSession extends ProjectSession {
42
+ readonly pid: number;
43
+ readonly controlToken: string;
44
+ }
45
+ export declare function defaultSessionRegistryRoot(): string;
46
+ /** Resolves a checkout or worktree without conflating related worktrees. */
47
+ export declare function resolveWorkspaceRoot(input: string): Promise<string>;
48
+ /**
49
+ * Starts the project-owned browser bridge and registers it for local MCP
50
+ * adapters. The descriptor contains a private control credential and is never
51
+ * suitable for browser bundles or user-facing tool results.
52
+ */
53
+ export declare function startProjectBridge(options: ProjectBridgeOptions): Promise<ProjectBridgeRuntime>;
54
+ export {};
@@ -0,0 +1,151 @@
1
+ import { execFile } from "node:child_process";
2
+ import { randomBytes, randomUUID } from "node:crypto";
3
+ import { chmod, lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
4
+ import { userInfo } from "node:os";
5
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { AGENT_PROTOCOL_VERSION, canonicalOrigin } from "./internal/agent-protocol.js";
8
+ import { createLoopbackBridge } from "./bridge.js";
9
+ const execFileAsync = promisify(execFile);
10
+ const REGISTRY_SCHEMA_VERSION = 1;
11
+ export function defaultSessionRegistryRoot() {
12
+ // MCP hosts commonly sanitize TMPDIR and XDG_RUNTIME_DIR for stdio child
13
+ // processes. The OS account home is stable across the development server
14
+ // and agent adapter, so both processes discover the same private registry.
15
+ return join(userInfo().homedir, ".cache", "nudge-ui", "sessions");
16
+ }
17
+ async function gitValue(cwd, args) {
18
+ try {
19
+ const result = await execFileAsync("git", ["-C", cwd, ...args], {
20
+ encoding: "utf8",
21
+ timeout: 2_000,
22
+ windowsHide: true,
23
+ });
24
+ const value = result.stdout.trim();
25
+ return value.length > 0 ? value : undefined;
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ }
31
+ async function workspaceMetadata(appRootInput, workspaceRootInput) {
32
+ const appRoot = await realpath(resolve(appRootInput));
33
+ const workspaceRoot = await resolveWorkspaceRoot(workspaceRootInput ?? appRoot);
34
+ assertAppInsideWorkspace(appRoot, workspaceRoot);
35
+ const git = await readGitMetadata(workspaceRoot);
36
+ return { appRoot, workspaceRoot, ...git };
37
+ }
38
+ function assertAppInsideWorkspace(appRoot, workspaceRoot) {
39
+ const appRelative = relative(workspaceRoot, appRoot);
40
+ if (appRelative === ".." || appRelative.startsWith(`..${sep}`) || isAbsolute(appRelative)) {
41
+ throw new TypeError("appRoot must be inside the canonical workspace root");
42
+ }
43
+ }
44
+ async function readGitMetadata(workspaceRoot) {
45
+ const [commonDirectory, branch] = await Promise.all([
46
+ gitValue(workspaceRoot, ["rev-parse", "--git-common-dir"]),
47
+ gitValue(workspaceRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]),
48
+ ]);
49
+ const gitCommonDir = commonDirectory === undefined
50
+ ? undefined
51
+ : await realpath(resolve(workspaceRoot, commonDirectory));
52
+ return { ...(gitCommonDir ? { gitCommonDir } : {}), ...(branch ? { branch } : {}) };
53
+ }
54
+ /** Resolves a checkout or worktree without conflating related worktrees. */
55
+ export async function resolveWorkspaceRoot(input) {
56
+ const canonicalInput = await realpath(resolve(input));
57
+ const gitTopLevel = await gitValue(canonicalInput, ["rev-parse", "--show-toplevel"]);
58
+ return gitTopLevel ? await realpath(gitTopLevel) : canonicalInput;
59
+ }
60
+ async function ensurePrivateRegistryDirectory(registryRoot) {
61
+ await mkdir(registryRoot, { recursive: true, mode: 0o700 });
62
+ const directory = await lstat(registryRoot);
63
+ if (!directory.isDirectory() || directory.isSymbolicLink()) {
64
+ throw new Error("The Nudge session registry must be a real directory.");
65
+ }
66
+ if (typeof process.getuid === "function" && directory.uid !== process.getuid()) {
67
+ throw new Error("The Nudge session registry must be owned by the current user.");
68
+ }
69
+ await chmod(registryRoot, 0o700);
70
+ }
71
+ async function writeDescriptor(registryRoot, descriptor) {
72
+ await ensurePrivateRegistryDirectory(registryRoot);
73
+ const destination = join(registryRoot, `${descriptor.sessionId}.json`);
74
+ const temporary = join(registryRoot, `.${descriptor.sessionId}.${process.pid}.tmp`);
75
+ // Publish by renaming a private file so discovery never reads a partial record.
76
+ const file = await open(temporary, "wx", 0o600);
77
+ try {
78
+ await file.writeFile(`${JSON.stringify(descriptor)}\n`, "utf8").finally(() => file.close());
79
+ await rename(temporary, destination);
80
+ await chmod(destination, 0o600);
81
+ }
82
+ finally {
83
+ await rm(temporary, { force: true });
84
+ }
85
+ return destination;
86
+ }
87
+ async function closeProjectBridge(descriptorPath, bridge) {
88
+ try {
89
+ await rm(descriptorPath, { force: true });
90
+ }
91
+ finally {
92
+ await bridge.close();
93
+ }
94
+ }
95
+ /**
96
+ * Starts the project-owned browser bridge and registers it for local MCP
97
+ * adapters. The descriptor contains a private control credential and is never
98
+ * suitable for browser bundles or user-facing tool results.
99
+ */
100
+ export async function startProjectBridge(options) {
101
+ const origin = canonicalOrigin(options.origin);
102
+ if (!origin)
103
+ throw new TypeError("origin must be a canonical HTTP(S) origin");
104
+ const metadata = await workspaceMetadata(options.appRoot, options.workspaceRoot);
105
+ const sessionId = randomUUID();
106
+ const projectId = options.projectId ?? basename(metadata.appRoot);
107
+ if (!projectId)
108
+ throw new TypeError("projectId must be a bounded non-empty string");
109
+ const controlToken = randomBytes(32).toString("base64url");
110
+ const bridge = createLoopbackBridge({
111
+ projectId,
112
+ origin,
113
+ allowedOrigins: options.allowedOrigins,
114
+ host: options.host,
115
+ port: options.port,
116
+ commandTimeoutMs: options.commandTimeoutMs,
117
+ agentControlToken: controlToken,
118
+ });
119
+ const address = await bridge.start();
120
+ const session = {
121
+ schemaVersion: REGISTRY_SCHEMA_VERSION,
122
+ protocolVersion: AGENT_PROTOCOL_VERSION,
123
+ sessionId,
124
+ projectId,
125
+ ...metadata,
126
+ origin,
127
+ endpoint: address.url,
128
+ startedAt: new Date().toISOString(),
129
+ };
130
+ const registryRoot = resolve(options.registryRoot ?? defaultSessionRegistryRoot());
131
+ let descriptorPath;
132
+ try {
133
+ descriptorPath = await writeDescriptor(registryRoot, { ...session, pid: process.pid, controlToken });
134
+ }
135
+ catch (error) {
136
+ await bridge.close();
137
+ throw error;
138
+ }
139
+ let closePromise = null;
140
+ const close = () => {
141
+ closePromise ??= closeProjectBridge(descriptorPath, bridge);
142
+ return closePromise;
143
+ };
144
+ return {
145
+ bridge,
146
+ address,
147
+ session,
148
+ browser: { projectId, origin, bridgeUrl: address.url },
149
+ close,
150
+ };
151
+ }
@@ -0,0 +1,28 @@
1
+ import type { AgentPromptRequest, AgentStatusSnapshot, AgentStatusUpdate, CanvasCommandResult } from "./internal/agent-protocol.js";
2
+ import type { CanvasCommandInput } from "./bridge.js";
3
+ import type { StoredProjectSession } from "./project.js";
4
+ export interface SessionHealth {
5
+ readonly projectId: string;
6
+ readonly available: true;
7
+ readonly claimed: boolean;
8
+ readonly status: AgentStatusSnapshot;
9
+ }
10
+ export declare function readSessionHealth(session: StoredProjectSession, timeoutMs?: number): Promise<SessionHealth>;
11
+ /** Owns one project's claim, heartbeat, and authenticated control requests. */
12
+ export declare class ProjectSessionClient {
13
+ readonly descriptor: StoredProjectSession;
14
+ readonly agentId: string;
15
+ private claimed;
16
+ private claimPromise;
17
+ private heartbeat;
18
+ private closing;
19
+ constructor(descriptor: StoredProjectSession, agentId: string);
20
+ private send;
21
+ private acquireClaim;
22
+ private claim;
23
+ waitForPrompt(signal?: AbortSignal): Promise<AgentPromptRequest>;
24
+ getStatus(): Promise<AgentStatusSnapshot>;
25
+ updateRequestStatus(update: AgentStatusUpdate): Promise<AgentStatusSnapshot>;
26
+ dispatchCanvasCommand(input: CanvasCommandInput): Promise<CanvasCommandResult>;
27
+ close(): Promise<void>;
28
+ }