@insitue/claude-plugin 0.4.1 → 0.4.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.
@@ -81,9 +81,25 @@ Either path is fine; pick whichever your runtime has.
81
81
  `[insitue]` (e.g. "companion disconnected"), tell the user
82
82
  what happened in one sentence and call `next_pick` again —
83
83
  the bridge auto-reconnects.
84
- 5. Exit the loop when the user says "stop", "done", "quit",
85
- "thanks", "exit", or anything else that clearly ends the
86
- session.
84
+ 5. **End the session properly.** When the user says "stop",
85
+ "done", "quit", "thanks", "exit", "disconnect", "stop
86
+ insitue", or anything else that clearly ends the InSitue
87
+ session, do BOTH of these:
88
+ a. Call `mcp__insitue__end_session` ONCE. This is non-
89
+ optional. Without it the browser launcher stays purple
90
+ forever — the user sees you as "still listening" when
91
+ you're not. The teardown is cheap (closes a WS, drops a
92
+ file) and safe to repeat.
93
+ b. Stop calling `next_pick`. Acknowledge the disconnect in
94
+ one short line.
95
+
96
+ If the user's "stop" is clearly scoped to *the current task*
97
+ ("stop reading that file", "stop, that's not what I meant")
98
+ — i.e. they're not signalling end-of-InSitue — leave the
99
+ subscriber attached and keep the loop alive. Read the room.
100
+
101
+ On Claude Code the user can also run `/insitue:disconnect`
102
+ directly; that hits `end_session` the same way.
87
103
 
88
104
  ## Guardrails
89
105
 
