slidemind 0.1.7 → 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.md +3 -1
- package/README.md +1 -0
- package/bin/slidemind.mjs +16 -0
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +7 -6
- package/src/builders/build.mjs +52 -10
- package/src/builders/gate-server.mjs +223 -0
- 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 +152 -11
- package/src/builders/review.mjs +136 -70
|
@@ -120,6 +120,7 @@
|
|
|
120
120
|
"@keyframes sm-pin-in{from{transform:translate(-50%,-50%) scale(0)}to{transform:translate(-50%,-50%) scale(1)}}" +
|
|
121
121
|
"@keyframes sm-veil-in{from{opacity:0}to{opacity:1}}" +
|
|
122
122
|
"@keyframes sm-rise{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}" +
|
|
123
|
+
"@keyframes sm-pulse{0%,100%{opacity:.45}50%{opacity:.85}}" +
|
|
123
124
|
":root{--sm-bg:var(--deck-chrome-bg,#111113);--sm-surface:var(--deck-chrome-bg-2,#161618);" +
|
|
124
125
|
"--sm-border:var(--deck-chrome-border,#2b2b30);--sm-fg:var(--deck-chrome-fg,#ededed);" +
|
|
125
126
|
"--sm-dim:var(--deck-chrome-dim,#9a9aa0);--sm-accent:#a9c2ff;--sm-success:#2ecc71;--sm-danger:#f87171;" +
|
|
@@ -195,6 +196,7 @@
|
|
|
195
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;}" +
|
|
196
197
|
".sm-veil .status svg{width:24px;height:24px;color:var(--sm-success);}" +
|
|
197
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;}" +
|
|
198
200
|
/* hover box */
|
|
199
201
|
".sm-hover{position:fixed;z-index:2147483450;pointer-events:none;border-radius:5px;border:2px solid var(--sm-accent);" +
|
|
200
202
|
"box-shadow:0 0 0 1px rgba(0,0,0,.3),0 6px 22px rgba(0,0,0,.28);opacity:0;" +
|
|
@@ -676,10 +678,96 @@
|
|
|
676
678
|
lockHoverBoxToBbox(comment.resolvedTarget && comment.resolvedTarget.bbox);
|
|
677
679
|
}
|
|
678
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
|
+
|
|
679
764
|
// ---------- submit ----------
|
|
680
765
|
function submit(decision) {
|
|
681
766
|
if (submitted) return;
|
|
682
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();
|
|
683
771
|
var comments = [];
|
|
684
772
|
Object.keys(commentsBySlide).forEach(function (stem) {
|
|
685
773
|
commentsBySlide[stem].forEach(function (c) {
|
|
@@ -695,8 +783,13 @@
|
|
|
695
783
|
}),
|
|
696
784
|
})
|
|
697
785
|
.then(function () {
|
|
698
|
-
|
|
699
|
-
|
|
786
|
+
if (decision === "approve") {
|
|
787
|
+
showDone("approve");
|
|
788
|
+
requestTabClose();
|
|
789
|
+
} else {
|
|
790
|
+
showWaiting();
|
|
791
|
+
startNextRoundPolling();
|
|
792
|
+
}
|
|
700
793
|
})
|
|
701
794
|
.catch(function () {
|
|
702
795
|
submitted = false;
|
|
@@ -713,24 +806,71 @@
|
|
|
713
806
|
}
|
|
714
807
|
}, 120);
|
|
715
808
|
}
|
|
716
|
-
|
|
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) {
|
|
717
842
|
closePopover();
|
|
718
843
|
if (pinLayer) pinLayer.style.display = "none";
|
|
719
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);
|
|
720
847
|
var veil = el("div", "sm-o sm-veil");
|
|
721
848
|
veil.setAttribute("data-sm-overlay", "1");
|
|
722
849
|
veil.appendChild(logoNode("sm-logo", "-v"));
|
|
723
850
|
veil.appendChild(el("div", "word", wordmarkHtml()));
|
|
724
|
-
veil.appendChild(
|
|
725
|
-
|
|
726
|
-
"div",
|
|
727
|
-
"status",
|
|
728
|
-
ICONS.check + "<span>" + (decision === "approve" ? "Approved" : "Comments sent") + "</span>",
|
|
729
|
-
),
|
|
730
|
-
);
|
|
731
|
-
veil.appendChild(el("div", "sub", "Returned to your terminal."));
|
|
851
|
+
veil.appendChild(el("div", "status", statusHtml));
|
|
852
|
+
veil.appendChild(el("div", "sub" + (subPulse ? " sm-pulse" : ""), subText));
|
|
732
853
|
document.body.appendChild(veil);
|
|
733
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
|
+
}
|
|
734
874
|
|
|
735
875
|
// ---------- slide tracking ----------
|
|
736
876
|
function syncSlideFromNav() {
|
|
@@ -826,6 +966,7 @@
|
|
|
826
966
|
}
|
|
827
967
|
syncSlideFromNav();
|
|
828
968
|
applyStageFit();
|
|
969
|
+
announceNewRound(); // notification + title marker when this load IS the next round
|
|
829
970
|
}
|
|
830
971
|
|
|
831
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,10 +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
|
-
port =
|
|
343
|
+
port = null,
|
|
295
344
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
345
|
+
open = openInBrowser,
|
|
296
346
|
} = {}) {
|
|
297
347
|
await buildDeck(deckDir);
|
|
298
348
|
const prev = loadReviewState(deckDir);
|
|
@@ -303,21 +353,35 @@ export async function runReview({
|
|
|
303
353
|
// a plain slide glyph — the CLI no longer re-derives an orb.
|
|
304
354
|
const mark = resolveBrandAsset(deckDir, ["logoMarkDark", "logoMark", "logoMarkLight"]);
|
|
305
355
|
const logoMark = mark ? "/assets/" + mark.filename : null;
|
|
356
|
+
const wantPort = port == null ? preferredPortForDeck(deckDir) : port;
|
|
306
357
|
const server = await createReviewServer({
|
|
307
358
|
deckDir,
|
|
308
359
|
previousComments: prev.comments || [],
|
|
309
360
|
logoMark,
|
|
310
|
-
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.`,
|
|
311
372
|
});
|
|
312
|
-
process.stderr.write(`Preview review ready at ${server.url}\n`);
|
|
313
|
-
openInBrowser(server.url);
|
|
314
|
-
process.stderr.write(
|
|
315
|
-
"Opened your browser — leave comments or approve, then return here.\n",
|
|
316
|
-
);
|
|
317
373
|
|
|
318
374
|
let decision;
|
|
319
375
|
try {
|
|
320
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;
|
|
321
385
|
} finally {
|
|
322
386
|
server.close();
|
|
323
387
|
}
|
|
@@ -342,7 +406,9 @@ export async function runReview({
|
|
|
342
406
|
}
|
|
343
407
|
|
|
344
408
|
function parseArgs(argv) {
|
|
345
|
-
|
|
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 };
|
|
346
412
|
for (let i = 0; i < argv.length; i++) {
|
|
347
413
|
const a = argv[i];
|
|
348
414
|
if (a === "--deck" && i + 1 < argv.length) {
|