slidemind 0.1.5 → 0.1.7

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.
@@ -6,8 +6,11 @@
6
6
  // open PowerPoint pane. No localhost, no shared loopback token, no cert.
7
7
 
8
8
  import { randomUUID } from "node:crypto";
9
+ import fs from "node:fs";
9
10
  import http from "node:http";
10
11
  import https from "node:https";
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
11
14
 
12
15
  import {
13
16
  deriveEnvLabel,
@@ -15,6 +18,39 @@ import {
15
18
  readSessions,
16
19
  } from "./config.mjs";
17
20
 
21
+ // This CLI's own version, for the add-in's minCliVersion floor (see
22
+ // assertCliVersionSupported). Read lazily from the package manifest so the
23
+ // module stays importable even from an odd install layout.
24
+ let cliVersionCache = null;
25
+ export function cliVersion() {
26
+ if (cliVersionCache) return cliVersionCache;
27
+ try {
28
+ const pkgPath = path.join(
29
+ path.dirname(fileURLToPath(import.meta.url)),
30
+ "..",
31
+ "..",
32
+ "package.json",
33
+ );
34
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
35
+ cliVersionCache = typeof pkg.version === "string" ? pkg.version : "0.0.0";
36
+ } catch {
37
+ cliVersionCache = "0.0.0";
38
+ }
39
+ return cliVersionCache;
40
+ }
41
+
42
+ // Numeric-aware dotted-version compare (no prerelease semantics — the CLI
43
+ // releases plain X.Y.Z). Returns <0 / 0 / >0 like a comparator.
44
+ export function compareCliVersions(a, b) {
45
+ const pa = String(a ?? "").split(".").map((n) => parseInt(n, 10) || 0);
46
+ const pb = String(b ?? "").split(".").map((n) => parseInt(n, 10) || 0);
47
+ for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
48
+ const d = (pa[i] || 0) - (pb[i] || 0);
49
+ if (d !== 0) return d < 0 ? -1 : 1;
50
+ }
51
+ return 0;
52
+ }
53
+
18
54
  function isLoopbackHostname(hostname) {
19
55
  return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
20
56
  }
@@ -41,10 +77,30 @@ function engineTimeoutMs() {
41
77
  );
42
78
  }
43
79
 
80
+ function readinessTimeoutMs() {
81
+ return (
82
+ parseInt(process.env.SLIDEMIND_BRIDGE_READY_TIMEOUT_MS || "20000", 10) ||
83
+ 20000
84
+ );
85
+ }
86
+
44
87
  const NOT_PAIRED_MESSAGE =
45
88
  'slidemind is not paired. In the slidemind add-in, click "Connect CLI" ' +
46
89
  "and run the shown `slidemind pair …` command, then retry.";
47
90
 
91
+ // The one-command remedy every host-precondition error now points at:
92
+ // `slidemind ready` runs the whole pairing → reachability → version → window →
93
+ // design ladder and materializes the bundle, so a stuck author has a single
94
+ // next step instead of a scavenger hunt across doctor/presentations/download.
95
+ // Appended (never substituted) to the existing prose so callers' assert.match
96
+ // on the original text keeps passing.
97
+ const READY_HINT =
98
+ "Run `slidemind ready` to check the connection and set up in one step.";
99
+ // pickPresentationEntry's variant: the caller already HAS a host, it's just
100
+ // ambiguous — `ready` resolves the window AND the design in one shot.
101
+ const READY_HINT_PICK =
102
+ "Or run `slidemind ready` first — it resolves the window and design in one step.";
103
+
48
104
  // One transient blip — a pane mid-reconnect after a backend deploy, a stale
49
105
  // registration the pane is about to refresh — should not fail a whole skill
50
106
  // step. Idempotent reads retry a couple of times; mutations never do (the
@@ -251,6 +307,80 @@ async function listPresentationsForBackend(backendURL) {
251
307
  });
252
308
  }
253
309
 
310
+ async function readinessForBackend(backendURL) {
311
+ const env = deriveEnvLabel(backendURL);
312
+ try {
313
+ const res = await bridgeFetch(
314
+ "/api/cli/readiness",
315
+ { timeoutMs: listTimeoutMs() },
316
+ backendURL,
317
+ );
318
+ const data = (await res.json()) ?? {};
319
+ const presentations = Array.isArray(data.presentations)
320
+ ? data.presentations.map((p) => ({ ...p, backendURL, env }))
321
+ : [];
322
+ // Version-floor check for diagnostics (doctor / connection_status): newer
323
+ // add-ins advertise minCliVersion in the readiness capabilities, so the
324
+ // skew is visible BEFORE a render trips over it.
325
+ const minCliVersion =
326
+ typeof data.capabilities?.minCliVersion === "string"
327
+ ? data.capabilities.minCliVersion
328
+ : null;
329
+ return {
330
+ env,
331
+ backendURL,
332
+ ok: true,
333
+ readinessV1: data.readinessV1 === true,
334
+ capabilities: data.capabilities ?? null,
335
+ minCliVersion,
336
+ cliUpdateRequired: Boolean(
337
+ minCliVersion && compareCliVersions(cliVersion(), minCliVersion) < 0,
338
+ ),
339
+ presentations,
340
+ summary: data.summary ?? null,
341
+ };
342
+ } catch (err) {
343
+ if (err?.status !== 404) throw err;
344
+ // Older add-ins do not serve /readiness. Fall back to the legacy
345
+ // presentations route and mark the result as a compatibility response.
346
+ const presentations = await listPresentationsForBackend(backendURL);
347
+ return {
348
+ env,
349
+ backendURL,
350
+ ok: true,
351
+ readinessV1: false,
352
+ capabilities: null,
353
+ presentations,
354
+ summary: null,
355
+ };
356
+ }
357
+ }
358
+
359
+ export async function connectionStatus() {
360
+ const sessions = sessionsInScope();
361
+ const settled = await Promise.allSettled(
362
+ sessions.map((s) => readinessForBackend(s.backendURL)),
363
+ );
364
+ const backends = settled.map((r, i) => {
365
+ const backendURL = sessions[i].backendURL;
366
+ const env = deriveEnvLabel(backendURL);
367
+ if (r.status === "fulfilled") return r.value;
368
+ return {
369
+ env,
370
+ backendURL,
371
+ ok: false,
372
+ error: r.reason?.message ? r.reason.message : String(r.reason),
373
+ presentations: [],
374
+ };
375
+ });
376
+ return {
377
+ paired: sessions.length > 0,
378
+ backendCount: sessions.length,
379
+ backends,
380
+ presentations: backends.flatMap((b) => b.presentations ?? []),
381
+ };
382
+ }
383
+
254
384
  // Fan out across every backend in scope, concurrently. Never rejects: each
