slidemind 0.1.6 → 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
  }
@@ -52,6 +88,19 @@ const NOT_PAIRED_MESSAGE =
52
88
  'slidemind is not paired. In the slidemind add-in, click "Connect CLI" ' +
53
89
  "and run the shown `slidemind pair …` command, then retry.";
54
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
+
55
104
  // One transient blip — a pane mid-reconnect after a backend deploy, a stale
56
105
  // registration the pane is about to refresh — should not fail a whole skill
57
106
  // step. Idempotent reads retry a couple of times; mutations never do (the
@@ -270,12 +319,23 @@ async function readinessForBackend(backendURL) {
270
319
  const presentations = Array.isArray(data.presentations)
271
320
  ? data.presentations.map((p) => ({ ...p, backendURL, env }))
272
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;
273
329
  return {
274
330
  env,
275
331
  backendURL,
276
332
  ok: true,
277
333
  readinessV1: data.readinessV1 === true,
278
334
  capabilities: data.capabilities ?? null,
335
+ minCliVersion,
336
+ cliUpdateRequired: Boolean(
337
+ minCliVersion && compareCliVersions(cliVersion(), minCliVersion) < 0,
338
+ ),
279
339
  presentations,
280
340
  summary: data.summary ?? null,
281
341
  };
@@ -372,8 +432,10 @@ export async function listOpenPresentations() {
372
432
  // The user/agent-facing shape of a presentation entry. `backendURL` is internal
373
433
  // routing state — every caller targets a presentation by `presId` (the backend
374
434
  // is re-resolved server-side from it), so the URL must never leave the process
375
- // in any printed line or MCP payload. Keeps presId, title, fileName, connected,
376
- // 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.
377
439
  export function toPublicPresentation(p) {
378
440
  const { backendURL, ...rest } = p;
379
441
  return rest;
@@ -412,13 +474,15 @@ export async function requireHost() {
412
474
  "Open PowerPoint with the slidemind add-in and an active corporate design, then retry.\n" +
413
475
  unreachable
414
476
  .map((u) => ` (bridge: ${u.env}: ${u.error})`)
415
- .join("\n"),
477
+ .join("\n") +
478
+ `\n${READY_HINT}`,
416
479
  );
417
480
  }
418
481
  throw new Error(
419
482
  "No PowerPoint window is registered with the slidemind add-in.\n" +
420
483
  "If PowerPoint is already open, bring it to the foreground and click the slidemind pane once so it re-registers, then retry.\n" +
421
- "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,
422
486
  );
423
487
  }
424
488
 
@@ -467,6 +531,32 @@ async function waitForReadyPresentation(predicate, { timeoutMs = null } = {}) {
467
531
  }
468
532
  }
