@nudge-ui/mcp 0.2.4 → 0.2.5

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,96 @@
1
+ import type { AgentActivity, AgentDeliveredPrompt, AgentRequestOutcome, AgentSketchAttachment, AgentStatusSnapshot, AgentStatusUpdate, BridgeEnvelope, CanvasCommand, CanvasCommandResult, FitCanvasCommand, FocusCanvasGroupCommand, PairingResponse, PresentRoutesCommand, PromptDispatchResponse, ReadCanvasStateCommand, RemoveCanvasGroupCommand } from "./internal/agent-protocol.js";
2
+ export declare const AGENT_CLAIM_TIMEOUT_MS = 30000;
3
+ export declare const AGENT_ACKNOWLEDGMENT_TIMEOUT_MS = 60000;
4
+ /** A clock port keeps command timeout and claim expiry tests deterministic. */
5
+ export interface BridgeClock {
6
+ now(): number;
7
+ setTimeout(callback: () => void, delayMs: number): unknown;
8
+ clearTimeout(handle: unknown): void;
9
+ }
10
+ type WithOptionalCommandId<T extends CanvasCommand> = Omit<T, "commandId"> & {
11
+ readonly commandId?: string;
12
+ };
13
+ export type CanvasCommandInput = WithOptionalCommandId<ReadCanvasStateCommand> | WithOptionalCommandId<PresentRoutesCommand> | WithOptionalCommandId<FocusCanvasGroupCommand> | WithOptionalCommandId<FitCanvasCommand> | WithOptionalCommandId<RemoveCanvasGroupCommand>;
14
+ export type BridgeSessionErrorCode = "project_not_found" | "origin_not_allowed" | "origin_mismatch" | "invalid_session" | "invalid_page_url" | "already_paired" | "browser_unpaired" | "listener_active" | "listener_unavailable" | "listener_cancelled" | "listener_aborted" | "request_in_flight" | "unknown_request" | "inactive_request" | "invalid_status" | "invalid_activity" | "invalid_canvas_command" | "invalid_routes" | "command_in_flight" | "no_command" | "unknown_command" | "session_claimed" | "agent_not_owner" | "session_closed";
15
+ /** A rejected session transition. Transports map the code to their own status. */
16
+ export declare class BridgeSessionError extends Error {
17
+ readonly code: BridgeSessionErrorCode;
18
+ constructor(code: BridgeSessionErrorCode, message: string);
19
+ }
20
+ /** One attached browser event stream; `send` returns false once the stream is gone. */
21
+ export interface BrowserStream {
22
+ send(envelope: BridgeEnvelope): boolean;
23
+ end(): void;
24
+ }
25
+ /** Proof that a request came from the paired browser. */
26
+ export interface BrowserGrant {
27
+ readonly origin: string;
28
+ readonly sessionToken: string;
29
+ }
30
+ export interface PromptDispatchInput {
31
+ readonly prompt: string;
32
+ readonly changeRevision?: number;
33
+ readonly clientDispatchId?: string;
34
+ readonly attachments: readonly AgentSketchAttachment[];
35
+ }
36
+ export interface ListenOptions {
37
+ readonly signal?: AbortSignal;
38
+ /** MCP client name shown in the browser while this listener is active. */
39
+ readonly agentClientName?: string;
40
+ }
41
+ /** In-process controls a host or an embedded agent uses on one project session. */
42
+ export interface BridgeSessionControls {
43
+ getStatus(): AgentStatusSnapshot;
44
+ /** Host-side approval of a browser origin from the allow-list. */
45
+ pairBrowser(origin: string, pageUrl?: string): PairingResponse;
46
+ /** Revokes the pairing and interrupts any working request. */
47
+ disconnectBrowser(sessionToken: string): void;
48
+ /** Blocks until the paired browser dispatches a prompt. */
49
+ waitForPrompt(options?: ListenOptions): Promise<AgentDeliveredPrompt>;
50
+ /** Retrieves an unacknowledged request, including its sketch attachments. */
51
+ getRequest(requestId: string): AgentDeliveredPrompt;
52
+ /** Reports the status of the request currently in flight. */
53
+ updateRequestStatus(update: AgentStatusUpdate | AgentRequestOutcome): void;
54
+ /** Emits temporary source attribution for the working request. */
55
+ reportActivity(activity: AgentActivity): void;
56
+ /** Sends a Canvas command to the browser and waits for one bounded acknowledgement. */
57
+ dispatchCanvasCommand(input: CanvasCommandInput): Promise<CanvasCommandResult>;
58
+ }
59
+ /**
60
+ * The transport-free state of one project bridge. Browser-facing transitions
61
+ * trust their caller to have authorized the request first.
62
+ */
63
+ export interface BridgeSession extends BridgeSessionControls {
64
+ readonly projectId: string;
65
+ readonly agentClaimed: boolean;
66
+ assertProject(projectId: unknown): void;
67
+ /** Accepts a configured origin, an allow-listed alias, or the paired origin. */
68
+ authorizeOrigin(origin: string | null): string;
69
+ authorizeBrowser(origin: string | null, sessionToken: string | null | undefined): BrowserGrant;
70
+ /** Passing the current token refreshes the pairing's page instead of failing. */
71
+ pairBrowser(origin: string, pageUrl?: string, sessionToken?: string): PairingResponse;
72
+ /** Replaces any existing pairing after an explicit user choice. */
73
+ takeOver(origin: string, pageUrl?: string): PairingResponse;
74
+ /** Attaches a stream to the current pairing and sends it the current status. */
75
+ attachStream(stream: BrowserStream): () => void;
76
+ dispatchPrompt(input: PromptDispatchInput): PromptDispatchResponse;
77
+ acknowledgeCanvas(result: CanvasCommandResult): void;
78
+ claimAgent(agentId: string): void;
79
+ /** Confirms the agent owns the session and extends its claim. */
80
+ confirmAgent(agentId: string | null): void;
81
+ releaseAgent(agentId: string | null): void;
82
+ close(): void;
83
+ }
84
+ export interface BridgeSessionOptions {
85
+ readonly projectId: string;
86
+ /** Canonical app origin that pairing and Canvas routes must use. */
87
+ readonly origin: string;
88
+ /** Other explicit browser origins, such as loopback aliases on the same port. */
89
+ readonly allowedOrigins?: readonly string[];
90
+ readonly commandTimeoutMs?: number;
91
+ readonly clock?: BridgeClock;
92
+ readonly tokenFactory: () => string;
93
+ readonly idFactory: () => string;
94
+ }
95
+ export declare function createBridgeSession(options: BridgeSessionOptions): BridgeSession;
96
+ export {};
@@ -0,0 +1,438 @@
1
+ import { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, canonicalOrigin, deriveConnectionState, isAgentActivity, isAgentStatusUpdate, isAllowedOrigin, isCanvasCommand, isSameOriginRoute, validateRoutes, } from "./internal/agent-protocol.js";
2
+ const DEFAULT_COMMAND_TIMEOUT_MS = 5_000;
3
+ export const AGENT_CLAIM_TIMEOUT_MS = 30_000;
4
+ export const AGENT_ACKNOWLEDGMENT_TIMEOUT_MS = 60_000;
5
+ const SYSTEM_CLOCK = {
6
+ now: () => Date.now(),
7
+ setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
8
+ clearTimeout: (handle) => clearTimeout(handle),
9
+ };
10
+ /** A rejected session transition. Transports map the code to their own status. */
11
+ export class BridgeSessionError extends Error {
12
+ code;
13
+ constructor(code, message) {
14
+ super(message);
15
+ this.name = "BridgeSessionError";
16
+ this.code = code;
17
+ }
18
+ }
19
+ function canonicalOrigins(options) {
20
+ const origins = [options.origin, ...(options.allowedOrigins ?? [])].map((candidate) => canonicalOrigin(candidate));
21
+ if (origins[0] === null)
22
+ throw new TypeError("origin must be a canonical HTTP(S) origin");
23
+ if (origins.some((candidate) => candidate === null)) {
24
+ throw new TypeError("allowedOrigins must contain canonical HTTP(S) origins");
25
+ }
26
+ return origins;
27
+ }
28
+ export function createBridgeSession(options) {
29
+ const { projectId } = options;
30
+ if (typeof projectId !== "string" || projectId.length === 0 || projectId.length > AGENT_PROTOCOL_LIMITS.projectId) {
31
+ throw new TypeError("projectId must be a bounded non-empty string");
32
+ }
33
+ const allowedOrigins = canonicalOrigins(options);
34
+ const timeoutMs = options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
35
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60_000) {
36
+ throw new TypeError("commandTimeoutMs must be between 1 and 60000 milliseconds");
37
+ }
38
+ const clock = options.clock ?? SYSTEM_CLOCK;
39
+ let closed = false;
40
+ let pairing = null;
41
+ const streams = new Set();
42
+ let listener = null;
43
+ let currentRequest = null;
44
+ let deliveredRequest = null;
45
+ let acknowledgmentTimeout = null;
46
+ let pendingCanvas = null;
47
+ let agentOwner = null;
48
+ let agentClientName;
49
+ let agentOwnerSeenAt = 0;
50
+ let agentOwnerExpiry = null;
51
+ const getStatus = () => {
52
+ const listenerActive = listener !== null;
53
+ const paired = pairing !== null;
54
+ return {
55
+ protocolVersion: AGENT_PROTOCOL_VERSION,
56
+ projectId,
57
+ connection: deriveConnectionState({ listenerActive, paired, request: currentRequest }),
58
+ listenerActive,
59
+ paired,
60
+ pageUrl: pairing?.pageUrl ?? null,
61
+ ...(agentClientName === undefined ? {} : { agentClientName }),
62
+ request: currentRequest,
63
+ };
64
+ };
65
+ const envelope = (token, event) => ({
66
+ protocolVersion: AGENT_PROTOCOL_VERSION,
67
+ projectId,
68
+ sessionToken: token,
69
+ event,
70
+ });
71
+ const publish = (event) => {
72
+ if (!pairing)
73
+ return;
74
+ const message = envelope(pairing.token, event);
75
+ for (const stream of streams) {
76
+ if (!stream.send(message))
77
+ streams.delete(stream);
78
+ }
79
+ };
80
+ const publishStatus = () => publish({ type: "status", status: getStatus() });
81
+ const authorizeOrigin = (candidate) => {
82
+ // Before pairing only a configured origin is eligible; an empty allow-list
83
+ // stays closed rather than trusting the first caller.
84
+ if (candidate && (candidate === pairing?.origin || isAllowedOrigin(candidate, allowedOrigins)))
85
+ return candidate;
86
+ throw new BridgeSessionError("origin_not_allowed", "The browser origin is not allowed for this project.");
87
+ };
88
+ const requirePairing = (token) => {
89
+ if (!pairing || token !== pairing.token) {
90
+ throw new BridgeSessionError("invalid_session", "The browser session token is invalid or expired.");
91
+ }
92
+ return pairing;
93
+ };
94
+ const authorizeBrowser = (candidate, token) => {
95
+ const origin = authorizeOrigin(candidate);
96
+ if (origin !== pairing?.origin) {
97
+ throw new BridgeSessionError("origin_mismatch", "The browser origin does not match the paired project.");
98
+ }
99
+ return { origin, sessionToken: requirePairing(token).token };
100
+ };
101
+ const pairingTarget = (originValue, requestedPageUrl) => {
102
+ const origin = authorizeOrigin(canonicalOrigin(originValue));
103
+ if (requestedPageUrl === undefined)
104
+ return { origin };
105
+ if (!isSameOriginRoute({ url: requestedPageUrl }, origin)) {
106
+ throw new BridgeSessionError("invalid_page_url", "pageUrl must be a bounded same-origin URL.");
107
+ }
108
+ return { origin, pageUrl: new URL(requestedPageUrl, origin).href };
109
+ };
110
+ const pairingResponse = ({ token, origin, pageUrl }) => ({
111
+ protocolVersion: AGENT_PROTOCOL_VERSION,
112
+ projectId,
113
+ origin,
114
+ sessionToken: token,
115
+ ...(pageUrl === null ? {} : { pageUrl }),
116
+ status: getStatus(),
117
+ });
118
+ const openPairing = (target) => {
119
+ const token = options.tokenFactory();
120
+ if (!token || token.length > AGENT_PROTOCOL_LIMITS.sessionToken) {
121
+ throw new Error("tokenFactory returned an invalid session token");
122
+ }
123
+ const opened = { token, origin: target.origin, pageUrl: target.pageUrl ?? null };
124
+ pairing = opened;
125
+ publish({ type: "connected", status: getStatus() });
126
+ const response = pairingResponse(opened);
127
+ publishStatus();
128
+ return response;
129
+ };
130
+ const settleCanvas = (code, message) => {
131
+ if (!pendingCanvas)
132
+ return;
133
+ const pending = pendingCanvas;
134
+ pendingCanvas = null;
135
+ clock.clearTimeout(pending.timeout);
136
+ pending.resolve({ commandId: pending.command.commandId, ok: false, error: { code, message } });
137
+ };
138
+ const clearAcknowledgmentTimeout = () => {
139
+ if (acknowledgmentTimeout !== null)
140
+ clock.clearTimeout(acknowledgmentTimeout);
141
+ acknowledgmentTimeout = null;
142
+ };
143
+ const interruptWorkingRequest = (reason) => {
144
+ if (currentRequest?.status !== "working" && currentRequest?.status !== "delivered")
145
+ return;
146
+ clearAcknowledgmentTimeout();
147
+ deliveredRequest = null;
148
+ currentRequest = { ...currentRequest, status: "interrupted", error: reason };
149
+ publishStatus();
150
+ };
151
+ const revokePairing = () => {
152
+ interruptWorkingRequest("The paired browser disconnected while the request was in flight.");
153
+ publish({ type: "disconnected", reason: "browser_disconnected" });
154
+ pairing = null;
155
+ for (const stream of streams)
156
+ stream.end();
157
+ streams.clear();
158
+ settleCanvas("browser_disconnected", "The paired browser disconnected before acknowledging the command.");
159
+ };
160
+ const pairBrowser = (originValue, requestedPageUrl, presentedToken) => {
161
+ const target = pairingTarget(originValue, requestedPageUrl);
162
+ if (!pairing)
163
+ return openPairing(target);
164
+ if (presentedToken !== pairing.token) {
165
+ throw new BridgeSessionError("already_paired", "A browser is already paired with this project.");
166
+ }
167
+ pairing.pageUrl = target.pageUrl ?? pairing.pageUrl;
168
+ return pairingResponse(pairing);
169
+ };
170
+ const takeOver = (originValue, requestedPageUrl) => {
171
+ const target = pairingTarget(originValue, requestedPageUrl);
172
+ if (pairing)
173
+ revokePairing();
174
+ return openPairing(target);
175
+ };
176
+ const disconnectBrowser = (token) => {
177
+ requirePairing(token);
178
+ revokePairing();
179
+ };
180
+ const attachStream = (stream) => {
181
+ if (!pairing)
182
+ throw new BridgeSessionError("invalid_session", "The browser session token is invalid or expired.");
183
+ streams.add(stream);
184
+ if (!stream.send(envelope(pairing.token, { type: "status", status: getStatus() })))
185
+ streams.delete(stream);
186
+ return () => { streams.delete(stream); };
187
+ };
188
+ const cancelListener = (reason) => {
189
+ if (!listener)
190
+ return;
191
+ const pending = listener;
192
+ listener = null;
193
+ pending.detachAbort();
194
+ pending.reject(new BridgeSessionError("listener_cancelled", reason));
195
+ publishStatus();
196
+ };
197
+ const waitForPrompt = async ({ signal, agentClientName: clientName } = {}) => {
198
+ if (closed)
199
+ throw new BridgeSessionError("session_closed", "The browser bridge is closed.");
200
+ if (listener)
201
+ throw new BridgeSessionError("listener_active", "A listening call is already active for this project.");
202
+ if (signal?.aborted)
203
+ throw new BridgeSessionError("listener_aborted", "The listening call was aborted.");
204
+ if (currentRequest?.status === "working") {
205
+ throw new BridgeSessionError("request_in_flight", `Request ${currentRequest.requestId} is already working. Report its outcome before listening again.`);
206
+ }
207
+ agentClientName = clientName?.trim().slice(0, AGENT_PROTOCOL_LIMITS.agentClientName) || undefined;
208
+ if (currentRequest?.status === "delivered" && deliveredRequest) {
209
+ publishStatus();
210
+ return structuredClone(deliveredRequest);
211
+ }
212
+ // A terminal outcome stays visible until the agent rearms; rearming ends the old request.
213
+ currentRequest = null;
214
+ deliveredRequest = null;
215
+ return await new Promise((resolve, reject) => {
216
+ const onAbort = () => {
217
+ if (listener !== pending)
218
+ return;
219
+ listener = null;
220
+ reject(new BridgeSessionError("listener_aborted", "The listening call was aborted."));
221
+ publishStatus();
222
+ };
223
+ const pending = {
224
+ resolve,
225
+ reject,
226
+ detachAbort: () => signal?.removeEventListener("abort", onAbort),
227
+ };
228
+ signal?.addEventListener("abort", onAbort, { once: true });
229
+ listener = pending;
230
+ publishStatus();
231
+ });
232
+ };
233
+ const dispatchPrompt = (input) => {
234
+ if (!listener)
235
+ throw new BridgeSessionError("listener_unavailable", "The agent has not opened a listening call.");
236
+ if (currentRequest)
237
+ throw new BridgeSessionError("request_in_flight", "Another Nudge request is still in flight.");
238
+ const { attachments } = input;
239
+ const request = {
240
+ requestId: options.idFactory(),
241
+ projectId,
242
+ prompt: input.prompt,
243
+ ...(input.changeRevision === undefined ? {} : { changeRevision: input.changeRevision }),
244
+ ...(input.clientDispatchId === undefined ? {} : { clientDispatchId: input.clientDispatchId }),
245
+ ...(attachments.length === 0 ? {} : { sketches: attachments.map(({ data: _data, ...metadata }) => metadata) }),
246
+ };
247
+ const pending = listener;
248
+ listener = null;
249
+ pending.detachAbort();
250
+ currentRequest = { ...request, status: "delivered" };
251
+ deliveredRequest = { ...request, ...(attachments.length === 0 ? {} : { attachments }) };
252
+ acknowledgmentTimeout = clock.setTimeout(() => {
253
+ if (currentRequest?.requestId !== request.requestId || currentRequest.status !== "delivered")
254
+ return;
255
+ interruptWorkingRequest("The agent did not acknowledge this request within 60 seconds. Ask the agent to listen again, then resend the prompt.");
256
+ }, AGENT_ACKNOWLEDGMENT_TIMEOUT_MS);
257
+ pending.resolve(structuredClone(deliveredRequest));
258
+ publishStatus();
259
+ return { request, status: "delivered" };
260
+ };
261
+ const getRequest = (requestId) => {
262
+ if (!deliveredRequest || deliveredRequest.requestId !== requestId || currentRequest?.status !== "delivered") {
263
+ throw new BridgeSessionError("unknown_request", "No unacknowledged request exists with this ID.");
264
+ }
265
+ return structuredClone(deliveredRequest);
266
+ };
267
+ const updateRequestStatus = (update) => {
268
+ if (!isAgentStatusUpdate(update))
269
+ throw new BridgeSessionError("invalid_status", "update must be a valid agent status update.");
270
+ if (currentRequest?.requestId !== update.requestId) {
271
+ throw new BridgeSessionError("unknown_request", "The request is not in flight for this project.");
272
+ }
273
+ if (currentRequest.status === update.status && currentRequest.status !== "working")
274
+ return;
275
+ if (currentRequest.status !== "delivered" && currentRequest.status !== "working") {
276
+ throw new BridgeSessionError("inactive_request", "The request already has a terminal outcome.");
277
+ }
278
+ clearAcknowledgmentTimeout();
279
+ deliveredRequest = null;
280
+ currentRequest = update.status === "working"
281
+ ? { ...currentRequest, status: "working" }
282
+ : {
283
+ ...currentRequest,
284
+ status: update.status,
285
+ ...(update.summary === undefined ? {} : { summary: update.summary }),
286
+ ...(update.error === undefined ? {} : { error: update.error }),
287
+ };
288
+ publishStatus();
289
+ };
290
+ const reportActivity = (activity) => {
291
+ if (!isAgentActivity(activity)) {
292
+ throw new BridgeSessionError("invalid_activity", "activity must identify a workspace-relative source file.");
293
+ }
294
+ if (currentRequest?.requestId !== activity.requestId || currentRequest.status !== "working") {
295
+ throw new BridgeSessionError("inactive_request", "Activity requires the currently working request.");
296
+ }
297
+ publish({ type: "activity", activity });
298
+ };
299
+ const dispatchCanvasCommand = async (input) => {
300
+ if (closed)
301
+ throw new BridgeSessionError("session_closed", "The browser bridge is closed.");
302
+ if (!pairing)
303
+ throw new BridgeSessionError("browser_unpaired", "No browser is paired with this project.");
304
+ const { origin } = pairing;
305
+ if (pendingCanvas)
306
+ throw new BridgeSessionError("command_in_flight", "A Canvas command is already in flight.");
307
+ const command = { ...input, commandId: input.commandId ?? options.idFactory() };
308
+ if (!isCanvasCommand(command))
309
+ throw new BridgeSessionError("invalid_canvas_command", "command must be a valid Canvas command.");
310
+ if (command.type === "present-routes") {
311
+ // The browser checks routes again; rejecting here keeps cross-origin URLs out of the project.
312
+ try {
313
+ validateRoutes(command.routes, origin);
314
+ }
315
+ catch (error) {
316
+ throw new BridgeSessionError("invalid_routes", error instanceof Error ? error.message : "Canvas routes are invalid.");
317
+ }
318
+ }
319
+ return await new Promise((resolve) => {
320
+ const timeout = clock.setTimeout(() => {
321
+ if (pendingCanvas?.command.commandId !== command.commandId)
322
+ return;
323
+ settleCanvas("ack_timeout", "The browser did not acknowledge the Canvas command in time.");
324
+ }, timeoutMs);
325
+ pendingCanvas = { command, resolve, timeout };
326
+ publish({ type: "canvas-command", command });
327
+ });
328
+ };
329
+ const acknowledgeCanvas = (result) => {
330
+ if (!pendingCanvas)
331
+ throw new BridgeSessionError("no_command", "No Canvas command is awaiting an acknowledgement.");
332
+ if (result.commandId !== pendingCanvas.command.commandId) {
333
+ throw new BridgeSessionError("unknown_command", "The Canvas command is no longer awaiting an acknowledgement.");
334
+ }
335
+ const pending = pendingCanvas;
336
+ pendingCanvas = null;
337
+ clock.clearTimeout(pending.timeout);
338
+ pending.resolve(result);
339
+ };
340
+ const clearAgentOwner = () => {
341
+ if (agentOwnerExpiry !== null)
342
+ clock.clearTimeout(agentOwnerExpiry);
343
+ agentOwnerExpiry = null;
344
+ agentOwner = null;
345
+ agentClientName = undefined;
346
+ agentOwnerSeenAt = 0;
347
+ };
348
+ const releaseAgentOwner = (reason) => {
349
+ clearAgentOwner();
350
+ cancelListener(reason);
351
+ interruptWorkingRequest(reason);
352
+ settleCanvas("agent_released", reason);
353
+ publishStatus();
354
+ };
355
+ const scheduleAgentOwnerExpiry = () => {
356
+ if (agentOwnerExpiry !== null)
357
+ clock.clearTimeout(agentOwnerExpiry);
358
+ agentOwnerExpiry = null;
359
+ const expectedOwner = agentOwner;
360
+ if (!expectedOwner)
361
+ return;
362
+ const expireIfStale = () => {
363
+ agentOwnerExpiry = null;
364
+ if (agentOwner !== expectedOwner)
365
+ return;
366
+ const remaining = AGENT_CLAIM_TIMEOUT_MS - (clock.now() - agentOwnerSeenAt);
367
+ if (remaining > 0) {
368
+ agentOwnerExpiry = clock.setTimeout(expireIfStale, remaining);
369
+ return;
370
+ }
371
+ releaseAgentOwner("The MCP adapter claim expired.");
372
+ };
373
+ agentOwnerExpiry = clock.setTimeout(expireIfStale, AGENT_CLAIM_TIMEOUT_MS);
374
+ };
375
+ const claimAgent = (agentId) => {
376
+ if (agentOwner && agentOwner !== agentId) {
377
+ if (clock.now() - agentOwnerSeenAt <= AGENT_CLAIM_TIMEOUT_MS) {
378
+ throw new BridgeSessionError("session_claimed", "Another MCP adapter owns this project session.");
379
+ }
380
+ releaseAgentOwner("The previous MCP adapter claim expired.");
381
+ }
382
+ agentOwner = agentId;
383
+ agentOwnerSeenAt = clock.now();
384
+ scheduleAgentOwnerExpiry();
385
+ };
386
+ const confirmAgent = (agentId) => {
387
+ if (!agentId || agentOwner !== agentId) {
388
+ throw new BridgeSessionError("agent_not_owner", "This MCP adapter does not own the project session.");
389
+ }
390
+ agentOwnerSeenAt = clock.now();
391
+ scheduleAgentOwnerExpiry();
392
+ };
393
+ const releaseAgent = (agentId) => {
394
+ confirmAgent(agentId);
395
+ releaseAgentOwner(`Agent ${agentId} released the project session.`);
396
+ };
397
+ const close = () => {
398
+ if (closed)
399
+ return;
400
+ closed = true;
401
+ clearAgentOwner();
402
+ clearAcknowledgmentTimeout();
403
+ deliveredRequest = null;
404
+ cancelListener("bridge_closed");
405
+ settleCanvas("bridge_closed", "The browser bridge closed before acknowledging the command.");
406
+ for (const stream of streams)
407
+ stream.end();
408
+ streams.clear();
409
+ pairing = null;
410
+ };
411
+ return {
412
+ projectId,
413
+ get agentClaimed() { return agentOwner !== null; },
414
+ assertProject(candidate) {
415
+ if (candidate !== projectId) {
416
+ throw new BridgeSessionError("project_not_found", "The requested project is not served by this bridge.");
417
+ }
418
+ },
419
+ authorizeOrigin,
420
+ authorizeBrowser,
421
+ getStatus,
422
+ pairBrowser,
423
+ takeOver,
424
+ disconnectBrowser,
425
+ attachStream,
426
+ waitForPrompt,
427
+ dispatchPrompt,
428
+ getRequest,
429
+ updateRequestStatus,
430
+ reportActivity,
431
+ dispatchCanvasCommand,
432
+ acknowledgeCanvas,
433
+ claimAgent,
434
+ confirmAgent,
435
+ releaseAgent,
436
+ close,
437
+ };
438
+ }
package/dist/cli.d.ts CHANGED
@@ -1,37 +1,11 @@
1
- import { type ChildProcess } from "node:child_process";
2
1
  export interface CliOptions {
3
2
  readonly command: "serve" | "doctor";
4
- readonly projectId: string;
5
- readonly origin?: string;
6
3
  readonly workspaceRoot?: string;
7
- readonly host: "127.0.0.1" | "::1" | "localhost";
8
- readonly port: number;
9
- readonly commandTimeoutMs: number;
10
4
  readonly help: boolean;
11
5
  }