255
385
  // backend's outcome is partitioned into merged presentations vs. a failure
256
386
  // record, so one unreachable environment can't hide the others.
@@ -302,8 +432,10 @@ export async function listOpenPresentations() {
302
432
  // The user/agent-facing shape of a presentation entry. `backendURL` is internal
303
433
  // routing state — every caller targets a presentation by `presId` (the backend
304
434
  // is re-resolved server-side from it), so the URL must never leave the process
305
- // in any printed line or MCP payload. Keeps presId, title, fileName, connected,
306
- // suspendedAt, and the short `env` label (the only discriminator a user sees).
435
+ // in any printed line or command output. Keeps everything else — presId, title,
436
+ // fileName, connected, suspendedAt, the short `env` label, and the readiness
437
+ // fields (connectionState, designState, capabilities, nextAction) that agents
438
+ // parse from `presentations --json` to decide whether/how to target a pane.
307
439
  export function toPublicPresentation(p) {
308
440
  const { backendURL, ...rest } = p;
309
441
  return rest;
@@ -342,16 +474,133 @@ export async function requireHost() {
342
474
  "Open PowerPoint with the slidemind add-in and an active corporate design, then retry.\n" +
343
475
  unreachable
344
476
  .map((u) => ` (bridge: ${u.env}: ${u.error})`)
345
- .join("\n"),
477
+ .join("\n") +
478
+ `\n${READY_HINT}`,
346
479
  );
347
480
  }
348
481
  throw new Error(
349
482
  "No PowerPoint window is registered with the slidemind add-in.\n" +
350
483
  "If PowerPoint is already open, bring it to the foreground and click the slidemind pane once so it re-registers, then retry.\n" +
351
- "Otherwise open PowerPoint with the slidemind add-in and an active corporate design, then retry.",
484
+ "Otherwise open PowerPoint with the slidemind add-in and an active corporate design, then retry.\n" +
485
+ READY_HINT,
486
+ );
487
+ }
488
+
489
+ function hasReadinessFields(p) {
490
+ return (
491
+ p &&
492
+ (typeof p.connectionState === "string" ||
493
+ typeof p.designState === "string" ||
494
+ (p.capabilities && typeof p.capabilities === "object"))
352
495
  );
353
496
  }
354
497
 