469
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
+
470
560
  export async function requireRenderHost({ timeoutMs = null } = {}) {
471
561
  const first = await listOpenPresentationsDetailed();
472
562
  if (first.presentations.some(isRenderReadyPresentation)) {
@@ -482,7 +572,8 @@ export async function requireRenderHost({ timeoutMs = null } = {}) {
482
572
  }
483
573
  throw new Error(
484
574
  "PowerPoint is registered with the slidemind add-in, but no window is render-ready yet. " +
485
- "Wait for the corporate design to finish matching, or pick/create a corporate design in the add-in, then retry.",
575
+ "Wait for the corporate design to finish matching, or pick/create a corporate design in the add-in, then retry. " +
576
+ READY_HINT,
486
577
  );
487
578
  }
488
579
  return requireHost();
@@ -491,19 +582,20 @@ export async function requireRenderHost({ timeoutMs = null } = {}) {
491
582
  export async function requirePaneHost({ timeoutMs = null } = {}) {
492
583
  const first = await listOpenPresentationsDetailed();
493
584
  if (first.presentations.some(isPaneReadyPresentation)) {
494
- return first.presentations;
585
+ return first.presentations.filter(isPaneReadyPresentation);
495
586
  }
496
587
  if (first.presentations.length > 0) {
497
588
  const waited = await waitForReadyPresentation(isPaneReadyPresentation, {
498
589
  timeoutMs,
499
590
  });
500
- if (Array.isArray(waited)) return waited;
591
+ if (Array.isArray(waited)) return waited.filter(isPaneReadyPresentation);
501
592
  if (waited.presentations.some(isPaneReadyPresentation)) {
502
- return waited.presentations;
593
+ return waited.presentations.filter(isPaneReadyPresentation);
503
594
  }
504
595
  throw new Error(
505
596
  "PowerPoint is registered with the slidemind add-in, but the task pane is not ready for pane operations. " +
506
- "Bring PowerPoint forward and click or reopen the slidemind pane, then retry.",
597
+ "Bring PowerPoint forward and click or reopen the slidemind pane, then retry. " +
598
+ READY_HINT,
507
599
  );
508
600
  }
509
601
  return requireHost();
@@ -612,7 +704,7 @@ export async function waitForPaneReady(
612
704
  }
613
705
 
614
706
  // The one actionable line a human needs when a pane op is waiting. stderr so
615
- // it reaches the terminal from the CLI and the MCP server log alike.
707
+ // it reaches the terminal from the CLI.
616
708
  function paneWaitNotice(seconds) {
617
709
  process.stderr.write(
618
710
  "task pane suspended — click the slidemind pane in PowerPoint to wake it " +
@@ -639,6 +731,64 @@ export async function getDesignBundle(
639
731
  });
640
732
  }
641
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
+
642
792
  // List the LIVE engine catalog (NOT the bundle's curated set) for the paired
643
793
  // backend(s). `GET /api/cli/engines` is backend-scoped, not presId-scoped, so
644
794
  // when several environments are paired this fans out and tags each engine with
@@ -665,8 +815,8 @@ async function listEnginesForBackend(backendURL, { type = null, subtype = null }
665
815
  });
666
816
  }
667
817
 
668
- export async function listEngines({ type = null, subtype = null } = {}) {
669
- const sessions = sessionsInScope();
818
+ export async function listEngines({ type = null, subtype = null, backendURL = null } = {}) {
819
+ const sessions = backendURL ? [sessionForBackend(backendURL)] : sessionsInScope();
670
820
  const settled = await Promise.allSettled(
671
821
  sessions.map((s) => listEnginesForBackend(s.backendURL, { type, subtype })),
672
822
  );
@@ -716,10 +866,10 @@ function sleep(ms) {
716
866
  // One capability probe per backend per process. Keyed by the RESOLVED backend
717
867
  // URL (callers always resolve before probing), so multiple paired backends on
718
868
  // mixed versions each get their own answer.
719
- const renderJobsCapability = new Map();
869
+ const backendCapabilitiesCache = new Map();
720
870
 
721
- function backendSupportsRenderJobs(backendURL) {
722
- let probe = renderJobsCapability.get(backendURL);
871
+ function backendCapabilities(backendURL) {
872
+ let probe = backendCapabilitiesCache.get(backendURL);
723
873
  if (!probe) {
724
874
  probe = (async () => {
725
875
  try {
@@ -728,26 +878,59 @@ function backendSupportsRenderJobs(backendURL) {
728
878
  { timeoutMs: listTimeoutMs() },
729
879
  backendURL,
730
880
  );
731
- const data = await res.json();
732
- return data?.renderJobs === true;
881
+ return (await res.json()) ?? {};
733
882
  } catch {
734
- // Older backends 404 this path (with a non-JSON body) — sync render.
735
- return false;
883
+ // Older backends 404 this path (with a non-JSON body) — no
884
+ // capabilities: sync render, no version floor.
885
+ return {};
736
886
  }
737
887
  })();
738
- renderJobsCapability.set(backendURL, probe);
888
+ backendCapabilitiesCache.set(backendURL, probe);
739
889
  }
740
890
  return probe;
741
891
  }
742
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
+
743
926
  // Rebuild a job failure into the exact error shape bridgeFetch throws for the
744
- // 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
745
928
  // protocols apart.
746
- function jobFailureError(jobError, presId) {
929
+ function jobFailureError(jobError, label) {
747
930
  const status = jobError?.status ?? 500;
748
931
  const detail = jobError?.error ?? "render_failed";
749
932
  const err = new Error(
750
- `slidemind bridge ${status} render job failed for ${presId}: ${detail}`,
933
+ `slidemind bridge ${status} render job failed for ${label}: ${detail}`,
751
934
  );
752
935
  err.status = status;
753
936
  err.code = detail;
@@ -755,9 +938,18 @@ function jobFailureError(jobError, presId) {
755
938
  return err;
756
939
  }
757
940
 
758
- 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 }) {
759
951
  const res = await bridgeFetch(
760
- `/api/cli/presentations/${encodeURIComponent(presId)}/render-pptx`,
952
+ `${basePath}/render-pptx`,
761
953
  {
762
954
  method: "POST",
763
955
  headers: { "content-type": "application/json" },
@@ -774,7 +966,7 @@ async function renderViaSync({ presId, body, backendURL }) {
774
966
  return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
775
967
  }
776
968
 
777
- async function renderViaJob({ presId, body, backendURL, onProgress }) {
969
+ async function renderViaJob({ basePath, label, body, backendURL, onProgress }) {
778
970
  const pollMs = envInt("SLIDEMIND_BRIDGE_RENDER_POLL_MS", 2500);
779
971
  const deadlineMs = envInt("SLIDEMIND_BRIDGE_RENDER_DEADLINE_MS", 900000);
780
972
  const resultTimeoutMs = envInt(
@@ -787,7 +979,7 @@ async function renderViaJob({ presId, body, backendURL, onProgress }) {
787
979
  // would re-upload a multi-MB slide payload, and creation errors (stale
788
980
  // etag, no CD, unknown presId) are definitive.
789
981
  const res = await bridgeFetch(
790
- `/api/cli/presentations/${encodeURIComponent(presId)}/render-jobs`,
982
+ `${basePath}/render-jobs`,
791
983
  {
792
984
  method: "POST",
793
985
  headers: { "content-type": "application/json" },
@@ -832,7 +1024,7 @@ async function renderViaJob({ presId, body, backendURL, onProgress }) {
832
1024
  // The job record vanished (backend Redis flush/restart) — same recovery
833
1025
  // as a lost replica: resubmit once.
834
1026
  if (err?.code === "job_not_found") {
835
- if (resubmitted) throw jobFailureError({ status: 500, error: "job_lost" }, presId);
1027
+ if (resubmitted) throw jobFailureError({ status: 500, error: "job_lost" }, label);
836
1028
  resubmitted = true;
837
1029
  onProgress?.({ state: "restarted" });
838
1030
  jobId = await createJob();
@@ -867,7 +1059,7 @@ async function renderViaJob({ presId, body, backendURL, onProgress }) {
867
1059
  jobId = await createJob();
868
1060
  continue;
869
1061
  }
870
- throw jobFailureError(jobError, presId);
1062
+ throw jobFailureError(jobError, label);
871
1063
  }
872
1064
 
873
1065
  if (status.state === "done") {
@@ -901,8 +1093,15 @@ async function renderViaJob({ presId, body, backendURL, onProgress }) {
901
1093
  // `slides`: { "<file>.jsx": "<jsx source>" }. `deckAssets`: { "assets/<f>": "<base64>" }.
902
1094
  // `onProgress` (optional) receives `{ state, done?, total?, stage? }` ticks on
903
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.
904
1101
  export async function renderPptx({
905
- presId,
1102
+ presId = null,
1103
+ brandId = null,
1104
+ deckName = null,
906
1105
  slides,
907
1106
  deckAssets = null,
908
1107
  nativeSlides = null,
@@ -910,13 +1109,25 @@ export async function renderPptx({
910
1109
  backendURL = null,
911
1110
  onProgress = null,
912
1111
  }) {
913
- if (!presId) throw new Error("renderPptx: presId is required");
1112
+ if (!presId && !brandId) {
1113
+ throw new Error("renderPptx: presId or brandId is required");
1114
+ }
914
1115
  const hasSlides = slides && Object.keys(slides).length > 0;
915
1116
  const hasNative = nativeSlides && Object.keys(nativeSlides).length > 0;
916
1117
  if (!hasSlides && !hasNative) {
917
1118
  throw new Error("renderPptx: slides or nativeSlides is required");
918
1119
  }
919
- 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;
920
1131
  const body = { slides: slides ?? {} };
921
1132
  if (deckAssets && Object.keys(deckAssets).length > 0) {
922
1133
  body.deckAssets = deckAssets;
@@ -925,17 +1136,20 @@ export async function renderPptx({
925
1136
  // server splices them in by numeric-aware sorted filename (contract #7).
926
1137
  if (hasNative) body.nativeSlides = nativeSlides;
927
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;
928
1142
 
929
1143
  if (await backendSupportsRenderJobs(be)) {
930
1144
  try {
931
- return await renderViaJob({ presId, body, backendURL: be, onProgress });
1145
+ return await renderViaJob({ basePath, label, body, backendURL: be, onProgress });
932
1146
  } catch (err) {
933
1147
  // The backend speaks the protocol but its job store is down — the sync
934
1148
  // route renders in-process without Redis, so fall back to it.
935
1149
  if (err?.code !== "jobs_unavailable") throw err;
936
1150
  }
937
1151
  }
938
- return renderViaSync({ presId, body, backendURL: be });
1152
+ return renderViaSync({ basePath, body, backendURL: be });
939
1153
  }
940
1154
 
941
1155
  // Translate a deck the CLI captured LOCALLY (the shipped capture engine) into a
@@ -943,15 +1157,24 @@ export async function renderPptx({
943
1157
  // single synchronous request — the server runs only the IP-sensitive boxes→OOXML
944
1158
  // half. Returns the same shape as renderPptx ({ ok, base64, slideCount, … }).
945
1159
  export async function translateDeck({
946
- presId,
1160
+ presId = null,
1161
+ brandId = null,
1162
+ deckName = null,
947
1163
  captured,
948
1164
  nativeSlides = null,
949
1165
  etag = null,
950
1166
  backendURL = null,
951
1167
  }) {
952
- if (!presId) throw new Error("translateDeck: presId is required");
1168
+ if (!presId && !brandId) {
1169
+ throw new Error("translateDeck: presId or brandId is required");
1170
+ }
953
1171
  if (!captured) throw new Error("translateDeck: captured is required");
954
- 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);
955
1178
  const body = { captured };
956
1179
  // Reused native one-slide .pptx (base64), keyed by play-order filename — the
957
1180
  // server splices them in alongside the locally-captured JSX slides (contract
@@ -960,8 +1183,9 @@ export async function translateDeck({
960
1183
  body.nativeSlides = nativeSlides;
961
1184
  }
962
1185
  if (etag) body.etag = etag;
1186
+ if (brandId && deckName) body.deckName = deckName;
963
1187
  const res = await bridgeFetch(
964
- `/api/cli/presentations/${encodeURIComponent(presId)}/translate`,
1188
+ `${renderBasePath({ presId, brandId })}/translate`,
965
1189
  {
966
1190
  method: "POST",
967
1191
  headers: { "content-type": "application/json" },
@@ -978,14 +1202,20 @@ export async function translateDeck({
978
1202
  }
979
1203
 
980
1204
  // Run a pane-relayed engine op; on `pane_suspended` (the server already waited
981
- // ~25s for a reconnect) wait CLIENT-side for the pane to wake — one actionable
982
- // line, then a single retry against the live entry (covers presId churn too).
983
- // 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.
984
1212
  async function engineOpWithPaneWait(presId, backendURL, attempt) {
985
1213
  try {
986
1214
  return await attempt(presId, backendURL);
987
1215
  } catch (err) {
988
- if (err?.code !== "pane_suspended") throw err;
1216
+ if (err?.code !== "pane_suspended" && err?.code !== "pane_timeout") {
1217
+ throw err;
1218
+ }
989
1219
  const live = await waitForPaneReady(presId, {
990
1220
  backendURL,
991
1221
  onWait: paneWaitNotice,
@@ -1266,7 +1496,7 @@ export async function viewSlide({
1266
1496
  }
1267
1497
 
1268
1498
  // Read the CURRENT content of an open presentation (the "pull content" path
1269
- // behind `slidemind read` / slidemind_read_presentation). `mode` selects the
1499
+ // behind `slidemind read`). `mode` selects the
1270
1500
  // depth: "summary" returns a per-slide outline (index, layout, title, subtitle);
1271
1501
  // "raw" (default) returns each slide's full plain text — NOT the add-in's editing
1272
1502
  // markup. Unlike engine ops, content is WINDOW-BOUND: it reads THAT presentation,
@@ -1356,6 +1586,106 @@ export async function replaceSlides({
1356
1586
  return ensurePaneAckOk(await res.json().catch(() => ({})), "replace");
1357
1587
  }
1358
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) };
1687
+ }
1688
+
1359
1689
  // Pick a target presentation given the (possibly multi-backend) registry and an
1360
1690
  // optional explicit presId. Returns the matched registry ENTRY (which carries
1361
1691
  // `backendURL`/`env` after a fan-out list), or throws with a helpful message if
@@ -1410,7 +1740,7 @@ export function pickPresentationEntry(
1410
1740
  )
1411
1741
  .join("\n");
1412
1742
  throw new Error(
1413
- `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}`,
1414
1744
  );
1415
1745
  }
1416
1746