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.
@@ -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
- showDone(decision);
699
- requestTabClose();
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
- function showDone(decision) {
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
- el(
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") {
@@ -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,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 = 0,
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
- const args = { deck: null, 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 };
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) {