498
+ export function isRenderReadyPresentation(p) {
499
+ if (!p) return false;
500
+ if (!hasReadinessFields(p)) return true;
501
+ if (p.capabilities?.render === true) return true;
502
+ if (p.designState === "ready" && p.capabilities?.render !== false) return true;
503
+ return false;
504
+ }
505
+
506
+ export function isPaneReadyPresentation(p) {
507
+ if (!p) return false;
508
+ if (!hasReadinessFields(p)) return p.connected !== false && !p.suspendedAt;
509
+ if (p.capabilities?.paneOps === true) return true;
510
+ return (
511
+ p.connectionState === "connected" &&
512
+ p.connected !== false &&
513
+ !p.suspendedAt &&
514
+ p.connectionState !== "stale"
515
+ );
516
+ }
517
+
518
+ async function waitForReadyPresentation(predicate, { timeoutMs = null } = {}) {
519
+ const waitMs = timeoutMs ?? readinessTimeoutMs();
520
+ const pollMs = envInt("SLIDEMIND_BRIDGE_READY_POLL_MS", 1500);
521
+ const deadline = Date.now() + waitMs;
522
+ for (;;) {
523
+ const { presentations, unreachable } = await listOpenPresentationsDetailed();
524
+ if (presentations.some(predicate)) return presentations;
525
+ // Do not wait when nothing is registered at all. Waiting helps only for an
526
+ // already-open pane that is still hydrating brand/readiness metadata.
527
+ if (presentations.length === 0 || Date.now() >= deadline) {
528
+ return { presentations, unreachable, timedOut: presentations.length > 0 };
529
+ }
530
+ await sleep(pollMs);
531
+ }
532
+ }
533
+
534
+ // The bounded poll behind `slidemind presentations`. Unlike
535
+ // waitForReadyPresentation it PURPOSELY does NOT early-return on an empty
536
+ // registry: the empty window right after a pane reload / PowerPoint wake is
537
+ // exactly the flaky case this papers over. Polls the merged registry every
538
+ // SLIDEMIND_BRIDGE_READY_POLL_MS (default 1.5s) up to
539
+ // SLIDEMIND_BRIDGE_PRESENTATIONS_WAIT_MS (default 6s), stopping as soon as at
540
+ // least one pane is pane-ready. A `timeoutMs` of 0 means single-shot — one
541
+ // fetch, no sleep. Returns { all, ready } so the caller can list EVERY entry
542
+ // (suspended included, with its status) yet still know which are live. Does not
543
+ // touch waitForReadyPresentation / requirePaneHost — agent target selection
544
+ // still uses only ready panes.
545
+ export async function listReadyPresentationsWithWait({ timeoutMs = null } = {}) {
546
+ const waitMs =
547
+ timeoutMs ?? envInt("SLIDEMIND_BRIDGE_PRESENTATIONS_WAIT_MS", 6000);
548
+ const pollMs = envInt("SLIDEMIND_BRIDGE_READY_POLL_MS", 1500);
549
+ const deadline = Date.now() + waitMs;
550
+ for (;;) {
551
+ const all = await listOpenPresentations();
552
+ const ready = all.filter(isPaneReadyPresentation);
553
+ if (ready.length > 0 || waitMs <= 0 || Date.now() >= deadline) {
554
+ return { all, ready };
555
+ }
556
+ await sleep(pollMs);
557
+ }
558
+ }
559
+
560
+ export async function requireRenderHost({ timeoutMs = null } = {}) {
561
+ const first = await listOpenPresentationsDetailed();
562
+ if (first.presentations.some(isRenderReadyPresentation)) {
563
+ return first.presentations;
564
+ }
565
+ if (first.presentations.length > 0) {
566
+ const waited = await waitForReadyPresentation(isRenderReadyPresentation, {
567
+ timeoutMs,
568
+ });
569
+ if (Array.isArray(waited)) return waited;
570
+ if (waited.presentations.some(isRenderReadyPresentation)) {
571
+ return waited.presentations;
572
+ }
573
+ throw new Error(
574
+ "PowerPoint is registered with the slidemind add-in, but no window is render-ready yet. " +
575
+ "Wait for the corporate design to finish matching, or pick/create a corporate design in the add-in, then retry. " +
576
+ READY_HINT,
577
+ );
578
+ }
579
+ return requireHost();
580
+ }
581
+
582
+ export async function requirePaneHost({ timeoutMs = null } = {}) {
583
+ const first = await listOpenPresentationsDetailed();
584
+ if (first.presentations.some(isPaneReadyPresentation)) {
585
+ return first.presentations.filter(isPaneReadyPresentation);
586
+ }
587
+ if (first.presentations.length > 0) {
588
+ const waited = await waitForReadyPresentation(isPaneReadyPresentation, {
589
+ timeoutMs,
590
+ });
591
+ if (Array.isArray(waited)) return waited.filter(isPaneReadyPresentation);
592
+ if (waited.presentations.some(isPaneReadyPresentation)) {
593
+ return waited.presentations.filter(isPaneReadyPresentation);
594
+ }
595
+ throw new Error(
596
+ "PowerPoint is registered with the slidemind add-in, but the task pane is not ready for pane operations. " +
597
+ "Bring PowerPoint forward and click or reopen the slidemind pane, then retry. " +
598
+ READY_HINT,
599
+ );
600
+ }
601
+ return requireHost();
602
+ }
603
+
355
604
  // Tie-break a multi-backend presId collision without bothering the user when
356
605
  // only one candidate can actually serve the deck:
357
606
  // (a) exactly one CONNECTED pane whose active CD matches the deck's bundle