@@ -0,0 +1,37 @@
1
+ ---
2
+ description: Cleanly disconnect this Claude session from InSitue — mutes the browser launcher, frees the companion port.
3
+ ---
4
+
5
+ # /insitue:disconnect
6
+
7
+ Tears down the InSitue session in this Claude Code window
8
+ without exiting claude. Use when you're done picking for now —
9
+ the launcher in the browser goes muted, the companion process
10
+ the MCP server spawned is killed, the stale session file is
11
+ removed.
12
+
13
+ ## Your behaviour
14
+
15
+ 1. Call `mcp__insitue__end_session` once.
16
+ 2. Summarise what was torn down in a single line — e.g.
17
+ *"Disconnected. Companion stopped, session cleared. Run
18
+ `/insitue:connect` again whenever you want to start picking."*
19
+ Don't narrate every field of the response; the user just
20
+ needs to know it worked.
21
+ 3. **Exit any active pick loop** — stop calling `next_pick`.
22
+ 4. Stay in the chat. The user may have more for you here that
23
+ isn't pick-related. Don't `/exit`; that's their call.
24
+
25
+ ## Symmetric with /insitue:connect
26
+
27
+ `/insitue:connect` attaches the subscriber → browser launcher
28
+ goes purple. `/insitue:disconnect` detaches → browser launcher
29
+ goes muted. Both safe to call any number of times, in any
30
+ order; the MCP holds the lifecycle straight.
31
+
32
+ ## Reconnecting after disconnect
33
+
34
+ Just run `/insitue:connect` again. The MCP re-spawns the
35
+ companion if needed (the kill in `end_session` left `npx` ready
36
+ to restart), re-attaches the WS, and you're back in the loop.
37
+ No claude restart required.
@@ -9,7 +9,7 @@ import {
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
11
  import { spawn } from "child_process";
12
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
12
+ import { existsSync as existsSync2, readFileSync as readFileSync3, rmSync } from "fs";
13
13
  import { request as httpRequest } from "http";
14
14
  import { dirname as dirname3, join as join3 } from "path";
15
15
  import { fileURLToPath as fileURLToPath2 } from "url";
@@ -360,11 +360,15 @@ process.on("SIGTERM", () => {
360
360
  process.exit(143);
361
361
  });
362
362
  var buffer = new PickBuffer();
363
- function connectToCompanion(session2) {
364
- const url = `ws://127.0.0.1:${session2.port}/insitue/cli`;
363
+ var activeWs = null;
364
+ var reconnectTimer = null;
365
+ var disconnecting = false;
366
+ function connectToCompanion(s) {
367
+ const url = `ws://127.0.0.1:${s.port}/insitue/cli`;
365
368
  const ws = new WebSocket(url, {
366
369
  headers: { "user-agent": "insitue-claude-plugin" }
367
370
  });
371
+ activeWs = ws;
368
372
  ws.on("open", () => {
369
373
  ws.send(
370
374
  JSON.stringify({
@@ -372,7 +376,7 @@ function connectToCompanion(session2) {
372
376
  // Pin to the companion's pinned protocol version. Bump
373
377
  // when the wire format breaks.
374
378
  protocolVersion: 5,
375
- token: session2.token
379
+ token: s.token
376
380
  })
377
381
  );
378
382
  });
@@ -413,8 +417,10 @@ function connectToCompanion(session2) {
413
417
  }
414
418
  });
415
419
  ws.on("close", () => {
420
+ if (activeWs === ws) activeWs = null;
416
421
  buffer.rejectAll("companion disconnected \u2014 restart `claude` to reconnect");
417
- setTimeout(() => connectToCompanion(session2), 2e3);
422
+ if (disconnecting) return;
423
+ reconnectTimer = setTimeout(() => connectToCompanion(s), 2e3);
418
424
  });
419
425
  ws.on("error", () => {
420
426
  });
@@ -431,11 +437,52 @@ if (!session) {
431
437
  );
432
438
  }
433
439
  var attached = false;
434
- function ensureSubscriberAttached() {
435
- if (attached || !session) return;
440
+ async function ensureSubscriberAttached(opts = {}) {
441
+ if (attached) return;
442
+ if (disconnecting && !opts.explicit) return;
443
+ disconnecting = false;
444
+ if (!session) {
445
+ session = await ensureCompanion(projectDir.dir);
446
+ if (!session) return;
447
+ }
436
448
  attached = true;
437
449
  connectToCompanion(session);
438
450
  }
451
+ function endSession() {
452
+ disconnecting = true;
453
+ attached = false;
454
+ let closedWs = false;
455
+ let killedCompanion = false;
456
+ let removedSessionFile = false;
457
+ if (reconnectTimer) {
458
+ clearTimeout(reconnectTimer);
459
+ reconnectTimer = null;
460
+ }
461
+ if (activeWs) {
462
+ try {
463
+ activeWs.close();
464
+ closedWs = true;
465
+ } catch {
466
+ }
467
+ activeWs = null;
468
+ }
469
+ if (ownedChild) {
470
+ killedCompanion = true;
471
+ cleanupOwnedChild();
472
+ }
473
+ if (killedCompanion) {
474
+ const f = join3(projectDir.dir, ".insitue", "session.json");
475
+ if (existsSync2(f)) {
476
+ try {
477
+ rmSync(f);
478
+ removedSessionFile = true;
479
+ } catch {
480
+ }
481
+ }
482
+ }
483
+ session = null;
484
+ return { closedWs, killedCompanion, removedSessionFile };
485
+ }
439
486
  var server = new McpServer({
440
487
  name: "insitue",
441
488
  version: "0.3.0"
@@ -451,7 +498,20 @@ server.registerTool(
451
498
  }
452
499
  },
453
500
  async ({ timeout_ms }) => {
454
- ensureSubscriberAttached();
501
+ await ensureSubscriberAttached();
502
+ if (disconnecting) {
503
+ return {
504
+ content: [
505
+ {
506
+ type: "text",
507
+ text: JSON.stringify({
508
+ status: "disconnected",
509
+ message: "InSitue session was disconnected. Run /insitue:connect (Code) or call start_session (Desktop) to reattach."
510
+ })
511
+ }
512
+ ]
513
+ };
514
+ }
455
515
  const ms = timeout_ms ?? NEXT_PICK_DEFAULT_TIMEOUT_MS;
456
516
  const pick = await buffer.next(ms);
457
517
  if (!pick) {
@@ -480,7 +540,7 @@ server.registerTool(
480
540
  }
481
541
  },
482
542
  async ({ limit }) => {
483
- ensureSubscriberAttached();
543
+ await ensureSubscriberAttached({ explicit: true });
484
544
  const picks = buffer.recent(limit ?? 10);
485
545
  return {
486
546
  content: [
@@ -499,7 +559,7 @@ server.registerTool(
499
559
  inputSchema: {}
500
560
  },
501
561
  async () => {
502
- ensureSubscriberAttached();
562
+ await ensureSubscriberAttached({ explicit: true });
503
563
  const instructions = loadInstructions();
504
564
  const buffered = buffer.recent(32).length;
505
565
  const status = `
@@ -520,6 +580,24 @@ Begin the loop by calling \`list_recent_picks\` once, then loop on \`next_pick\`
520
580
  };
521
581
  }
522
582
  );
583
+ server.registerTool(
584
+ "end_session",
585
+ {
586
+ description: "Cleanly disconnect this MCP from the InSitue companion: close the WS subscriber (browser launcher mutes immediately), suppress auto-reconnect, kill the companion if we spawned it, and remove the stale session file. The user can reconnect later in the same claude session via `/insitue:connect` (Code) or by calling `start_session` again (Desktop). Safe to call repeatedly. Returns what was actually torn down.",
587
+ inputSchema: {}
588
+ },
589
+ async () => {
590
+ const r = endSession();
591
+ return {
592
+ content: [
593
+ {
594
+ type: "text",
595
+ text: JSON.stringify({ status: "disconnected", ...r })
596
+ }
597
+ ]
598
+ };
599
+ }
600
+ );
523
601
  server.registerTool(
524
602
  "diagnose",
525
603
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@insitue/claude-plugin",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Drive Claude (Code AND Desktop) 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",