slidemind 0.1.6 → 0.1.8

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.
@@ -22,6 +22,7 @@
22
22
  (function () {
23
23
  var CFG = window.__SLIDEMIND_REVIEW__;
24
24
  if (!CFG) return;
25
+ if (new URLSearchParams(location.search).get("thumb") === "1") return;
25
26
 
26
27
  var STAGE_SEL = "#stage";
27
28
  var HEADER_H = 62;
@@ -119,15 +120,16 @@
119
120
  "@keyframes sm-pin-in{from{transform:translate(-50%,-50%) scale(0)}to{transform:translate(-50%,-50%) scale(1)}}" +
120
121
  "@keyframes sm-veil-in{from{opacity:0}to{opacity:1}}" +
121
122
  "@keyframes sm-rise{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}" +
122
- ":root{--sm-bg:#0f0f0f;--sm-surface:#161618;--sm-border:#2b2b30;--sm-fg:#ededed;" +
123
- "--sm-dim:#9a9aa0;--sm-accent:#a9c2ff;--sm-success:#2ecc71;--sm-danger:#f87171;" +
123
+ "@keyframes sm-pulse{0%,100%{opacity:.45}50%{opacity:.85}}" +
124
+ ":root{--sm-bg:var(--deck-chrome-bg,#111113);--sm-surface:var(--deck-chrome-bg-2,#161618);" +
125
+ "--sm-border:var(--deck-chrome-border,#2b2b30);--sm-fg:var(--deck-chrome-fg,#ededed);" +
126
+ "--sm-dim:var(--deck-chrome-dim,#9a9aa0);--sm-accent:#a9c2ff;--sm-success:#2ecc71;--sm-danger:#f87171;" +
124
127
  "--sm-font:'Poppins',ui-sans-serif,system-ui,sans-serif;--sm-word:'Urbanist',var(--sm-font);}" +
125
128
  ".sm-o,.sm-o *{box-sizing:border-box;}" +
126
129
  /* header */
127
130
  ".sm-bar{position:fixed;top:0;left:0;right:0;z-index:2147483600;display:flex;align-items:center;" +
128
- "gap:16px;height:62px;padding:0 18px;color:var(--sm-fg);font-family:var(--sm-font);font-size:13px;" +
129
- "background:linear-gradient(180deg,rgba(13,13,15,.97),rgba(13,13,15,.86));" +
130
- "-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-bottom:1px solid var(--sm-border);" +
131
+ "gap:16px;height:var(--deck-chrome-h,62px);padding:0 18px;color:var(--sm-fg);font-family:var(--sm-font);font-size:13px;" +
132
+ "background:var(--sm-bg);border-bottom:1px solid var(--sm-border);" +
131
133
  "user-select:none;animation:sm-bar-in .5s cubic-bezier(.2,.7,.2,1);}" +
132
134
  ".sm-brand{display:flex;align-items:center;gap:11px;}" +
133
135
  ".sm-logo{position:relative;width:48px;height:27px;border-radius:5px;overflow:hidden;flex-shrink:0;background:#0a0c11;box-shadow:0 1px 3px rgba(0,0,0,.5);}" +
@@ -142,6 +144,7 @@
142
144
  ".sm-meta{display:flex;flex-direction:column;gap:2px;}" +
143
145
  ".sm-meta .t{font-weight:600;font-size:13px;}" +
144
146
  ".sm-meta .h{font-size:11.5px;color:var(--sm-dim);}" +
147
+ ".sm-bar~.deck__rail,.deck__rail{top:var(--deck-chrome-h,62px);}" +
145
148
  ".sm-grow{flex:1;}" +
146
149
  ".sm-count{font-size:12px;color:var(--sm-dim);font-variant-numeric:tabular-nums;}" +
147
150
  /* buttons */
@@ -193,6 +196,7 @@
193
196
  ".sm-veil .status{display:flex;align-items:center;gap:10px;font-size:18px;font-weight:600;animation:sm-rise .6s .12s cubic-bezier(.2,.7,.2,1) both;}" +
194
197
  ".sm-veil .status svg{width:24px;height:24px;color:var(--sm-success);}" +
195
198
  ".sm-veil .sub{color:var(--sm-dim);font-size:13.5px;animation:sm-rise .6s .18s cubic-bezier(.2,.7,.2,1) both;}" +
199
+ ".sm-veil .sub.sm-pulse{animation:sm-rise .6s .18s cubic-bezier(.2,.7,.2,1) both,sm-pulse 2s .9s ease-in-out infinite;}" +
196
200
  /* hover box */
197
201
  ".sm-hover{position:fixed;z-index:2147483450;pointer-events:none;border-radius:5px;border:2px solid var(--sm-accent);" +
198
202
  "box-shadow:0 0 0 1px rgba(0,0,0,.3),0 6px 22px rgba(0,0,0,.28);opacity:0;" +
@@ -408,8 +412,10 @@
408
412
  function applyStageFit() {
409
413
  var st = stage();
410
414
  if (!st) return;
415
+ var rail = document.getElementById("slide-rail");
416
+ var railW = rail && getComputedStyle(rail).display !== "none" ? rail.offsetWidth : 0;
411
417
  var reserve = sidebarOpen ? SIDEBAR_W : 0;
412
- var availW = window.innerWidth - reserve;
418
+ var availW = window.innerWidth - railW - reserve;
413
419
  var availH = window.innerHeight - HEADER_H - 8;
414
420
  var s = Math.min(availW / 1280, availH / 720) * 0.94;
415
421
  var offX = -reserve / 2;
@@ -672,10 +678,96 @@
672
678
  lockHoverBoxToBbox(comment.resolvedTarget && comment.resolvedTarget.bbox);
673
679
  }
674
680
 
681
+ // ---------- next-round attention ----------
682
+ // Same trio as plan-overlay.js: a background tab cannot steal focus (browser
683
+ // design), but it CAN fire a system notification whose click is allowed to
684
+ // focus it, and flag itself in the tab bar until it is looked at. The
685
+ // permission prompt only shows inside a user gesture, so it is requested at
686
+ // the send-comments click; the "announce" wish survives the reload via
687
+ // sessionStorage (the reload wipes this JS context).
688
+ var ROUND_READY_FLAG = "slidemind-review-round-ready";
689
+ var ATTENTION_TITLE = "● Preview ready — slidemind";
690
+ var WAITING_TITLE = "⏳ Agent revising slides — slidemind";
691
+ var notifyPermPromise = null;
692
+
693
+ function notifyPermission() {
694
+ try {
695
+ return "Notification" in window ? Notification.permission : "unsupported";
696
+ } catch {
697
+ return "unsupported";
698
+ }
699
+ }
700
+ function requestNotifyPermission() {
701
+ try {
702
+ if (!("Notification" in window)) return Promise.resolve("unsupported");
703
+ if (Notification.permission !== "default") return Promise.resolve(Notification.permission);
704
+ return Promise.resolve(Notification.requestPermission());
705
+ } catch {
706
+ return Promise.resolve("unsupported");
707
+ }
708
+ }
709
+ function markRoundReadyAndReload() {
710
+ try {
711
+ sessionStorage.setItem(ROUND_READY_FLAG, "1");
712
+ } catch {
713
+ /* storage blocked — the reload still lands, just without the announce */
714
+ }
715
+ location.reload();
716
+ }
717
+ function announceNewRound() {
718
+ var flagged = false;
719
+ try {
720
+ flagged = sessionStorage.getItem(ROUND_READY_FLAG) === "1";
721
+ if (flagged) sessionStorage.removeItem(ROUND_READY_FLAG);
722
+ } catch {
723
+ /* no storage — nothing to announce */
724
+ }
725
+ if (!flagged) return;
726
+ try {
727
+ window.focus(); // best-effort; most browsers ignore it for background tabs
728
+ } catch {
729
+ /* ignore */
730
+ }
731
+ if (!document.hidden) return; // user is already looking at it
732
+ var origTitle = document.title;
733
+ var restore = function () {
734
+ document.title = origTitle;
735
+ document.removeEventListener("visibilitychange", onVis);
736
+ window.removeEventListener("focus", restore);
737
+ };
738
+ var onVis = function () {
739
+ if (!document.hidden) restore();
740
+ };
741
+ document.title = ATTENTION_TITLE;
742
+ document.addEventListener("visibilitychange", onVis);
743
+ window.addEventListener("focus", restore);
744
+ try {
745
+ if (notifyPermission() === "granted") {
746
+ var n = new Notification("slidemind — preview ready", {
747
+ body: "The revised slides are ready for another look. Click to jump back to the tab.",
748
+ tag: "slidemind-review-round",
749
+ });
750
+ n.onclick = function () {
751
+ try {
752
+ window.focus();
753
+ } catch {
754
+ /* ignore */
755
+ }
756
+ n.close();
757
+ };
758
+ }
759
+ } catch {
760
+ /* notification construction can throw on some platforms — title marker stands */
761
+ }
762
+ }
763
+
675
764
  // ---------- submit ----------
676
765
  function submit(decision) {
677
766
  if (submitted) return;
678
767
  submitted = true;
768
+ // Ask for notification permission HERE — the send/approve click is a user
769
+ // gesture, the only context where the browser will show the prompt.
770
+ if (decision === "comments") notifyPermPromise = requestNotifyPermission();
679
771
  var comments = [];
680
772
  Object.keys(commentsBySlide).forEach(function (stem) {
681
773
  commentsBySlide[stem].forEach(function (c) {
@@ -691,31 +783,94 @@
691
783
  }),
692
784
  })
693
785
  .then(function () {
694
- showDone(decision);
786
+ if (decision === "approve") {
787
+ showDone("approve");
788
+ requestTabClose();
789
+ } else {
790
+ showWaiting();
791
+ startNextRoundPolling();
792
+ }
695
793
  })
696
794
  .catch(function () {
697
795
  submitted = false;
698
796
  alert("Could not reach the slidemind review server — is the terminal command still running?");
699
797
  });
700
798
  }
701
- function showDone(decision) {
799
+ function requestTabClose() {
800
+ setTimeout(function () {
801
+ try {
802
+ window.open("", "_self");
803
+ window.close();
804
+ } catch {
805
+ // Some browsers only allow script-opened tabs to close themselves.
806
+ }
807
+ }, 120);
808
+ }
809
+ // After a comments round the tab stays open and polls the heartbeat so the
810
+ // NEXT run (which binds the same deterministic port) reloads this tab instead
811
+ // of opening a fresh browser window.
812
+ function startNextRoundPolling() {
813
+ var startedAt = Date.now();
814
+ var pollMs = CFG.pollMs || 1000;
815
+ var ttl = CFG.pollTtlMs || 35 * 60 * 1000;
816
+ var hb = CFG.heartbeat || "/__review/heartbeat";
817
+ var timer = setInterval(function () {
818
+ if (Date.now() - startedAt > ttl) {
819
+ clearInterval(timer);
820
+ showSessionEnded();
821
+ return;
822
+ }
823
+ fetch(hb, { cache: "no-store" })
824
+ .then(function (r) { return r.ok ? r.json() : null; })
825
+ .then(function (data) {
826
+ // Reload only for a genuine NEWER slidemind review round: shape-guard the
827
+ // service marker (a foreign app may have taken the port) and require a
828
+ // different nonce (same nonce = this run's own orphan — never self-reload).
829
+ if (!data || data.service !== "slidemind-review" || !data.round) return;
830
+ if (data.round !== CFG.round) {
831
+ clearInterval(timer);
832
+ markRoundReadyAndReload();
833
+ }
834
+ })
835
+ .catch(function () { /* server down between rounds — keep polling */ });
836
+ }, pollMs);
837
+ }
838
+ // Shared full-screen veil (logo + wordmark + status + sub). Replaces any veil
839
+ // already shown rather than stacking a second one, so a comments round's
840
+ // "waiting" veil can be swapped for "session ended" in place.
841
+ function showVeil(statusHtml, subText, subPulse) {
702
842
  closePopover();
703
843
  if (pinLayer) pinLayer.style.display = "none";
704
844
  if (bar) bar.style.display = "none";
845
+ var existing = document.querySelector(".sm-veil[data-sm-overlay]");
846
+ if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
705
847
  var veil = el("div", "sm-o sm-veil");
706
848
  veil.setAttribute("data-sm-overlay", "1");
707
849
  veil.appendChild(logoNode("sm-logo", "-v"));
708
850
  veil.appendChild(el("div", "word", wordmarkHtml()));
709
- veil.appendChild(
710
- el(
711
- "div",
712
- "status",
713
- ICONS.check + "<span>" + (decision === "approve" ? "Approved" : "Comments sent") + "</span>",
714
- ),
715
- );
716
- veil.appendChild(el("div", "sub", "Returned to your terminal — you can close this tab."));
851
+ veil.appendChild(el("div", "status", statusHtml));
852
+ veil.appendChild(el("div", "sub" + (subPulse ? " sm-pulse" : ""), subText));
717
853
  document.body.appendChild(veil);
718
854
  }
855
+ function showDone() {
856
+ showVeil(ICONS.check + "<span>Approved</span>", "Returned to your terminal.");
857
+ }
858
+ function showWaiting() {
859
+ document.title = WAITING_TITLE; // the tab label signals the state from the tab bar
860
+ showVeil(
861
+ ICONS.check + "<span>Comments sent</span>",
862
+ "The agent is revising the slides — this tab refreshes automatically when the next round is ready.",
863
+ true,
864
+ );
865
+ (notifyPermPromise || Promise.resolve(notifyPermission())).then(function (perm) {
866
+ if (perm !== "granted") return;
867
+ var sub = document.querySelector(".sm-veil[data-sm-overlay] .sub");
868
+ if (sub) sub.textContent += " You'll get a notification the moment it's ready.";
869
+ });
870
+ }
871
+ function showSessionEnded() {
872
+ showVeil("<span>Session ended</span>", "You can close this tab.");
873
+ }
719
874
 
720
875
  // ---------- slide tracking ----------
721
876
  function syncSlideFromNav() {
@@ -811,6 +966,7 @@
811
966
  }
812
967
  syncSlideFromNav();
813
968
  applyStageFit();
969
+ announceNewRound(); // notification + title marker when this load IS the next round
814
970
  }
815
971
 
816
972
  if (document.readyState === "loading") {
@@ -16,6 +16,15 @@
16
16
  // previously-materialized bundle (like `slidemind build`) but no open host.
17
17
  // Each round's comments persist to <deck>/review.json so the next run shows them
18
18
  // as read-only "ghost" pins for 1:1 verification.
19
+ //
20
+ // Port + tab-reuse contract: each run binds a deterministic per-deck port (a
21
+ // hash of the deck path folded into a fixed high band, 40000..40999) so a tab
22
+ // left waiting from the previous round can reconnect to the same URL. A taken
23
+ // port never fails — it falls back to an OS-assigned port with a stderr notice.
24
+ // On a comments round the browser tab does NOT close: it polls
25
+ // /__review/heartbeat and reloads itself once the next run binds the same port
26
+ // (matched by a service marker + a fresh round nonce), so a fresh tab isn't
27
+ // opened every round.
19
28
 
20
29
  import fs from "node:fs";
21
30
  import http from "node:http";
@@ -25,6 +34,18 @@ import { fileURLToPath, pathToFileURL } from "node:url";
25
34
  import { buildDeck, openInBrowser } from "./build.mjs";
26
35
  import { resolveSkillForDeck } from "./resolve-skill.mjs";
27
36
  import { resolveBrandAsset } from "../bridge/bundle.mjs";
37
+ import {
38
+ MIME,
39
+ serveStaticFile,
40
+ createDecisionGate,
41
+ createHeartbeat,
42
+ listenWithFallback,
43
+ preferredGatePort,
44
+ serveAndOpen as gateServeAndOpen,
45
+ GRACE_MS,
46
+ POLL_MS,
47
+ POLL_TTL_MS,
48
+ } from "./gate-server.mjs";
28
49
 
29
50
  const __filename = fileURLToPath(import.meta.url);
30
51
  const __dirname = path.dirname(__filename);
@@ -32,21 +53,6 @@ const OVERLAY_PATH = path.join(__dirname, "review-overlay.js");
32
53
  const REVIEW_STATE_FILE = "review.json";
33
54
  const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
34
55
 
35
- const MIME = {
36
- ".html": "text/html; charset=utf-8",
37
- ".css": "text/css; charset=utf-8",
38
- ".js": "text/javascript; charset=utf-8",
39
- ".json": "application/json; charset=utf-8",
40
- ".svg": "image/svg+xml",
41
- ".png": "image/png",
42
- ".jpg": "image/jpeg",
43
- ".jpeg": "image/jpeg",
44
- ".gif": "image/gif",
45
- ".webp": "image/webp",
46
- ".woff": "font/woff",
47
- ".woff2": "font/woff2",
48
- };
49
-
50
56
  function clamp01(n) {
51
57
  return n < 0 ? 0 : n > 1 ? 1 : n;
52
58
  }
@@ -132,16 +138,45 @@ export function formatCommentsForHarness(deckDir, comments) {
132
138
  out.push(` Comment: ${c.comment}`);
133
139
  out.push("");
134
140
  });
135
- out.push(
136
- `Address every comment by editing the cited slides/*.jsx file(s) at the given ` +
137
- `line, then re-run: slidemind review ${path.basename(deckDir)}`,
138
- );
141
+ // Imperative, stateful tail (mirrors plan.mjs's formatPlanFeedbackForHarness):
142
+ // after a comments round the user's tab waits for the NEXT review run — an
143
+ // agent that ends its turn here deadlocks the session.
144
+ out.push("The user's browser tab is still open, waiting for the next round. They are");
145
+ out.push("waiting on YOU now — do both steps in this same turn:");
146
+ out.push(" 1. Edit the cited slides/*.jsx file(s) at the given lines.");
147
+ out.push(` 2. Re-run: slidemind review ${path.basename(deckDir)}`);
148
+ out.push("The re-run rebuilds the preview and reopens their same tab (the prior round");
149
+ out.push("shows as ghost pins). Do NOT end your turn, do NOT export anything, and do");
150
+ out.push("NOT send the user back to the browser before step 2.");
139
151
  return out.join("\n") + "\n";
140
152
  }
141
153
 
142
- function injectReview(html, previousComments, overlayJs, logoMark = null) {
154
+ // Render the no-answer outcome for the harness — same contract as plan.mjs's
155
+ // formatPlanPendingForHarness: an expected state with resume instructions, not
156
+ // a failure and never an implicit approval.
157
+ export function formatReviewPendingForHarness(deckDir, { timeoutMs } = {}) {
158
+ const mins = Math.max(1, Math.round((timeoutMs ?? DEFAULT_TIMEOUT_MS) / 60000));
159
+ const base = path.basename(deckDir);
160
+ return [
161
+ "DECISION: pending",
162
+ `No decision yet — the gate waited ${mins} minutes and the user hasn't answered.`,
163
+ "This is NOT an approval and NOT a rejection. Do not push or export anything.",
164
+ "Close out your turn gracefully:",
165
+ " 1. Tell the user the preview is still waiting for their review in the browser.",
166
+ " 2. Tell them that when they're ready, you'll re-open the gate — then, on",
167
+ ` their go-ahead, re-run: slidemind review ${base}`,
168
+ "A still-open tab reconnects to the re-run automatically; a closed one just",
169
+ "gets a fresh window. Nothing is lost by the wait.",
170
+ ].join("\n") + "\n";
171
+ }
172
+
173
+ function injectReview(html, previousComments, overlayJs, logoMark = null, round = null) {
143
174
  const cfg = safeInlineJson({
144
175
  endpoint: "/review",
176
+ heartbeat: "/__review/heartbeat",
177
+ round,
178
+ pollMs: POLL_MS,
179
+ pollTtlMs: POLL_TTL_MS,
145
180
  previousComments,
146
181
  logoMark,
147
182
  });
@@ -154,12 +189,16 @@ function injectReview(html, previousComments, overlayJs, logoMark = null) {
154
189
 
155
190
  export function loadReviewState(deckDir) {
156
191
  const f = path.join(deckDir, REVIEW_STATE_FILE);
157
- if (!fs.existsSync(f)) return { version: 1, comments: [] };
192
+ if (!fs.existsSync(f)) return { version: 1, decision: null, comments: [] };
158
193
  try {
159
194
  const data = JSON.parse(fs.readFileSync(f, "utf8"));
160
- return { version: 1, comments: Array.isArray(data.comments) ? data.comments : [] };
195
+ return {
196
+ version: 1,
197
+ decision: data.decision ?? null,
198
+ comments: Array.isArray(data.comments) ? data.comments : [],
199
+ };
161
200
  } catch {
162
- return { version: 1, comments: [] };
201
+ return { version: 1, decision: null, comments: [] };
163
202
  }
164
203
  }
165
204
 
@@ -170,7 +209,15 @@ function saveReviewState(deckDir, state) {
170
209
  );
171
210
  }
172
211
 
173
- // Start the local review server. Returns { url, port, waitForDecision, close }.
212
+ // Deterministic per-deck port so a tab left waiting from the previous round can
213
+ // reconnect to the same URL. Thin wrapper over the shared gate primitive, salted
214
+ // with this gate's name so the preview and plan gates of one deck get distinct
215
+ // ports and their waiting tabs never sit on each other's URL.
216
+ export function preferredPortForDeck(deckDir) {
217
+ return preferredGatePort(deckDir, "preview");
218
+ }
219
+
220
+ // Start the local review server. Returns { url, port, waitForDecision, waitForHeartbeat, close }.
174
221
  // presentation.html must already exist in deckDir (build it first).
175
222
  export async function createReviewServer({
176
223
  deckDir,
@@ -179,10 +226,11 @@ export async function createReviewServer({
179
226
  logoMark = null,
180
227
  port = 0,
181
228
  }) {
182
- let resolveDecision;
183
- const decisionPromise = new Promise((res) => {
184
- resolveDecision = res;
185
- });
229
+ const gate = createDecisionGate();
230
+ // The heartbeat carries a per-run nonce so a waiting tab can tell a genuinely
231
+ // NEWER round (different nonce) from this run's own heartbeat (same nonce) and
232
+ // never self-reload; the service marker guards against a foreign app.
233
+ const hb = createHeartbeat({ service: "slidemind-review" });
186
234
  const overlay =
187
235
  overlayJs ?? fs.readFileSync(OVERLAY_PATH, "utf8");
188
236
  const indexHtml = injectReview(
@@ -190,6 +238,7 @@ export async function createReviewServer({
190
238
  previousComments,
191
239
  overlay,
192
240
  logoMark,
241
+ hb.round,
193
242
  );
194
243
  const deckRoot = path.resolve(deckDir);
195
244
 
@@ -212,7 +261,7 @@ export async function createReviewServer({
212
261
  }
213
262
  res.writeHead(200, { "Content-Type": MIME[".json"] });
214
263
  res.end(JSON.stringify({ ok: true }));
215
- resolveDecision({
264
+ gate.resolve({
216
265
  decision: payload.decision === "comments" ? "comments" : "approve",
217
266
  comments: Array.isArray(payload.comments) ? payload.comments : [],
218
267
  });
@@ -226,6 +275,14 @@ export async function createReviewServer({
226
275
  return;
227
276
  }
228
277
 
278
+ if (pathname === "/__review/heartbeat") {
279
+ // The `service` marker is the shape guard: a waiting tab only reloads for
280
+ // a genuine slidemind review round, never because a foreign app grabbed
281
+ // the same port. The `round` nonce distinguishes a newer round from self.
282
+ hb.respond(res);
283
+ return;
284
+ }
285
+
229
286
  if (
230
287
  pathname === "/" ||
231
288
  pathname === "/index.html" ||
@@ -236,29 +293,18 @@ export async function createReviewServer({
236
293
  return;
237
294
  }
238
295
 
239
- const normalized = path.resolve(deckRoot, pathname.replace(/^\/+/, ""));
240
- if (normalized !== deckRoot && !normalized.startsWith(deckRoot + path.sep)) {
241
- res.writeHead(403);
242
- res.end("forbidden");
243
- return;
244
- }
245
- fs.readFile(normalized, (err, data) => {
246
- if (err) {
247
- res.writeHead(404);
248
- res.end("not found");
249
- return;
250
- }
251
- const ext = path.extname(normalized).toLowerCase();
252
- res.writeHead(200, {
253
- "Content-Type": MIME[ext] || "application/octet-stream",
254
- });
255
- res.end(data);
256
- });
296
+ // Traversal-guarded static serving from the deck root; a miss or an escape
297
+ // attempt falls through to the caller's 404 (serveStaticFile never writes on
298
+ // a non-match).
299
+ if (serveStaticFile({ roots: [deckRoot], pathname, res })) return;
300
+ res.writeHead(404);
301
+ res.end("not found");
257
302
  });
258
303
 
259
- await new Promise((resolve, reject) => {
260
- server.once("error", reject);
261
- server.listen(port, "127.0.0.1", resolve);
304
+ await listenWithFallback(server, port, "127.0.0.1", (wanted, err) => {
305
+ process.stderr.write(
306
+ `review: port ${wanted} unavailable (${err.code}); using an OS-assigned port instead.\n`,
307
+ );
262
308
  });
263
309
  const addr = server.address();
264
310
  const url = `http://127.0.0.1:${addr.port}/`;
@@ -266,18 +312,11 @@ export async function createReviewServer({
266
312
  return {
267
313
  url,
268
314
  port: addr.port,
269
- waitForDecision({ timeoutMs } = {}) {
270
- if (!timeoutMs) return decisionPromise;
271
- return new Promise((resolve, reject) => {
272
- const t = setTimeout(
273
- () => reject(new Error("review timed out — no decision received")),
274
- timeoutMs,
275
- );
276
- decisionPromise.then((d) => {
277
- clearTimeout(t);
278
- resolve(d);
279
- });
280
- });
315
+ waitForHeartbeat(ms) {
316
+ return hb.waitForHeartbeat(ms);
317
+ },
318
+ waitForDecision(opts = {}) {
319
+ return gate.waitForDecision(opts);
281
320
  },
282
321
  close() {
283
322
  try {
@@ -289,11 +328,21 @@ export async function createReviewServer({
289
328
  };
290
329
  }
291
330
 
331
+ // Print the review URL, then decide whether to open a browser. If a tab left
332
+ // waiting from a previous comments round pings /__review/heartbeat within the
333
+ // grace window, reuse it (it reloads itself) instead of opening a duplicate.
334
+ // Returns true if an existing tab was reused. The testable seam for runReview —
335
+ // a thin wrapper over the shared gate primitive that supplies this gate's
336
+ // browser opener; the preview-gate message strings are gate-server's defaults.
337
+ export function serveAndOpen(server, opts = {}) {
338
+ return gateServeAndOpen(server, { open: openInBrowser, ...opts });
339
+ }
340
+
292
341
  export async function runReview({
293
342
  deckDir,
294
- open = true,
295
- port = 0,
343
+ port = null,
296
344
  timeoutMs = DEFAULT_TIMEOUT_MS,
345
+ open = openInBrowser,
297
346
  } = {}) {
298
347
  await buildDeck(deckDir);
299
348
  const prev = loadReviewState(deckDir);
@@ -304,25 +353,35 @@ export async function runReview({
304
353
  // a plain slide glyph — the CLI no longer re-derives an orb.
305
354
  const mark = resolveBrandAsset(deckDir, ["logoMarkDark", "logoMark", "logoMarkLight"]);
306
355
  const logoMark = mark ? "/assets/" + mark.filename : null;
356
+ const wantPort = port == null ? preferredPortForDeck(deckDir) : port;
307
357
  const server = await createReviewServer({
308
358
  deckDir,
309
359
  previousComments: prev.comments || [],
310
360
  logoMark,
311
- port,
361
+ port: wantPort,
362
+ });
363
+ // A waiting tab can only exist when the previous round ended in comments — so
364
+ // first runs / post-approve runs open the browser immediately (graceMs 0).
365
+ await serveAndOpen(server, {
366
+ open,
367
+ graceMs: prev.decision === "comments" ? GRACE_MS : 0,
368
+ waitLine:
369
+ `Waiting for the user's decision — this command BLOCKS (up to ${Math.round(
370
+ timeoutMs / 60000,
371
+ )} min) while they review. A long quiet wait is normal; do not cancel it.`,
312
372
  });
313
- process.stderr.write(`Preview review ready at ${server.url}\n`);
314
- if (open) {
315
- openInBrowser(server.url);
316
- process.stderr.write(
317
- "Opened your browser — leave comments or approve, then return here.\n",
318
- );
319
- } else {
320
- process.stderr.write("Open the URL above in your browser to review.\n");
321
- }
322
373
 
323
374
  let decision;
324
375
  try {
325
376
  decision = await server.waitForDecision({ timeoutMs });
377
+ } catch (err) {
378
+ if (err && err.code === "GATE_TIMEOUT") {
379
+ // Expected outcome, not an error: exit 0 with the pending contract so the
380
+ // agent wraps up cleanly instead of reading a scary non-zero failure.
381
+ process.stdout.write(formatReviewPendingForHarness(deckDir, { timeoutMs }));
382
+ return null;
383
+ }
384
+ throw err;
326
385
  } finally {
327
386
  server.close();
328
387
  }
@@ -347,19 +406,19 @@ export async function runReview({
347
406
  }
348
407
 
349
408
  function parseArgs(argv) {
350
- const args = { deck: null, open: true, port: 0 };
409
+ // port null ⇒ derive the deterministic per-deck port; an explicit --port
410
+ // (incl. --port 0 for OS-random) overrides it.
411
+ const args = { deck: null, port: null };
351
412
  for (let i = 0; i < argv.length; i++) {
352
413
  const a = argv[i];
353
414
  if (a === "--deck" && i + 1 < argv.length) {
354
415
  args.deck = argv[++i];
355
- } else if (a === "--no-open") {
356
- args.open = false;
357
416
  } else if (a === "--port" && i + 1 < argv.length) {
358
417
  args.port = parseInt(argv[++i], 10) || 0;
359
418
  } else if (a === "-h" || a === "--help") {
360
419
  process.stdout.write(
361
- "Usage: slidemind review <deck> [--no-open] [--port <n>]\n" +
362
- " node src/builders/review.mjs --deck <deck> [--no-open] [--port <n>]\n",
420
+ "Usage: slidemind review <deck> [--port <n>]\n" +
421
+ " node src/builders/review.mjs --deck <deck> [--port <n>]\n",
363
422
  );
364
423
  process.exit(0);
365
424
  } else {
@@ -377,7 +436,7 @@ function parseArgs(argv) {
377
436
  async function main() {
378
437
  const args = parseArgs(process.argv.slice(2));
379
438
  const deckDir = path.resolve(args.deck);
380
- await runReview({ deckDir, open: args.open, port: args.port });
439
+ await runReview({ deckDir, port: args.port });
381
440
  }
382
441
 
383
442
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -62,27 +62,20 @@ function saveClaudeConfig(cfg) {
62
62
  fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
63
63
  }
64
64
 
65
+ // slidemind is a CLI steered by the skill — there is no MCP server. This only
66
+ // tears down any `mcpServers.slidemind` entry an older install registered, so
67
+ // re-running `init` converges to the CLI-only setup. Returns whether it removed one.
65
68
  function unregisterMcpServer() {
66
69
  const file = claudeUserConfigFile();
67
- if (!fs.existsSync(file)) return;
70
+ if (!fs.existsSync(file)) return false;
68
71
  const cfg = loadClaudeConfig();
69
72
  if (cfg.mcpServers && cfg.mcpServers[NAMESPACE]) {
70
73
  delete cfg.mcpServers[NAMESPACE];
71
74
  if (Object.keys(cfg.mcpServers).length === 0) delete cfg.mcpServers;
72
75
  saveClaudeConfig(cfg);
76
+ return true;
73
77
  }
74
- }
75
-
76
- function registerMcpServer(packageRoot) {
77
- const mcpServer = path.join(packageRoot, "src", "mcp", "server.mjs");
78
- const cfg = loadClaudeConfig();
79
- cfg.mcpServers = cfg.mcpServers || {};
80
- cfg.mcpServers[NAMESPACE] = {
81
- type: "stdio",
82
- command: "node",
83
- args: [mcpServer],
84
- };
85
- saveClaudeConfig(cfg);
78
+ return false;
86
79
  }
87
80
 
88
81
  export async function install({ packageRoot, flags }) {
@@ -111,10 +104,10 @@ export async function install({ packageRoot, flags }) {
111
104
  );
112
105
  }
113
106
 
114
- // Register the slidemind MCP server so agents can list/push into open
115
- // PowerPoint windows via slidemind_list_open_presentations / slidemind_push_deck.
116
- registerMcpServer(packageRoot);
117
- ui.step({ status: "ok", label: "mcp server", value: claudeUserConfigFile() });
107
+ // slidemind is a CLI now — remove any MCP registration an older install left.
108
+ if (unregisterMcpServer()) {
109
+ ui.step({ status: "remove", label: "mcp server", value: "removed legacy registration (slidemind is a CLI)" });
110
+ }
118
111
  }
119
112
 
120
113
  export async function uninstall() {