@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 +103 -75
- package/dist/adapterRouting.d.ts +16 -0
- package/dist/adapterRouting.js +59 -0
- package/dist/bridge.d.ts +4 -2
- package/dist/bridge.js +51 -6
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +14 -6
- package/dist/cli.mjs +319 -69
- package/dist/discovery.d.ts +22 -3
- package/dist/discovery.js +62 -10
- package/dist/internal/agent-protocol.d.ts +19 -2
- package/dist/internal/agent-protocol.js +13 -0
- package/dist/projectSessionClient.d.ts +8 -1
- package/dist/projectSessionClient.js +18 -4
- package/dist/protocol.d.ts +4 -2
- package/dist/protocol.js +3 -1
- package/dist/server.d.ts +3 -2
- package/dist/server.js +98 -34
- package/dist/sessionRegistry.d.ts +10 -1
- package/dist/sessionRegistry.js +48 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,99 +1,127 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @nudge-ui/mcp
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
+
If Nudge UI is already installed, run this from the application directory:
|
|
28
22
|
|
|
29
23
|
```sh
|
|
30
|
-
|
|
24
|
+
npx nudge-ui agent setup
|
|
31
25
|
```
|
|
32
26
|
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
30
|
+
```sh
|
|
31
|
+
npm create nudge-ui@latest -- --agent-only
|
|
32
|
+
```
|
|
37
33
|
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
console.log(runtime.browser.bridgeUrl, runtime.browser.projectId);
|
|
95
|
+
Run diagnostics from the app directory:
|
|
54
96
|
|
|
55
|
-
|
|
97
|
+
```sh
|
|
98
|
+
npx nudge-ui agent doctor
|
|
56
99
|
```
|
|
57
100
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
owns its browser bridge:
|
|
114
|
+
## Custom integrations
|
|
86
115
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
95
|
-
framework integrations should use the project-owned bridge.
|
|
121
|
+
## Links
|
|
96
122
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
|
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
|
|
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>
|
|
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>
|
|
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]
|
|
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
|
|
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;
|