@insitue/claude-plugin 0.1.1 → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "insitue",
3
- "version": "0.1.1",
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
  "mcpServers": {
6
6
  "insitue": {
package/README.md CHANGED
@@ -1,92 +1,211 @@
1
- # @insitue/claude-plugin
1
+ # InSitue Dev — Claude Code plugin
2
2
 
3
- Drive a Claude Code session from the InSitue browser overlay. Pick an
4
- element in your running app, claude reads the file and proposes the
5
- edit no copy/paste of file paths, no typing line numbers.
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
- ## What you get
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
- - A namespaced slash command **`/insitue:connect`** that puts the
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
- The bridge connects to the same loopback companion the browser
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
- ## Install
29
+ ### 1. Install the plugin
22
30
 
23
- Inside an interactive `claude` session:
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 marketplace lives at the InSitue monorepo
31
- (`github.com/InSitue/insitue`). The plugin is cached locally after
32
- install; refresh with `/plugin marketplace update insitue-plugins`.
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
- ### Local development
42
+ ### 2. Mount the widget in your app
43
+
44
+ Install the SDK:
35
45
 
36
46
  ```bash
37
- claude --plugin-dir /absolute/path/to/packages/claude-plugin
47
+ npm install -D @insitue/sdk
48
+ # or pnpm add -D / yarn add -D
38
49
  ```
39
50
 
40
- (npm is also wired up `npx @insitue/claude-plugin` runs the MCP
41
- server standalone for Claude Desktop / other clients that take an
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
- ## Use
54
+ **Next.js (app/layout.tsx):**
46
55
 
47
- Three terminals:
56
+ ```tsx
57
+ import { InSitueCapture } from "@insitue/sdk";
48
58
 
49
- ```bash
50
- # Terminal A — your dev server (the app you're editing)
51
- pnpm dev # or npm run dev, etc.
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
- # Terminal B — the InSitue companion (writes .insitu/session.json)
54
- npx insitue dev
71
+ **Vite (src/main.tsx):**
55
72
 
56
- # Terminal C — Claude Code
57
- claude
58
- > /insitue:connect
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
- Now click **Select** in the InSitue overlay, pick an element, and
62
- type your request in the panel's "User note" field. Claude in
63
- Terminal C reads the file at the exact location, proposes the diff,
64
- and waits for your "approve" before writing.
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
- ## Architecture
89
+ ---
67
90
 
68
- ```
69
- ┌───────────────────┐ ┌───────────────────┐
70
- │ Browser overlay │ ──WS──▶ │ Local companion │
71
- │ (insitue/sdk) │ │ (insitue dev) │
72
- └───────────────────┘ └─────────┬─────────┘
73
- WS broadcast-capture
74
-
75
- ┌───────────────────┐
76
- @insitue/ │
77
- claude-plugin │ ◀── stdio MCP ──┐
78
- │ (MCP bridge) │ │
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
- The bridge never writes files — it just hands picks to `claude`, and
87
- `claude` proposes edits through its normal Edit/Write tools (which
88
- still respect your `claude` permissions).
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
+ `.insitue/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 `.insitue/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}/.insitue/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 same as the rest of InSitue.
211
+ MIT. Same as the rest of InSitue.
@@ -1,38 +1,79 @@
1
1
  ---
2
- description: Watch the InSitue browser overlay and act on each pick. Reads picks from the running local InSitue companion, edits the file the user pointed at, asks for confirmation, loops.
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
- Connect this Claude Code session to the running InSitue companion and turn each browser pick into a real code change.
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
- ## How it works
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
- 1. The user runs `npx insitue dev` in their project (the companion).
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
- ## Your behavior on /insitue:connect
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
- - **First**, call `mcp__insitue__list_recent_picks` once to see if the user already picked things before attaching. If there are recent picks the user hasn't acted on, summarise them so they can choose which to address.
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
- **Why the double-poll on note-less picks**: the user's hands are on the browser, not the terminal. Their natural workflow is pick type intent in the panel's ASK textbox → click Send. That ASK arrives via MCP as a `broadcast-ask` joined to the previous pick. If you ask in the terminal while the user is typing in the browser, both messages land at once and the user is confused. Default to "user is about to type in the browser" — re-poll first, only ask in the terminal as a last resort.
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
- ## Guardrails
70
+ ## Failure modes to handle gracefully
34
71
 
35
- - Don't touch any file outside the pick's `pick.source.file` unless the user explicitly asks for cross-file changes.
36
- - Don't auto-approve. Every write is gated by an explicit user "yes" in chat.
37
- - If `pick.source` is `null` (selector-only pick), don't guess — tell the user the source wasn't resolved and ask which file to edit.
38
- - If `pick.confidence` is `"approximate"` (owner-fiber fallback, not the exact JSX site), warn the user before editing.
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 `.insitue/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.
@@ -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";
@@ -13,11 +15,13 @@ var NEXT_PICK_MAX_TIMEOUT_MS = 30 * 60 * 1e3;
13
15
  function findSession(start = process.cwd()) {
14
16
  let dir = resolve(start);
15
17
  while (true) {
16
- const candidate = join(dir, ".insitu", "session.json");
18
+ const candidate = join(dir, ".insitue", "session.json");
17
19
  if (existsSync(candidate)) {
18
20
  try {
19
- const session = JSON.parse(readFileSync(candidate, "utf8"));
20
- return { dir, session };
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 ? { file: t.source.file, ...t.source.line ? { line: t.source.line } : {} } : null,
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
- const next = this.picks.shift();
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
- const next = this.picks.shift();
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
- /** When the WS reconnects, drop pending waiters with a sentinel so
138
- * claude sees the disruption instead of hanging forever. */
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: `[insitu] ${reason}`,
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: "/insitue/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(session) {
159
- const url = `ws://127.0.0.1:${session.port}/insitu/cli`;
211
+ function connectToCompanion(session2) {
212
+ const url = `ws://127.0.0.1:${session2.port}/insitue/cli`;
160
213
  const ws = new WebSocket(url, {
161
- headers: { "user-agent": "insitue-claude-plugin/0.0.1" }
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 published companion protocol version — bumped
168
- // when the wire format breaks, NOT for every release.
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: session.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" && m.t === "hello-ok") {
184
- ws.send(JSON.stringify({ t: "subscribe" }));
185
- return;
186
- }
187
- if (m && typeof m === "object" && m.t === "broadcast-ask") {
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
- return;
193
- }
194
- if (m && typeof m === "object" && m.t === "broadcast-capture") {
195
- try {
196
- buffer.push(summariseBundle(m));
197
- } catch (err) {
198
- process.stderr.write(
199
- `[insitue-mcp] dropped malformed pick: ${err.message}
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 `insitue dev`?");
207
- setTimeout(() => connectToCompanion(session), 2e3);
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 found = findSession();
213
- if (!found) {
263
+ var session = await ensureCompanion();
264
+ if (!session) {
214
265
  process.stderr.write(
215
- "[insitue-mcp] no `.insitu/session.json` found in cwd or any parent.\n Start the companion first: `npx insitue dev` from your project root.\n"
266
+ "[insitue-mcp] no companion available \u2014 `next_pick` will time out.\n"
216
267
  );
217
- process.exit(1);
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.1"
273
+ version: "0.2.0"
223
274
  });
224
275
  server.registerTool(
225
276
  "next_pick",
226
277
  {
227
- description: 'Long-polls for the next element the user picks in the InSitue browser overlay. Returns the resolved source location (file + line), component name, optional user note, and surrounding context (URL, selector, component stack). Use this in a loop: call \u2192 wait \u2192 edit the returned file \u2192 call again. Returns a special `target: "[insitue] ..."` envelope on companion disconnect.',
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
- `How long to wait for the next pick (ms). Default ${NEXT_PICK_DEFAULT_TIMEOUT_MS}; max ${NEXT_PICK_MAX_TIMEOUT_MS}.`
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 the MCP server started. Use this once at session start to see what the user already selected before claude attached, or to re-read context without consuming a pick.",
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.1.1",
3
+ "version": "0.3.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",