12
- export interface CliEnvironment {
13
- readonly CI?: string;
14
- readonly INIT_CWD?: string;
15
- readonly NUDGE_UI_PROJECT_ID?: string;
16
- readonly NUDGE_UI_ORIGIN?: string;
17
- readonly NUDGE_UI_WORKSPACE_ROOT?: string;
18
- readonly NUDGE_UI_BRIDGE_HOST?: string;
19
- readonly NUDGE_UI_BRIDGE_PORT?: string;
20
- readonly NUDGE_UI_COMMAND_TIMEOUT_MS?: string;
21
- }
22
- export interface PageLaunchDependencies {
23
- readonly fetch: typeof fetch;
24
- readonly platform: NodeJS.Platform;
25
- readonly spawn: (command: string, args: readonly string[], options: {
26
- detached: true;
27
- stdio: "ignore";
28
- }) => Pick<ChildProcess, "unref">;
29
- readonly warn: (message: string) => void;
30
- }
31
- /** Opens a reachable paired project page using the platform URL handler. */
32
- export declare function openPairedPage(pageUrl: string, dependencies?: PageLaunchDependencies): Promise<boolean>;
33
- /** Parses only project-scoped, host-neutral CLI options. */
6
+ export type CliEnvironment = Readonly<Record<string, string | undefined>>;
7
+ /** Serving takes no options; `doctor` accepts a diagnostic scope. */
34
8
  export declare function parseCliArguments(argv?: readonly string[], environment?: CliEnvironment): CliOptions;
35
- export declare const CLI_USAGE = "Usage: nudge-mcp [doctor] [options]\n\nStarts a standard MCP stdio adapter. It discovers the bridge owned by the\nrunning project unless --origin selects the legacy coupled mode.\n\nOptions:\n --project-id <id> Stable project pairing identity\n --origin <origin> Exact app origin required for browser pairing\n --workspace-root <path> Optional legacy project restriction; omitted for reusable adapters\n --host <loopback> 127.0.0.1, ::1, or localhost\n --port <port> Bridge port; default is deterministic per project, 0 is ephemeral\n --command-timeout-ms <ms> Canvas acknowledgement timeout\n --help Show this message\n";
9
+ export declare const CLI_USAGE = "Usage: nudge-mcp [doctor] [options]\n\nStarts a standard MCP stdio adapter that discovers the bridges owned by running\nprojects. Every tool call names the checkout or application it targets.\n\nOptions:\n --workspace-root <path> doctor only: limit diagnostics to one checkout or application\n --help Show this message\n";
36
10
  /** Runs the CLI until the stdio MCP connection closes or the process stops. */
37
11
  export declare function runCli(argv?: readonly string[], environment?: CliEnvironment): Promise<void>;