@nudge-ui/mcp 0.2.1 → 0.2.3

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 CHANGED
@@ -1,99 +1,127 @@
1
- # `@nudge-ui/mcp`
1
+ # @nudge-ui/mcp
2
2
 
3
- > **Early alpha:** This integration can make breaking changes while host
4
- > support and the setup workflow stabilize.
3
+ Send design changes from Nudge UI directly to your coding agent.
4
+ `@nudge-ui/mcp` connects the browser editor to an MCP-capable agent so it can
5
+ receive your edits, comments, and sketches, implement them, and report progress
6
+ back to the editor.
5
7
 
6
- `@nudge-ui/mcp` connects a local Nudge inspector to an MCP-capable coding
7
- agent. The development server owns the browser bridge. The agent host starts a
8
- small stdio adapter that discovers live bridges in the current checkout or Git
9
- worktree.
8
+ It also lets the agent present app routes as labeled comparison groups in
9
+ Canvas. For example, ask it to create design variations and show them side by
10
+ side for review.
10
11
 
11
- ## Configure an agent
12
+ This package is an optional companion to
13
+ [`nudge-ui`](https://www.npmjs.com/package/nudge-ui). You can use Nudge's copied
14
+ prompts without installing it.
12
15
 
13
- The Nudge initializer can install the dependency and configure supported agent
14
- hosts. A manual project-scoped configuration uses this command:
16
+ > **Early alpha:** Host support and setup are still evolving. Expect breaking
17
+ > changes and differences in how agent hosts handle long-running listening calls.
15
18
 
16
- ```sh
17
- node ./node_modules/@nudge-ui/mcp/dist/cli.mjs --workspace-root /path/to/project
18
- ```
19
-
20
- The adapter does not need an origin, port, project ID, or branch. A running
21
- development integration registers those values in a private per-user runtime
22
- directory. Restart the agent host after changing its MCP configuration, then
23
- ask it to **Listen to Nudge**. Agent configuration contains absolute checkout
24
- and application paths, so rerun agent setup in each clone or Git worktree that
25
- you intend the agent to edit.
19
+ ## Get started
26
20
 
27
- Run diagnostics without starting MCP stdio framing:
21
+ If Nudge UI is already installed, run this from the application directory:
28
22
 
29
23
  ```sh
30
- node ./node_modules/@nudge-ui/mcp/dist/cli.mjs doctor --workspace-root /path/to/project
24
+ npx nudge-ui agent setup
31
25
  ```
32
26
 
33
- The doctor reports live and exact-workspace session counts. It never prints
34
- control credentials.
27
+ For a new Nudge installation, run `npm create nudge-ui@latest` and choose to
28
+ connect a coding agent. To configure only the agent integration, use:
35
29
 
36
- ## Project integration
30
+ ```sh
31
+ npm create nudge-ui@latest -- --agent-only
32
+ ```
37
33
 
38
- Framework adapters start and stop the project bridge with the development
39
- server:
34
+ Then:
35
+
36
+ 1. Fully restart your coding-agent host to load the MCP configuration.
37
+ 2. Start your app's development server and open Nudge in the browser.
38
+ 3. Ask the agent to **listen to Nudge in this workspace**.
39
+ 4. Make visual edits, add comments or sketches, and send the request from Nudge.
40
+
41
+ The agent receives the request, applies it using its normal permissions and
42
+ approval flow, verifies the result, reports status, and listens for the next
43
+ request. Keep that agent turn active while sending changes. Delivery confirms
44
+ that the agent received a request; it does not confirm implementation.
45
+
46
+ ## How it connects
47
+
48
+ Setup installs two parts:
49
+
50
+ - **Project bridge:** A local `@nudge-ui/mcp` development dependency. The Nudge
51
+ framework integration starts the bridge with your development server.
52
+ - **Agent adapter:** A reusable MCP stdio process under `~/.nudge-ui/adapters/`,
53
+ registered globally with your selected agent hosts.
54
+
55
+ The adapter discovers running apps through a private local session registry.
56
+ It can serve multiple checkouts and Git worktrees without a separate agent
57
+ registration for each one. Each app still needs its Nudge integration and
58
+ bridge dependency. Setup requires npm for the reusable adapter, even if the app
59
+ uses pnpm, Yarn, or Bun. The package requires Node.js 20 or later.
60
+
61
+ ## MCP tools
62
+
63
+ | Tool | Purpose |
64
+ | --- | --- |
65
+ | `nudge_list_sessions` | Discover running apps in the requested workspace. |
66
+ | `nudge_listen` | Wait for the next implementation request. `nudge_connect` is an alias. |
67
+ | `nudge_get_status` | Read connection and request status. |
68
+ | `nudge_report_activity` | Highlight Canvas frames associated with a file read or edit. |
69
+ | `nudge_report_status` | Report working, completed, failed, or interrupted status. |
70
+ | `nudge_read_canvas` | Read Canvas groups and focus. |
71
+ | `nudge_present_routes` | Present same-origin app routes in a labeled comparison group. |
72
+ | `nudge_focus_canvas_group` | Focus a comparison group. |
73
+ | `nudge_fit_canvas` | Fit all frames into view. |
74
+ | `nudge_remove_canvas_group` | Remove an agent-owned comparison group. |
75
+ | `nudge_diagnose` | Diagnose local session discovery and connectivity. |
76
+ | `nudge_release` | Stop listening and release the session for another agent. |
77
+
78
+ Supply `workspaceRoot` on every tool call, using the absolute path of the
79
+ checkout or application being edited. For example, call `nudge_list_sessions`
80
+ with:
81
+
82
+ ```json
83
+ {
84
+ "workspaceRoot": "/absolute/path/to/my-app"
85
+ }
86
+ ```
40
87
 
41
- ```ts
42
- import { startProjectBridge } from "@nudge-ui/mcp/project";
88
+ If multiple sessions match, select the app's `sessionId` and include it on
89
+ subsequent calls alongside `workspaceRoot`. The adapter does not infer the
90
+ current project from its working directory or select a different checkout as
91
+ a fallback. One adapter can claim a session at a time.
43
92
 
44
- const runtime = await startProjectBridge({
45
- workspaceRoot: process.cwd(),
46
- appRoot: process.cwd(),
47
- projectId: "my-app",
48
- origin: "http://localhost:5173",
49
- allowedOrigins: ["http://127.0.0.1:5173"],
50
- });
93
+ ## Troubleshooting
51
94
 
52
- // Give browser code only these non-secret values.
53
- console.log(runtime.browser.bridgeUrl, runtime.browser.projectId);
95
+ Run diagnostics from the app directory:
54
96
 
55
- await runtime.close();
97
+ ```sh
98
+ npx nudge-ui agent doctor
56
99
  ```
57
100
 
58
- `origin` must be the actual canonical development origin after the framework
59
- chooses its port. `workspaceRoot` defaults to the Git worktree root when one is
60
- available and otherwise defaults to `appRoot`. `appRoot` distinguishes several
61
- applications running from one monorepo.
62
-
63
- Each live bridge registers a random session ID, canonical workspace and app
64
- paths, optional Git common directory and branch metadata, origin, and loopback
65
- endpoint. The descriptor is mode `0600` inside a mode `0700` registry. It also
66
- contains a private random credential used only between the bridge and adapter.
67
-
68
- The adapter lists all live local sessions through `nudge_list_sessions`, but it
69
- can listen only to sessions whose canonical workspace and configured
70
- application path match. An adapter configured at the worktree root can select
71
- any app in that worktree explicitly. A branch name or shared Git common
72
- directory never causes fallback to another worktree. When several apps run in
73
- one workspace, pass the chosen `sessionId` to `nudge_listen`. The adapter keeps
74
- that app selection across a bridge restart and does not replay old requests.
101
+ Diagnostics report the registry path, workspace scope, reachable sessions, and
102
+ invalid, unreachable, or incompatible registrations. If another agent owns the
103
+ session, ask it to stop listening and call `nudge_release`.
75
104
 
76
- One adapter owns a selected project session until it closes. Other adapters
77
- receive a claimed-session error instead of taking browser work. A heartbeat
78
- allows the bridge to recover a claim after an adapter process is killed. Call
79
- `nudge_release` when the user stops listening so another adapter can claim the
80
- session without restarting the MCP host.
105
+ After updating the adapter, fully restart the agent host. Updating the project's bridge dependency alone does not update the
106
+ managed adapter. Update Nudge UI and rerun agent setup, or use the latest
107
+ initializer's `--agent-only` mode to update the agent integration.
81
108
 
82
- ## Legacy coupled mode
109
+ The MCP adapter delivers requests through an active tool call. It cannot start
110
+ a new agent turn after the host has ended the conversation or stopped waiting.
111
+ If a listening call times out, ask the agent to listen again and adjust the
112
+ host's MCP timeout if supported.
83
113
 
84
- Passing `--origin` retains the previous behavior in which the MCP process also
85
- owns its browser bridge:
114
+ ## Custom integrations
86
115
 
87
- ```sh
88
- pnpm exec nudge-mcp \
89
- --project-id my-app \
90
- --origin http://localhost:5173 \
91
- --workspace-root /path/to/my-app
92
- ```
116
+ Guided setup handles standard framework and agent integrations. For manual
117
+ stdio configuration, the `startProjectBridge` API, local tarball testing,
118
+ and OpenCode configuration, see
119
+ the [coding-agent guide](https://github.com/charlessmart/nudge-ui/blob/main/docs/agent-integration.md).
93
120
 
94
- This mode remains available for custom integrations during migration. New
95
- framework integrations should use the project-owned bridge.
121
+ ## Links
96
122
 
97
- The agent calls `nudge_listen`, applies the delivered prompt, reports the
98
- result through `nudge_report_status`, and listens again. Canvas tools remain
99
- limited to routes from the paired application origin and agent-owned groups.
123
+ [Nudge UI](https://www.npmjs.com/package/nudge-ui) ·
124
+ [Initializer](https://www.npmjs.com/package/create-nudge-ui) ·
125
+ [Repository](https://github.com/charlessmart/nudge-ui) ·
126
+ [Issues](https://github.com/charlessmart/nudge-ui/issues) ·
127
+ [MIT license](https://github.com/charlessmart/nudge-ui/blob/main/LICENSE)
@@ -0,0 +1,16 @@
1
+ import { DiscoveredProjectRouter } from "./discovery.js";
2
+ export interface ProjectTarget {
3
+ readonly workspaceRoot?: string;
4
+ readonly sessionId?: string;
5
+ }
6
+ /** Keeps each explicit project selection independent for the lifetime of an MCP connection. */
7
+ export declare class AdapterRouting {
8
+ readonly registryRoot: string;
9
+ private closing;
10
+ private readonly selections;
11
+ private readonly applications;
12
+ constructor(registryRoot?: string);
13
+ target({ workspaceRoot, sessionId }: ProjectTarget): Promise<DiscoveredProjectRouter>;
14
+ private select;
15
+ close(): Promise<void>;
16
+ }
@@ -0,0 +1,59 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { isAbsolute } from "node:path";
3
+ import { DiscoveredProjectRouter } from "./discovery.js";
4
+ import { defaultSessionRegistryRoot, resolveWorkspaceRoot } from "./project.js";
5
+ import { readLiveProjectSessions } from "./sessionRegistry.js";
6
+ import { selectProjectSession } from "./sessionSelection.js";
7
+ /** Keeps each explicit project selection independent for the lifetime of an MCP connection. */
8
+ export class AdapterRouting {
9
+ registryRoot;
10
+ closing = false;
11
+ selections = new Map();
12
+ applications = new Map();
13
+ constructor(registryRoot = defaultSessionRegistryRoot()) {
14
+ this.registryRoot = registryRoot;
15
+ }
16
+ async target({ workspaceRoot, sessionId }) {
17
+ if (!workspaceRoot || !isAbsolute(workspaceRoot)) {
18
+ throw new Error("Supply workspaceRoot as the absolute path of the checkout or application you are editing.");
19
+ }
20
+ const applicationRoot = await realpath(workspaceRoot);
21
+ if (this.closing)
22
+ throw new Error("The MCP adapter is closing.");
23
+ const key = JSON.stringify([applicationRoot, sessionId]);
24
+ let selection = this.selections.get(key);
25
+ if (!selection) {
26
+ selection = this.select(applicationRoot, sessionId);
27
+ this.selections.set(key, selection);
28
+ void selection.catch(() => {
29
+ if (this.selections.get(key) === selection)
30
+ this.selections.delete(key);
31
+ });
32
+ }
33
+ return await selection;
34
+ }
35
+ async select(applicationRoot, sessionId) {
36
+ const workspaceRoot = await resolveWorkspaceRoot(applicationRoot);
37
+ const live = await readLiveProjectSessions(this.registryRoot);
38
+ if (this.closing)
39
+ throw new Error("The MCP adapter is closing.");
40
+ const selected = selectProjectSession(live.map(({ descriptor }) => descriptor), {
41
+ workspaceRoot, applicationRoot, requestedSessionId: sessionId,
42
+ });
43
+ const key = selected.sessionId;
44
+ let router = this.applications.get(key)
45
+ ?? [...this.applications.values()].find((candidate) => candidate.selectedSessionId === selected.sessionId);
46
+ if (!router) {
47
+ router = DiscoveredProjectRouter.forSession(selected, this.registryRoot);
48
+ this.applications.set(key, router);
49
+ }
50
+ return router;
51
+ }
52
+ async close() {
53
+ this.closing = true;
54
+ await Promise.allSettled(this.selections.values());
55
+ await Promise.all([...this.applications.values()].map((router) => router.close()));
56
+ this.selections.clear();
57
+ this.applications.clear();
58
+ }
59
+ }
package/dist/bridge.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type Server } from "node:http";
2
- import type { AgentDeliveredPrompt, AgentRequestOutcome, AgentStatusSnapshot, AgentStatusUpdate, CanvasCommandResult, PairingResponse } from "./internal/agent-protocol.js";
2
+ import type { AgentActivity, AgentDeliveredPrompt, AgentRequestOutcome, AgentStatusSnapshot, AgentStatusUpdate, CanvasCommandResult, PairingResponse } from "./internal/agent-protocol.js";
3
3
  /** A clock port keeps command timeout tests deterministic. */
4
4
  export interface BridgeClock {
5
5
  now(): number;
@@ -51,9 +51,11 @@ export interface BrowserBridge {
51
51
  start(): Promise<BridgeAddress>;
52
52
  close(): Promise<void>;
53
53
  /** Blocks until a paired browser dispatches a prompt. */
54
- waitForPrompt(signal?: AbortSignal): Promise<AgentDeliveredPrompt>;
54
+ waitForPrompt(signal?: AbortSignal, sessionId?: string, agentClientName?: string): Promise<AgentDeliveredPrompt>;
55
55
  /** Marks the agent-side listener as stopped without ending the project. */
56
56
  cancelListener(reason?: string): void;
57
+ /** Emits temporary source attribution for the currently working request. */
58
+ reportActivity(activity: AgentActivity): void;
57
59
  /** Reports the terminal result for the currently running browser request. */
58
60
  updateRequestStatus(update: AgentStatusUpdate | AgentRequestOutcome): void;
59
61
  /** Sends a Canvas command to the browser and waits for one bounded ack. */
package/dist/bridge.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createServer, } from "node:http";
2
2
  import { randomBytes, randomUUID } from "node:crypto";
3
- import { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, canonicalOrigin, isAgentStatusUpdate, isAllowedOrigin, isCanvasCommand, isCanvasGroup, isCanvasState, isSameOriginRoute, validateSketchAttachments, validateRoutes, } from "./internal/agent-protocol.js";
3
+ import { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, canonicalOrigin, isAgentStatusUpdate, isAgentActivity, isAllowedOrigin, isCanvasCommand, isCanvasGroup, isCanvasState, isSameOriginRoute, validateSketchAttachments, validateRoutes, } from "./internal/agent-protocol.js";
4
4
  import { AGENT_CONTROL_ENDPOINTS, BRIDGE_ENDPOINTS } from "./protocol.js";
5
5
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
6
6
  const MAX_BODY_BYTES = 256 * 1024;
@@ -165,7 +165,7 @@ function connectionFor(listenerActive, paired, request) {
165
165
  return "listening";
166
166
  return "offline";
167
167
  }
168
- function statusWith(projectId, listenerActive, paired, request, pageUrl) {
168
+ function statusWith(projectId, listenerActive, paired, request, pageUrl, agentClientName) {
169
169
  return {
170
170
  protocolVersion: AGENT_PROTOCOL_VERSION,
171
171
  projectId,
@@ -173,6 +173,7 @@ function statusWith(projectId, listenerActive, paired, request, pageUrl) {
173
173
  listenerActive,
174
174
  paired,
175
175
  pageUrl,
176
+ ...(agentClientName === undefined ? {} : { agentClientName }),
176
177
  request,
177
178
  };
178
179
  }
@@ -225,9 +226,10 @@ export function createLoopbackBridge(options) {
225
226
  let closePromise = null;
226
227
  let startPromise = null;
227
228
  let agentOwner = null;
229
+ let agentClientName;
228
230
  let agentOwnerSeenAt = 0;
229
231
  let agentOwnerExpiry = null;
230
- const getStatus = () => statusWith(options.projectId, listener !== null, sessionToken !== null, currentRequest, lastPageUrl);
232
+ const getStatus = () => statusWith(options.projectId, listener !== null, sessionToken !== null, currentRequest, lastPageUrl, agentClientName);
231
233
  const broadcast = (event) => {
232
234
  if (!sessionToken)
233
235
  return;
@@ -310,7 +312,7 @@ export function createLoopbackBridge(options) {
310
312
  pageUrl: lastPageUrl,
311
313
  }, incomingOrigin);
312
314
  };
313
- const handlePair = async (request, response) => {
315
+ const readPairRequest = async (request) => {
314
316
  if (!loopbackAddress(request.socket.remoteAddress)) {
315
317
  throw new BridgeRequestError(403, "loopback_only", "The browser bridge accepts loopback connections only.");
316
318
  }
@@ -332,6 +334,10 @@ export function createLoopbackBridge(options) {
332
334
  }
333
335
  requestedPageUrl = new URL(body.pageUrl, requestedOrigin).href;
334
336
  }
337
+ return { body, incomingOrigin, requestedPageUrl };
338
+ };
339
+ const handlePair = async (request, response) => {
340
+ const { body, incomingOrigin, requestedPageUrl } = await readPairRequest(request);
335
341
  if (sessionToken) {
336
342
  if (body.sessionToken !== sessionToken) {
337
343
  throw new BridgeRequestError(409, "already_paired", "A browser is already paired with this project.");
@@ -367,6 +373,15 @@ export function createLoopbackBridge(options) {
367
373
  sendJson(response, 200, result, incomingOrigin);
368
374
  broadcastStatus();
369
375
  };
376
+ const handleTakeover = async (request, response) => {
377
+ const { incomingOrigin, requestedPageUrl } = await readPairRequest(request);
378
+ // Takeover is intentionally separate from normal pairing. The caller has
379
+ // made an explicit UI choice to revoke the existing browser session.
380
+ if (sessionToken)
381
+ disconnectBrowser(sessionToken);
382
+ const pairing = pairBrowser(options.projectId, incomingOrigin, requestedPageUrl);
383
+ sendJson(response, 200, pairing, incomingOrigin);
384
+ };
370
385
  const handleEvents = (request, response, url) => {
371
386
  parseProjectId(url.searchParams.get("projectId"), options.projectId);
372
387
  const incomingOrigin = requireBrowserRequest(request, url);
@@ -523,6 +538,7 @@ export function createLoopbackBridge(options) {
523
538
  clock.clearTimeout(agentOwnerExpiry);
524
539
  agentOwnerExpiry = null;
525
540
  agentOwner = null;
541
+ agentClientName = undefined;
526
542
  agentOwnerSeenAt = 0;
527
543
  cancelListener(reason);
528
544
  if (currentRequest?.status === "working") {
@@ -539,6 +555,7 @@ export function createLoopbackBridge(options) {
539
555
  error: { code: "agent_released", message: reason },
540
556
  });
541
557
  }
558
+ broadcastStatus();
542
559
  };
543
560
  const scheduleAgentOwnerExpiry = () => {
544
561
  if (agentOwnerExpiry !== null)
@@ -587,7 +604,8 @@ export function createLoopbackBridge(options) {
587
604
  requireAgentOwner(request, body);
588
605
  const abort = new AbortController();
589
606
  response.once("close", () => abort.abort());
590
- const prompt = await waitForPrompt(abort.signal);
607
+ const clientName = typeof body.agentClientName === "string" ? body.agentClientName.trim().slice(0, 120) : undefined;
608
+ const prompt = await waitForPrompt(abort.signal, undefined, clientName);
591
609
  sendJson(response, 200, prompt, null);
592
610
  };
593
611
  const handleAgentReportStatus = async (request, response) => {
@@ -599,6 +617,14 @@ export function createLoopbackBridge(options) {
599
617
  updateRequestStatus(body.update);
600
618
  sendJson(response, 200, getStatus(), null);
601
619
  };
620
+ const handleAgentActivity = async (request, response) => {
621
+ const body = await readJsonBody(request);
622
+ requireAgentOwner(request, body);
623
+ if (!isAgentActivity(body.activity))
624
+ throw new BridgeRequestError(400, "invalid_activity", "activity must identify a workspace-relative source file.");
625
+ reportActivity(body.activity);
626
+ sendJson(response, 200, { accepted: true }, null);
627
+ };
602
628
  const handleAgentCanvas = async (request, response) => {
603
629
  const body = await readJsonBody(request);
604
630
  requireAgentOwner(request, body);
@@ -645,6 +671,10 @@ export function createLoopbackBridge(options) {
645
671
  await handleAgentReportStatus(request, response);
646
672
  return;
647
673
  }
674
+ if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.activity) {
675
+ await handleAgentActivity(request, response);
676
+ return;
677
+ }
648
678
  if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.canvas) {
649
679
  await handleAgentCanvas(request, response);
650
680
  return;
@@ -661,6 +691,10 @@ export function createLoopbackBridge(options) {
661
691
  await handlePair(request, response);
662
692
  return;
663
693
  }
694
+ if (request.method === "POST" && url.pathname === BRIDGE_ENDPOINTS.takeover) {
695
+ await handleTakeover(request, response);
696
+ return;
697
+ }
664
698
  if (request.method === "GET" && url.pathname === BRIDGE_ENDPOINTS.events) {
665
699
  handleEvents(request, response, url);
666
700
  return;
@@ -731,13 +765,14 @@ export function createLoopbackBridge(options) {
731
765
  pending.reject(new BridgeRequestError(409, "listener_cancelled", reason));
732
766
  broadcastStatus();
733
767
  };
734
- const waitForPrompt = (signal) => {
768
+ const waitForPrompt = (signal, _sessionId, clientName) => {
735
769
  if (lifecycle === "closed")
736
770
  return Promise.reject(new Error("The browser bridge is closed."));
737
771
  if (listener)
738
772
  return Promise.reject(new Error("A listening call is already active for this project."));
739
773
  if (currentRequest?.status === "working")
740
774
  return Promise.reject(new Error("A request is already in flight for this project."));
775
+ agentClientName = clientName?.trim().slice(0, 120) || undefined;
741
776
  // Keep the terminal outcome visible until the agent explicitly rearms its
742
777
  // listening call. Rearming is the lifecycle boundary for the old request.
743
778
  if (currentRequest)
@@ -786,6 +821,14 @@ export function createLoopbackBridge(options) {
786
821
  }
787
822
  broadcastStatus();
788
823
  };
824
+ const reportActivity = (activity) => {
825
+ if (!isAgentActivity(activity))
826
+ throw new TypeError("Invalid agent activity");
827
+ if (!currentRequest || currentRequest.requestId !== activity.requestId || currentRequest.status !== "working") {
828
+ throw new BridgeRequestError(409, "inactive_request", "Activity requires the currently working request.");
829
+ }
830
+ broadcast({ type: "activity", activity });
831
+ };
789
832
  const dispatchCanvasCommand = (input) => {
790
833
  if (lifecycle === "closed")
791
834
  return Promise.reject(new Error("The browser bridge is closed."));
@@ -904,6 +947,7 @@ export function createLoopbackBridge(options) {
904
947
  clock.clearTimeout(agentOwnerExpiry);
905
948
  agentOwnerExpiry = null;
906
949
  agentOwner = null;
950
+ agentClientName = undefined;
907
951
  agentOwnerSeenAt = 0;
908
952
  cancelListener("bridge_closed");
909
953
  if (pendingCanvas) {
@@ -945,6 +989,7 @@ export function createLoopbackBridge(options) {
945
989
  waitForPrompt,
946
990
  cancelListener,
947
991
  updateRequestStatus,
992
+ reportActivity,
948
993
  dispatchCanvasCommand,
949
994
  pairBrowser,
950
995
  disconnectBrowser,
package/dist/cli.d.ts CHANGED
@@ -3,7 +3,7 @@ export interface CliOptions {
3
3
  readonly command: "serve" | "doctor";
4
4
  readonly projectId: string;
5
5
  readonly origin?: string;
6
- readonly workspaceRoot: string;
6
+ readonly workspaceRoot?: string;
7
7
  readonly host: "127.0.0.1" | "::1" | "localhost";
8
8
  readonly port: number;
9
9
  readonly commandTimeoutMs: number;
@@ -32,6 +32,6 @@ export interface PageLaunchDependencies {
32
32
  export declare function openPairedPage(pageUrl: string, dependencies?: PageLaunchDependencies): Promise<boolean>;
33
33
  /** Parses only project-scoped, host-neutral CLI options. */
34
34
  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> Project root supplied to the agent\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";
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";
36
36
  /** Runs the CLI until the stdio MCP connection closes or the process stops. */
37
37
  export declare function runCli(argv?: readonly string[], environment?: CliEnvironment): Promise<void>;
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { basename, resolve } from "node:path";
4
4
  import { defaultBridgePort } from "./internal/agent-protocol.js";
5
- import { discoverProjectSessions } from "./discovery.js";
5
+ import { diagnoseProjectSessions, discoverProjectSessions } from "./discovery.js";
6
6
  import { createAgentCompanion, createDiscoveredAgentAdapter, } from "./server.js";
7
7
  const DEFAULT_PORT = 0;
8
8
  const DEFAULT_COMMAND_TIMEOUT_MS = 5_000;
@@ -73,7 +73,7 @@ export function parseCliArguments(argv = process.argv.slice(2), environment = pr
73
73
  let commandTimeoutMs = environment.NUDGE_UI_COMMAND_TIMEOUT_MS === undefined
74
74
  ? DEFAULT_COMMAND_TIMEOUT_MS
75
75
  : parseInteger(environment.NUDGE_UI_COMMAND_TIMEOUT_MS, "NUDGE_UI_COMMAND_TIMEOUT_MS", 60_000);
76
- let workspaceRoot = root;
76
+ let workspaceRoot = environment.NUDGE_UI_WORKSPACE_ROOT === undefined ? undefined : root;
77
77
  let help = false;
78
78
  let explicitPort = environment.NUDGE_UI_BRIDGE_PORT !== undefined;
79
79
  for (let index = 0; index < argumentsToParse.length; index += 1) {
@@ -133,7 +133,7 @@ running project unless --origin selects the legacy coupled mode.
133
133
  Options:
134
134
  --project-id <id> Stable project pairing identity
135
135
  --origin <origin> Exact app origin required for browser pairing
136
- --workspace-root <path> Project root supplied to the agent
136
+ --workspace-root <path> Optional legacy project restriction; omitted for reusable adapters
137
137
  --host <loopback> 127.0.0.1, ::1, or localhost
138
138
  --port <port> Bridge port; default is deterministic per project, 0 is ephemeral
139
139
  --command-timeout-ms <ms> Canvas acknowledgement timeout
@@ -148,13 +148,19 @@ export async function runCli(argv = process.argv.slice(2), environment = process
148
148
  }
149
149
  if (options.command === "doctor") {
150
150
  const sessions = await discoverProjectSessions(options.workspaceRoot);
151
- const matches = sessions.filter((session) => session.matchesApplication);
151
+ const matches = sessions.filter((session) => options.workspaceRoot === undefined || session.matchesApplication);
152
+ const diagnostics = await diagnoseProjectSessions(options.workspaceRoot);
152
153
  process.stdout.write([
153
- `[nudge-ui] workspace: ${options.workspaceRoot}`,
154
+ `[nudge-ui] registry: ${diagnostics.registryRoot}`,
155
+ `[nudge-ui] workspace: ${diagnostics.workspaceRoot ?? "all projects"}`,
156
+ `[nudge-ui] application: ${diagnostics.applicationRoot ?? "all applications"}`,
157
+ `[nudge-ui] descriptors: ${diagnostics.descriptorCount}; invalid: ${diagnostics.invalidCount}; incompatible: ${diagnostics.incompatibleCount}`,
158
+ `[nudge-ui] matching unreachable descriptors: ${diagnostics.matchingUnreachableCount}`,
159
+ ...diagnostics.guidance.map((message) => `[nudge-ui] ${message}`),
154
160
  `[nudge-ui] live sessions: ${sessions.length}`,
155
161
  `[nudge-ui] matching sessions: ${matches.length}`,
156
162
  ...(matches.length === 0
157
- ? ["[nudge-ui] no matching project bridge is running; start the development server to make Nudge available"]
163
+ ? ["[nudge-ui] no reachable compatible bridge matches; inspect the diagnostics above or start the development server"]
158
164
  : matches.map((session) => {
159
165
  const state = session.status.connection === "working"
160
166
  ? "working"
@@ -195,8 +201,10 @@ export async function runCli(argv = process.argv.slice(2), environment = process
195
201
  }
196
202
  return;
197
203
  }
204
+ process.stderr.write("[nudge-ui] --origin uses deprecated coupled mode. Rerun agent setup to use project-owned bridges and avoid port collisions.\n");
198
205
  const companion = createAgentCompanion({
199
206
  ...options,
207
+ workspaceRoot: options.workspaceRoot ?? resolve(environment.INIT_CWD ?? process.cwd()),
200
208
  onControllerUnavailable: (pageUrl) => openPairedPage(pageUrl).then(() => undefined),
201
209
  });
202
210
  let stopping = false;