@buoy-gg/mcp 7.0.17 → 7.0.19

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.
@@ -24,8 +24,12 @@ var SyncClient = class {
24
24
  this.devices = [];
25
25
  /** Latest snapshot payload per `${deviceId}::${toolId}`. */
26
26
  this.snapshots = /* @__PURE__ */ new Map();
27
- /** Tools we've asked the broker to keep streaming, as `${deviceId}::${toolId}`. */
28
- this.watching = /* @__PURE__ */ new Set();
27
+ /**
28
+ * Capture-mode watches this client holds, keyed by snapKey. Stored with
29
+ * their addressing because they have to be RE-SENT on every reconnect: the
30
+ * broker keys watch state by socket id, and a reconnect gets a new one.
31
+ */
32
+ this.watching = /* @__PURE__ */ new Map();
29
33
  /** In-flight actions awaiting a result, keyed by requestId. */
30
34
  this.pendingActions = /* @__PURE__ */ new Map();
31
35
  /** Callbacks waiting for the next snapshot of `${deviceId}::${toolId}`. */
@@ -81,6 +85,7 @@ var SyncClient = class {
81
85
  resolve();
82
86
  };
83
87
  socket.on("connect", onConnect);
88
+ socket.on("connect", () => this.rearmWatches());
84
89
  socket.once("connect_error", (err) => {
85
90
  if (settled) return;
86
91
  settled = true;
@@ -191,11 +196,50 @@ var SyncClient = class {
191
196
  // ----------------------------------------------------------------
192
197
  // Watch + snapshots
193
198
  // ----------------------------------------------------------------
199
+ /**
200
+ * Keep the device RECORDING this tool, without asking it to stream.
201
+ *
202
+ * A dashboard watches for as long as its panel is open; this client is
203
+ * request/response, so it used to watch and unwatch around every single
204
+ * read. That works for a tool whose state just IS (the query cache, the env
205
+ * table) and is useless for one that records a timeline: the unwatch drops
206
+ * the device's subscriber count to zero, which stops capture, so nothing is
207
+ * ever recorded between two reads. The list comes back empty and reads as
208
+ * "the app made no requests" — a wrong conclusion that is very expensive to
209
+ * chase, because everything about the app looks fine.
210
+ *
211
+ * Capture-mode watches are held for the life of the connection and released
212
+ * on disconnect (the broker also drops them for us). They cost the device's
213
+ * ring buffer, not the socket: no snapshot is pushed, and reads still go
214
+ * through `request-devtool-state`.
215
+ */
194
216
  ensureWatching(toolId, targetDeviceId) {
195
217
  const key = snapKey(targetDeviceId, toolId);
196
218
  if (this.watching.has(key)) return;
197
- this.watching.add(key);
198
- this.socket?.emit("devtool-watch", { toolId, targetDeviceId, watching: true });
219
+ this.watching.set(key, { toolId, targetDeviceId });
220
+ this.sendWatch(toolId, targetDeviceId);
221
+ }
222
+ sendWatch(toolId, targetDeviceId) {
223
+ this.socket?.emit("devtool-watch", {
224
+ toolId,
225
+ targetDeviceId,
226
+ watching: true,
227
+ stream: false
228
+ });
229
+ }
230
+ /**
231
+ * Re-arm every held watch after a reconnect.
232
+ *
233
+ * The broker tracks watches per SOCKET, so a reconnect (broker restart, a
234
+ * dropped wifi link) silently discards all of ours while `watching` still
235
+ * says we hold them — `ensureWatching` would then short-circuit forever and
236
+ * the device would never record again. Silent and permanent: the exact
237
+ * failure this whole capture-mode change exists to remove.
238
+ */
239
+ rearmWatches() {
240
+ for (const { toolId, targetDeviceId } of this.watching.values()) {
241
+ this.sendWatch(toolId, targetDeviceId);
242
+ }
199
243
  }
200
244
  /** Stop streaming a tool (releases the device-side backpressure). */
201
245
  unwatch(toolId, targetDeviceId) {
@@ -208,26 +252,25 @@ var SyncClient = class {
208
252
  return this.snapshots.get(snapKey(deviceId, toolId));
209
253
  }
210
254
  /**
211
- * Get a tool's current state. Starts watching (so the device begins emitting),
212
- * explicitly requests state, and resolves with the next snapshot. Falls back
213
- * to the last cached snapshot if the request times out.
255
+ * Get a tool's current state: arm capture (once, for the connection), ask
256
+ * for state explicitly, and resolve with the snapshot that comes back.
257
+ * Falls back to the last cached snapshot if the request times out.
258
+ *
259
+ * The capture watch is deliberately NOT released afterwards — see
260
+ * `ensureWatching`. It costs the device nothing on the socket, and dropping
261
+ * it is what made every timeline tool look permanently empty from here.
214
262
  */
215
263
  async getSnapshot(toolId, deviceId, opts = {}) {
216
264
  const target = this.resolveDevice(deviceId);
217
265
  const key = snapKey(target, toolId);
218
- const wasWatching = this.watching.has(key);
219
266
  this.ensureWatching(toolId, target);
220
- try {
221
- const next = this.waitForSnapshot(key, opts.timeoutMs ?? DEFAULT_SNAPSHOT_TIMEOUT_MS);
222
- this.socket?.emit("request-devtool-state", { toolId, targetDeviceId: target });
223
- const payload = await next;
224
- if (payload !== void 0) return { deviceId: target, payload };
225
- const cached = this.snapshots.get(key);
226
- if (cached !== void 0) return { deviceId: target, payload: cached };
227
- throw new Error(`Timed out waiting for "${toolId}" state from ${target} (is the tool active?)`);
228
- } finally {
229
- if (!wasWatching) this.unwatch(toolId, target);
230
- }
267
+ const next = this.waitForSnapshot(key, opts.timeoutMs ?? DEFAULT_SNAPSHOT_TIMEOUT_MS);
268
+ this.socket?.emit("request-devtool-state", { toolId, targetDeviceId: target });
269
+ const payload = await next;
270
+ if (payload !== void 0) return { deviceId: target, payload };
271
+ const cached = this.snapshots.get(key);
272
+ if (cached !== void 0) return { deviceId: target, payload: cached };
273
+ throw new Error(`Timed out waiting for "${toolId}" state from ${target} (is the tool active?)`);
231
274
  }
232
275
  waitForSnapshot(key, timeoutMs) {
233
276
  return new Promise((resolve) => {
@@ -301,7 +344,7 @@ async function waitForBroker(url, attempts = 20) {
301
344
  var delay = (ms) => new Promise((r) => setTimeout(r, ms));
302
345
 
303
346
  // src/version.ts
304
- var VERSION = "7.0.17" ? "7.0.17" : "0.0.0-dev";
347
+ var VERSION = "7.0.19" ? "7.0.19" : "0.0.0-dev";
305
348
  var PACKAGE_NAME = "@buoy-gg/mcp";
306
349
 
307
350
  // src/update/checkForUpdate.ts
@@ -2079,6 +2122,60 @@ function truncate2(v, n = 600) {
2079
2122
  const s = typeof v === "string" ? v : JSON.stringify(v);
2080
2123
  return s.length > n ? `${s.slice(0, n)}\u2026(+${s.length - n} chars)` : s;
2081
2124
  }
2125
+ var MAX_BODY_FETCHES = 8;
2126
+ async function resolveBodies(sync, rows, target, max = MAX_BODY_FETCHES) {
2127
+ const needed = rows.filter(
2128
+ (e) => e.id && (e.requestBodyOmitted === true && e.requestData === void 0 || e.responseBodyOmitted === true && e.responseData === void 0)
2129
+ );
2130
+ const bodies = /* @__PURE__ */ new Map();
2131
+ if (needed.length === 0) return { bodies, skipped: 0 };
2132
+ const take = needed.slice(0, max);
2133
+ const settled = await Promise.allSettled(
2134
+ take.map(
2135
+ (e) => sync.callAction(NETWORK_TOOL_ID, "getEventBody", { id: e.id }, target)
2136
+ )
2137
+ );
2138
+ settled.forEach((r, i) => {
2139
+ const id = take[i].id;
2140
+ const body = r.status === "fulfilled" ? r.value : null;
2141
+ bodies.set(id, body ? { ...body } : { unresolved: true });
2142
+ });
2143
+ return { bodies, skipped: needed.length - take.length };
2144
+ }
2145
+ async function captureDiagnosis(sync, target) {
2146
+ let status = null;
2147
+ try {
2148
+ status = await sync.callAction(
2149
+ NETWORK_TOOL_ID,
2150
+ "getCaptureStatus",
2151
+ {},
2152
+ target
2153
+ );
2154
+ } catch {
2155
+ return "The network tool has captured nothing yet.";
2156
+ }
2157
+ if (!status || status.capturing !== false) {
2158
+ return "The device IS capturing, so nothing has been requested since it started.";
2159
+ }
2160
+ return "The device is NOT capturing. Reading from here arms capture, so the very next read should see traffic \u2014 trigger it and call again. This is not an app or interception problem. (If it stays off, the device is on a Buoy older than the capture-mode watch: open the Network panel on the device or in Buoy Desktop to record.)";
2161
+ }
2162
+ function skippedNote(skipped) {
2163
+ return skipped > 0 ? `
2164
+
2165
+ _${skipped} more withheld ${skipped === 1 ? "body was" : "bodies were"} not fetched (cap ${MAX_BODY_FETCHES} per listing, they are large). Get one in full with its id._` : "";
2166
+ }
2167
+ function bodyLines(e, fetched) {
2168
+ const got = e.id ? fetched.get(e.id) : void 0;
2169
+ const label = (inline, resolved, omitted) => {
2170
+ const value = inline ?? resolved;
2171
+ if (value !== void 0 && value !== null) return truncate2(value);
2172
+ if (!omitted) return "(none)";
2173
+ return got?.unresolved ? "(withheld \u2014 the device did not answer getEventBody)" : "(none)";
2174
+ };
2175
+ return `
2176
+ \u2514\u2500 request: ${label(e.requestData, got?.requestData, e.requestBodyOmitted)}
2177
+ \u2514\u2500 response: ${label(e.responseData, got?.responseData, e.responseBodyOmitted)}`;
2178
+ }
2082
2179
  function formatRow(index, e, markers) {
2083
2180
  const where = e.path || e.url;
2084
2181
  const bits = [
@@ -2132,6 +2229,7 @@ function registerNetworkTools(server, sync) {
2132
2229
  The developer pins/saves a request from the Network tool (long-press a row, or the pin/bookmark buttons in the request detail header), or you can with network_action.`
2133
2230
  );
2134
2231
  }
2232
+ const kept = args.includeBodies ? await resolveBodies(sync, rows2.map((r) => r.event), target) : { bodies: /* @__PURE__ */ new Map(), skipped: 0 };
2135
2233
  const body2 = rows2.map((r, i) => {
2136
2234
  const marks = [r.pinned ? "\u{1F4CC} pinned" : "", r.saved ? "\u{1F516} saved" : ""].filter(Boolean).join(" ");
2137
2235
  const stale = r.event.id?.startsWith("saved:") ? " \xB7 from an earlier app run" : "";
@@ -2139,12 +2237,7 @@ The developer pins/saves a request from the Network tool (long-press a row, or t
2139
2237
  let out = formatRow(i + 1, r.event, marks);
2140
2238
  out += `
2141
2239
  \u2514\u2500 key \`${r.key}\`${stale}${trunc}`;
2142
- if (args.includeBodies) {
2143
- out += `
2144
- \u2514\u2500 request: ${truncate2(r.event.requestData)}`;
2145
- out += `
2146
- \u2514\u2500 response: ${truncate2(r.event.responseData)}`;
2147
- }
2240
+ if (args.includeBodies) out += bodyLines(r.event, kept.bodies);
2148
2241
  return out;
2149
2242
  }).join("\n");
2150
2243
  return text10(
@@ -2152,23 +2245,19 @@ The developer pins/saves a request from the Network tool (long-press a row, or t
2152
2245
 
2153
2246
  ${body2}
2154
2247
 
2155
- _These are snapshots: they survive Clear, the 500-request cap and app restarts. Remove one with network_action({action:"remove", key}). Ids starting \`saved:\` are from an earlier run of the app._`
2248
+ _These are snapshots: they survive Clear, the 500-request cap and app restarts. Remove one with network_action({action:"remove", key}). Ids starting \`saved:\` are from an earlier run of the app._${skippedNote(kept.skipped)}`
2156
2249
  );
2157
2250
  }
2158
2251
  const rows = events.filter((e) => matchesStatus(e, args.status)).filter((e) => !pattern || (e.url ?? "").toLowerCase().includes(pattern)).slice(0, limit);
2159
2252
  if (rows.length === 0) {
2160
- const why = events.length === 0 ? "The network tool has captured nothing yet (is it enabled, and has the app made a request?)." : "No request matched the filter.";
2253
+ const why = events.length === 0 ? await captureDiagnosis(sync, target) : "No request matched the filter.";
2161
2254
  return text10(`No requests on ${target}. ${why}`);
2162
2255
  }
2256
+ const live = args.includeBodies ? await resolveBodies(sync, rows, target) : { bodies: /* @__PURE__ */ new Map(), skipped: 0 };
2163
2257
  const body = rows.map((e, i) => {
2164
2258
  const marks = [pinnedIds.has(e.id) ? "\u{1F4CC}" : "", savedIds.has(e.id) ? "\u{1F516}" : ""].filter(Boolean).join("");
2165
2259
  let out = formatRow(i + 1, e, marks);
2166
- if (args.includeBodies) {
2167
- out += `
2168
- \u2514\u2500 request: ${truncate2(e.requestData)}`;
2169
- out += `
2170
- \u2514\u2500 response: ${truncate2(e.responseData)}`;
2171
- }
2260
+ if (args.includeBodies) out += bodyLines(e, live.bodies);
2172
2261
  return out;
2173
2262
  }).join("\n");
2174
2263
  const keptNote = saved.length > 0 ? ` \xB7 ${saved.length} kept (flagged:'any' to list)` : "";
@@ -2177,7 +2266,7 @@ _These are snapshots: they survive Clear, the 500-request cap and app restarts.
2177
2266
 
2178
2267
  ${body}
2179
2268
 
2180
- _\u{1F4CC} pinned \xB7 \u{1F516} saved. Act on one with its id, e.g. network_action({action:"pin", id:"${rows[0].id}"})._`
2269
+ _\u{1F4CC} pinned \xB7 \u{1F516} saved. Act on one with its id, e.g. network_action({action:"pin", id:"${rows[0].id}"})._${skippedNote(live.skipped)}`
2181
2270
  );
2182
2271
  } catch (e) {
2183
2272
  return fail12(errMsg12(e));
@@ -2530,12 +2619,13 @@ ${formatOutcome(p.lastOutcome)}`);
2530
2619
  server.registerTool(
2531
2620
  "time_machine_action",
2532
2621
  {
2533
- description: "Manage Time Machine state snapshots: capture the app's current state as a named restore point, restore one (mode 'live' replaces state in place; 'reload' restores persisted storage then reloads the JS bundle \u2014 baselines always reload), capture_baseline (an empty wipe-to-fresh-install restore point), delete, or rename. The restore result reports per-source applied/skipped counts \u2014 check them rather than assuming success.",
2622
+ description: "Manage Time Machine state snapshots: capture the app's current state as a named restore point, preview what restoring one would change (per-item added/removed/changed/won't-apply verdicts \u2014 check the blast radius BEFORE restoring), restore one (mode 'live' replaces state in place; 'reload' restores persisted storage then reloads the JS bundle \u2014 baselines always reload), capture_baseline (an empty wipe-to-fresh-install restore point), delete, or rename. The restore result reports per-source applied/skipped counts \u2014 check them rather than assuming success.",
2534
2623
  inputSchema: {
2535
- action: z13.enum(["capture", "capture_baseline", "restore", "delete", "rename"]).describe("What to do."),
2624
+ action: z13.enum(["capture", "capture_baseline", "preview", "restore", "delete", "rename"]).describe("What to do."),
2536
2625
  id: z13.string().optional().describe("Snapshot id (required for restore/delete/rename; from get_time_machine)."),
2537
2626
  name: z13.string().optional().describe("Snapshot name (for capture and rename)."),
2538
2627
  mode: z13.enum(["live", "reload"]).optional().describe("Restore mode. Default 'live'."),
2628
+ excludeKeys: z13.array(z13.string()).optional().describe('Restore only: item keys to LEAVE UNTOUCHED, as "sourceId::itemKey" (itemKey = the key shown by the preview action, e.g. "query::[\\"pokemon\\"]" or "storage::async:@cart").'),
2539
2629
  sourceIds: z13.array(z13.string()).optional().describe("Capture/restore only these source ids (default all)."),
2540
2630
  deviceId: z13.string().optional().describe("Target device id (auto-selected if one is connected).")
2541
2631
  }
@@ -2556,9 +2646,36 @@ ${formatOutcome(p.lastOutcome)}`);
2556
2646
  const result = await call("captureBaseline", {});
2557
2647
  return text11(`Captured baseline \`${result.snapshot.id}\` \u2014 restoring it wipes app storage and reloads.`);
2558
2648
  }
2649
+ case "preview": {
2650
+ if (!args.id) return fail13("preview requires `id` (from get_time_machine).");
2651
+ const result = await call("preview", { id: args.id });
2652
+ const lines = [];
2653
+ let totalApplies = 0;
2654
+ for (const src of result.sources) {
2655
+ const counts = {};
2656
+ for (const item of src.items) {
2657
+ counts[item.verdict] = (counts[item.verdict] ?? 0) + 1;
2658
+ }
2659
+ const applies = (counts.added ?? 0) + (counts.removed ?? 0) + (counts.changed ?? 0) + (counts["too-large"] ?? 0);
2660
+ totalApplies += applies;
2661
+ const parts = Object.entries(counts).filter(([v]) => v !== "unchanged").map(([v, n]) => `${n} ${v}`);
2662
+ lines.push(`- **${src.label}**: ${parts.length ? parts.join(", ") : "no changes"}${src.error ? ` \u2014 \u26A0 ${src.error}` : ""}`);
2663
+ for (const item of src.items.filter((i) => i.verdict !== "unchanged").slice(0, 8)) {
2664
+ lines.push(` ${item.verdict === "added" ? "+" : item.verdict === "removed" ? "\u2212" : item.verdict === "wont-apply" ? "\u2298" : "\u2248"} ${item.label}${item.reason ? ` (${item.reason})` : ""}`);
2665
+ }
2666
+ for (const note of src.notes) lines.push(` \xB7 ${note}`);
2667
+ }
2668
+ const head = totalApplies === 0 ? "**Restore would change NOTHING** (states identical or nothing applicable)." : `**Restore would apply ${totalApplies} change(s):**`;
2669
+ return text11(`${head}
2670
+ ${lines.join("\n")}`);
2671
+ }
2559
2672
  case "restore": {
2560
2673
  if (!args.id) return fail13("restore requires `id` (from get_time_machine).");
2561
- const outcome = await call("restore", { id: args.id, mode: args.mode ?? "live" });
2674
+ const outcome = await call("restore", {
2675
+ id: args.id,
2676
+ mode: args.mode ?? "live",
2677
+ excludeKeys: args.excludeKeys
2678
+ });
2562
2679
  return text11(formatOutcome(outcome));
2563
2680
  }
2564
2681
  case "delete": {
package/dist/cli.js CHANGED
@@ -52,8 +52,12 @@ var SyncClient = class {
52
52
  this.devices = [];
53
53
  /** Latest snapshot payload per `${deviceId}::${toolId}`. */
54
54
  this.snapshots = /* @__PURE__ */ new Map();
55
- /** Tools we've asked the broker to keep streaming, as `${deviceId}::${toolId}`. */
56
- this.watching = /* @__PURE__ */ new Set();
55
+ /**
56
+ * Capture-mode watches this client holds, keyed by snapKey. Stored with
57
+ * their addressing because they have to be RE-SENT on every reconnect: the
58
+ * broker keys watch state by socket id, and a reconnect gets a new one.
59
+ */
60
+ this.watching = /* @__PURE__ */ new Map();
57
61
  /** In-flight actions awaiting a result, keyed by requestId. */
58
62
  this.pendingActions = /* @__PURE__ */ new Map();
59
63
  /** Callbacks waiting for the next snapshot of `${deviceId}::${toolId}`. */
@@ -109,6 +113,7 @@ var SyncClient = class {
109
113
  resolve();
110
114
  };
111
115
  socket.on("connect", onConnect);
116
+ socket.on("connect", () => this.rearmWatches());
112
117
  socket.once("connect_error", (err) => {
113
118
  if (settled) return;
114
119
  settled = true;
@@ -219,11 +224,50 @@ var SyncClient = class {
219
224
  // ----------------------------------------------------------------
220
225
  // Watch + snapshots
221
226
  // ----------------------------------------------------------------
227
+ /**
228
+ * Keep the device RECORDING this tool, without asking it to stream.
229
+ *
230
+ * A dashboard watches for as long as its panel is open; this client is
231
+ * request/response, so it used to watch and unwatch around every single
232
+ * read. That works for a tool whose state just IS (the query cache, the env
233
+ * table) and is useless for one that records a timeline: the unwatch drops
234
+ * the device's subscriber count to zero, which stops capture, so nothing is
235
+ * ever recorded between two reads. The list comes back empty and reads as
236
+ * "the app made no requests" — a wrong conclusion that is very expensive to
237
+ * chase, because everything about the app looks fine.
238
+ *
239
+ * Capture-mode watches are held for the life of the connection and released
240
+ * on disconnect (the broker also drops them for us). They cost the device's
241
+ * ring buffer, not the socket: no snapshot is pushed, and reads still go
242
+ * through `request-devtool-state`.
243
+ */
222
244
  ensureWatching(toolId, targetDeviceId) {
223
245
  const key = snapKey(targetDeviceId, toolId);
224
246
  if (this.watching.has(key)) return;
225
- this.watching.add(key);
226
- this.socket?.emit("devtool-watch", { toolId, targetDeviceId, watching: true });
247
+ this.watching.set(key, { toolId, targetDeviceId });
248
+ this.sendWatch(toolId, targetDeviceId);
249
+ }
250
+ sendWatch(toolId, targetDeviceId) {
251
+ this.socket?.emit("devtool-watch", {
252
+ toolId,
253
+ targetDeviceId,
254
+ watching: true,
255
+ stream: false
256
+ });
257
+ }
258
+ /**
259
+ * Re-arm every held watch after a reconnect.
260
+ *
261
+ * The broker tracks watches per SOCKET, so a reconnect (broker restart, a
262
+ * dropped wifi link) silently discards all of ours while `watching` still
263
+ * says we hold them — `ensureWatching` would then short-circuit forever and
264
+ * the device would never record again. Silent and permanent: the exact
265
+ * failure this whole capture-mode change exists to remove.
266
+ */
267
+ rearmWatches() {
268
+ for (const { toolId, targetDeviceId } of this.watching.values()) {
269
+ this.sendWatch(toolId, targetDeviceId);
270
+ }
227
271
  }
228
272
  /** Stop streaming a tool (releases the device-side backpressure). */
229
273
  unwatch(toolId, targetDeviceId) {
@@ -236,26 +280,25 @@ var SyncClient = class {
236
280
  return this.snapshots.get(snapKey(deviceId, toolId));
237
281
  }
238
282
  /**
239
- * Get a tool's current state. Starts watching (so the device begins emitting),
240
- * explicitly requests state, and resolves with the next snapshot. Falls back
241
- * to the last cached snapshot if the request times out.
283
+ * Get a tool's current state: arm capture (once, for the connection), ask
284
+ * for state explicitly, and resolve with the snapshot that comes back.
285
+ * Falls back to the last cached snapshot if the request times out.
286
+ *
287
+ * The capture watch is deliberately NOT released afterwards — see
288
+ * `ensureWatching`. It costs the device nothing on the socket, and dropping
289
+ * it is what made every timeline tool look permanently empty from here.
242
290
  */
243
291
  async getSnapshot(toolId, deviceId, opts = {}) {
244
292
  const target = this.resolveDevice(deviceId);
245
293
  const key = snapKey(target, toolId);
246
- const wasWatching = this.watching.has(key);
247
294
  this.ensureWatching(toolId, target);
248
- try {
249
- const next = this.waitForSnapshot(key, opts.timeoutMs ?? DEFAULT_SNAPSHOT_TIMEOUT_MS);
250
- this.socket?.emit("request-devtool-state", { toolId, targetDeviceId: target });
251
- const payload = await next;
252
- if (payload !== void 0) return { deviceId: target, payload };
253
- const cached = this.snapshots.get(key);
254
- if (cached !== void 0) return { deviceId: target, payload: cached };
255
- throw new Error(`Timed out waiting for "${toolId}" state from ${target} (is the tool active?)`);
256
- } finally {
257
- if (!wasWatching) this.unwatch(toolId, target);
258
- }
295
+ const next = this.waitForSnapshot(key, opts.timeoutMs ?? DEFAULT_SNAPSHOT_TIMEOUT_MS);
296
+ this.socket?.emit("request-devtool-state", { toolId, targetDeviceId: target });
297
+ const payload = await next;
298
+ if (payload !== void 0) return { deviceId: target, payload };
299
+ const cached = this.snapshots.get(key);
300
+ if (cached !== void 0) return { deviceId: target, payload: cached };
301
+ throw new Error(`Timed out waiting for "${toolId}" state from ${target} (is the tool active?)`);
259
302
  }
260
303
  waitForSnapshot(key, timeoutMs) {
261
304
  return new Promise((resolve) => {
@@ -341,7 +384,7 @@ var import_node_os = __toESM(require("os"));
341
384
  var import_node_path = __toESM(require("path"));
342
385
 
343
386
  // src/version.ts
344
- var VERSION = "7.0.17" ? "7.0.17" : "0.0.0-dev";
387
+ var VERSION = "7.0.19" ? "7.0.19" : "0.0.0-dev";
345
388
  var PACKAGE_NAME = "@buoy-gg/mcp";
346
389
 
347
390
  // src/update/checkForUpdate.ts
@@ -1342,6 +1385,60 @@ function truncate(v, n = 600) {
1342
1385
  const s = typeof v === "string" ? v : JSON.stringify(v);
1343
1386
  return s.length > n ? `${s.slice(0, n)}\u2026(+${s.length - n} chars)` : s;
1344
1387
  }
1388
+ var MAX_BODY_FETCHES = 8;
1389
+ async function resolveBodies(sync, rows, target, max = MAX_BODY_FETCHES) {
1390
+ const needed = rows.filter(
1391
+ (e) => e.id && (e.requestBodyOmitted === true && e.requestData === void 0 || e.responseBodyOmitted === true && e.responseData === void 0)
1392
+ );
1393
+ const bodies = /* @__PURE__ */ new Map();
1394
+ if (needed.length === 0) return { bodies, skipped: 0 };
1395
+ const take = needed.slice(0, max);
1396
+ const settled = await Promise.allSettled(
1397
+ take.map(
1398
+ (e) => sync.callAction(NETWORK_TOOL_ID, "getEventBody", { id: e.id }, target)
1399
+ )
1400
+ );
1401
+ settled.forEach((r, i) => {
1402
+ const id = take[i].id;
1403
+ const body = r.status === "fulfilled" ? r.value : null;
1404
+ bodies.set(id, body ? { ...body } : { unresolved: true });
1405
+ });
1406
+ return { bodies, skipped: needed.length - take.length };
1407
+ }
1408
+ async function captureDiagnosis(sync, target) {
1409
+ let status = null;
1410
+ try {
1411
+ status = await sync.callAction(
1412
+ NETWORK_TOOL_ID,
1413
+ "getCaptureStatus",
1414
+ {},
1415
+ target
1416
+ );
1417
+ } catch {
1418
+ return "The network tool has captured nothing yet.";
1419
+ }
1420
+ if (!status || status.capturing !== false) {
1421
+ return "The device IS capturing, so nothing has been requested since it started.";
1422
+ }
1423
+ return "The device is NOT capturing. Reading from here arms capture, so the very next read should see traffic \u2014 trigger it and call again. This is not an app or interception problem. (If it stays off, the device is on a Buoy older than the capture-mode watch: open the Network panel on the device or in Buoy Desktop to record.)";
1424
+ }
1425
+ function skippedNote(skipped) {
1426
+ return skipped > 0 ? `
1427
+
1428
+ _${skipped} more withheld ${skipped === 1 ? "body was" : "bodies were"} not fetched (cap ${MAX_BODY_FETCHES} per listing, they are large). Get one in full with its id._` : "";
1429
+ }
1430
+ function bodyLines(e, fetched) {
1431
+ const got = e.id ? fetched.get(e.id) : void 0;
1432
+ const label = (inline, resolved, omitted) => {
1433
+ const value = inline ?? resolved;
1434
+ if (value !== void 0 && value !== null) return truncate(value);
1435
+ if (!omitted) return "(none)";
1436
+ return got?.unresolved ? "(withheld \u2014 the device did not answer getEventBody)" : "(none)";
1437
+ };
1438
+ return `
1439
+ \u2514\u2500 request: ${label(e.requestData, got?.requestData, e.requestBodyOmitted)}
1440
+ \u2514\u2500 response: ${label(e.responseData, got?.responseData, e.responseBodyOmitted)}`;
1441
+ }
1345
1442
  function formatRow(index, e, markers) {
1346
1443
  const where = e.path || e.url;
1347
1444
  const bits = [
@@ -1395,6 +1492,7 @@ function registerNetworkTools(server, sync) {
1395
1492
  The developer pins/saves a request from the Network tool (long-press a row, or the pin/bookmark buttons in the request detail header), or you can with network_action.`
1396
1493
  );
1397
1494
  }
1495
+ const kept = args.includeBodies ? await resolveBodies(sync, rows2.map((r) => r.event), target) : { bodies: /* @__PURE__ */ new Map(), skipped: 0 };
1398
1496
  const body2 = rows2.map((r, i) => {
1399
1497
  const marks = [r.pinned ? "\u{1F4CC} pinned" : "", r.saved ? "\u{1F516} saved" : ""].filter(Boolean).join(" ");
1400
1498
  const stale = r.event.id?.startsWith("saved:") ? " \xB7 from an earlier app run" : "";
@@ -1402,12 +1500,7 @@ The developer pins/saves a request from the Network tool (long-press a row, or t
1402
1500
  let out = formatRow(i + 1, r.event, marks);
1403
1501
  out += `
1404
1502
  \u2514\u2500 key \`${r.key}\`${stale}${trunc}`;
1405
- if (args.includeBodies) {
1406
- out += `
1407
- \u2514\u2500 request: ${truncate(r.event.requestData)}`;
1408
- out += `
1409
- \u2514\u2500 response: ${truncate(r.event.responseData)}`;
1410
- }
1503
+ if (args.includeBodies) out += bodyLines(r.event, kept.bodies);
1411
1504
  return out;
1412
1505
  }).join("\n");
1413
1506
  return text3(
@@ -1415,23 +1508,19 @@ The developer pins/saves a request from the Network tool (long-press a row, or t
1415
1508
 
1416
1509
  ${body2}
1417
1510
 
1418
- _These are snapshots: they survive Clear, the 500-request cap and app restarts. Remove one with network_action({action:"remove", key}). Ids starting \`saved:\` are from an earlier run of the app._`
1511
+ _These are snapshots: they survive Clear, the 500-request cap and app restarts. Remove one with network_action({action:"remove", key}). Ids starting \`saved:\` are from an earlier run of the app._${skippedNote(kept.skipped)}`
1419
1512
  );
1420
1513
  }
1421
1514
  const rows = events.filter((e) => matchesStatus(e, args.status)).filter((e) => !pattern || (e.url ?? "").toLowerCase().includes(pattern)).slice(0, limit);
1422
1515
  if (rows.length === 0) {
1423
- const why = events.length === 0 ? "The network tool has captured nothing yet (is it enabled, and has the app made a request?)." : "No request matched the filter.";
1516
+ const why = events.length === 0 ? await captureDiagnosis(sync, target) : "No request matched the filter.";
1424
1517
  return text3(`No requests on ${target}. ${why}`);
1425
1518
  }
1519
+ const live = args.includeBodies ? await resolveBodies(sync, rows, target) : { bodies: /* @__PURE__ */ new Map(), skipped: 0 };
1426
1520
  const body = rows.map((e, i) => {
1427
1521
  const marks = [pinnedIds.has(e.id) ? "\u{1F4CC}" : "", savedIds.has(e.id) ? "\u{1F516}" : ""].filter(Boolean).join("");
1428
1522
  let out = formatRow(i + 1, e, marks);
1429
- if (args.includeBodies) {
1430
- out += `
1431
- \u2514\u2500 request: ${truncate(e.requestData)}`;
1432
- out += `
1433
- \u2514\u2500 response: ${truncate(e.responseData)}`;
1434
- }
1523
+ if (args.includeBodies) out += bodyLines(e, live.bodies);
1435
1524
  return out;
1436
1525
  }).join("\n");
1437
1526
  const keptNote = saved.length > 0 ? ` \xB7 ${saved.length} kept (flagged:'any' to list)` : "";
@@ -1440,7 +1529,7 @@ _These are snapshots: they survive Clear, the 500-request cap and app restarts.
1440
1529
 
1441
1530
  ${body}
1442
1531
 
1443
- _\u{1F4CC} pinned \xB7 \u{1F516} saved. Act on one with its id, e.g. network_action({action:"pin", id:"${rows[0].id}"})._`
1532
+ _\u{1F4CC} pinned \xB7 \u{1F516} saved. Act on one with its id, e.g. network_action({action:"pin", id:"${rows[0].id}"})._${skippedNote(live.skipped)}`
1444
1533
  );
1445
1534
  } catch (e) {
1446
1535
  return fail4(errMsg4(e));
@@ -2388,12 +2477,13 @@ ${formatOutcome(p.lastOutcome)}`);
2388
2477
  server.registerTool(
2389
2478
  "time_machine_action",
2390
2479
  {
2391
- description: "Manage Time Machine state snapshots: capture the app's current state as a named restore point, restore one (mode 'live' replaces state in place; 'reload' restores persisted storage then reloads the JS bundle \u2014 baselines always reload), capture_baseline (an empty wipe-to-fresh-install restore point), delete, or rename. The restore result reports per-source applied/skipped counts \u2014 check them rather than assuming success.",
2480
+ description: "Manage Time Machine state snapshots: capture the app's current state as a named restore point, preview what restoring one would change (per-item added/removed/changed/won't-apply verdicts \u2014 check the blast radius BEFORE restoring), restore one (mode 'live' replaces state in place; 'reload' restores persisted storage then reloads the JS bundle \u2014 baselines always reload), capture_baseline (an empty wipe-to-fresh-install restore point), delete, or rename. The restore result reports per-source applied/skipped counts \u2014 check them rather than assuming success.",
2392
2481
  inputSchema: {
2393
- action: import_zod12.z.enum(["capture", "capture_baseline", "restore", "delete", "rename"]).describe("What to do."),
2482
+ action: import_zod12.z.enum(["capture", "capture_baseline", "preview", "restore", "delete", "rename"]).describe("What to do."),
2394
2483
  id: import_zod12.z.string().optional().describe("Snapshot id (required for restore/delete/rename; from get_time_machine)."),
2395
2484
  name: import_zod12.z.string().optional().describe("Snapshot name (for capture and rename)."),
2396
2485
  mode: import_zod12.z.enum(["live", "reload"]).optional().describe("Restore mode. Default 'live'."),
2486
+ excludeKeys: import_zod12.z.array(import_zod12.z.string()).optional().describe('Restore only: item keys to LEAVE UNTOUCHED, as "sourceId::itemKey" (itemKey = the key shown by the preview action, e.g. "query::[\\"pokemon\\"]" or "storage::async:@cart").'),
2397
2487
  sourceIds: import_zod12.z.array(import_zod12.z.string()).optional().describe("Capture/restore only these source ids (default all)."),
2398
2488
  deviceId: import_zod12.z.string().optional().describe("Target device id (auto-selected if one is connected).")
2399
2489
  }
@@ -2414,9 +2504,36 @@ ${formatOutcome(p.lastOutcome)}`);
2414
2504
  const result = await call("captureBaseline", {});
2415
2505
  return text11(`Captured baseline \`${result.snapshot.id}\` \u2014 restoring it wipes app storage and reloads.`);
2416
2506
  }
2507
+ case "preview": {
2508
+ if (!args.id) return fail12("preview requires `id` (from get_time_machine).");
2509
+ const result = await call("preview", { id: args.id });
2510
+ const lines = [];
2511
+ let totalApplies = 0;
2512
+ for (const src of result.sources) {
2513
+ const counts = {};
2514
+ for (const item of src.items) {
2515
+ counts[item.verdict] = (counts[item.verdict] ?? 0) + 1;
2516
+ }
2517
+ const applies = (counts.added ?? 0) + (counts.removed ?? 0) + (counts.changed ?? 0) + (counts["too-large"] ?? 0);
2518
+ totalApplies += applies;
2519
+ const parts = Object.entries(counts).filter(([v]) => v !== "unchanged").map(([v, n]) => `${n} ${v}`);
2520
+ lines.push(`- **${src.label}**: ${parts.length ? parts.join(", ") : "no changes"}${src.error ? ` \u2014 \u26A0 ${src.error}` : ""}`);
2521
+ for (const item of src.items.filter((i) => i.verdict !== "unchanged").slice(0, 8)) {
2522
+ lines.push(` ${item.verdict === "added" ? "+" : item.verdict === "removed" ? "\u2212" : item.verdict === "wont-apply" ? "\u2298" : "\u2248"} ${item.label}${item.reason ? ` (${item.reason})` : ""}`);
2523
+ }
2524
+ for (const note of src.notes) lines.push(` \xB7 ${note}`);
2525
+ }
2526
+ const head = totalApplies === 0 ? "**Restore would change NOTHING** (states identical or nothing applicable)." : `**Restore would apply ${totalApplies} change(s):**`;
2527
+ return text11(`${head}
2528
+ ${lines.join("\n")}`);
2529
+ }
2417
2530
  case "restore": {
2418
2531
  if (!args.id) return fail12("restore requires `id` (from get_time_machine).");
2419
- const outcome = await call("restore", { id: args.id, mode: args.mode ?? "live" });
2532
+ const outcome = await call("restore", {
2533
+ id: args.id,
2534
+ mode: args.mode ?? "live",
2535
+ excludeKeys: args.excludeKeys
2536
+ });
2420
2537
  return text11(formatOutcome(outcome));
2421
2538
  }
2422
2539
  case "delete": {
package/dist/cli.mjs CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  SyncClient,
4
4
  createMcpServer,
5
5
  runInit
6
- } from "./chunk-7NCVMOMV.mjs";
6
+ } from "./chunk-TZ2C4X6V.mjs";
7
7
 
8
8
  // src/cli.ts
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
package/dist/index.d.mts CHANGED
@@ -47,7 +47,11 @@ declare class SyncClient {
47
47
  private devices;
48
48
  /** Latest snapshot payload per `${deviceId}::${toolId}`. */
49
49
  private snapshots;
50
- /** Tools we've asked the broker to keep streaming, as `${deviceId}::${toolId}`. */
50
+ /**
51
+ * Capture-mode watches this client holds, keyed by snapKey. Stored with
52
+ * their addressing because they have to be RE-SENT on every reconnect: the
53
+ * broker keys watch state by socket id, and a reconnect gets a new one.
54
+ */
51
55
  private watching;
52
56
  /** In-flight actions awaiting a result, keyed by requestId. */
53
57
  private pendingActions;
@@ -87,15 +91,47 @@ declare class SyncClient {
87
91
  * is blocked.
88
92
  */
89
93
  requireProDevice(deviceId?: string): string;
94
+ /**
95
+ * Keep the device RECORDING this tool, without asking it to stream.
96
+ *
97
+ * A dashboard watches for as long as its panel is open; this client is
98
+ * request/response, so it used to watch and unwatch around every single
99
+ * read. That works for a tool whose state just IS (the query cache, the env
100
+ * table) and is useless for one that records a timeline: the unwatch drops
101
+ * the device's subscriber count to zero, which stops capture, so nothing is
102
+ * ever recorded between two reads. The list comes back empty and reads as
103
+ * "the app made no requests" — a wrong conclusion that is very expensive to
104
+ * chase, because everything about the app looks fine.
105
+ *
106
+ * Capture-mode watches are held for the life of the connection and released
107
+ * on disconnect (the broker also drops them for us). They cost the device's
108
+ * ring buffer, not the socket: no snapshot is pushed, and reads still go
109
+ * through `request-devtool-state`.
110
+ */
90
111
  private ensureWatching;
112
+ private sendWatch;
113
+ /**
114
+ * Re-arm every held watch after a reconnect.
115
+ *
116
+ * The broker tracks watches per SOCKET, so a reconnect (broker restart, a
117
+ * dropped wifi link) silently discards all of ours while `watching` still
118
+ * says we hold them — `ensureWatching` would then short-circuit forever and
119
+ * the device would never record again. Silent and permanent: the exact
120
+ * failure this whole capture-mode change exists to remove.
121
+ */
122
+ private rearmWatches;
91
123
  /** Stop streaming a tool (releases the device-side backpressure). */
92
124
  unwatch(toolId: string, targetDeviceId: string): void;
93
125
  /** Last snapshot received for a tool/device, if any. */
94
126
  cachedSnapshot(toolId: string, deviceId: string): unknown;
95
127
  /**
96
- * Get a tool's current state. Starts watching (so the device begins emitting),
97
- * explicitly requests state, and resolves with the next snapshot. Falls back
98
- * to the last cached snapshot if the request times out.
128
+ * Get a tool's current state: arm capture (once, for the connection), ask
129
+ * for state explicitly, and resolve with the snapshot that comes back.
130
+ * Falls back to the last cached snapshot if the request times out.
131
+ *
132
+ * The capture watch is deliberately NOT released afterwards — see
133
+ * `ensureWatching`. It costs the device nothing on the socket, and dropping
134
+ * it is what made every timeline tool look permanently empty from here.
99
135
  */
100
136
  getSnapshot(toolId: string, deviceId?: string, opts?: {
101
137
  timeoutMs?: number;
package/dist/index.d.ts CHANGED
@@ -47,7 +47,11 @@ declare class SyncClient {
47
47
  private devices;
48
48
  /** Latest snapshot payload per `${deviceId}::${toolId}`. */
49
49
  private snapshots;
50
- /** Tools we've asked the broker to keep streaming, as `${deviceId}::${toolId}`. */
50
+ /**
51
+ * Capture-mode watches this client holds, keyed by snapKey. Stored with
52
+ * their addressing because they have to be RE-SENT on every reconnect: the
53
+ * broker keys watch state by socket id, and a reconnect gets a new one.
54
+ */
51
55
  private watching;
52
56
  /** In-flight actions awaiting a result, keyed by requestId. */
53
57
  private pendingActions;
@@ -87,15 +91,47 @@ declare class SyncClient {
87
91
  * is blocked.
88
92
  */
89
93
  requireProDevice(deviceId?: string): string;
94
+ /**
95
+ * Keep the device RECORDING this tool, without asking it to stream.
96
+ *
97
+ * A dashboard watches for as long as its panel is open; this client is
98
+ * request/response, so it used to watch and unwatch around every single
99
+ * read. That works for a tool whose state just IS (the query cache, the env
100
+ * table) and is useless for one that records a timeline: the unwatch drops
101
+ * the device's subscriber count to zero, which stops capture, so nothing is
102
+ * ever recorded between two reads. The list comes back empty and reads as
103
+ * "the app made no requests" — a wrong conclusion that is very expensive to
104
+ * chase, because everything about the app looks fine.
105
+ *
106
+ * Capture-mode watches are held for the life of the connection and released
107
+ * on disconnect (the broker also drops them for us). They cost the device's
108
+ * ring buffer, not the socket: no snapshot is pushed, and reads still go
109
+ * through `request-devtool-state`.
110
+ */
90
111
  private ensureWatching;
112
+ private sendWatch;
113
+ /**
114
+ * Re-arm every held watch after a reconnect.
115
+ *
116
+ * The broker tracks watches per SOCKET, so a reconnect (broker restart, a
117
+ * dropped wifi link) silently discards all of ours while `watching` still
118
+ * says we hold them — `ensureWatching` would then short-circuit forever and
119
+ * the device would never record again. Silent and permanent: the exact
120
+ * failure this whole capture-mode change exists to remove.
121
+ */
122
+ private rearmWatches;
91
123
  /** Stop streaming a tool (releases the device-side backpressure). */
92
124
  unwatch(toolId: string, targetDeviceId: string): void;
93
125
  /** Last snapshot received for a tool/device, if any. */
94
126
  cachedSnapshot(toolId: string, deviceId: string): unknown;
95
127
  /**
96
- * Get a tool's current state. Starts watching (so the device begins emitting),
97
- * explicitly requests state, and resolves with the next snapshot. Falls back
98
- * to the last cached snapshot if the request times out.
128
+ * Get a tool's current state: arm capture (once, for the connection), ask
129
+ * for state explicitly, and resolve with the snapshot that comes back.
130
+ * Falls back to the last cached snapshot if the request times out.
131
+ *
132
+ * The capture watch is deliberately NOT released afterwards — see
133
+ * `ensureWatching`. It costs the device nothing on the socket, and dropping
134
+ * it is what made every timeline tool look permanently empty from here.
99
135
  */
100
136
  getSnapshot(toolId: string, deviceId?: string, opts?: {
101
137
  timeoutMs?: number;
package/dist/index.js CHANGED
@@ -87,8 +87,12 @@ var SyncClient = class {
87
87
  this.devices = [];
88
88
  /** Latest snapshot payload per `${deviceId}::${toolId}`. */
89
89
  this.snapshots = /* @__PURE__ */ new Map();
90
- /** Tools we've asked the broker to keep streaming, as `${deviceId}::${toolId}`. */
91
- this.watching = /* @__PURE__ */ new Set();
90
+ /**
91
+ * Capture-mode watches this client holds, keyed by snapKey. Stored with
92
+ * their addressing because they have to be RE-SENT on every reconnect: the
93
+ * broker keys watch state by socket id, and a reconnect gets a new one.
94
+ */
95
+ this.watching = /* @__PURE__ */ new Map();
92
96
  /** In-flight actions awaiting a result, keyed by requestId. */
93
97
  this.pendingActions = /* @__PURE__ */ new Map();
94
98
  /** Callbacks waiting for the next snapshot of `${deviceId}::${toolId}`. */
@@ -144,6 +148,7 @@ var SyncClient = class {
144
148
  resolve();
145
149
  };
146
150
  socket.on("connect", onConnect);
151
+ socket.on("connect", () => this.rearmWatches());
147
152
  socket.once("connect_error", (err) => {
148
153
  if (settled) return;
149
154
  settled = true;
@@ -254,11 +259,50 @@ var SyncClient = class {
254
259
  // ----------------------------------------------------------------
255
260
  // Watch + snapshots
256
261
  // ----------------------------------------------------------------
262
+ /**
263
+ * Keep the device RECORDING this tool, without asking it to stream.
264
+ *
265
+ * A dashboard watches for as long as its panel is open; this client is
266
+ * request/response, so it used to watch and unwatch around every single
267
+ * read. That works for a tool whose state just IS (the query cache, the env
268
+ * table) and is useless for one that records a timeline: the unwatch drops
269
+ * the device's subscriber count to zero, which stops capture, so nothing is
270
+ * ever recorded between two reads. The list comes back empty and reads as
271
+ * "the app made no requests" — a wrong conclusion that is very expensive to
272
+ * chase, because everything about the app looks fine.
273
+ *
274
+ * Capture-mode watches are held for the life of the connection and released
275
+ * on disconnect (the broker also drops them for us). They cost the device's
276
+ * ring buffer, not the socket: no snapshot is pushed, and reads still go
277
+ * through `request-devtool-state`.
278
+ */
257
279
  ensureWatching(toolId, targetDeviceId) {
258
280
  const key = snapKey(targetDeviceId, toolId);
259
281
  if (this.watching.has(key)) return;
260
- this.watching.add(key);
261
- this.socket?.emit("devtool-watch", { toolId, targetDeviceId, watching: true });
282
+ this.watching.set(key, { toolId, targetDeviceId });
283
+ this.sendWatch(toolId, targetDeviceId);
284
+ }
285
+ sendWatch(toolId, targetDeviceId) {
286
+ this.socket?.emit("devtool-watch", {
287
+ toolId,
288
+ targetDeviceId,
289
+ watching: true,
290
+ stream: false
291
+ });
292
+ }
293
+ /**
294
+ * Re-arm every held watch after a reconnect.
295
+ *
296
+ * The broker tracks watches per SOCKET, so a reconnect (broker restart, a
297
+ * dropped wifi link) silently discards all of ours while `watching` still
298
+ * says we hold them — `ensureWatching` would then short-circuit forever and
299
+ * the device would never record again. Silent and permanent: the exact
300
+ * failure this whole capture-mode change exists to remove.
301
+ */
302
+ rearmWatches() {
303
+ for (const { toolId, targetDeviceId } of this.watching.values()) {
304
+ this.sendWatch(toolId, targetDeviceId);
305
+ }
262
306
  }
263
307
  /** Stop streaming a tool (releases the device-side backpressure). */
264
308
  unwatch(toolId, targetDeviceId) {
@@ -271,26 +315,25 @@ var SyncClient = class {
271
315
  return this.snapshots.get(snapKey(deviceId, toolId));
272
316
  }
273
317
  /**
274
- * Get a tool's current state. Starts watching (so the device begins emitting),
275
- * explicitly requests state, and resolves with the next snapshot. Falls back
276
- * to the last cached snapshot if the request times out.
318
+ * Get a tool's current state: arm capture (once, for the connection), ask
319
+ * for state explicitly, and resolve with the snapshot that comes back.
320
+ * Falls back to the last cached snapshot if the request times out.
321
+ *
322
+ * The capture watch is deliberately NOT released afterwards — see
323
+ * `ensureWatching`. It costs the device nothing on the socket, and dropping
324
+ * it is what made every timeline tool look permanently empty from here.
277
325
  */
278
326
  async getSnapshot(toolId, deviceId, opts = {}) {
279
327
  const target = this.resolveDevice(deviceId);
280
328
  const key = snapKey(target, toolId);
281
- const wasWatching = this.watching.has(key);
282
329
  this.ensureWatching(toolId, target);
283
- try {
284
- const next = this.waitForSnapshot(key, opts.timeoutMs ?? DEFAULT_SNAPSHOT_TIMEOUT_MS);
285
- this.socket?.emit("request-devtool-state", { toolId, targetDeviceId: target });
286
- const payload = await next;
287
- if (payload !== void 0) return { deviceId: target, payload };
288
- const cached = this.snapshots.get(key);
289
- if (cached !== void 0) return { deviceId: target, payload: cached };
290
- throw new Error(`Timed out waiting for "${toolId}" state from ${target} (is the tool active?)`);
291
- } finally {
292
- if (!wasWatching) this.unwatch(toolId, target);
293
- }
330
+ const next = this.waitForSnapshot(key, opts.timeoutMs ?? DEFAULT_SNAPSHOT_TIMEOUT_MS);
331
+ this.socket?.emit("request-devtool-state", { toolId, targetDeviceId: target });
332
+ const payload = await next;
333
+ if (payload !== void 0) return { deviceId: target, payload };
334
+ const cached = this.snapshots.get(key);
335
+ if (cached !== void 0) return { deviceId: target, payload: cached };
336
+ throw new Error(`Timed out waiting for "${toolId}" state from ${target} (is the tool active?)`);
294
337
  }
295
338
  waitForSnapshot(key, timeoutMs) {
296
339
  return new Promise((resolve) => {
@@ -376,7 +419,7 @@ var import_node_os = __toESM(require("os"));
376
419
  var import_node_path = __toESM(require("path"));
377
420
 
378
421
  // src/version.ts
379
- var VERSION = "7.0.17" ? "7.0.17" : "0.0.0-dev";
422
+ var VERSION = "7.0.19" ? "7.0.19" : "0.0.0-dev";
380
423
  var PACKAGE_NAME = "@buoy-gg/mcp";
381
424
 
382
425
  // src/update/checkForUpdate.ts
@@ -1377,6 +1420,60 @@ function truncate(v, n = 600) {
1377
1420
  const s = typeof v === "string" ? v : JSON.stringify(v);
1378
1421
  return s.length > n ? `${s.slice(0, n)}\u2026(+${s.length - n} chars)` : s;
1379
1422
  }
1423
+ var MAX_BODY_FETCHES = 8;
1424
+ async function resolveBodies(sync, rows, target, max = MAX_BODY_FETCHES) {
1425
+ const needed = rows.filter(
1426
+ (e) => e.id && (e.requestBodyOmitted === true && e.requestData === void 0 || e.responseBodyOmitted === true && e.responseData === void 0)
1427
+ );
1428
+ const bodies = /* @__PURE__ */ new Map();
1429
+ if (needed.length === 0) return { bodies, skipped: 0 };
1430
+ const take = needed.slice(0, max);
1431
+ const settled = await Promise.allSettled(
1432
+ take.map(
1433
+ (e) => sync.callAction(NETWORK_TOOL_ID, "getEventBody", { id: e.id }, target)
1434
+ )
1435
+ );
1436
+ settled.forEach((r, i) => {
1437
+ const id = take[i].id;
1438
+ const body = r.status === "fulfilled" ? r.value : null;
1439
+ bodies.set(id, body ? { ...body } : { unresolved: true });
1440
+ });
1441
+ return { bodies, skipped: needed.length - take.length };
1442
+ }
1443
+ async function captureDiagnosis(sync, target) {
1444
+ let status = null;
1445
+ try {
1446
+ status = await sync.callAction(
1447
+ NETWORK_TOOL_ID,
1448
+ "getCaptureStatus",
1449
+ {},
1450
+ target
1451
+ );
1452
+ } catch {
1453
+ return "The network tool has captured nothing yet.";
1454
+ }
1455
+ if (!status || status.capturing !== false) {
1456
+ return "The device IS capturing, so nothing has been requested since it started.";
1457
+ }
1458
+ return "The device is NOT capturing. Reading from here arms capture, so the very next read should see traffic \u2014 trigger it and call again. This is not an app or interception problem. (If it stays off, the device is on a Buoy older than the capture-mode watch: open the Network panel on the device or in Buoy Desktop to record.)";
1459
+ }
1460
+ function skippedNote(skipped) {
1461
+ return skipped > 0 ? `
1462
+
1463
+ _${skipped} more withheld ${skipped === 1 ? "body was" : "bodies were"} not fetched (cap ${MAX_BODY_FETCHES} per listing, they are large). Get one in full with its id._` : "";
1464
+ }
1465
+ function bodyLines(e, fetched) {
1466
+ const got = e.id ? fetched.get(e.id) : void 0;
1467
+ const label = (inline, resolved, omitted) => {
1468
+ const value = inline ?? resolved;
1469
+ if (value !== void 0 && value !== null) return truncate(value);
1470
+ if (!omitted) return "(none)";
1471
+ return got?.unresolved ? "(withheld \u2014 the device did not answer getEventBody)" : "(none)";
1472
+ };
1473
+ return `
1474
+ \u2514\u2500 request: ${label(e.requestData, got?.requestData, e.requestBodyOmitted)}
1475
+ \u2514\u2500 response: ${label(e.responseData, got?.responseData, e.responseBodyOmitted)}`;
1476
+ }
1380
1477
  function formatRow(index, e, markers) {
1381
1478
  const where = e.path || e.url;
1382
1479
  const bits = [
@@ -1430,6 +1527,7 @@ function registerNetworkTools(server, sync) {
1430
1527
  The developer pins/saves a request from the Network tool (long-press a row, or the pin/bookmark buttons in the request detail header), or you can with network_action.`
1431
1528
  );
1432
1529
  }
1530
+ const kept = args.includeBodies ? await resolveBodies(sync, rows2.map((r) => r.event), target) : { bodies: /* @__PURE__ */ new Map(), skipped: 0 };
1433
1531
  const body2 = rows2.map((r, i) => {
1434
1532
  const marks = [r.pinned ? "\u{1F4CC} pinned" : "", r.saved ? "\u{1F516} saved" : ""].filter(Boolean).join(" ");
1435
1533
  const stale = r.event.id?.startsWith("saved:") ? " \xB7 from an earlier app run" : "";
@@ -1437,12 +1535,7 @@ The developer pins/saves a request from the Network tool (long-press a row, or t
1437
1535
  let out = formatRow(i + 1, r.event, marks);
1438
1536
  out += `
1439
1537
  \u2514\u2500 key \`${r.key}\`${stale}${trunc}`;
1440
- if (args.includeBodies) {
1441
- out += `
1442
- \u2514\u2500 request: ${truncate(r.event.requestData)}`;
1443
- out += `
1444
- \u2514\u2500 response: ${truncate(r.event.responseData)}`;
1445
- }
1538
+ if (args.includeBodies) out += bodyLines(r.event, kept.bodies);
1446
1539
  return out;
1447
1540
  }).join("\n");
1448
1541
  return text3(
@@ -1450,23 +1543,19 @@ The developer pins/saves a request from the Network tool (long-press a row, or t
1450
1543
 
1451
1544
  ${body2}
1452
1545
 
1453
- _These are snapshots: they survive Clear, the 500-request cap and app restarts. Remove one with network_action({action:"remove", key}). Ids starting \`saved:\` are from an earlier run of the app._`
1546
+ _These are snapshots: they survive Clear, the 500-request cap and app restarts. Remove one with network_action({action:"remove", key}). Ids starting \`saved:\` are from an earlier run of the app._${skippedNote(kept.skipped)}`
1454
1547
  );
1455
1548
  }
1456
1549
  const rows = events.filter((e) => matchesStatus(e, args.status)).filter((e) => !pattern || (e.url ?? "").toLowerCase().includes(pattern)).slice(0, limit);
1457
1550
  if (rows.length === 0) {
1458
- const why = events.length === 0 ? "The network tool has captured nothing yet (is it enabled, and has the app made a request?)." : "No request matched the filter.";
1551
+ const why = events.length === 0 ? await captureDiagnosis(sync, target) : "No request matched the filter.";
1459
1552
  return text3(`No requests on ${target}. ${why}`);
1460
1553
  }
1554
+ const live = args.includeBodies ? await resolveBodies(sync, rows, target) : { bodies: /* @__PURE__ */ new Map(), skipped: 0 };
1461
1555
  const body = rows.map((e, i) => {
1462
1556
  const marks = [pinnedIds.has(e.id) ? "\u{1F4CC}" : "", savedIds.has(e.id) ? "\u{1F516}" : ""].filter(Boolean).join("");
1463
1557
  let out = formatRow(i + 1, e, marks);
1464
- if (args.includeBodies) {
1465
- out += `
1466
- \u2514\u2500 request: ${truncate(e.requestData)}`;
1467
- out += `
1468
- \u2514\u2500 response: ${truncate(e.responseData)}`;
1469
- }
1558
+ if (args.includeBodies) out += bodyLines(e, live.bodies);
1470
1559
  return out;
1471
1560
  }).join("\n");
1472
1561
  const keptNote = saved.length > 0 ? ` \xB7 ${saved.length} kept (flagged:'any' to list)` : "";
@@ -1475,7 +1564,7 @@ _These are snapshots: they survive Clear, the 500-request cap and app restarts.
1475
1564
 
1476
1565
  ${body}
1477
1566
 
1478
- _\u{1F4CC} pinned \xB7 \u{1F516} saved. Act on one with its id, e.g. network_action({action:"pin", id:"${rows[0].id}"})._`
1567
+ _\u{1F4CC} pinned \xB7 \u{1F516} saved. Act on one with its id, e.g. network_action({action:"pin", id:"${rows[0].id}"})._${skippedNote(live.skipped)}`
1479
1568
  );
1480
1569
  } catch (e) {
1481
1570
  return fail4(errMsg4(e));
@@ -2423,12 +2512,13 @@ ${formatOutcome(p.lastOutcome)}`);
2423
2512
  server.registerTool(
2424
2513
  "time_machine_action",
2425
2514
  {
2426
- description: "Manage Time Machine state snapshots: capture the app's current state as a named restore point, restore one (mode 'live' replaces state in place; 'reload' restores persisted storage then reloads the JS bundle \u2014 baselines always reload), capture_baseline (an empty wipe-to-fresh-install restore point), delete, or rename. The restore result reports per-source applied/skipped counts \u2014 check them rather than assuming success.",
2515
+ description: "Manage Time Machine state snapshots: capture the app's current state as a named restore point, preview what restoring one would change (per-item added/removed/changed/won't-apply verdicts \u2014 check the blast radius BEFORE restoring), restore one (mode 'live' replaces state in place; 'reload' restores persisted storage then reloads the JS bundle \u2014 baselines always reload), capture_baseline (an empty wipe-to-fresh-install restore point), delete, or rename. The restore result reports per-source applied/skipped counts \u2014 check them rather than assuming success.",
2427
2516
  inputSchema: {
2428
- action: import_zod12.z.enum(["capture", "capture_baseline", "restore", "delete", "rename"]).describe("What to do."),
2517
+ action: import_zod12.z.enum(["capture", "capture_baseline", "preview", "restore", "delete", "rename"]).describe("What to do."),
2429
2518
  id: import_zod12.z.string().optional().describe("Snapshot id (required for restore/delete/rename; from get_time_machine)."),
2430
2519
  name: import_zod12.z.string().optional().describe("Snapshot name (for capture and rename)."),
2431
2520
  mode: import_zod12.z.enum(["live", "reload"]).optional().describe("Restore mode. Default 'live'."),
2521
+ excludeKeys: import_zod12.z.array(import_zod12.z.string()).optional().describe('Restore only: item keys to LEAVE UNTOUCHED, as "sourceId::itemKey" (itemKey = the key shown by the preview action, e.g. "query::[\\"pokemon\\"]" or "storage::async:@cart").'),
2432
2522
  sourceIds: import_zod12.z.array(import_zod12.z.string()).optional().describe("Capture/restore only these source ids (default all)."),
2433
2523
  deviceId: import_zod12.z.string().optional().describe("Target device id (auto-selected if one is connected).")
2434
2524
  }
@@ -2449,9 +2539,36 @@ ${formatOutcome(p.lastOutcome)}`);
2449
2539
  const result = await call("captureBaseline", {});
2450
2540
  return text11(`Captured baseline \`${result.snapshot.id}\` \u2014 restoring it wipes app storage and reloads.`);
2451
2541
  }
2542
+ case "preview": {
2543
+ if (!args.id) return fail12("preview requires `id` (from get_time_machine).");
2544
+ const result = await call("preview", { id: args.id });
2545
+ const lines = [];
2546
+ let totalApplies = 0;
2547
+ for (const src of result.sources) {
2548
+ const counts = {};
2549
+ for (const item of src.items) {
2550
+ counts[item.verdict] = (counts[item.verdict] ?? 0) + 1;
2551
+ }
2552
+ const applies = (counts.added ?? 0) + (counts.removed ?? 0) + (counts.changed ?? 0) + (counts["too-large"] ?? 0);
2553
+ totalApplies += applies;
2554
+ const parts = Object.entries(counts).filter(([v]) => v !== "unchanged").map(([v, n]) => `${n} ${v}`);
2555
+ lines.push(`- **${src.label}**: ${parts.length ? parts.join(", ") : "no changes"}${src.error ? ` \u2014 \u26A0 ${src.error}` : ""}`);
2556
+ for (const item of src.items.filter((i) => i.verdict !== "unchanged").slice(0, 8)) {
2557
+ lines.push(` ${item.verdict === "added" ? "+" : item.verdict === "removed" ? "\u2212" : item.verdict === "wont-apply" ? "\u2298" : "\u2248"} ${item.label}${item.reason ? ` (${item.reason})` : ""}`);
2558
+ }
2559
+ for (const note of src.notes) lines.push(` \xB7 ${note}`);
2560
+ }
2561
+ const head = totalApplies === 0 ? "**Restore would change NOTHING** (states identical or nothing applicable)." : `**Restore would apply ${totalApplies} change(s):**`;
2562
+ return text11(`${head}
2563
+ ${lines.join("\n")}`);
2564
+ }
2452
2565
  case "restore": {
2453
2566
  if (!args.id) return fail12("restore requires `id` (from get_time_machine).");
2454
- const outcome = await call("restore", { id: args.id, mode: args.mode ?? "live" });
2567
+ const outcome = await call("restore", {
2568
+ id: args.id,
2569
+ mode: args.mode ?? "live",
2570
+ excludeKeys: args.excludeKeys
2571
+ });
2455
2572
  return text11(formatOutcome(outcome));
2456
2573
  }
2457
2574
  case "delete": {
package/dist/index.mjs CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  registerStorageTools,
28
28
  registerZustandTools,
29
29
  runInit
30
- } from "./chunk-7NCVMOMV.mjs";
30
+ } from "./chunk-TZ2C4X6V.mjs";
31
31
  export {
32
32
  ALL_DEVICES,
33
33
  BROKER_PORT,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@buoy-gg/mcp",
3
3
  "mcpName": "io.github.buoy-gg/mcp",
4
- "version": "7.0.17",
4
+ "version": "7.0.19",
5
5
  "description": "MCP server for Buoy devtools — lets any LLM copilot drive Buoy tools (perf-monitor benchmarks, etc.) over the devtool sync protocol",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -31,7 +31,7 @@
31
31
  "@modelcontextprotocol/sdk": "^1.29.0",
32
32
  "socket.io-client": "^4.8.1",
33
33
  "zod": "^3.25.0",
34
- "@buoy-gg/sync-broker": "^7.0.17"
34
+ "@buoy-gg/sync-broker": "^7.0.19"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^22.0.0",