@loupekit/mcp 0.10.25 → 0.10.26-next.73

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/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
6
6
  import { pathToFileURL } from "url";
7
7
  import { realpathSync } from "fs";
8
8
  import { argv } from "process";
9
+ import { existsSync as existsSync4 } from "fs";
9
10
  import { z } from "zod";
10
11
  import { Buffer as Buffer2 } from "buffer";
11
12
 
@@ -224,6 +225,404 @@ var PresenceRegistry = class {
224
225
  }
225
226
  };
226
227
 
228
+ // src/bridge/event-store.ts
229
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from "fs";
230
+ import { dirname } from "path";
231
+ var EVENT_CAP = 1e3;
232
+ var PAYLOAD_CHAR_CAP = 2e3;
233
+ var FLUSH_DEBOUNCE_MS = 250;
234
+ function truncate(value, cap = PAYLOAD_CHAR_CAP) {
235
+ if (value.length <= cap) return value;
236
+ return `${value.slice(0, cap)}\u2026 [truncated ${value.length - cap} chars]`;
237
+ }
238
+ function shrinkPayload(value, depth = 0, cap = PAYLOAD_CHAR_CAP) {
239
+ if (value === null || value === void 0) return value;
240
+ if (typeof value === "string") return truncate(value, cap);
241
+ if (typeof value === "number" || typeof value === "boolean") return value;
242
+ if (depth >= 4) return "[deeper]";
243
+ if (Array.isArray(value)) {
244
+ const head = value.slice(0, 20).map((v) => shrinkPayload(v, depth + 1, cap));
245
+ return value.length > 20 ? [...head, `\u2026 ${value.length - 20} more`] : head;
246
+ }
247
+ if (typeof value === "object") {
248
+ const out = {};
249
+ const entries = Object.entries(value).slice(0, 30);
250
+ for (const [k, v] of entries) out[k] = shrinkPayload(v, depth + 1, cap);
251
+ return out;
252
+ }
253
+ return String(value);
254
+ }
255
+ function newId() {
256
+ return `ev_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
257
+ }
258
+ var EventStore = class {
259
+ events = [];
260
+ cap;
261
+ file;
262
+ writeTimer;
263
+ dirty = false;
264
+ constructor(opts = {}) {
265
+ this.cap = opts.cap && opts.cap > 0 ? opts.cap : EVENT_CAP;
266
+ this.file = opts.file;
267
+ if (this.file) this.load();
268
+ }
269
+ /** Read what is on disk, ignoring anything unusable. A store that will not load must
270
+ * not stop the bridge — the events are a convenience, not the product. */
271
+ load() {
272
+ if (!this.file || !existsSync(this.file)) return;
273
+ try {
274
+ const parsed = JSON.parse(readFileSync(this.file, "utf8"));
275
+ if (!Array.isArray(parsed)) return;
276
+ this.events = parsed.filter((e) => e && typeof e.id === "string" && typeof e.type === "string").slice(-this.cap);
277
+ } catch {
278
+ this.events = [];
279
+ }
280
+ }
281
+ add(input) {
282
+ const event = {
283
+ id: newId(),
284
+ at: input.at ?? (/* @__PURE__ */ new Date()).toISOString(),
285
+ type: input.type,
286
+ sessionId: input.sessionId,
287
+ tool: input.tool ? truncate(input.tool, 200) : void 0,
288
+ summary: input.summary ? truncate(input.summary, 500) : void 0,
289
+ files: input.files?.length ? [...new Set(input.files)].slice(0, 50) : void 0,
290
+ payload: input.payload ? shrinkPayload(input.payload) : void 0
291
+ };
292
+ this.events.push(event);
293
+ if (this.events.length > this.cap) this.events = this.events.slice(-this.cap);
294
+ this.scheduleFlush();
295
+ return event;
296
+ }
297
+ /** Newest last. `limit` takes the most recent N, oldest of those first. */
298
+ recent(limit = 50) {
299
+ if (limit <= 0) return [];
300
+ return this.events.slice(-limit);
301
+ }
302
+ /** Newest first, for a feed. */
303
+ latest(limit = 50) {
304
+ return this.recent(limit).reverse();
305
+ }
306
+ size() {
307
+ return this.events.length;
308
+ }
309
+ clear() {
310
+ this.events = [];
311
+ this.scheduleFlush();
312
+ }
313
+ scheduleFlush() {
314
+ if (!this.file) return;
315
+ this.dirty = true;
316
+ if (this.writeTimer) return;
317
+ this.writeTimer = setTimeout(() => {
318
+ this.writeTimer = void 0;
319
+ this.flush();
320
+ }, FLUSH_DEBOUNCE_MS);
321
+ if (typeof this.writeTimer.unref === "function") this.writeTimer.unref();
322
+ }
323
+ /** Write now. Atomic: a temp file renamed into place, so a crash cannot leave a
324
+ * half-written store that then fails to parse on the next start. */
325
+ flush() {
326
+ if (!this.file || !this.dirty) return;
327
+ if (this.writeTimer) {
328
+ clearTimeout(this.writeTimer);
329
+ this.writeTimer = void 0;
330
+ }
331
+ try {
332
+ mkdirSync(dirname(this.file), { recursive: true });
333
+ const tmp = `${this.file}.tmp`;
334
+ writeFileSync(tmp, JSON.stringify(this.events));
335
+ renameSync(tmp, this.file);
336
+ this.dirty = false;
337
+ } catch {
338
+ }
339
+ }
340
+ };
341
+
342
+ // src/bridge/companion-queue.ts
343
+ var CompanionQueue = class {
344
+ queue = [];
345
+ /** Taken but not yet acknowledged. Kept so a failure can put them back. */
346
+ inFlight = [];
347
+ replies = [];
348
+ replyCap;
349
+ now;
350
+ // Explicit fields, not constructor parameter properties — Node's strip-only TypeScript
351
+ // mode rejects those and this file runs unbundled from source.
352
+ constructor(replyCap = 100, now = Date.now) {
353
+ this.replyCap = replyCap;
354
+ this.now = now;
355
+ }
356
+ push(message) {
357
+ const full = {
358
+ ...message,
359
+ id: message.id ?? `cm_${this.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`,
360
+ at: message.at ?? (/* @__PURE__ */ new Date()).toISOString()
361
+ };
362
+ this.queue.push(full);
363
+ return full;
364
+ }
365
+ /** What is waiting, without taking it. Safe to call repeatedly. */
366
+ peek() {
367
+ return [...this.queue];
368
+ }
369
+ size() {
370
+ return this.queue.length;
371
+ }
372
+ /** In-flight count, for the panel to show "delivering…". */
373
+ pending() {
374
+ return this.inFlight.length;
375
+ }
376
+ /**
377
+ * Take everything waiting, marking it in flight.
378
+ *
379
+ * Deliberately *not* a plain drain: the caller must `ack()` for the messages to be
380
+ * gone. If the tool call they were attached to throws, the queue still holds them.
381
+ */
382
+ take() {
383
+ const taken = this.queue;
384
+ this.queue = [];
385
+ this.inFlight = [...this.inFlight, ...taken];
386
+ return taken;
387
+ }
388
+ /** Confirm delivery. Only what was actually handed over is dropped. */
389
+ ack(ids) {
390
+ if (!ids) {
391
+ this.inFlight = [];
392
+ return;
393
+ }
394
+ const done = new Set(ids);
395
+ this.inFlight = this.inFlight.filter((m) => !done.has(m.id));
396
+ }
397
+ /**
398
+ * Put the in-flight messages back at the front, in their original order.
399
+ *
400
+ * Used when a delivery did not happen — the tool failed, or the transport went away.
401
+ * Front, not back, because they were sent first and the conversation should read in
402
+ * the order it was written.
403
+ */
404
+ requeue() {
405
+ const restored = this.inFlight;
406
+ this.inFlight = [];
407
+ this.queue = [...restored, ...this.queue];
408
+ return restored;
409
+ }
410
+ /** All messages, queued and in flight — what the panel shows. */
411
+ list() {
412
+ return [...this.inFlight, ...this.queue];
413
+ }
414
+ addReply(reply) {
415
+ const full = {
416
+ ...reply,
417
+ id: reply.id ?? `cr_${this.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`,
418
+ at: reply.at ?? (/* @__PURE__ */ new Date()).toISOString()
419
+ };
420
+ this.replies.push(full);
421
+ if (this.replies.length > this.replyCap) this.replies = this.replies.slice(-this.replyCap);
422
+ return full;
423
+ }
424
+ listReplies(limit = 50) {
425
+ return this.replies.slice(-limit);
426
+ }
427
+ clear() {
428
+ this.queue = [];
429
+ this.inFlight = [];
430
+ }
431
+ };
432
+ function companionPreamble(messages) {
433
+ const lines = messages.map((m) => {
434
+ const where = m.contexts?.length ? ` (re: ${m.contexts.map((c) => c.label ?? c.url ?? c.id ?? c.kind).join(", ")})` : "";
435
+ const atts = m.attachments?.length ? ` [${m.attachments.length} attachment(s)]` : "";
436
+ return `- ${m.author?.name ? `${m.author.name}: ` : ""}${m.body}${where}${atts}`;
437
+ });
438
+ return [
439
+ `\u26A0\uFE0F The person watching sent you ${messages.length} message${messages.length === 1 ? "" : "s"} while you were working. Read and act on this before continuing:`,
440
+ ...lines,
441
+ "",
442
+ "Answer with reply_to_companion so it reaches them, then carry on.",
443
+ ""
444
+ ].join("\n");
445
+ }
446
+ function prependToResult(result, messages) {
447
+ if (!messages.length) return result;
448
+ const content = Array.isArray(result?.content) ? result.content : [];
449
+ return { ...result, content: [{ type: "text", text: companionPreamble(messages) }, ...content] };
450
+ }
451
+
452
+ // src/bridge/session-manager.ts
453
+ var SESSION_IDLE_MS = 10 * 60 * 1e3;
454
+ var FILE_KEYS = ["file", "file_path", "path", "filepath", "notebook_path", "filename", "target_file"];
455
+ function extractFiles(payload, depth = 0) {
456
+ if (depth >= 5 || !payload || typeof payload !== "object") return [];
457
+ const out = [];
458
+ const consider = (key, value) => {
459
+ if (typeof value !== "string" || !value) return;
460
+ const keyed = FILE_KEYS.includes(key.toLowerCase());
461
+ const pathish = /^[\w./~-]+\/[\w./~-]*\.[A-Za-z0-9]+$/.test(value) || /^[\w.-]+\.[A-Za-z0-9]{1,8}$/.test(value);
462
+ if (keyed && value.length < 500) out.push(value);
463
+ else if (pathish && !keyed && /[/.]/.test(value) && !value.includes(" ")) out.push(value);
464
+ };
465
+ if (Array.isArray(payload)) {
466
+ for (const item of payload) out.push(...extractFiles(item, depth + 1));
467
+ return out;
468
+ }
469
+ for (const [k, v] of Object.entries(payload)) {
470
+ consider(k, v);
471
+ if (v && typeof v === "object") out.push(...extractFiles(v, depth + 1));
472
+ }
473
+ return out;
474
+ }
475
+ function sessionsFromEvents(events2, now = Date.now()) {
476
+ const byId = /* @__PURE__ */ new Map();
477
+ for (const e of events2) {
478
+ const id = e.sessionId || "unattributed";
479
+ const list = byId.get(id) ?? [];
480
+ list.push(e);
481
+ byId.set(id, list);
482
+ }
483
+ const sessions = [];
484
+ for (const [id, list] of byId) {
485
+ const sorted = [...list].sort((a, b) => a.at.localeCompare(b.at));
486
+ const counts = /* @__PURE__ */ new Map();
487
+ const files = [];
488
+ let prompts = 0;
489
+ let tools = 0;
490
+ let endedAt;
491
+ for (const e of sorted) {
492
+ if (e.type === "prompt_submit") prompts++;
493
+ if (e.type === "tool_use") {
494
+ tools++;
495
+ if (e.tool) counts.set(e.tool, (counts.get(e.tool) ?? 0) + 1);
496
+ }
497
+ if (e.type === "session_end") endedAt = e.at;
498
+ for (const f of e.files ?? []) if (!files.includes(f)) files.push(f);
499
+ for (const f of extractFiles(e.payload)) if (!files.includes(f)) files.push(f);
500
+ }
501
+ const lastEventAt = sorted[sorted.length - 1].at;
502
+ const quiet = now - Date.parse(lastEventAt) > SESSION_IDLE_MS;
503
+ sessions.push({
504
+ id,
505
+ startedAt: sorted[0].at,
506
+ endedAt,
507
+ // An explicit end wins; otherwise a session that has gone quiet is not "live"
508
+ // just because no hook said goodbye — a killed terminal never does.
509
+ active: !endedAt && !quiet,
510
+ prompts,
511
+ tools,
512
+ toolCounts: [...counts.entries()].map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)),
513
+ files,
514
+ eventCount: sorted.length,
515
+ lastEventAt
516
+ });
517
+ }
518
+ return sessions.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
519
+ }
520
+ function sessionSummaryText(s) {
521
+ const parts = [
522
+ `${s.active ? "\u25CF live" : "\u25CB ended"} ${s.id}`,
523
+ `${s.prompts} prompt${s.prompts === 1 ? "" : "s"}`,
524
+ `${s.tools} tool call${s.tools === 1 ? "" : "s"}`,
525
+ `${s.files.length} file${s.files.length === 1 ? "" : "s"} touched`
526
+ ];
527
+ const top = s.toolCounts.slice(0, 3).map((t) => `${t.tool} \xD7${t.count}`).join(", ");
528
+ return `${parts.join(" \xB7 ")}${top ? `
529
+ most used: ${top}` : ""}`;
530
+ }
531
+
532
+ // src/hooks/hook-installer.ts
533
+ import { copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "fs";
534
+ import { dirname as dirname2 } from "path";
535
+ var HOOK_MARKER = "loupe-hook";
536
+ var HOOK_EVENTS = [
537
+ "SessionStart",
538
+ "UserPromptSubmit",
539
+ "PreToolUse",
540
+ "PostToolUse",
541
+ "SubagentStop",
542
+ "Notification",
543
+ "SessionEnd"
544
+ ];
545
+ function hookCommand(scriptPath, event) {
546
+ return `node "${scriptPath}" ${event}`;
547
+ }
548
+ function installHooks(settings, scriptPath) {
549
+ const base = settings && typeof settings === "object" && !Array.isArray(settings) ? { ...settings } : {};
550
+ const hooks = base.hooks && typeof base.hooks === "object" && !Array.isArray(base.hooks) ? { ...base.hooks } : {};
551
+ const added = [];
552
+ const repaired = [];
553
+ const preserved = [];
554
+ for (const event of HOOK_EVENTS) {
555
+ const existing = Array.isArray(hooks[event]) ? [...hooks[event]] : [];
556
+ const want = hookCommand(scriptPath, event);
557
+ let found = false;
558
+ let alreadyRight = false;
559
+ const next = existing.map((entry) => {
560
+ if (!entry || typeof entry !== "object" || !Array.isArray(entry.hooks)) return entry;
561
+ const commands = entry.hooks.map((h) => {
562
+ if (!h || typeof h !== "object" || typeof h.command !== "string") return h;
563
+ if (!h.command.includes(HOOK_MARKER)) return h;
564
+ if (!found) {
565
+ found = true;
566
+ if (h.command === want) alreadyRight = true;
567
+ else return { ...h, command: want };
568
+ }
569
+ return h;
570
+ });
571
+ return { ...entry, hooks: commands };
572
+ });
573
+ if (found && alreadyRight) {
574
+ preserved.push(event);
575
+ hooks[event] = existing;
576
+ continue;
577
+ }
578
+ if (found) {
579
+ repaired.push(event);
580
+ hooks[event] = next;
581
+ continue;
582
+ }
583
+ added.push(event);
584
+ hooks[event] = [...existing, { hooks: [{ type: "command", command: want }] }];
585
+ }
586
+ return {
587
+ settings: { ...base, hooks },
588
+ changed: added.length > 0 || repaired.length > 0,
589
+ added,
590
+ repaired,
591
+ preserved
592
+ };
593
+ }
594
+ function installHooksFile(path, scriptPath, opts = {}) {
595
+ const empty = { settings: {}, changed: false, added: [], repaired: [], preserved: [], wrote: false };
596
+ try {
597
+ let current = null;
598
+ if (existsSync2(path)) {
599
+ const raw = readFileSync2(path, "utf8");
600
+ if (raw.trim()) {
601
+ try {
602
+ current = JSON.parse(raw);
603
+ } catch (e) {
604
+ return { ...empty, error: `settings file is not valid JSON (${e.message}); left untouched` };
605
+ }
606
+ }
607
+ }
608
+ const result = installHooks(current, scriptPath);
609
+ if (!result.changed) return { ...result, wrote: false };
610
+ let backup;
611
+ if (opts.backup !== false && existsSync2(path)) {
612
+ backup = `${path}.loupe-backup`;
613
+ copyFileSync(path, backup);
614
+ }
615
+ mkdirSync2(dirname2(path), { recursive: true });
616
+ const tmp = `${path}.tmp`;
617
+ writeFileSync2(tmp, `${JSON.stringify(result.settings, null, 2)}
618
+ `);
619
+ renameSync2(tmp, path);
620
+ return { ...result, backup, wrote: true };
621
+ } catch (e) {
622
+ return { ...empty, error: e.message };
623
+ }
624
+ }
625
+
227
626
  // src/bridge/events.ts
228
627
  var EventBus = class {
229
628
  subscribers = /* @__PURE__ */ new Set();
@@ -252,6 +651,10 @@ var EventBus = class {
252
651
  }
253
652
  }
254
653
  /** Publish a thread update. The seam the MCP tools use. */
654
+ /** A companion message or reply, so the panel updates without polling. */
655
+ publishCompanion(eventType, data) {
656
+ this.publish({ type: "companion", eventType, data, at: (/* @__PURE__ */ new Date()).toISOString() });
657
+ }
255
658
  publishThread(threadId, eventType, data) {
256
659
  this.publish({ type: "thread", threadId, eventType, data, at: (/* @__PURE__ */ new Date()).toISOString() });
257
660
  }
@@ -262,6 +665,102 @@ var EventBus = class {
262
665
 
263
666
  // src/bridge/http-bridge.ts
264
667
  import { createServer } from "http";
668
+
669
+ // src/hooks/hook-events.ts
670
+ var EVENT_MAP = {
671
+ sessionstart: "session_start",
672
+ sessionend: "session_end",
673
+ userpromptsubmit: "prompt_submit",
674
+ pretooluse: "tool_use",
675
+ posttooluse: "tool_result",
676
+ subagentstop: "subagent_stop",
677
+ subagentstart: "subagent_start",
678
+ notification: "notification",
679
+ stop: "turn_end",
680
+ precompact: "compact"
681
+ };
682
+ function eventTypeFor(hookName) {
683
+ const key = String(hookName || "").toLowerCase().replace(/[^a-z]/g, "");
684
+ return EVENT_MAP[key] ?? (hookName ? String(hookName) : "unknown");
685
+ }
686
+ function summarize(hookName, payload) {
687
+ const type = eventTypeFor(hookName);
688
+ const at = (s) => typeof s === "string" && s.trim() ? s.trim() : void 0;
689
+ if (type === "prompt_submit") return at(payload.prompt)?.slice(0, 200);
690
+ if (type === "notification") return at(payload.message)?.slice(0, 200);
691
+ if (type === "tool_use" || type === "tool_result") {
692
+ const tool = at(payload.tool_name);
693
+ return tool ? `${type === "tool_use" ? "used" : "finished"} ${tool}` : void 0;
694
+ }
695
+ if (type === "session_start" || type === "session_end") {
696
+ return at(payload.cwd) ?? at(payload.source);
697
+ }
698
+ return void 0;
699
+ }
700
+ function toolFailed(payload) {
701
+ const resp = payload.tool_response;
702
+ if (resp && typeof resp === "object") {
703
+ if (typeof resp.is_error === "boolean") return resp.is_error;
704
+ if (typeof resp.error === "string" && resp.error) return true;
705
+ if (typeof resp.success === "boolean") return !resp.success;
706
+ if (typeof resp.exit_code === "number") return resp.exit_code !== 0;
707
+ if (typeof resp.status === "number") return resp.status >= 400;
708
+ }
709
+ return void 0;
710
+ }
711
+ function normalizeHookEvent(hookName, payload, at) {
712
+ const type = eventTypeFor(hookName);
713
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id : void 0;
714
+ const tool = typeof payload.tool_name === "string" ? payload.tool_name : void 0;
715
+ const files = [.../* @__PURE__ */ new Set([...extractFiles(payload.tool_input ?? {}), ...extractFiles(payload)])];
716
+ const extra = {};
717
+ const failed = type === "tool_result" ? toolFailed(payload) : void 0;
718
+ if (failed !== void 0) extra.ok = !failed;
719
+ return {
720
+ type,
721
+ sessionId,
722
+ tool,
723
+ summary: summarize(hookName, payload),
724
+ files: files.length ? files : void 0,
725
+ at,
726
+ payload: Object.keys(extra).length ? { ...extra, hook: hookName } : { hook: hookName }
727
+ };
728
+ }
729
+
730
+ // src/bridge/state-file.ts
731
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync3 } from "fs";
732
+ import { homedir } from "os";
733
+ import { dirname as dirname3, join } from "path";
734
+ function stateDir(env = process.env) {
735
+ return env.LOUPE_STATE_DIR || join(homedir(), ".loupe");
736
+ }
737
+ function stateFilePath(env = process.env) {
738
+ return join(stateDir(env), "bridge.json");
739
+ }
740
+ function writeBridgeState(port, env = process.env) {
741
+ const file = stateFilePath(env);
742
+ try {
743
+ mkdirSync3(dirname3(file), { recursive: true });
744
+ const state = { url: `http://127.0.0.1:${port}`, port, pid: process.pid, at: (/* @__PURE__ */ new Date()).toISOString() };
745
+ writeFileSync3(file, `${JSON.stringify(state, null, 2)}
746
+ `);
747
+ return file;
748
+ } catch {
749
+ return null;
750
+ }
751
+ }
752
+ function clearBridgeState(port, env = process.env) {
753
+ const file = stateFilePath(env);
754
+ try {
755
+ if (!existsSync3(file)) return;
756
+ const state = JSON.parse(readFileSync3(file, "utf8"));
757
+ if (state?.port !== port) return;
758
+ rmSync(file, { force: true });
759
+ } catch {
760
+ }
761
+ }
762
+
763
+ // src/bridge/http-bridge.ts
265
764
  var MAX_BODY_BYTES = 1024 * 1024;
266
765
  var SSE_KEEPALIVE_MS = 15e3;
267
766
  async function startHttpBridge(port, deps, opts = {}) {
@@ -274,10 +773,14 @@ async function startHttpBridge(port, deps, opts = {}) {
274
773
  try {
275
774
  await listen(server2, port);
276
775
  const bound = server2.address()?.port ?? port;
776
+ writeBridgeState(bound);
277
777
  return {
278
778
  port: bound,
279
779
  url: `http://127.0.0.1:${bound}`,
280
- close: () => close(server2)
780
+ close: async () => {
781
+ clearBridgeState(bound);
782
+ await close(server2);
783
+ }
281
784
  };
282
785
  } catch (e) {
283
786
  const code = e?.code;
@@ -451,13 +954,13 @@ async function handle(req, res, deps) {
451
954
  } catch {
452
955
  return send(res, 400, { error: "body is not valid JSON" }, origin);
453
956
  }
454
- const join2 = parsed;
455
- if (!join2 || typeof join2 !== "object" || !join2.url || !join2.userId || !join2.name) {
957
+ const join3 = parsed;
958
+ if (!join3 || typeof join3 !== "object" || !join3.url || !join3.userId || !join3.name) {
456
959
  return send(res, 400, { error: "url, userId and name are required" }, origin);
457
960
  }
458
- const peer = deps.presence.join({ url: join2.url, userId: join2.userId, name: join2.name, tab: join2.tab });
961
+ const peer = deps.presence.join({ url: join3.url, userId: join3.userId, name: join3.name, tab: join3.tab });
459
962
  deps.bus.publish({ type: "presence", data: deps.presence.list(), at: (/* @__PURE__ */ new Date()).toISOString() });
460
- return send(res, 201, { ok: true, peer, peers: deps.presence.list(join2.url, peer.id) }, origin);
963
+ return send(res, 201, { ok: true, peer, peers: deps.presence.list(join3.url, peer.id) }, origin);
461
964
  }
462
965
  if (method === "GET" && path === "/presence") {
463
966
  const pageUrl = url.searchParams.get("url") ?? void 0;
@@ -495,6 +998,90 @@ async function handle(req, res, deps) {
495
998
  deps.bus.publishThread(evt.threadId, evt.eventType, evt.data);
496
999
  return send(res, 202, { ok: true }, origin);
497
1000
  }
1001
+ if (method === "POST" && path === "/events/ingest") {
1002
+ const body = await readBody(req);
1003
+ if (!body.ok) return send(res, 400, { error: body.error }, origin);
1004
+ let parsed;
1005
+ try {
1006
+ parsed = JSON.parse(body.text);
1007
+ } catch {
1008
+ return send(res, 400, { error: "body is not valid JSON" }, origin);
1009
+ }
1010
+ if (!parsed || typeof parsed !== "object" || typeof parsed.event !== "string" || !parsed.event) {
1011
+ return send(res, 400, { error: "event is required" }, origin);
1012
+ }
1013
+ if (!deps.events) return send(res, 503, { error: "event store is not enabled" }, origin);
1014
+ const payload = parsed.payload && typeof parsed.payload === "object" ? parsed.payload : {};
1015
+ const event = deps.events.add(normalizeHookEvent(parsed.event, payload, parsed.at));
1016
+ return send(res, 202, { ok: true, event }, origin);
1017
+ }
1018
+ if (method === "GET" && path === "/events/recent") {
1019
+ if (!deps.events) return send(res, 503, { error: "event store is not enabled" }, origin);
1020
+ const limit = Number(url.searchParams.get("limit") ?? 50);
1021
+ const type = url.searchParams.get("type") ?? void 0;
1022
+ const events2 = deps.events.latest(Number.isFinite(limit) ? limit : 50);
1023
+ return send(res, 200, { events: type ? events2.filter((e) => e.type === type) : events2, total: deps.events.size() }, origin);
1024
+ }
1025
+ if (method === "GET" && path === "/sessions") {
1026
+ if (!deps.events) return send(res, 503, { error: "event store is not enabled" }, origin);
1027
+ return send(res, 200, { sessions: sessionsFromEvents(deps.events.recent(deps.events.size())) }, origin);
1028
+ }
1029
+ if (method === "POST" && path === "/companion") {
1030
+ const body = await readBody(req);
1031
+ if (!body.ok) return send(res, 400, { error: body.error }, origin);
1032
+ let parsed;
1033
+ try {
1034
+ parsed = JSON.parse(body.text);
1035
+ } catch {
1036
+ return send(res, 400, { error: "body is not valid JSON" }, origin);
1037
+ }
1038
+ if (!parsed || typeof parsed !== "object" || typeof parsed.body !== "string" || !parsed.body.trim()) {
1039
+ return send(res, 400, { error: "body is required" }, origin);
1040
+ }
1041
+ if (!deps.companion) return send(res, 503, { error: "companion is not enabled" }, origin);
1042
+ const message = deps.companion.push({
1043
+ body: parsed.body,
1044
+ author: parsed.author,
1045
+ contexts: Array.isArray(parsed.contexts) ? parsed.contexts.slice(0, 20) : void 0,
1046
+ attachments: Array.isArray(parsed.attachments) ? parsed.attachments.slice(0, 10) : void 0,
1047
+ voice: parsed.voice === true
1048
+ });
1049
+ deps.bus.publishCompanion("message", message);
1050
+ return send(res, 201, { ok: true, message, queued: deps.companion.size() }, origin);
1051
+ }
1052
+ if (method === "GET" && path === "/companion") {
1053
+ if (!deps.companion) return send(res, 503, { error: "companion is not enabled" }, origin);
1054
+ if (url.searchParams.get("since")) {
1055
+ return send(res, 200, {
1056
+ replies: deps.companion.listReplies(50),
1057
+ messages: deps.companion.list(),
1058
+ queued: deps.companion.size()
1059
+ }, origin);
1060
+ }
1061
+ return send(res, 200, {
1062
+ messages: deps.companion.list(),
1063
+ replies: deps.companion.listReplies(50),
1064
+ queued: deps.companion.size(),
1065
+ pending: deps.companion.pending()
1066
+ }, origin);
1067
+ }
1068
+ if (method === "POST" && path === "/companion/reply") {
1069
+ const body = await readBody(req);
1070
+ if (!body.ok) return send(res, 400, { error: body.error }, origin);
1071
+ let parsed;
1072
+ try {
1073
+ parsed = JSON.parse(body.text);
1074
+ } catch {
1075
+ return send(res, 400, { error: "body is not valid JSON" }, origin);
1076
+ }
1077
+ if (!parsed || typeof parsed.body !== "string" || !parsed.body.trim()) {
1078
+ return send(res, 400, { error: "body is required" }, origin);
1079
+ }
1080
+ if (!deps.companion) return send(res, 503, { error: "companion is not enabled" }, origin);
1081
+ const reply = deps.companion.addReply({ body: parsed.body, inReplyTo: parsed.inReplyTo });
1082
+ deps.bus.publishCompanion("reply", reply);
1083
+ return send(res, 201, { ok: true, reply }, origin);
1084
+ }
498
1085
  if (method === "GET" && (path === "/events" || path === "/thread-updates")) {
499
1086
  return streamEvents(req, res, deps, path === "/thread-updates" ? "thread" : null, origin);
500
1087
  }
@@ -535,7 +1122,7 @@ data: ${JSON.stringify(event)}
535
1122
 
536
1123
  // src/mapper.ts
537
1124
  import { readdir, readFile, stat } from "fs/promises";
538
- import { basename, join, relative, sep } from "path";
1125
+ import { basename, join as join2, relative, sep } from "path";
539
1126
  var SIGNAL_WEIGHTS = {
540
1127
  id: 0.85,
541
1128
  "aria-label": 0.8,
@@ -693,7 +1280,7 @@ async function collectFiles(root, opts = {}) {
693
1280
  truncated = true;
694
1281
  break;
695
1282
  }
696
- const full = join(dir, entry.name);
1283
+ const full = join2(dir, entry.name);
697
1284
  if (entry.isDirectory()) {
698
1285
  if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
699
1286
  queue.push(full);
@@ -1578,6 +2165,8 @@ var BRIDGE_PORT = Number(process.env.LOUPE_BRIDGE_PORT ?? 9800);
1578
2165
  var store = new SelectionStore(Number(process.env.LOUPE_SELECTION_CAP ?? 50));
1579
2166
  var registry = new AgentRegistry();
1580
2167
  var presence = new PresenceRegistry();
2168
+ var events = new EventStore({ file: process.env.LOUPE_EVENT_FILE || void 0 });
2169
+ var companion = new CompanionQueue();
1581
2170
  var bus = new EventBus();
1582
2171
  var ADMIN = process.env.LOUPE_ADMIN_KEY || "";
1583
2172
  async function api(path, init) {
@@ -1727,8 +2316,28 @@ async function proposeChange({ id, html, css, notes }) {
1727
2316
  bus.publishThread(id, "preview_live", { author: proposal.author });
1728
2317
  return wrap(`Proposal saved for #${id}. The dev team can now review your modified HTML/CSS in the dashboard.`);
1729
2318
  }
1730
- var server = new McpServer({ name: "loupe", version: "0.10.25" });
1731
- server.tool(
2319
+ function hookScriptPath() {
2320
+ const candidates = [
2321
+ new URL("./hooks/loupe-hook.mjs", import.meta.url).pathname,
2322
+ new URL("../hooks/loupe-hook.mjs", import.meta.url).pathname
2323
+ ];
2324
+ return candidates.find((c) => existsSync4(c)) ?? candidates[0];
2325
+ }
2326
+ var server = new McpServer({ name: "loupe", version: "0.10.26" });
2327
+ function withCompanion(handler) {
2328
+ return (async (...args) => {
2329
+ const result = await handler(...args);
2330
+ const pending = companion.take();
2331
+ if (!pending.length) return result;
2332
+ const wrapped = prependToResult(result, pending);
2333
+ companion.ack(pending.map((m) => m.id));
2334
+ return wrapped;
2335
+ });
2336
+ }
2337
+ function registerTool(name, description, schema, handler) {
2338
+ return server.tool(name, description, schema, withCompanion(handler));
2339
+ }
2340
+ registerTool(
1732
2341
  "list_comments",
1733
2342
  "List Loupe product-feedback comments for the project as a task backlog. Each item carries its board stage, priority and change type, so you can start with the most urgent. Use this to see what a PM has flagged, then work through the items.",
1734
2343
  {
@@ -1741,19 +2350,19 @@ server.tool(
1741
2350
  },
1742
2351
  listComments
1743
2352
  );
1744
- server.tool(
2353
+ registerTool(
1745
2354
  "get_comment",
1746
2355
  "Get the full context for one comment: the request, the page, the target element's HTML, and its computed styles \u2014 everything needed to make the change.",
1747
2356
  { id: z.string().describe("The comment id from list_comments.") },
1748
2357
  getComment
1749
2358
  );
1750
- server.tool(
2359
+ registerTool(
1751
2360
  "update_status",
1752
2361
  "Move a comment along the board. Set In Progress when you start it, and In Review when the change is ready for a human \u2014 only a person resolves a comment, so never set Resolved yourself.",
1753
2362
  { id: z.string(), status: STAGE_ARG },
1754
2363
  updateStatus
1755
2364
  );
1756
- server.tool(
2365
+ registerTool(
1757
2366
  "propose_change",
1758
2367
  "Submit the modified UI for a comment: the rewritten HTML (and optional CSS) that resolves the PM's request. This stores your proposal on the comment so the dev team can review the code and a live preview in the dashboard. Use get_comment first to see the original element, its computed styles, and the screenshot.",
1759
2368
  {
@@ -1784,19 +2393,19 @@ var SELECTION_ARGS = {
1784
2393
  "A specific selection by correlation id (see get_selection_history). Omit for the most recent."
1785
2394
  )
1786
2395
  };
1787
- server.tool(
2396
+ registerTool(
1788
2397
  "get_latest_selection",
1789
2398
  `What the user last selected in the browser, with its element, computed styles and the source files most likely to render it. Use this when someone says "this element" or "what I'm looking at" and no thread exists yet.`,
1790
2399
  {},
1791
2400
  async () => wrap((await elementTools.getLatestSelection()).text)
1792
2401
  );
1793
- server.tool(
2402
+ registerTool(
1794
2403
  "get_selection_history",
1795
2404
  "The recent element selections, newest first \u2014 useful when the user clicked a few things and you need to pick the right one.",
1796
2405
  { limit: z.number().int().positive().max(50).optional().describe("How many to list. Defaults to 10.") },
1797
2406
  async ({ limit }) => wrap((await elementTools.getSelectionHistory({ limit })).text)
1798
2407
  );
1799
- server.tool(
2408
+ registerTool(
1800
2409
  "get_element_context",
1801
2410
  "Full context for one element: the element, the page, its key computed styles, the ranked source files that probably render it, and a ready-made edit prompt. Pass thread_id to work from a stored comment, or nothing to use the latest live selection. Call this before editing anything.",
1802
2411
  {
@@ -1805,12 +2414,92 @@ server.tool(
1805
2414
  },
1806
2415
  async ({ thread_id, selection_id, include_prompt }) => wrap((await elementTools.getElementContext({ thread_id, selection_id }, { include_prompt })).text)
1807
2416
  );
1808
- server.tool(
2417
+ registerTool(
1809
2418
  "find_source_for_selection",
1810
2419
  "Just the ranked source files for the current selection or a stored thread \u2014 for when you only need to know where the component lives. Heuristic: verify the top candidate before editing it.",
1811
2420
  SELECTION_ARGS,
1812
2421
  async ({ thread_id, selection_id }) => wrap((await elementTools.findSourceForSelection({ thread_id, selection_id })).text)
1813
2422
  );
2423
+ registerTool(
2424
+ "get_companion_messages",
2425
+ "Read what the person watching has said while you were working. Messages are ALSO delivered automatically on every other tool result, so you rarely need this \u2014 use it when you want to check before finishing a task, or when someone asked you to wait for their input.",
2426
+ { drain: z.boolean().optional().describe("Set false to read without consuming. Default true.") },
2427
+ async ({ drain }) => {
2428
+ const messages = drain === false ? companion.peek() : companion.take();
2429
+ if (drain !== false) companion.ack(messages.map((m) => m.id));
2430
+ if (!messages.length) return wrap("No companion messages waiting.");
2431
+ return wrap(
2432
+ messages.map((m) => {
2433
+ const where = m.contexts?.length ? ` (re: ${m.contexts.map((c) => c.label ?? c.url ?? c.id ?? c.kind).join(", ")})` : "";
2434
+ const atts = m.attachments?.length ? ` [${m.attachments.map((a) => a.url).join(", ")}]` : "";
2435
+ return `[${m.at}] ${m.author?.name ? `${m.author.name}: ` : ""}${m.body}${where}${atts}`;
2436
+ }).join("\n")
2437
+ );
2438
+ }
2439
+ );
2440
+ registerTool(
2441
+ "reply_to_companion",
2442
+ "Answer the person watching, in the panel they are looking at. Use this when a companion message needs a response, when you need a decision before continuing, or when you finish something they asked about.",
2443
+ {
2444
+ body: z.string().describe("Your reply. Markdown is fine."),
2445
+ inReplyTo: z.string().optional().describe("The id of the message you are answering.")
2446
+ },
2447
+ async ({ body, inReplyTo }) => {
2448
+ const reply = companion.addReply({ body, inReplyTo });
2449
+ bus.publishCompanion("reply", reply);
2450
+ return wrap(`Sent to the panel at ${reply.at}.`);
2451
+ }
2452
+ );
2453
+ registerTool(
2454
+ "get_activity_summary",
2455
+ `What this machine's agent sessions have been doing: sessions, tool-call counts, files touched, and whether a session is still live. Use it to understand what has already been tried before starting, or to answer "what have you been working on?".`,
2456
+ {
2457
+ session_id: z.string().optional().describe("Just this session."),
2458
+ limit: z.number().int().positive().max(50).optional().describe("How many sessions to summarize. Default 5.")
2459
+ },
2460
+ async ({ session_id, limit }) => {
2461
+ const all = sessionsFromEvents(events.recent(events.size()));
2462
+ const picked = session_id ? all.filter((s) => s.id === session_id) : all.slice(0, limit ?? 5);
2463
+ if (!picked.length) {
2464
+ return wrap(
2465
+ events.size() ? `No session matched. Known sessions: ${all.map((s) => s.id).join(", ") || "(none)"}.` : "No agent events recorded yet. The hooks may not be installed \u2014 install_agent_hooks adds them."
2466
+ );
2467
+ }
2468
+ return wrap(picked.map(sessionSummaryText).join("\n\n"));
2469
+ }
2470
+ );
2471
+ registerTool(
2472
+ "install_agent_hooks",
2473
+ "Install the Claude Code hooks that report tool use, prompts and sessions to Loupe. Idempotent (running it twice changes nothing), backed up before writing, and it never touches another tool's hook entries. Opt-in: nothing installs these on its own.",
2474
+ {
2475
+ path: z.string().optional().describe("The settings file to write. Defaults to ~/.claude/settings.json.")
2476
+ },
2477
+ async ({ path: custom }) => {
2478
+ const script = process.env.LOUPE_HOOK_SCRIPT || hookScriptPath();
2479
+ const target = custom || process.env.LOUPE_CLAUDE_SETTINGS || `${process.env.HOME || ""}/.claude/settings.json`;
2480
+ const result = installHooksFile(target, script);
2481
+ if (result.error) return wrap(`Could not install: ${result.error}
2482
+
2483
+ Nothing was changed.`);
2484
+ if (!result.wrote) {
2485
+ return wrap(`Already installed and current \u2014 nothing to change (${result.preserved.length} events already wired).
2486
+
2487
+ Settings: ${target}
2488
+ Hook script: ${script}`);
2489
+ }
2490
+ const lines = [
2491
+ `Installed into ${target}.`,
2492
+ result.added.length ? `Added: ${result.added.join(", ")}` : null,
2493
+ result.repaired.length ? `Repaired (pointed somewhere stale): ${result.repaired.join(", ")}` : null,
2494
+ result.backup ? `Backup: ${result.backup}` : null,
2495
+ `Hook script: ${script}`,
2496
+ "",
2497
+ "Restart Claude Code for the hooks to take effect. Loupe's own hooks are marked with",
2498
+ `"${HOOK_MARKER}" and your other hook entries were left untouched.`
2499
+ ].filter(Boolean);
2500
+ return wrap(lines.join("\n"));
2501
+ }
2502
+ );
1814
2503
  var AGENT = {
1815
2504
  id: process.env.LOUPE_AGENT_ID || "loupe-agent",
1816
2505
  name: process.env.LOUPE_AGENT_NAME || "Claude Code"
@@ -1827,7 +2516,7 @@ var handoff = createHandoffTools({
1827
2516
  }),
1828
2517
  listMessages: async (threadId) => await api(`/v1/comments/${encodeURIComponent(threadId)}/messages`)
1829
2518
  });
1830
- server.tool(
2519
+ registerTool(
1831
2520
  "mark_thread_addressed",
1832
2521
  "Hand a thread back to a human: it moves to In Review and, optionally, posts your closing note. Use this when the change is ready. It CANNOT resolve a thread \u2014 only a person does that \u2014 which is why there is no status argument.",
1833
2522
  {
@@ -1836,7 +2525,7 @@ server.tool(
1836
2525
  },
1837
2526
  async ({ thread_id, message }) => wrap((await handoff.markThreadAddressed({ thread_id, message })).text)
1838
2527
  );
1839
- server.tool(
2528
+ registerTool(
1840
2529
  "add_thread_message",
1841
2530
  "Reply on a thread without changing its status \u2014 progress notes, questions, or the preview URL when it goes live. The status is left exactly as it was.",
1842
2531
  {
@@ -1845,13 +2534,13 @@ server.tool(
1845
2534
  },
1846
2535
  async ({ thread_id, message }) => wrap((await handoff.addThreadMessage({ thread_id, message })).text)
1847
2536
  );
1848
- server.tool(
2537
+ registerTool(
1849
2538
  "get_thread_conversation",
1850
2539
  "The whole conversation on a thread: the original request, then every reply with its author. Read it before answering so you are not repeating something already said.",
1851
2540
  { thread_id: z.string() },
1852
2541
  async ({ thread_id }) => wrap((await handoff.getThreadConversation({ thread_id })).text)
1853
2542
  );
1854
- server.tool(
2543
+ registerTool(
1855
2544
  "create_pr_for_thread",
1856
2545
  "Open a pull request for a fix \u2014 or, more usually, add it to the one the repo already has. One working branch per repo accumulates every fix as its own commit, and the PR body keeps a table of them. If that PR was merged or closed, a fresh branch is started automatically. Use `get_element_context` first to find the file, then pass the full new contents of each file you changed.",
1857
2546
  {
@@ -1914,7 +2603,7 @@ function isEntrypoint() {
1914
2603
  }
1915
2604
  }
1916
2605
  if (isEntrypoint()) {
1917
- const bridge = BRIDGE_PORT ? await startHttpBridge(BRIDGE_PORT, { store, registry, presence, bus }) : null;
2606
+ const bridge = BRIDGE_PORT ? await startHttpBridge(BRIDGE_PORT, { store, registry, presence, events, companion, bus }) : null;
1918
2607
  const self = registry.register({
1919
2608
  name: process.env.LOUPE_AGENT_NAME || "loupe-mcp",
1920
2609
  type: process.env.LOUPE_AGENT_TYPE || "claude-code",
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The Loupe hook.
4
+ *
5
+ * Claude Code runs this on each configured event and hands the event on stdin. It
6
+ * forwards to the bridge and gets out of the way.
7
+ *
8
+ * Three rules, all of them about not being the reason someone's agent misbehaves:
9
+ * - **Never block.** A short timeout, and nothing is awaited that could hang.
10
+ * - **Never throw.** A non-zero exit prints noise into the agent's session, and a
11
+ * hook that fails is worse than a hook that did nothing.
12
+ * - **Never write to stdout.** Claude Code interprets hook stdout; a stray line could
13
+ * be read as a decision. Diagnostics go to stderr only when LOUPE_HOOK_DEBUG is set.
14
+ *
15
+ * The bridge binds an ephemeral port, so it publishes where it can be found rather
16
+ * than the port being baked into this command.
17
+ */
18
+
19
+ import { readFileSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ const TIMEOUT_MS = 1200;
24
+
25
+ function debug(...args) {
26
+ if (process.env.LOUPE_HOOK_DEBUG) console.error("[loupe-hook]", ...args);
27
+ }
28
+
29
+ function readStdin() {
30
+ return new Promise((resolve) => {
31
+ // A hook can be run with no stdin at all; resolve empty rather than hang.
32
+ if (process.stdin.isTTY) return resolve("");
33
+ let data = "";
34
+ let done = false;
35
+ const finish = () => {
36
+ if (done) return;
37
+ done = true;
38
+ resolve(data);
39
+ };
40
+ process.stdin.setEncoding("utf8");
41
+ process.stdin.on("data", (chunk) => (data += chunk));
42
+ process.stdin.on("end", finish);
43
+ process.stdin.on("error", finish);
44
+ // Claude Code always closes stdin, but a wedged pipe must not hold the agent.
45
+ setTimeout(finish, 500).unref?.();
46
+ });
47
+ }
48
+
49
+ function stateFile() {
50
+ const dir = process.env.LOUPE_STATE_DIR || join(homedir(), ".loupe");
51
+ return join(dir, "bridge.json");
52
+ }
53
+
54
+ function bridgeUrl() {
55
+ if (process.env.LOUPE_BRIDGE_URL) return process.env.LOUPE_BRIDGE_URL.replace(/\/$/, "");
56
+ try {
57
+ const state = JSON.parse(readFileSync(stateFile(), "utf8"));
58
+ if (state && typeof state.url === "string" && state.url) return state.url.replace(/\/$/, "");
59
+ } catch {
60
+ // No bridge has run on this machine, or the file is stale. Either way there is
61
+ // nothing to report to, and that is not an error.
62
+ }
63
+ return null;
64
+ }
65
+
66
+ async function main() {
67
+ const event = process.argv[2] || "unknown";
68
+ const raw = await readStdin();
69
+ const url = bridgeUrl();
70
+ if (!url) {
71
+ debug("no bridge state file; nothing to report to");
72
+ return;
73
+ }
74
+
75
+ let payload = {};
76
+ if (raw.trim()) {
77
+ try {
78
+ payload = JSON.parse(raw);
79
+ } catch {
80
+ // Keep the raw text; a hook that sends something unparseable still tells us the
81
+ // event happened and when.
82
+ payload = { raw: raw.slice(0, 2000) };
83
+ }
84
+ }
85
+
86
+ const controller = new AbortController();
87
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
88
+ try {
89
+ await fetch(`${url}/events/ingest`, {
90
+ method: "POST",
91
+ headers: { "content-type": "application/json" },
92
+ body: JSON.stringify({ event, payload, at: new Date().toISOString() }),
93
+ signal: controller.signal,
94
+ });
95
+ } catch (e) {
96
+ debug("forward failed:", e?.message ?? e);
97
+ } finally {
98
+ clearTimeout(timer);
99
+ }
100
+ }
101
+
102
+ // Exit 0 whatever happened — see the rules at the top.
103
+ main()
104
+ .catch((e) => debug("unexpected:", e?.message ?? e))
105
+ .finally(() => process.exit(0));
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "email": "m.ashraf.saed@gmail.com",
6
6
  "url": "https://www.linkedin.com/in/mohamedashrafelsaed/"
7
7
  },
8
- "version": "0.10.25",
8
+ "version": "0.10.26-next.73",
9
9
  "description": "MCP server that exposes Loupe comments to Claude Code as an actionable, fully-contextualized backlog.",
10
10
  "keywords": [
11
11
  "loupe",
@@ -30,7 +30,8 @@
30
30
  "type": "module",
31
31
  "main": "./dist/index.js",
32
32
  "files": [
33
- "dist"
33
+ "dist",
34
+ "hooks"
34
35
  ],
35
36
  "bin": {
36
37
  "loupe-mcp": "./dist/index.js"
@@ -46,6 +47,6 @@
46
47
  "zod": "^3.24.1"
47
48
  },
48
49
  "devDependencies": {
49
- "@loupekit/shared": "0.10.25"
50
+ "@loupekit/shared": "0.10.26-next.73"
50
51
  }
51
52
  }