@@ -361,7 +610,7 @@ export async function requireHost() {
361
610
  // e.g. a local dev backend connected without a CD next to prod with one.
362
611
  // Returns the winning entry or null when genuinely ambiguous.
363
612
  function tieBreakMatches(matches, preferBrandId) {
364
- const live = matches.filter((m) => m.connected !== false && !m.suspendedAt);
613
+ const live = matches.filter(isPaneReadyPresentation);
365
614
  if (preferBrandId) {
366
615
  const want = live.filter((m) => m.brandId === preferBrandId);
367
616
  if (want.length === 1) return want[0];
@@ -433,7 +682,7 @@ export async function waitForPaneReady(
433
682
  /* transient list failure — poll again until the deadline */
434
683
  }
435
684
  const connected = list.filter(
436
- (p) => p.connected !== false && !p.suspendedAt,
685
+ (p) => isPaneReadyPresentation(p),
437
686
  );
438
687
  const same = connected.find((p) => p.presId === presId);
439
688
  if (same) return same;
@@ -455,7 +704,7 @@ export async function waitForPaneReady(
455
704
  }
456
705
 
457
706
  // The one actionable line a human needs when a pane op is waiting. stderr so
458
- // it reaches the terminal from the CLI and the MCP server log alike.
707
+ // it reaches the terminal from the CLI.
459
708
  function paneWaitNotice(seconds) {
460
709
  process.stderr.write(
461
710
  "task pane suspended — click the slidemind pane in PowerPoint to wake it " +
@@ -482,6 +731,64 @@ export async function getDesignBundle(
482
731
  });
483
732
  }
484
733
 
734
+ // GET the design bundle by brandId over the pane-free brand route. 404
735
+ // `unknown_brand` is definitive (unknown / cross-tenant / not granted to this
736
+ // user), so no transient retry. Requires the resolved backendURL — use
737
+ // getDesignBundleForBrandAnyBackend when it isn't known yet.
738
+ export async function getDesignBundleForBrand(brandId, { backendURL } = {}) {
739
+ if (!brandId) {
740
+ throw new Error("getDesignBundleForBrand: brandId is required");
741
+ }
742
+ if (!backendURL) {
743
+ throw new Error("getDesignBundleForBrand: backendURL is required");
744
+ }
745
+ const res = await bridgeFetch(
746
+ `/api/cli/brands/${encodeURIComponent(brandId)}/design-bundle`,
747
+ {},
748
+ backendURL,
749
+ );
750
+ return res.json();
751
+ }
752
+
753
+ // Resolve WHICH paired backend serves a brand by asking each one that
754
+ // advertises brand routes — a bound deck has no open window, so there is no
755
+ // registry entry to resolve a backend from. presFP/brandId is globally unique
756
+ // across deployments (the bundle-cache contract), so at most one backend
757
+ // answers 2xx. Returns { wire, backendURL }; throws a typed
758
+ // `brand_route_unavailable` when no backend serves the brand (old add-ins,
759
+ // unknown brand, missing grant) so callers fall back to the pane flow.
760
+ export async function getDesignBundleForBrandAnyBackend(brandId) {
761
+ if (!brandId) {
762
+ throw new Error("getDesignBundleForBrandAnyBackend: brandId is required");
763
+ }
764
+ const sessions = sessionsInScope();
765
+ const failures = [];
766
+ for (const s of sessions) {
767
+ try {
768
+ if (!(await backendSupportsBrandRoutes(s.backendURL))) {
769
+ failures.push({ env: deriveEnvLabel(s.backendURL), reason: "no brand routes" });
770
+ continue;
771
+ }
772
+ const wire = await getDesignBundleForBrand(brandId, {
773
+ backendURL: s.backendURL,
774
+ });
775
+ return { wire, backendURL: s.backendURL };
776
+ } catch (err) {
777
+ failures.push({
778
+ env: deriveEnvLabel(s.backendURL),
779
+ reason: err instanceof Error ? err.message : String(err),
780
+ });
781
+ }
782
+ }
783
+ const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
784
+ const err = new Error(
785
+ `no paired backend serves design '${brandId}' over the pane-free brand ` +
786
+ `route${detail ? ` (${detail})` : ""}.`,
787
+ );
788
+ err.code = "brand_route_unavailable";
789
+ throw err;
790
+ }
791
+
485
792
  // List the LIVE engine catalog (NOT the bundle's curated set) for the paired
486
793
  // backend(s). `GET /api/cli/engines` is backend-scoped, not presId-scoped, so
487
794
  // when several environments are paired this fans out and tags each engine with
@@ -508,8 +815,8 @@ async function listEnginesForBackend(backendURL, { type = null, subtype = null }
508
815
  });
509
816
  }
510
817
 
511
- export async function listEngines({ type = null, subtype = null } = {}) {
512
- const sessions = sessionsInScope();
818
+ export async function listEngines({ type = null, subtype = null, backendURL = null } = {}) {
819
+ const sessions = backendURL ? [sessionForBackend(backendURL)] : sessionsInScope();
513
820
  const settled = await Promise.allSettled(
514
821
  sessions.map((s) => listEnginesForBackend(s.backendURL, { type, subtype })),
515
822
  );
@@ -559,10 +866,10 @@ function sleep(ms) {
559
866
  // One capability probe per backend per process. Keyed by the RESOLVED backend
560
867
  // URL (callers always resolve before probing), so multiple paired backends on
561
868
  // mixed versions each get their own answer.
562
- const renderJobsCapability = new Map();
869
+ const backendCapabilitiesCache = new Map();
563
870
 
564
- function backendSupportsRenderJobs(backendURL) {
565
- let probe = renderJobsCapability.get(backendURL);
871
+ function backendCapabilities(backendURL) {
872
+ let probe = backendCapabilitiesCache.get(backendURL);
566
873
  if (!probe) {
567
874
  probe = (async () => {
568
875
  try {
@@ -571,26 +878,59 @@ function backendSupportsRenderJobs(backendURL) {
571
878
  { timeoutMs: listTimeoutMs() },
572
879
  backendURL,
573
880
  );
574
- const data = await res.json();
575
- return data?.renderJobs === true;
881
+ return (await res.json()) ?? {};
576
882
  } catch {
577
- // Older backends 404 this path (with a non-JSON body) — sync render.
578
- return false;
883
+ // Older backends 404 this path (with a non-JSON body) — no
884
+ // capabilities: sync render, no version floor.
885
+ return {};
579
886
  }
580
887
  })();
581
- renderJobsCapability.set(backendURL, probe);
888
+ backendCapabilitiesCache.set(backendURL, probe);
582
889
  }
583
890
  return probe;
584
891
  }
585
892
 
893
+ async function backendSupportsRenderJobs(backendURL) {
894
+ const caps = await backendCapabilities(backendURL);
895
+ return caps?.renderJobs === true;
896
+ }
897
+
898
+ // Pane-free brand-scoped routes (/api/cli/brands/:brandId/*): design-bundle,
899
+ // translate and render keyed by the brandId the deck's .bundle-ref.json already
900
+ // stores (presFP == brandId), instead of the pane registry — so a bound deck
901
+ // keeps building after every pane key aged out of the add-in's registry (or
902
+ // with PowerPoint closed entirely). Advertised by newer add-ins only.
903
+ export async function backendSupportsBrandRoutes(backendURL) {
904
+ const caps = await backendCapabilities(backendURL);
905
+ return caps?.brandRoutes === true;
906
+ }
907
+
908
+ // The add-in's hard version floor: /api/cli/capabilities advertises the oldest
909
+ // CLI it still speaks (`minCliVersion`). An older CLI must refuse to proceed —
910
+ // mid-rollout skew otherwise surfaces as confusing route/contract errors deep
911
+ // in a render — and prompt the one actionable fix. No-op when the backend
912
+ // doesn't advertise a floor (older add-ins) or the CLI meets it.
913
+ export async function assertCliVersionSupported(backendURL) {
914
+ const caps = await backendCapabilities(backendURL);
915
+ const min = typeof caps?.minCliVersion === "string" ? caps.minCliVersion : null;
916
+ if (!min || compareCliVersions(cliVersion(), min) >= 0) return;
917
+ const err = new Error(
918
+ `slidemind CLI ${cliVersion()} is older than the minimum this add-in ` +
919
+ `supports (${min}). Run \`slidemind update\` (or ` +
920
+ "`npm install -g slidemind@latest`), then retry.",
921
+ );
922
+ err.code = "cli_update_required";
923
+ throw err;
924
+ }
925
+
586
926
  // Rebuild a job failure into the exact error shape bridgeFetch throws for the
587
- // sync route, so callers (etag self-heal, MCP error texts) can't tell the
927
+ // sync route, so callers (etag self-heal, error texts) can't tell the
588
928
  // protocols apart.
589
- function jobFailureError(jobError, presId) {
929
+ function jobFailureError(jobError, label) {
590
930
  const status = jobError?.status ?? 500;
591
931
  const detail = jobError?.error ?? "render_failed";
592
932
  const err = new Error(
593
- `slidemind bridge ${status} render job failed for ${presId}: ${detail}`,
933
+ `slidemind bridge ${status} render job failed for ${label}: ${detail}`,
594
934
  );
595
935
  err.status = status;
596
936
  err.code = detail;
@@ -598,9 +938,18 @@ function jobFailureError(jobError, presId) {
598
938
  return err;
599
939
  }
600
940
 
601
- async function renderViaSync({ presId, body, backendURL }) {
941
+ // The render/translate routes exist on two surfaces sharing one contract:
942
+ // /api/cli/presentations/:presId — brandId resolved from the pane registry
943
+ // /api/cli/brands/:brandId — pane-free (bound deck, window optional)
944
+ function renderBasePath({ presId, brandId }) {
945
+ return brandId
946
+ ? `/api/cli/brands/${encodeURIComponent(brandId)}`
947
+ : `/api/cli/presentations/${encodeURIComponent(presId)}`;
948
+ }
949
+
950
+ async function renderViaSync({ basePath, body, backendURL }) {
602
951
  const res = await bridgeFetch(
603
- `/api/cli/presentations/${encodeURIComponent(presId)}/render-pptx`,
952
+ `${basePath}/render-pptx`,
604
953
  {
605
954
  method: "POST",
606
955
  headers: { "content-type": "application/json" },
@@ -617,7 +966,7 @@ async function renderViaSync({ presId, body, backendURL }) {
617
966
  return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
618
967
  }
619
968
 
620
- async function renderViaJob({ presId, body, backendURL, onProgress }) {
969
+ async function renderViaJob({ basePath, label, body, backendURL, onProgress }) {
621
970
  const pollMs = envInt("SLIDEMIND_BRIDGE_RENDER_POLL_MS", 2500);
622
971
  const deadlineMs = envInt("SLIDEMIND_BRIDGE_RENDER_DEADLINE_MS", 900000);
623
972
  const resultTimeoutMs = envInt(
@@ -630,7 +979,7 @@ async function renderViaJob({ presId, body, backendURL, onProgress }) {
630
979
  // would re-upload a multi-MB slide payload, and creation errors (stale
631
980
  // etag, no CD, unknown presId) are definitive.
632
981
  const res = await bridgeFetch(
633
- `/api/cli/presentations/${encodeURIComponent(presId)}/render-jobs`,
982
+ `${basePath}/render-jobs`,
634
983
  {
635
984
  method: "POST",
636
985
  headers: { "content-type": "application/json" },
@@ -675,7 +1024,7 @@ async function renderViaJob({ presId, body, backendURL, onProgress }) {
675
1024
  // The job record vanished (backend Redis flush/restart) — same recovery
676
1025
  // as a lost replica: resubmit once.
677
1026
  if (err?.code === "job_not_found") {
678
- if (resubmitted) throw jobFailureError({ status: 500, error: "job_lost" }, presId);
1027
+ if (resubmitted) throw jobFailureError({ status: 500, error: "job_lost" }, label);
679
1028
  resubmitted = true;
680
1029
  onProgress?.({ state: "restarted" });
681
1030
  jobId = await createJob();
@@ -710,7 +1059,7 @@ async function renderViaJob({ presId, body, backendURL, onProgress }) {
710
1059
  jobId = await createJob();
711
1060
  continue;
712
1061
  }
713
- throw jobFailureError(jobError, presId);
1062
+ throw jobFailureError(jobError, label);
714
1063
  }
715
1064
 
716
1065
  if (status.state === "done") {
@@ -744,8 +1093,15 @@ async function renderViaJob({ presId, body, backendURL, onProgress }) {
744
1093
  // `slides`: { "<file>.jsx": "<jsx source>" }. `deckAssets`: { "assets/<f>": "<base64>" }.
745
1094
  // `onProgress` (optional) receives `{ state, done?, total?, stage? }` ticks on
746
1095
  // the job protocol; the sync fallback emits none.
1096
+ //
1097
+ // Target is EXACTLY ONE of:
1098
+ // presId — render via the pane registry (any add-in version)
1099
+ // brandId — render via the pane-free brand route (needs backendURL + a
1100
+ // backend advertising `brandRoutes`); `deckName` labels the render.
747
1101
  export async function renderPptx({
748
- presId,
1102
+ presId = null,
1103
+ brandId = null,
1104
+ deckName = null,
749
1105
  slides,
750
1106
  deckAssets = null,
751
1107
  nativeSlides = null,
@@ -753,13 +1109,25 @@ export async function renderPptx({
753
1109
  backendURL = null,
754
1110
  onProgress = null,
755
1111
  }) {
756
- if (!presId) throw new Error("renderPptx: presId is required");
1112
+ if (!presId && !brandId) {
1113
+ throw new Error("renderPptx: presId or brandId is required");
1114
+ }
757
1115
  const hasSlides = slides && Object.keys(slides).length > 0;
758
1116
  const hasNative = nativeSlides && Object.keys(nativeSlides).length > 0;
759
1117
  if (!hasSlides && !hasNative) {
760
1118
  throw new Error("renderPptx: slides or nativeSlides is required");
761
1119
  }
762
- const be = backendURL || (await resolveBackendForPres(presId));
1120
+ let be = backendURL;
1121
+ if (!be) {
1122
+ if (brandId) throw new Error("renderPptx: backendURL is required with brandId");
1123
+ be = await resolveBackendForPres(presId);
1124
+ }
1125
+ // Version floor first: shipping a whole deck to a backend that no longer
1126
+ // speaks this CLI's contract fails deep and confusingly; fail here instead
1127
+ // with the actionable `slidemind update`.
1128
+ await assertCliVersionSupported(be);
1129
+ const basePath = renderBasePath({ presId, brandId });
1130
+ const label = brandId ? (deckName ?? brandId) : presId;
763
1131
  const body = { slides: slides ?? {} };
764
1132
  if (deckAssets && Object.keys(deckAssets).length > 0) {
765
1133
  body.deckAssets = deckAssets;
@@ -768,17 +1136,20 @@ export async function renderPptx({
768
1136
  // server splices them in by numeric-aware sorted filename (contract #7).
769
1137
  if (hasNative) body.nativeSlides = nativeSlides;
770
1138
  if (etag) body.etag = etag;
1139
+ // The brand surface has no presId to label the render with — pass the deck
1140
+ // slug so job records/temp dirs stay readable (server-side sanitized).
1141
+ if (brandId && deckName) body.deckName = deckName;
771
1142
 
772
1143
  if (await backendSupportsRenderJobs(be)) {
773
1144
  try {
774
- return await renderViaJob({ presId, body, backendURL: be, onProgress });
1145
+ return await renderViaJob({ basePath, label, body, backendURL: be, onProgress });
775
1146
  } catch (err) {
776
1147
  // The backend speaks the protocol but its job store is down — the sync
777
1148
  // route renders in-process without Redis, so fall back to it.
778
1149
  if (err?.code !== "jobs_unavailable") throw err;
779
1150
  }
780
1151
  }
781
- return renderViaSync({ presId, body, backendURL: be });
1152
+ return renderViaSync({ basePath, body, backendURL: be });
782
1153
  }
783
1154
 
784
1155
  // Translate a deck the CLI captured LOCALLY (the shipped capture engine) into a
@@ -786,15 +1157,24 @@ export async function renderPptx({
786
1157
  // single synchronous request — the server runs only the IP-sensitive boxes→OOXML
787
1158
  // half. Returns the same shape as renderPptx ({ ok, base64, slideCount, … }).
788
1159
  export async function translateDeck({
789
- presId,
1160
+ presId = null,
1161
+ brandId = null,
1162
+ deckName = null,
790
1163
  captured,
791
1164
  nativeSlides = null,
792
1165
  etag = null,
793
1166
  backendURL = null,
794
1167
  }) {
795
- if (!presId) throw new Error("translateDeck: presId is required");
1168
+ if (!presId && !brandId) {
1169
+ throw new Error("translateDeck: presId or brandId is required");
1170
+ }
796
1171
  if (!captured) throw new Error("translateDeck: captured is required");
797
- const be = backendURL || (await resolveBackendForPres(presId));
1172
+ let be = backendURL;
1173
+ if (!be) {
1174
+ if (brandId) throw new Error("translateDeck: backendURL is required with brandId");
1175
+ be = await resolveBackendForPres(presId);
1176
+ }
1177
+ await assertCliVersionSupported(be);
798
1178
  const body = { captured };
799
1179
  // Reused native one-slide .pptx (base64), keyed by play-order filename — the
800
1180
  // server splices them in alongside the locally-captured JSX slides (contract
@@ -803,8 +1183,9 @@ export async function translateDeck({
803
1183
  body.nativeSlides = nativeSlides;
804
1184
  }
805
1185
  if (etag) body.etag = etag;
1186
+ if (brandId && deckName) body.deckName = deckName;
806
1187
  const res = await bridgeFetch(
807
- `/api/cli/presentations/${encodeURIComponent(presId)}/translate`,
1188
+ `${renderBasePath({ presId, brandId })}/translate`,
808
1189
  {
809
1190
  method: "POST",
810
1191
  headers: { "content-type": "application/json" },
@@ -821,14 +1202,20 @@ export async function translateDeck({
821
1202
  }
822
1203
 
823
1204
  // Run a pane-relayed engine op; on `pane_suspended` (the server already waited
824
- // ~25s for a reconnect) wait CLIENT-side for the pane to wake — one actionable
825
- // line, then a single retry against the live entry (covers presId churn too).
826
- // Engine ops are idempotent reads, so the retry is safe.
1205
+ // ~25s for a reconnect) OR `pane_timeout` (the pane looked connected but never
1206
+ // acked within the server's 60s window — the server marks it suspended on the
1207
+ // way out, so the same wake applies) wait CLIENT-side for the pane to wake —
1208
+ // one actionable line, then a single retry against the live entry (covers
1209
+ // presId churn too). Engine ops are idempotent reads, and the one mutation
1210
+ // (live insert) rides the SAME operationId through the retry so the pane-side
1211
+ // dedup makes a re-send safe.
827
1212
  async function engineOpWithPaneWait(presId, backendURL, attempt) {
828
1213
  try {
829
1214
  return await attempt(presId, backendURL);
830
1215
  } catch (err) {
831
- if (err?.code !== "pane_suspended") throw err;
1216
+ if (err?.code !== "pane_suspended" && err?.code !== "pane_timeout") {
1217
+ throw err;
1218
+ }
832
1219
  const live = await waitForPaneReady(presId, {
833
1220
  backendURL,
834
1221
  onWait: paneWaitNotice,
@@ -1108,6 +1495,46 @@ export async function viewSlide({
1108
1495
  });
1109
1496
  }
1110
1497
 
1498
+ // Read the CURRENT content of an open presentation (the "pull content" path
1499
+ // behind `slidemind read`). `mode` selects the
1500
+ // depth: "summary" returns a per-slide outline (index, layout, title, subtitle);
1501
+ // "raw" (default) returns each slide's full plain text — NOT the add-in's editing
1502
+ // markup. Unlike engine ops, content is WINDOW-BOUND: it reads THAT presentation,
1503
+ // so we resolve the origin backend from the presId and relay through that
1504
+ // window's pane. It's an idempotent read, so engineOpWithPaneWait's single
1505
+ // pane_suspended client-wait + retry against the live entry is safe (like viewSlide).
1506
+ export async function getPresentationContent({
1507
+ presId,
1508
+ mode = "raw",
1509
+ backendURL = null,
1510
+ }) {
1511
+ if (!presId) throw new Error("getPresentationContent: presId is required");
1512
+ const be = backendURL || (await resolveBackendForPres(presId));
1513
+ const body = { mode: mode === "summary" ? "summary" : "raw" };
1514
+ return engineOpWithPaneWait(presId, be, async (pid, b) => {
1515
+ const res = await bridgeFetch(
1516
+ `/api/cli/presentations/${encodeURIComponent(pid)}/content`,
1517
+ {
1518
+ method: "POST",
1519
+ headers: { "content-type": "application/json" },
1520
+ body: JSON.stringify(body),
1521
+ timeoutMs: engineTimeoutMs(),
1522
+ },
1523
+ b,
1524
+ );
1525
+ return res.json(); // { ok, mode, slideCount, slides }
1526
+ });
1527
+ }
1528
+
1529
+ function ensurePaneAckOk(data, operation) {
1530
+ if (!data || data.ok !== false) return data ?? {};
1531
+ const detail = data.error || `${operation} failed`;
1532
+ const err = new Error(detail);
1533
+ err.code = data.code || `${operation}_failed`;
1534
+ err.body = data;
1535
+ throw err;
1536
+ }
1537
+
1111
1538
  export async function insertSlides({
1112
1539
  presId,
1113
1540
  base64,
@@ -1132,7 +1559,7 @@ export async function insertSlides({
1132
1559
  },
1133
1560
  be,
1134
1561
  );
1135
- return res.json().catch(() => ({}));
1562
+ return ensurePaneAckOk(await res.json().catch(() => ({})), "insert");
1136
1563
  }
1137
1564
 
1138
1565
  export async function replaceSlides({
@@ -1156,7 +1583,107 @@ export async function replaceSlides({
1156
1583
  },
1157
1584
  be,
1158
1585
  );
1159
- return res.json().catch(() => ({}));
1586
+ return ensurePaneAckOk(await res.json().catch(() => ({})), "replace");
1587
+ }
1588
+
1589
+ // Non-throwing, PURE (no network) sibling of pickPresentationEntry that answers
1590
+ // the one question `slidemind ready` needs: which corporate design should this
1591
+ // session bind to? Where pickPresentationEntry PROMPTS the user by throwing,
1592
+ // resolveDesignSource returns a STRUCTURED verdict the CLI turns into a bound
1593
+ // bundle, a single blocker, or a "which design?" question — no exceptions as
1594
+ // control flow.
1595
+ //
1596
+ // The bar is RENDER-ready, never pane-ready: the add-in is the source of truth
1597
+ // for the design, and a backgrounded (pane-suspended) window still serves its
1598
+ // bundle and still renders — so it is a valid design source (the caller reports
1599
+ // `paneSuspended` but stays exit 0). Pane readiness only matters later, when a
1600
+ // push/search actually needs the live pane.
1601
+ //
1602
+ // The ONE user-facing ambiguity is ≥2 render-ready windows on DISTINCT designs
1603
+ // (distinct `brandId`). We deliberately do NOT let the deck's own bundle ref
1604
+ // (`deckPresFP`) break that tie — the open add-in wins; a deck never pins a
1605
+ // design preference. The deck ref only:
1606
+ // (a) tie-breaks ONE window mirrored across paired backends (same presId on
1607
+ // dev+prod) via tieBreakMatches — the SAME rule pickPresentationEntry
1608
+ // uses, and its only sanctioned use here; and
1609
+ // (b) surfaces the matching candidate as `suggested`, so the agent can say
1610
+ // "this deck was last built against X" WITHOUT the CLI silently deciding.
1611
+ //
1612
+ // Returns exactly one of:
1613
+ // { status: "none" } — nothing render-ready is open.
1614
+ // { status: "unknown_presid", presId } — an explicit --to names no
1615
+ // render-ready window.
1616
+ // { status: "resolved", entry } — the RAW registry entry (backendURL
1617
+ // intact; the caller strips it before
1618
+ // any user-facing output).
1619
+ // { status: "ambiguous", candidates, suggested }
1620
+ // — candidates deduped to ONE entry per
1621
+ // distinct design; suggested is the
1622
+ // presId of the deck-ref match, else
1623
+ // null.
1624
+ export function resolveDesignSource(
1625
+ registry,
1626
+ { explicitPresId = null, deckPresFP = null } = {},
1627
+ ) {
1628
+ const all = (Array.isArray(registry) ? registry : []).filter(
1629
+ isRenderReadyPresentation,
1630
+ );
1631
+ if (all.length === 0) return { status: "none" };
1632
+
1633
+ // Group by DESIGN. A render-ready pane always carries a brandId, but be
1634
+ // defensive: a missing brandId is treated as its OWN pseudo-design keyed by
1635
+ // presId so two CD-less windows never collapse into one false match.
1636
+ const designKey = (p) =>
1637
+ p.brandId != null && p.brandId !== ""
1638
+ ? `brand:${p.brandId}`
1639
+ : `pres:${p.presId}`;
1640
+
1641
+ // Collapse a set of entries to one representative per distinct design,
1642
+ // preferring a live (pane-ready) pane over a suspended mirror, then compute
1643
+ // the deck-ref `suggested` presId over those representatives.
1644
+ const dedupeCandidates = (entries) => {
1645
+ const byKey = new Map();
1646
+ for (const p of entries) {
1647
+ const k = designKey(p);
1648
+ const cur = byKey.get(k);
1649
+ if (!cur || (!isPaneReadyPresentation(cur) && isPaneReadyPresentation(p))) {
1650
+ byKey.set(k, p);
1651
+ }
1652
+ }
1653
+ const candidates = [...byKey.values()];
1654
+ const suggested =
1655
+ (deckPresFP && candidates.find((c) => c.brandId === deckPresFP)?.presId) ||
1656
+ null;
1657
+ return { candidates, suggested };
1658
+ };
1659
+
1660
+ if (explicitPresId) {
1661
+ const matches = all.filter((p) => p.presId === explicitPresId);
1662
+ if (matches.length === 0) {
1663
+ return { status: "unknown_presid", presId: explicitPresId };
1664
+ }
1665
+ if (matches.length === 1) return { status: "resolved", entry: matches[0] };
1666
+ // >1 render-ready match == ONE window mirrored across paired backends.
1667
+ // Tie-break exactly as pickPresentationEntry; only a genuine tie is asked.
1668
+ const winner = tieBreakMatches(matches, deckPresFP);
1669
+ if (winner) return { status: "resolved", entry: winner };
1670
+ return { status: "ambiguous", ...dedupeCandidates(matches) };
1671
+ }
1672
+
1673
+ const distinctDesigns = new Set(all.map(designKey));
1674
+ if (distinctDesigns.size === 1) {
1675
+ // One design open (possibly one window mirrored across backends, or several
1676
+ // windows on the SAME CD). Pick via the mirror tie-break, else a live pane,
1677
+ // else the first — the design is unambiguous either way.
1678
+ const entry =
1679
+ tieBreakMatches(all, deckPresFP) ??
1680
+ all.find(isPaneReadyPresentation) ??
1681
+ all[0];
1682
+ return { status: "resolved", entry };
1683
+ }
1684
+ // ≥2 distinct designs open — the only case that asks the user (the deck ref
1685
+ // does NOT auto-pick; it only annotates `suggested`).
1686
+ return { status: "ambiguous", ...dedupeCandidates(all) };
1160
1687
  }
1161
1688
 
1162
1689
  // Pick a target presentation given the (possibly multi-backend) registry and an
@@ -1213,7 +1740,7 @@ export function pickPresentationEntry(
1213
1740
  )
1214
1741
  .join("\n");
1215
1742
  throw new Error(
1216
- `Multiple PowerPoint windows are open. Pick one with --to <presId>:\n${list}`,
1743
+ `Multiple PowerPoint windows are open. Pick one with --to <presId>:\n${list}\n${READY_HINT_PICK}`,
1217
1744
  );
1218
1745
  }
1219
1746
 
@@ -1238,7 +1765,9 @@ export function pickAnyPresentationEntry(registry, explicitPresId, opts = {}) {
1238
1765
  );
1239
1766
  }
1240
1767
  return (
1241
- registry.find((p) => p.connected !== false && !p.suspendedAt) ?? registry[0]
1768
+ registry.find(isPaneReadyPresentation) ??
1769
+ registry.find((p) => p.connected !== false && !p.suspendedAt) ??
1770
+ registry[0]
1242
1771
  );
1243
1772
  }
1244
1773