@nudge-ui/mcp 0.2.4 → 0.2.6
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/README.md +18 -0
- package/dist/adapterRouting.d.ts +0 -1
- package/dist/adapterRouting.js +0 -1
- package/dist/bridge.d.ts +6 -57
- package/dist/bridge.js +293 -806
- package/dist/bridgeSession.d.ts +87 -0
- package/dist/bridgeSession.js +438 -0
- package/dist/cli.d.ts +3 -30
- package/dist/cli.js +54 -169
- package/dist/cli.mjs +237 -1372
- package/dist/discovery.d.ts +5 -6
- package/dist/discovery.js +5 -8
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/internal/agent-protocol.d.ts +53 -38
- package/dist/internal/agent-protocol.js +151 -42
- package/dist/project.d.ts +2 -7
- package/dist/project.js +5 -10
- package/dist/projectSessionClient.d.ts +4 -6
- package/dist/projectSessionClient.js +37 -15
- package/dist/protocol.d.ts +4 -20
- package/dist/protocol.js +3 -18
- package/dist/server.d.ts +3 -28
- package/dist/server.js +65 -137
- package/dist/sessionRegistry.d.ts +0 -2
- package/dist/sessionRegistry.js +1 -23
- package/package.json +1 -16
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
/** Timer handle: Node returns Timeout objects and test clocks use numeric IDs; only the creating clock interprets it. */
|
|
5
|
+
export type BridgeTimeoutHandle = NodeJS.Timeout | number;
|
|
6
|
+
/** A clock port keeps command timeout and claim expiry tests deterministic. */
|
|
7
|
+
export interface BridgeClock {
|
|
8
|
+
now(): number;
|
|
9
|
+
setTimeout(callback: () => void, delayMs: number): BridgeTimeoutHandle;
|
|
10
|
+
clearTimeout(handle: BridgeTimeoutHandle): void;
|
|
11
|
+
}
|
|
12
|
+
type WithOptionalCommandId<T extends CanvasCommand> = Omit<T, "commandId"> & {
|
|
13
|
+
readonly commandId?: string;
|
|
14
|
+
};
|
|
15
|
+
export type CanvasCommandInput = WithOptionalCommandId<ReadCanvasStateCommand> | WithOptionalCommandId<PresentRoutesCommand> | WithOptionalCommandId<FocusCanvasGroupCommand> | WithOptionalCommandId<FitCanvasCommand> | WithOptionalCommandId<RemoveCanvasGroupCommand>;
|
|
16
|
+
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";
|
|
17
|
+
/** A rejected session transition. Transports map the code to their own status. */
|
|
18
|
+
export declare class BridgeSessionError extends Error {
|
|
19
|
+
readonly code: BridgeSessionErrorCode;
|
|
20
|
+
constructor(code: BridgeSessionErrorCode, message: string);
|
|
21
|
+
}
|
|
22
|
+
/** One attached browser event stream; `send` returns false once the stream is gone. */
|
|
23
|
+
export interface BrowserStream {
|
|
24
|
+
send(envelope: BridgeEnvelope): boolean;
|
|
25
|
+
end(): void;
|
|
26
|
+
}
|
|
27
|
+
export interface BrowserGrant {
|
|
28
|
+
readonly origin: string;
|
|
29
|
+
readonly sessionToken: string;
|
|
30
|
+
}
|
|
31
|
+
export interface PromptDispatchInput {
|
|
32
|
+
readonly prompt: string;
|
|
33
|
+
readonly changeRevision?: number;
|
|
34
|
+
readonly clientDispatchId?: string;
|
|
35
|
+
readonly attachments: readonly AgentSketchAttachment[];
|
|
36
|
+
}
|
|
37
|
+
export interface ListenOptions {
|
|
38
|
+
readonly signal?: AbortSignal;
|
|
39
|
+
/** Shown in the browser while this listener is active. */
|
|
40
|
+
readonly agentClientName?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface BridgeSessionControls {
|
|
43
|
+
getStatus(): AgentStatusSnapshot;
|
|
44
|
+
pairBrowser(origin: string, pageUrl?: string): PairingResponse;
|
|
45
|
+
/** Revokes the pairing and interrupts any working request. */
|
|
46
|
+
disconnectBrowser(sessionToken: string): void;
|
|
47
|
+
waitForPrompt(options?: ListenOptions): Promise<AgentDeliveredPrompt>;
|
|
48
|
+
/** Retrieves an unacknowledged request, including its sketch attachments. */
|
|
49
|
+
getRequest(requestId: string): AgentDeliveredPrompt;
|
|
50
|
+
updateRequestStatus(update: AgentStatusUpdate | AgentRequestOutcome): void;
|
|
51
|
+
reportActivity(activity: AgentActivity): void;
|
|
52
|
+
/** Sends a Canvas command to the browser and waits for one bounded acknowledgement. */
|
|
53
|
+
dispatchCanvasCommand(input: CanvasCommandInput): Promise<CanvasCommandResult>;
|
|
54
|
+
}
|
|
55
|
+
/** Browser-facing transitions trust their caller to have authorized the request first. */
|
|
56
|
+
export interface BridgeSession extends BridgeSessionControls {
|
|
57
|
+
readonly projectId: string;
|
|
58
|
+
readonly agentClaimed: boolean;
|
|
59
|
+
assertProject(projectId: unknown): void;
|
|
60
|
+
/** Accepts a configured origin, an allow-listed alias, or the paired origin. */
|
|
61
|
+
authorizeOrigin(origin: string | null): string;
|
|
62
|
+
authorizeBrowser(origin: string | null, sessionToken: string | null | undefined): BrowserGrant;
|
|
63
|
+
/** Passing the current token refreshes the pairing's page instead of failing. */
|
|
64
|
+
pairBrowser(origin: string, pageUrl?: string, sessionToken?: string): PairingResponse;
|
|
65
|
+
/** Replaces any existing pairing after an explicit user choice. */
|
|
66
|
+
takeOver(origin: string, pageUrl?: string): PairingResponse;
|
|
67
|
+
/** Attaches a stream to the current pairing and sends it the current status. */
|
|
68
|
+
attachStream(stream: BrowserStream): () => void;
|
|
69
|
+
dispatchPrompt(input: PromptDispatchInput): PromptDispatchResponse;
|
|
70
|
+
acknowledgeCanvas(result: CanvasCommandResult): void;
|
|
71
|
+
claimAgent(agentId: string): void;
|
|
72
|
+
/** Confirms the agent owns the session and extends its claim. */
|
|
73
|
+
confirmAgent(agentId: string | null): void;
|
|
74
|
+
releaseAgent(agentId: string | null): void;
|
|
75
|
+
close(): void;
|
|
76
|
+
}
|
|
77
|
+
export interface BridgeSessionOptions {
|
|
78
|
+
readonly projectId: string;
|
|
79
|
+
readonly origin: string;
|
|
80
|
+
readonly allowedOrigins?: readonly string[];
|
|
81
|
+
readonly commandTimeoutMs?: number;
|
|
82
|
+
readonly clock?: BridgeClock;
|
|
83
|
+
readonly tokenFactory: () => string;
|
|
84
|
+
readonly idFactory: () => string;
|
|
85
|
+
}
|
|
86
|
+
export declare function createBridgeSession(options: BridgeSessionOptions): BridgeSession;
|
|
87
|
+
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
|
+
const canonical = origins.filter((candidate) => candidate !== null);
|
|
24
|
+
if (canonical.length !== origins.length) {
|
|
25
|
+
throw new TypeError("allowedOrigins must contain canonical HTTP(S) origins");
|
|
26
|
+
}
|
|
27
|
+
return canonical;
|
|
28
|
+
}
|
|
29
|
+
export function createBridgeSession(options) {
|
|
30
|
+
const { projectId } = options;
|
|
31
|
+
if (typeof projectId !== "string" || projectId.length === 0 || projectId.length > AGENT_PROTOCOL_LIMITS.projectId) {
|
|
32
|
+
throw new TypeError("projectId must be a bounded non-empty string");
|
|
33
|
+
}
|
|
34
|
+
const allowedOrigins = canonicalOrigins(options);
|
|
35
|
+
const timeoutMs = options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
36
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60_000) {
|
|
37
|
+
throw new TypeError("commandTimeoutMs must be between 1 and 60000 milliseconds");
|
|
38
|
+
}
|
|
39
|
+
const clock = options.clock ?? SYSTEM_CLOCK;
|
|
40
|
+
let closed = false;
|
|
41
|
+
let pairing = null;
|
|
42
|
+
const streams = new Set();
|
|
43
|
+
let listener = null;
|
|
44
|
+
let currentRequest = null;
|
|
45
|
+
let deliveredRequest = null;
|
|
46
|
+
let acknowledgmentTimeout = null;
|
|
47
|
+
let pendingCanvas = null;
|
|
48
|
+
let agentOwner = null;
|
|
49
|
+
let agentClientName;
|
|
50
|
+
let agentOwnerSeenAt = 0;
|
|
51
|
+
let agentOwnerExpiry = null;
|
|
52
|
+
const getStatus = () => {
|
|
53
|
+
const listenerActive = listener !== null;
|
|
54
|
+
const paired = pairing !== null;
|
|
55
|
+
return {
|
|
56
|
+
protocolVersion: AGENT_PROTOCOL_VERSION,
|
|
57
|
+
projectId,
|
|
58
|
+
connection: deriveConnectionState({ listenerActive, paired, request: currentRequest }),
|
|
59
|
+
listenerActive,
|
|
60
|
+
paired,
|
|
61
|
+
pageUrl: pairing?.pageUrl ?? null,
|
|
62
|
+
...(agentClientName === undefined ? {} : { agentClientName }),
|
|
63
|
+
request: currentRequest,
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
const envelope = (token, event) => ({
|
|
67
|
+
protocolVersion: AGENT_PROTOCOL_VERSION,
|
|
68
|
+
projectId,
|
|
69
|
+
sessionToken: token,
|
|
70
|
+
event,
|
|
71
|
+
});
|
|
72
|
+
const publish = (event) => {
|
|
73
|
+
if (!pairing)
|
|
74
|
+
return;
|
|
75
|
+
const message = envelope(pairing.token, event);
|
|
76
|
+
for (const stream of streams) {
|
|
77
|
+
if (!stream.send(message))
|
|
78
|
+
streams.delete(stream);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const publishStatus = () => publish({ type: "status", status: getStatus() });
|
|
82
|
+
const authorizeOrigin = (candidate) => {
|
|
83
|
+
// An empty allow-list 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,10 @@
|
|
|
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
|
|
13
|
-
|
|
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
|
|
36
|
-
/** Runs the CLI until the stdio MCP connection closes or the process stops. */
|
|
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";
|
|
37
10
|
export declare function runCli(argv?: readonly string[], environment?: CliEnvironment): Promise<void>;
|