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.
- package/.claude-plugin/plugin.json +1 -7
- package/CLAUDE.md +21 -15
- package/README.md +21 -10
- package/bin/slidemind.mjs +836 -35
- package/package.json +2 -5
- package/skills/create-slides/SKILL.md +85 -60
- package/skills/search/SKILL.md +27 -27
- package/src/bridge/build-and-push.mjs +166 -55
- package/src/bridge/bundle.mjs +49 -7
- package/src/bridge/capture.mjs +2 -2
- package/src/bridge/client.mjs +374 -44
- package/src/bridge/engine-download.mjs +89 -0
- package/src/{mcp → bridge}/recolor.mjs +10 -9
- package/src/builders/build.mjs +261 -23
- package/src/builders/gate-server.mjs +223 -0
- package/src/builders/lint.mjs +2 -2
- package/src/builders/plan-md.mjs +216 -0
- package/src/builders/plan-overlay.js +747 -0
- package/src/builders/plan.mjs +575 -0
- package/src/builders/resolve-skill.mjs +40 -5
- package/src/builders/review-overlay.js +172 -16
- package/src/builders/review.mjs +139 -80
- package/src/installers/claude.mjs +10 -17
- package/src/installers/codex.mjs +14 -77
- package/src/installers/opencode.mjs +10 -18
- package/src/lib/reused-slides.mjs +2 -4
- package/src/mcp/server.mjs +0 -958
|
@@ -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
|
-
"
|
|
123
|
-
"--sm-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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") {
|
package/src/builders/review.mjs
CHANGED
|
@@ -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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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> [--
|
|
362
|
-
" node src/builders/review.mjs --deck <deck> [--
|
|
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,
|
|
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
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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() {
|