@insitue/claude-plugin 0.1.1 → 0.2.0
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/.claude-plugin/plugin.json +1 -1
- package/README.md +180 -61
- package/commands/connect.md +67 -26
- package/dist/mcp-server.js +150 -102
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,92 +1,211 @@
|
|
|
1
|
-
#
|
|
1
|
+
# InSitue Dev — Claude Code plugin
|
|
2
2
|
|
|
3
|
-
Drive a
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Drive a `claude` session from your running app. Pick an element
|
|
4
|
+
in the browser, describe what you want changed, hit Send.
|
|
5
|
+
`claude` reads the file at exactly the right line and proposes
|
|
6
|
+
the edit. No copy-pasting file paths. No fumbling for line
|
|
7
|
+
numbers. The picker IS the prompt.
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
```
|
|
10
|
+
┌────────────────────────────────┐ ┌────────────────────┐
|
|
11
|
+
│ Your app (any dev server) │ │ claude (terminal) │
|
|
12
|
+
│ ┌──────────────────────────┐ │ │ │
|
|
13
|
+
│ │ InSitue widget │ │ │ /insitue:connect │
|
|
14
|
+
│ │ · Pick │ │ pipe │ │
|
|
15
|
+
│ │ · Describe │ ├────────►│ receives pick │
|
|
16
|
+
│ │ · Send │ │ │ + description │
|
|
17
|
+
│ └──────────────────────────┘ │ │ │
|
|
18
|
+
└────────────────────────────────┘ │ → reads file │
|
|
19
|
+
│ → proposes diff │
|
|
20
|
+
│ → awaits approval │
|
|
21
|
+
│ → writes │
|
|
22
|
+
└────────────────────┘
|
|
23
|
+
```
|
|
8
24
|
|
|
9
|
-
|
|
10
|
-
current `claude` session into "watch InSitue" mode.
|
|
11
|
-
- An MCP server (`insitue`) with two tools:
|
|
12
|
-
- `insitue__next_pick` — long-polls until the next pick lands.
|
|
13
|
-
- `insitue__list_recent_picks` — replays recent picks (e.g. things
|
|
14
|
-
you selected before claude attached).
|
|
25
|
+
---
|
|
15
26
|
|
|
16
|
-
|
|
17
|
-
overlay uses (reads `.insitu/session.json` from your project), so
|
|
18
|
-
there's no extra config — start `insitue dev`, start `claude`, run
|
|
19
|
-
`/insitue:connect`, click in the browser.
|
|
27
|
+
## Setup (60 seconds, one-time)
|
|
20
28
|
|
|
21
|
-
|
|
29
|
+
### 1. Install the plugin
|
|
22
30
|
|
|
23
|
-
|
|
31
|
+
In any `claude` session:
|
|
24
32
|
|
|
25
33
|
```
|
|
26
34
|
/plugin marketplace add InSitue/insitue
|
|
27
35
|
/plugin install insitue@insitue-plugins
|
|
28
36
|
```
|
|
29
37
|
|
|
30
|
-
The
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
That's it for the plugin side. The MCP server it ships will
|
|
39
|
+
auto-start the InSitue companion process in the background of
|
|
40
|
+
your `claude` session — no separate terminal to babysit.
|
|
33
41
|
|
|
34
|
-
###
|
|
42
|
+
### 2. Mount the widget in your app
|
|
43
|
+
|
|
44
|
+
Install the SDK:
|
|
35
45
|
|
|
36
46
|
```bash
|
|
37
|
-
|
|
47
|
+
npm install -D @insitue/sdk
|
|
48
|
+
# or pnpm add -D / yarn add -D
|
|
38
49
|
```
|
|
39
50
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
MCP server config directly. The `/plugin install` flow above is the
|
|
43
|
-
canonical path for Claude Code.)
|
|
51
|
+
Add one line to your app's dev mount. Next.js / Vite / Remix
|
|
52
|
+
— any framework with a React tree works.
|
|
44
53
|
|
|
45
|
-
|
|
54
|
+
**Next.js (app/layout.tsx):**
|
|
46
55
|
|
|
47
|
-
|
|
56
|
+
```tsx
|
|
57
|
+
import { InSitueCapture } from "@insitue/sdk";
|
|
48
58
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
60
|
+
return (
|
|
61
|
+
<html>
|
|
62
|
+
<body>
|
|
63
|
+
{children}
|
|
64
|
+
{process.env.NODE_ENV !== "production" && <InSitueCapture />}
|
|
65
|
+
</body>
|
|
66
|
+
</html>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
```
|
|
52
70
|
|
|
53
|
-
|
|
54
|
-
npx insitue dev
|
|
71
|
+
**Vite (src/main.tsx):**
|
|
55
72
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
73
|
+
```tsx
|
|
74
|
+
import { InSitueCapture } from "@insitue/sdk";
|
|
75
|
+
|
|
76
|
+
createRoot(document.getElementById("root")!).render(
|
|
77
|
+
<StrictMode>
|
|
78
|
+
<App />
|
|
79
|
+
{import.meta.env.DEV && <InSitueCapture />}
|
|
80
|
+
</StrictMode>,
|
|
81
|
+
);
|
|
59
82
|
```
|
|
60
83
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
84
|
+
With no `projectKey`, the widget connects to the local
|
|
85
|
+
companion. (Pass `projectKey` and it ships to InSitue Cloud
|
|
86
|
+
instead — same widget, different sink. See
|
|
87
|
+
[`@insitue/sdk`](https://www.npmjs.com/package/@insitue/sdk).)
|
|
65
88
|
|
|
66
|
-
|
|
89
|
+
---
|
|
67
90
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
└───────────────────┘ │
|
|
80
|
-
┌─────────┴───────┐
|
|
81
|
-
│ claude (CLI) │
|
|
82
|
-
│ /insitue:connect│
|
|
83
|
-
└─────────────────┘
|
|
91
|
+
## Use it
|
|
92
|
+
|
|
93
|
+
Two terminals. That's it.
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# Terminal A — your normal dev server, any port
|
|
97
|
+
pnpm dev # or `npm run dev`, `next dev`, `vite`, whatever
|
|
98
|
+
|
|
99
|
+
# Terminal B — claude, in the project root
|
|
100
|
+
claude
|
|
101
|
+
> /insitue:connect
|
|
84
102
|
```
|
|
85
103
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
104
|
+
Then in your browser:
|
|
105
|
+
|
|
106
|
+
1. Look for the **InSitue Dev** pill in the bottom-right corner.
|
|
107
|
+
2. Click it. The picker activates — hover any element on the
|
|
108
|
+
page to highlight it.
|
|
109
|
+
3. Click the element you want to change.
|
|
110
|
+
4. The panel pops up with the target, a screenshot, and a
|
|
111
|
+
focused textbox.
|
|
112
|
+
5. Type your instruction: *"make the padding bigger"*, *"this
|
|
113
|
+
should be red on hover"*, *"swap these two for me"* —
|
|
114
|
+
anything.
|
|
115
|
+
6. Press **Enter** (or click *Send to claude*).
|
|
116
|
+
|
|
117
|
+
Back in your terminal, claude has the pick + your description
|
|
118
|
+
and starts working. It'll show you the diff and wait for "yes"
|
|
119
|
+
before writing. After it writes, your dev server's HMR picks up
|
|
120
|
+
the change and you see it live in the browser. Pick the next
|
|
121
|
+
thing.
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## What gets shipped to claude
|
|
126
|
+
|
|
127
|
+
Every pick that claude receives includes:
|
|
128
|
+
|
|
129
|
+
| Field | What it is |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `source.file` + `source.line` | Resolved JSX site (via React fiber `_debugSource` or the `@insitue/sdk/babel` plugin) |
|
|
132
|
+
| `target` | Component name (e.g. `HubHeroPoster`) or selector fallback |
|
|
133
|
+
| `componentStack` | Top-down owner chain |
|
|
134
|
+
| `userNote` | Your typed description |
|
|
135
|
+
| `screenshot` | A real bitmap of what you picked |
|
|
136
|
+
| `runtime` | URL, recent console/network/errors |
|
|
137
|
+
|
|
138
|
+
The widget **refuses to send a pick** whose source can't be
|
|
139
|
+
resolved (selector-only confidence). You get an inline
|
|
140
|
+
"couldn't resolve the source file — try a parent" prompt,
|
|
141
|
+
and claude never gets a useless tip.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Troubleshooting
|
|
146
|
+
|
|
147
|
+
**The pill says "InSitue Dev · offline"**
|
|
148
|
+
The companion isn't up yet — check the `claude` terminal for
|
|
149
|
+
output from `[insitue-mcp]` or `[companion]`. First run can take
|
|
150
|
+
~10 seconds while `npx` downloads the package. After that, it's
|
|
151
|
+
instant.
|
|
152
|
+
|
|
153
|
+
**`/insitue:connect` says the plugin isn't installed**
|
|
154
|
+
Run `/plugin marketplace update insitue-plugins` then `/plugin
|
|
155
|
+
install insitue@insitue-plugins` again. Restart claude (`/exit`,
|
|
156
|
+
then `claude`) so the MCP server reloads with the new version.
|
|
157
|
+
|
|
158
|
+
**Picks land but claude doesn't act**
|
|
159
|
+
Verify with `mcp__insitue__list_recent_picks` inside the claude
|
|
160
|
+
session — that confirms the bridge is delivering. If they're
|
|
161
|
+
there but ignored, you may have closed the `/insitue:connect`
|
|
162
|
+
loop. Restart it.
|
|
163
|
+
|
|
164
|
+
**I want to run the companion myself**
|
|
165
|
+
You can — `npx @insitue/companion@latest dev` in any terminal.
|
|
166
|
+
The MCP server detects an existing companion at
|
|
167
|
+
`.insitu/session.json` and reuses it instead of spawning its
|
|
168
|
+
own. Use this when you want to see the companion's logs
|
|
169
|
+
directly, or for debugging.
|
|
170
|
+
|
|
171
|
+
**It's still not working**
|
|
172
|
+
Open an issue at <https://github.com/InSitue/insitue/issues>
|
|
173
|
+
with the contents of `.insitu/session.json` and the last ~20
|
|
174
|
+
lines from the `claude` transcript. The MCP server logs
|
|
175
|
+
extensively to stderr; claude surfaces them in the transcript.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Architecture (skip unless curious)
|
|
180
|
+
|
|
181
|
+
The plugin is a stdio MCP server that:
|
|
182
|
+
|
|
183
|
+
1. On startup, reads `${CLAUDE_PROJECT_DIR}/.insitu/session.json`
|
|
184
|
+
to find a running companion. If one's alive, reuse it.
|
|
185
|
+
2. Otherwise spawns `npx -y @insitue/companion@latest dev` as a
|
|
186
|
+
child process, polls for the new `session.json` to appear,
|
|
187
|
+
then connects.
|
|
188
|
+
3. Subscribes to the companion's WS broadcast channel. Every
|
|
189
|
+
pick the browser sends arrives here.
|
|
190
|
+
4. Exposes two MCP tools:
|
|
191
|
+
- `insitue__next_pick` — long-polls until a pick lands
|
|
192
|
+
(default 5 min). Returns target + source + screenshot +
|
|
193
|
+
userNote.
|
|
194
|
+
- `insitue__list_recent_picks` — buffered picks since the
|
|
195
|
+
server started.
|
|
196
|
+
5. Auto-reconnects if the companion restarts (HMR, manual
|
|
197
|
+
stop). The widget reconnects too.
|
|
198
|
+
6. Cleans up on `process.exit` / `SIGTERM` — kills only the
|
|
199
|
+
companion it spawned, leaves user-started companions
|
|
200
|
+
untouched.
|
|
201
|
+
|
|
202
|
+
The bridge **never writes files**. Claude does, via its native
|
|
203
|
+
Edit tool. This keeps the InSitue trust boundary clean: the
|
|
204
|
+
companion is the only thing that touches fs, and only after
|
|
205
|
+
the user has approved a proposal in the terminal.
|
|
206
|
+
|
|
207
|
+
---
|
|
89
208
|
|
|
90
209
|
## License
|
|
91
210
|
|
|
92
|
-
MIT
|
|
211
|
+
MIT. Same as the rest of InSitue.
|
package/commands/connect.md
CHANGED
|
@@ -1,38 +1,79 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
2
|
+
description: Drive this Claude Code session from the InSitue browser overlay — pick + describe in the browser, claude acts in the terminal.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
# /insitue:connect
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Connects this session to the local InSitue companion. The user
|
|
8
|
+
picks an element in their app, types a description in the
|
|
9
|
+
InSitue panel, clicks Send — and you receive the pick (file,
|
|
10
|
+
line, component, screenshot) plus the description here, ready to
|
|
11
|
+
act on.
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
The companion auto-starts when this MCP server boots. You do
|
|
14
|
+
not need to ask the user to run any extra commands.
|
|
10
15
|
|
|
11
|
-
|
|
12
|
-
2. The user clicks **Select** in the InSitue browser overlay, then picks an element.
|
|
13
|
-
3. Claude (you) calls `mcp__insitue__next_pick` — blocks until a pick lands.
|
|
14
|
-
4. The tool returns the resolved `file:line`, component name, the user's note (what they typed in the panel), and surrounding context.
|
|
15
|
-
5. You read the file at the returned path, propose the edit, show the diff, and ask the user **here in chat** to approve before writing.
|
|
16
|
-
6. After applying, you call `next_pick` again and the loop continues.
|
|
16
|
+
## Your behaviour
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
1. Call `mcp__insitue__list_recent_picks` once. If there are
|
|
19
|
+
any picks the user made before you attached, summarise them
|
|
20
|
+
("you picked X but haven't sent a description yet — make sure
|
|
21
|
+
to click Send in the InSitue panel"). Otherwise just say
|
|
22
|
+
"Connected. Pick something in the browser when you're ready."
|
|
23
|
+
2. Enter the loop: call `mcp__insitue__next_pick`. It long-polls
|
|
24
|
+
(~5 min default). When it returns with `status: "ok"`:
|
|
25
|
+
- **`pick.userNote`** is the user's instruction. Treat it as
|
|
26
|
+
the prompt.
|
|
27
|
+
- **`pick.source.file:line`** is where to act. Read the file
|
|
28
|
+
around that line for context (Read tool — give it 30-40
|
|
29
|
+
lines of surrounding code).
|
|
30
|
+
- **`pick.confidence`**: if `"approximate"`, warn the user
|
|
31
|
+
before editing — the line might point at an owning
|
|
32
|
+
component, not the exact JSX site. If `"selector-only"`,
|
|
33
|
+
refuse: tell them the InSitue widget should refuse this
|
|
34
|
+
case already, and ask them to re-pick a parent.
|
|
35
|
+
- Propose the edit with a clear diff in this chat. Wait for
|
|
36
|
+
the user to say "yes" / "approve" / "go" before writing.
|
|
37
|
+
Don't auto-apply.
|
|
38
|
+
- On approval, write with the Edit tool. Confirm what
|
|
39
|
+
changed.
|
|
40
|
+
- Loop back to `next_pick`.
|
|
41
|
+
3. If `next_pick` returns `status: "timeout"`, the user simply
|
|
42
|
+
hasn't picked anything yet. Stay quiet and call `next_pick`
|
|
43
|
+
again.
|
|
44
|
+
4. If a pick comes back with `target` starting with
|
|
45
|
+
`[insitue]` (e.g. "companion disconnected"), tell the user
|
|
46
|
+
what happened in one sentence and call `next_pick` again —
|
|
47
|
+
the bridge auto-reconnects.
|
|
48
|
+
5. Exit the loop when the user says "stop", "done", "quit",
|
|
49
|
+
"thanks", "exit", or anything else that clearly ends the
|
|
50
|
+
session.
|
|
19
51
|
|
|
20
|
-
|
|
21
|
-
- **Then enter the loop**: call `mcp__insitue__next_pick`. It blocks for ~5 min by default. When it returns:
|
|
22
|
-
- If `status: "ok"`:
|
|
23
|
-
- **If `pick.userNote` is set**: the user typed an instruction in the browser panel before sending. Read the `pick.source.file` at `pick.source.line`, propose an edit, show the diff, wait for "approve"/"yes" before writing.
|
|
24
|
-
- **If `pick.userNote` is null** (user clicked without typing): the user might still be about to type in the browser panel — they have an ASK textbox in the panel that streams here as a `broadcast-ask` event. **Call `next_pick` AGAIN with a 30-second timeout** to wait for the follow-up note. The MCP bridge re-delivers the same pick with `userNote` populated once the user sends it. If that second call returns `status: "timeout"` instead, ONLY THEN fall back to asking "What would you like to change at `<componentName>` (`<file>:<line>`)?" in the terminal.
|
|
25
|
-
- Propose an edit. Show a small diff in chat. Wait for the user to say "go" / "approve" / "yes" before writing.
|
|
26
|
-
- On approval, apply with Edit/Write. Confirm what changed.
|
|
27
|
-
- Loop back to `next_pick`.
|
|
28
|
-
- **If a pick comes through with `target` starting with `[insitue]`**: the companion disconnected (HMR / restart). Tell the user, then call `next_pick` again — the bridge auto-reconnects.
|
|
29
|
-
- **Exit the loop** when the user says "stop", "done", "quit", or similar.
|
|
52
|
+
## Guardrails
|
|
30
53
|
|
|
31
|
-
|
|
54
|
+
- **One pick = one terminal-controlled edit.** Don't take
|
|
55
|
+
initiative across files unless the user explicitly asks for
|
|
56
|
+
cross-file changes.
|
|
57
|
+
- **Never auto-apply.** Every write is gated by explicit user
|
|
58
|
+
approval in this terminal.
|
|
59
|
+
- **Trust the user's intent.** The `userNote` is their
|
|
60
|
+
instruction. Don't reinterpret it as "the user wants to
|
|
61
|
+
discuss" — they want a code change.
|
|
62
|
+
- **Cite where you read from.** When you propose an edit, name
|
|
63
|
+
the file and the lines you read (Read returns line numbers
|
|
64
|
+
via the cat -n format).
|
|
65
|
+
- **Defer extras.** If the change you'd make requires touching
|
|
66
|
+
many files / refactoring broadly, propose the surgical edit
|
|
67
|
+
first and ask "want me to do X too?" rather than bundling
|
|
68
|
+
silently.
|
|
32
69
|
|
|
33
|
-
##
|
|
70
|
+
## Failure modes to handle gracefully
|
|
34
71
|
|
|
35
|
-
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
72
|
+
- **`source.file` doesn't exist**: tell the user the path the
|
|
73
|
+
pick resolved to and ask if they're in the right project
|
|
74
|
+
directory. The MCP server reads `.insitu/session.json` from
|
|
75
|
+
the cwd `claude` was started in.
|
|
76
|
+
- **The edit doesn't HMR cleanly**: surface the build error in
|
|
77
|
+
chat (run `cat` or relevant logs if you can find them); don't
|
|
78
|
+
pretend the change "applied" if the dev server is broken.
|
|
79
|
+
- **Approval was unclear**: ask. Don't write on ambiguity.
|
package/dist/mcp-server.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// src/mcp-server.ts
|
|
4
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { spawn } from "child_process";
|
|
6
7
|
import { existsSync, readFileSync } from "fs";
|
|
8
|
+
import { request as httpRequest } from "http";
|
|
7
9
|
import { dirname, join, resolve } from "path";
|
|
8
10
|
import WebSocket from "ws";
|
|
9
11
|
import { z } from "zod";
|
|
@@ -16,8 +18,10 @@ function findSession(start = process.cwd()) {
|
|
|
16
18
|
const candidate = join(dir, ".insitu", "session.json");
|
|
17
19
|
if (existsSync(candidate)) {
|
|
18
20
|
try {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
+
const session2 = JSON.parse(
|
|
22
|
+
readFileSync(candidate, "utf8")
|
|
23
|
+
);
|
|
24
|
+
return { dir, session: session2 };
|
|
21
25
|
} catch {
|
|
22
26
|
return null;
|
|
23
27
|
}
|
|
@@ -41,7 +45,10 @@ function summariseBundle(raw) {
|
|
|
41
45
|
source: raw.resolved?.file ? {
|
|
42
46
|
file: raw.resolved.file,
|
|
43
47
|
...raw.resolved.line ? { line: raw.resolved.line } : {}
|
|
44
|
-
} : t?.source ? {
|
|
48
|
+
} : t?.source ? {
|
|
49
|
+
file: t.source.file,
|
|
50
|
+
...t.source.line ? { line: t.source.line } : {}
|
|
51
|
+
} : null,
|
|
45
52
|
confidence: raw.resolved?.confidence ?? t?.confidence ?? "unknown",
|
|
46
53
|
target,
|
|
47
54
|
selector: t?.selector ?? null,
|
|
@@ -53,74 +60,20 @@ function summariseBundle(raw) {
|
|
|
53
60
|
var PickBuffer = class {
|
|
54
61
|
picks = [];
|
|
55
62
|
waiters = [];
|
|
56
|
-
/** Last pick id handed out via `next_pick` — defends against
|
|
57
|
-
* re-delivering the same pick across reconnects. */
|
|
58
|
-
lastDelivered = null;
|
|
59
63
|
push(p) {
|
|
60
64
|
this.picks.push(p);
|
|
61
65
|
if (this.picks.length > MAX_BUFFERED_PICKS) {
|
|
62
66
|
this.picks.shift();
|
|
63
67
|
}
|
|
64
|
-
this.flushWaiters();
|
|
65
|
-
}
|
|
66
|
-
/** #162: merge a `broadcast-ask` into the matching `pick.userNote`.
|
|
67
|
-
* Two ordering modes the user might create:
|
|
68
|
-
* 1. Pick first (auto on click), THEN type + Send → ask lands
|
|
69
|
-
* AFTER pick. If the pick is still queued (not yet delivered
|
|
70
|
-
* to claude), update it in place. If already delivered, the
|
|
71
|
-
* ask arrives as a standalone update — surfaced as a synthetic
|
|
72
|
-
* pick whose `userNote` is set so claude treats it as new
|
|
73
|
-
* input on the most-recent target.
|
|
74
|
-
* 2. Ask before pick is impossible (the panel needs a pick first
|
|
75
|
-
* to enable the ASK textbox), so we don't handle that. */
|
|
76
|
-
attachAsk(bundleId, text) {
|
|
77
|
-
const idx = this.picks.findIndex((p) => p.id === bundleId);
|
|
78
|
-
if (idx >= 0) {
|
|
79
|
-
this.picks[idx] = { ...this.picks[idx], userNote: text };
|
|
80
|
-
this.flushWaiters();
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
if (this.lastDelivered === bundleId && this.lastDeliveredPick) {
|
|
84
|
-
this.picks.push({
|
|
85
|
-
...this.lastDeliveredPick,
|
|
86
|
-
userNote: text,
|
|
87
|
-
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
88
|
-
});
|
|
89
|
-
this.flushWaiters();
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
const orphan = {
|
|
93
|
-
id: bundleId,
|
|
94
|
-
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
95
|
-
source: null,
|
|
96
|
-
confidence: "n/a",
|
|
97
|
-
target: "[insitue] note without pick",
|
|
98
|
-
selector: null,
|
|
99
|
-
userNote: text,
|
|
100
|
-
url: null,
|
|
101
|
-
componentStack: []
|
|
102
|
-
};
|
|
103
|
-
this.picks.push(orphan);
|
|
104
|
-
this.flushWaiters();
|
|
105
|
-
}
|
|
106
|
-
flushWaiters() {
|
|
107
68
|
while (this.waiters.length && this.picks.length) {
|
|
108
69
|
const w = this.waiters.shift();
|
|
109
70
|
clearTimeout(w.timer);
|
|
110
|
-
|
|
111
|
-
this.lastDelivered = next.id;
|
|
112
|
-
this.lastDeliveredPick = next;
|
|
113
|
-
w.resolve(next);
|
|
71
|
+
w.resolve(this.picks.shift());
|
|
114
72
|
}
|
|
115
73
|
}
|
|
116
|
-
lastDeliveredPick = null;
|
|
117
|
-
/** Resolve with the next pick to land OR null on timeout. */
|
|
118
74
|
next(timeoutMs) {
|
|
119
75
|
if (this.picks.length) {
|
|
120
|
-
|
|
121
|
-
this.lastDelivered = next.id;
|
|
122
|
-
this.lastDeliveredPick = next;
|
|
123
|
-
return Promise.resolve(next);
|
|
76
|
+
return Promise.resolve(this.picks.shift());
|
|
124
77
|
}
|
|
125
78
|
return new Promise((resolve2) => {
|
|
126
79
|
const timer = setTimeout(() => {
|
|
@@ -134,8 +87,8 @@ var PickBuffer = class {
|
|
|
134
87
|
recent(limit) {
|
|
135
88
|
return this.picks.slice(-limit);
|
|
136
89
|
}
|
|
137
|
-
/**
|
|
138
|
-
*
|
|
90
|
+
/** WS reconnects — drop pending waiters with a sentinel so claude
|
|
91
|
+
* sees the disruption instead of hanging forever. */
|
|
139
92
|
rejectAll(reason) {
|
|
140
93
|
for (const w of this.waiters) {
|
|
141
94
|
clearTimeout(w.timer);
|
|
@@ -144,7 +97,7 @@ var PickBuffer = class {
|
|
|
144
97
|
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
145
98
|
source: null,
|
|
146
99
|
confidence: "n/a",
|
|
147
|
-
target: `[
|
|
100
|
+
target: `[insitue] ${reason}`,
|
|
148
101
|
selector: null,
|
|
149
102
|
userNote: null,
|
|
150
103
|
url: null,
|
|
@@ -154,22 +107,120 @@ var PickBuffer = class {
|
|
|
154
107
|
this.waiters.length = 0;
|
|
155
108
|
}
|
|
156
109
|
};
|
|
110
|
+
async function probeCompanion(session2) {
|
|
111
|
+
try {
|
|
112
|
+
process.kill(session2.pid, 0);
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
return new Promise((resolve2) => {
|
|
117
|
+
const req = httpRequest(
|
|
118
|
+
{
|
|
119
|
+
host: "127.0.0.1",
|
|
120
|
+
port: session2.port,
|
|
121
|
+
path: "/insitu/handshake",
|
|
122
|
+
method: "GET",
|
|
123
|
+
timeout: 1500
|
|
124
|
+
},
|
|
125
|
+
(res) => {
|
|
126
|
+
res.resume();
|
|
127
|
+
resolve2(true);
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
req.on("error", () => resolve2(false));
|
|
131
|
+
req.on("timeout", () => {
|
|
132
|
+
req.destroy();
|
|
133
|
+
resolve2(false);
|
|
134
|
+
});
|
|
135
|
+
req.end();
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
var ownedChild = null;
|
|
139
|
+
async function ensureCompanion() {
|
|
140
|
+
const existing = findSession();
|
|
141
|
+
if (existing && await probeCompanion(existing.session)) {
|
|
142
|
+
process.stderr.write(
|
|
143
|
+
`[insitue-mcp] reusing companion at :${existing.session.port} (pid ${existing.session.pid})
|
|
144
|
+
`
|
|
145
|
+
);
|
|
146
|
+
return existing.session;
|
|
147
|
+
}
|
|
148
|
+
process.stderr.write(
|
|
149
|
+
"[insitue-mcp] starting companion via `npx -y @insitue/companion@latest dev`\u2026\n"
|
|
150
|
+
);
|
|
151
|
+
ownedChild = spawn(
|
|
152
|
+
"npx",
|
|
153
|
+
["-y", "@insitue/companion@latest", "dev"],
|
|
154
|
+
{
|
|
155
|
+
cwd: process.cwd(),
|
|
156
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
157
|
+
env: process.env
|
|
158
|
+
}
|
|
159
|
+
);
|
|
160
|
+
ownedChild.stdout?.on("data", (chunk) => {
|
|
161
|
+
process.stderr.write(`[companion] ${chunk.toString()}`);
|
|
162
|
+
});
|
|
163
|
+
ownedChild.stderr?.on("data", (chunk) => {
|
|
164
|
+
process.stderr.write(`[companion err] ${chunk.toString()}`);
|
|
165
|
+
});
|
|
166
|
+
ownedChild.on("exit", (code, signal) => {
|
|
167
|
+
process.stderr.write(
|
|
168
|
+
`[insitue-mcp] companion exited (code=${code} signal=${signal})
|
|
169
|
+
`
|
|
170
|
+
);
|
|
171
|
+
ownedChild = null;
|
|
172
|
+
});
|
|
173
|
+
const start = Date.now();
|
|
174
|
+
while (Date.now() - start < 5e3) {
|
|
175
|
+
const found = findSession();
|
|
176
|
+
if (found && await probeCompanion(found.session)) {
|
|
177
|
+
return found.session;
|
|
178
|
+
}
|
|
179
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
180
|
+
}
|
|
181
|
+
process.stderr.write(
|
|
182
|
+
"[insitue-mcp] companion didn't come up in 5s \u2014 see [companion] / [companion err] above\n"
|
|
183
|
+
);
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
function cleanupOwnedChild() {
|
|
187
|
+
if (!ownedChild) return;
|
|
188
|
+
const child = ownedChild;
|
|
189
|
+
ownedChild = null;
|
|
190
|
+
try {
|
|
191
|
+
child.kill("SIGTERM");
|
|
192
|
+
} catch {
|
|
193
|
+
}
|
|
194
|
+
setTimeout(() => {
|
|
195
|
+
try {
|
|
196
|
+
child.kill("SIGKILL");
|
|
197
|
+
} catch {
|
|
198
|
+
}
|
|
199
|
+
}, 500).unref();
|
|
200
|
+
}
|
|
201
|
+
process.on("exit", cleanupOwnedChild);
|
|
202
|
+
process.on("SIGINT", () => {
|
|
203
|
+
cleanupOwnedChild();
|
|
204
|
+
process.exit(130);
|
|
205
|
+
});
|
|
206
|
+
process.on("SIGTERM", () => {
|
|
207
|
+
cleanupOwnedChild();
|
|
208
|
+
process.exit(143);
|
|
209
|
+
});
|
|
157
210
|
var buffer = new PickBuffer();
|
|
158
|
-
function connectToCompanion(
|
|
159
|
-
const url = `ws://127.0.0.1:${
|
|
211
|
+
function connectToCompanion(session2) {
|
|
212
|
+
const url = `ws://127.0.0.1:${session2.port}/insitu/cli`;
|
|
160
213
|
const ws = new WebSocket(url, {
|
|
161
|
-
headers: { "user-agent": "insitue-claude-plugin
|
|
214
|
+
headers: { "user-agent": "insitue-claude-plugin" }
|
|
162
215
|
});
|
|
163
216
|
ws.on("open", () => {
|
|
164
217
|
ws.send(
|
|
165
218
|
JSON.stringify({
|
|
166
219
|
t: "hello",
|
|
167
|
-
// Pin to the
|
|
168
|
-
// when the wire format breaks
|
|
169
|
-
// v5 added broadcast-ask + subscribers-attached + agent-
|
|
170
|
-
// ask-external for the external-claude routing flow.
|
|
220
|
+
// Pin to the companion's pinned protocol version. Bump
|
|
221
|
+
// when the wire format breaks.
|
|
171
222
|
protocolVersion: 5,
|
|
172
|
-
token:
|
|
223
|
+
token: session2.token
|
|
173
224
|
})
|
|
174
225
|
);
|
|
175
226
|
});
|
|
@@ -180,54 +231,54 @@ function connectToCompanion(session) {
|
|
|
180
231
|
} catch {
|
|
181
232
|
return;
|
|
182
233
|
}
|
|
183
|
-
if (m && typeof m === "object"
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const ask = m;
|
|
189
|
-
if (typeof ask.bundleId === "string" && typeof ask.text === "string") {
|
|
190
|
-
buffer.attachAsk(ask.bundleId, ask.text);
|
|
234
|
+
if (m && typeof m === "object") {
|
|
235
|
+
const tag = m.t;
|
|
236
|
+
if (tag === "hello-ok") {
|
|
237
|
+
ws.send(JSON.stringify({ t: "subscribe" }));
|
|
238
|
+
return;
|
|
191
239
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
240
|
+
if (tag === "broadcast-capture") {
|
|
241
|
+
try {
|
|
242
|
+
buffer.push(
|
|
243
|
+
summariseBundle(m)
|
|
244
|
+
);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
process.stderr.write(
|
|
247
|
+
`[insitue-mcp] dropped malformed pick: ${err.message}
|
|
200
248
|
`
|
|
201
|
-
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
202
252
|
}
|
|
253
|
+
if (tag === "broadcast-ask") return;
|
|
203
254
|
}
|
|
204
255
|
});
|
|
205
256
|
ws.on("close", () => {
|
|
206
|
-
buffer.rejectAll("companion disconnected \u2014 restart `
|
|
207
|
-
setTimeout(() => connectToCompanion(
|
|
257
|
+
buffer.rejectAll("companion disconnected \u2014 restart `claude` to reconnect");
|
|
258
|
+
setTimeout(() => connectToCompanion(session2), 2e3);
|
|
208
259
|
});
|
|
209
260
|
ws.on("error", () => {
|
|
210
261
|
});
|
|
211
262
|
}
|
|
212
|
-
var
|
|
213
|
-
if (!
|
|
263
|
+
var session = await ensureCompanion();
|
|
264
|
+
if (!session) {
|
|
214
265
|
process.stderr.write(
|
|
215
|
-
"[insitue-mcp] no
|
|
266
|
+
"[insitue-mcp] no companion available \u2014 `next_pick` will time out.\n"
|
|
216
267
|
);
|
|
217
|
-
|
|
268
|
+
} else {
|
|
269
|
+
connectToCompanion(session);
|
|
218
270
|
}
|
|
219
|
-
connectToCompanion(found.session);
|
|
220
271
|
var server = new McpServer({
|
|
221
272
|
name: "insitue",
|
|
222
|
-
version: "0.0
|
|
273
|
+
version: "0.2.0"
|
|
223
274
|
});
|
|
224
275
|
server.registerTool(
|
|
225
276
|
"next_pick",
|
|
226
277
|
{
|
|
227
|
-
description:
|
|
278
|
+
description: "Long-polls until the user clicks Send in the InSitue browser overlay. Returns the pick (target, source file:line, screenshot) plus the user's typed description (`userNote`). Picks arrive complete \u2014 no separate ask event. Use in a loop: call \u2192 read `pick.source.file` around `pick.source.line` \u2192 propose an edit \u2192 wait for terminal approval \u2192 apply \u2192 loop.",
|
|
228
279
|
inputSchema: {
|
|
229
280
|
timeout_ms: z.number().int().positive().max(NEXT_PICK_MAX_TIMEOUT_MS).optional().describe(
|
|
230
|
-
`
|
|
281
|
+
`Long-poll timeout in ms. Default ${NEXT_PICK_DEFAULT_TIMEOUT_MS}; max ${NEXT_PICK_MAX_TIMEOUT_MS}.`
|
|
231
282
|
)
|
|
232
283
|
}
|
|
233
284
|
},
|
|
@@ -246,10 +297,7 @@ server.registerTool(
|
|
|
246
297
|
}
|
|
247
298
|
return {
|
|
248
299
|
content: [
|
|
249
|
-
{
|
|
250
|
-
type: "text",
|
|
251
|
-
text: JSON.stringify({ status: "ok", pick })
|
|
252
|
-
}
|
|
300
|
+
{ type: "text", text: JSON.stringify({ status: "ok", pick }) }
|
|
253
301
|
]
|
|
254
302
|
};
|
|
255
303
|
}
|
|
@@ -257,7 +305,7 @@ server.registerTool(
|
|
|
257
305
|
server.registerTool(
|
|
258
306
|
"list_recent_picks",
|
|
259
307
|
{
|
|
260
|
-
description: "Returns up to N most-recent picks buffered since
|
|
308
|
+
description: "Returns up to N most-recent picks buffered since this MCP server started. Use once at session start (e.g. on /insitue:connect) so the user can see if any picks slipped through before claude attached.",
|
|
261
309
|
inputSchema: {
|
|
262
310
|
limit: z.number().int().positive().max(MAX_BUFFERED_PICKS).optional().describe(`Max picks to return (1..${MAX_BUFFERED_PICKS}). Default 10.`)
|
|
263
311
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@insitue/claude-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Drive a Claude Code session from the InSitue browser overlay — pick an element in your app, claude reads the file and proposes the edit.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|