slidemind 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/slidemind.mjs CHANGED
@@ -68,7 +68,9 @@ function printHelp() {
68
68
  ` Verifies authorization/connection/CLI version, resolves the open\n` +
69
69
  ` design, materializes its bundle, and (with a deck) binds it.\n` +
70
70
  ` Exit 0 = ready; non-zero prints one actionable blocker.\n` +
71
+ ` A deck already bound to a design readies with PowerPoint closed.\n` +
71
72
  ` --to <presId> bind to a specific open window\n` +
73
+ ` --brand <id> bind to a design by id (presFP) — no window needed\n` +
72
74
  ` --force re-download the bundle even if current\n` +
73
75
  ` --connect-only check the connection only (no design/bundle)\n` +
74
76
  ` --json machine-readable readiness payload\n` +
@@ -80,7 +82,8 @@ function printHelp() {
80
82
  ` blocks until the user decides, prints DECISION: approved | comments\n` +
81
83
  ` --port <n> bind a fixed port (default: ephemeral)\n` +
82
84
  ` slidemind concepts <deck> After plan sign-off, generate one concept mockup PNG per planned\n` +
83
- ` slide into <deck>/concepts/concept-slide-NN.png (requires a host).\n` +
85
+ ` slide into <deck>/concepts/concept-slide-NN.png (bound deck,\n` +
86
+ ` pane-free — no PowerPoint window needed).\n` +
84
87
  ` Read each as a design target, then rebuild it as slide JSX.\n` +
85
88
  ` Optional aid — skips cleanly (exit 0) if unavailable.\n` +
86
89
  ` slidemind build <deck> Build self-contained HTML\n` +
@@ -88,31 +91,46 @@ function printHelp() {
88
91
  ` approves or leaves comments (host-free)\n` +
89
92
  ` --port <n> bind a fixed port (default: ephemeral)\n` +
90
93
  ` slidemind build-pptx <deck> Export native .pptx — via the add-in for compose decks (host), LOCALLY for template packs (host-free)\n` +
94
+ ` --pack <dir> explicit template-pack directory (plugin integrations)\n` +
91
95
  ` --to <presId> target a specific window (required if >1 open)\n` +
92
96
  ` --out <path> output path (default <deck>/<deck>.pptx)\n` +
97
+ ` slidemind inspect-template-deck <deck> Inspect a template deck as JSON for pack-owned validation\n` +
98
+ ` --pack <dir> explicit template-pack directory (required)\n` +
99
+ ` --allow-unrendered-freeform include JSX inserts without .pptx artifacts\n` +
100
+ ` --json print the normalized deck model\n` +
93
101
  ` slidemind view-draft <deck> Render the deck's slides to per-slide PNGs (no push, no .pptx)\n` +
94
102
  ` so the agent can look before review; host-free once the\n` +
95
103
  ` bundle is materialized\n` +
96
104
  ` --to <presId> window to fetch the bundle from (only if not cached)\n` +
97
105
  ` --out <dir> output dir (default <deck>/.draft-preview/)\n` +
98
106
  ` --open open each rendered PNG\n` +
99
- ` slidemind search "<query>" Search an engine or scope for images or slides (requires an open host)\n` +
107
+ ` slidemind search "<query>" Search an engine or scope for images or slides (no window needed on\n` +
108
+ ` add-ins that serve the pane-free engine routes; older ones relay\n` +
109
+ ` through an open host)\n` +
100
110
  ` --engine <id> engine, or scope:all | scope:sharepoint to search unfiltered\n` +
101
111
  ` (default: first image engine; scope:all for slide intents)\n` +
102
112
  ` --result-type <t> slide | image — required for a scope:* target\n` +
103
113
  ` --intent <i> auto | content | visual (slide engines)\n` +
104
114
  ` --top <N> max results\n` +
105
- ` --to <presId> target a specific window (required if >1 open)\n` +
106
- ` --json print the raw result payload\n` +
107
- ` slidemind view-slide [opts] Fetch a slide search result's preview image to a file (requires a host)\n` +
115
+ ` --deck <deck> route by this deck's bound design (multi-environment)\n` +
116
+ ` --to <presId> pin a window on the relayed path (ignored pane-free)\n` +
117
+ ` --json print the raw result payload (carries the searchId)\n` +
118
+ ` slidemind view-slide [opts] Fetch a slide search result's preview image to a file\n` +
108
119
  ` --search <id> searchId from 'slidemind search'\n` +
109
120
  ` --pick <n> 1-based selection number to view\n` +
110
- ` --to <presId> the window the search ran on (required if >1 open)\n` +
121
+ ` --to <presId> the window the search ran on (relayed path only)\n` +
111
122
  ` --out <dir> output dir (default: a slidemind temp dir)\n` +
112
- ` slidemind ls engines List the live engine catalog + search scopes for an active pane\n` +
123
+ ` slidemind read-source [opts] Read the ORIGINAL deck a slide search result came from\n` +
124
+ ` --search <id> searchId from 'slidemind search'\n` +
125
+ ` --pick <n> 1-based selection number\n` +
126
+ ` --from-slide <n> first slide to read (1-based)\n` +
127
+ ` --to-slide <n> last slide to read (1-based)\n` +
128
+ ` --to <presId> the window the search ran on (relayed path only)\n` +
129
+ ` --out <file> write the Markdown to a file instead of stdout\n` +
130
+ ` slidemind ls engines List the live engine catalog + search scopes\n` +
113
131
  ` --type <t> collection | search | scope\n` +
114
132
  ` --subtype <s> image | slide | template | content\n` +
115
- ` --to <presId> target a specific window/backend\n` +
133
+ ` --to <presId> target a specific window/backend (relayed path only)\n` +
116
134
  ` --all include every paired backend\n` +
117
135
  ` --json print the raw engine list\n` +
118
136
  ` slidemind insert-slide [opts] Insert reused slide search results live into a window (requires a host)\n` +
@@ -121,17 +139,18 @@ function printHelp() {
121
139
  ` --to <presId> target window (required if >1 open)\n` +
122
140
  ` --at <N> insert after slide N (0-based); default = append\n` +
123
141
  ` slidemind add-slide [opts] Fold reused slide(s) into a deck as native .pptx\n` +
124
- ` --search <id> searchId from 'slidemind search' (requires a host)\n` +
142
+ ` --search <id> searchId from 'slidemind search'\n` +
125
143
  ` --from <file.pptx> fold slide(s) from a local .pptx instead (host-free)\n` +
126
144
  ` --pick <n,..> 1-based selection number(s)\n` +
127
145
  ` --deck <deck> deck to add the slides to\n` +
128
- ` --to <presId> target the window the search ran on (required if >1 open; ignored with --from)\n` +
146
+ ` --to <presId> the window the search ran on (relayed path only; ignored with --from)\n` +
129
147
  ` --at <N> numeric prefix to start at (default: append)\n` +
130
- ` slidemind download-result [opts] Download image/icon search result(s) into a deck's assets/ (requires a host)\n` +
148
+ ` --json machine-readable result (carries the searchId)\n` +
149
+ ` slidemind download-result [opts] Download image/icon search result(s) into a deck's assets/\n` +
131
150
  ` --search <id> searchId from 'slidemind search'\n` +
132
151
  ` --pick <n,..> 1-based selection number(s)\n` +
133
152
  ` --deck <deck> deck to write the asset(s) into\n` +
134
- ` --to <presId> the window the search ran on (required if >1 open)\n` +
153
+ ` --to <presId> the window the search ran on (relayed path only)\n` +
135
154
  ` --icon-color <hex[,hex,..]> bake a colour into SVG result(s) (one for all, or one per --pick)\n` +
136
155
  ` slidemind imagen "<prompt>" Generate ONE image with the add-in's image model into a deck's assets/ (paired backend, pane-free)\n` +
137
156
  ` --deck <deck> bound deck to write the image into (required)\n` +
@@ -144,7 +163,9 @@ function printHelp() {
144
163
  ` --force re-download even if the cached bundle is current\n` +
145
164
  ` slidemind presentations List active PowerPoint panes registered with the add-in\n` +
146
165
  ` --all include retained suspended entries\n` +
147
- ` slidemind doctor Diagnose CLI/add-in authorization, backend, and pane readiness\n` +
166
+ ` slidemind doctor Diagnose two independent ladders: backend authoring readiness\n` +
167
+ ` (session, backend, CLI version, engine routes) and live\n` +
168
+ ` PowerPoint readiness (windows, pane)\n` +
148
169
  ` --json print machine-readable status\n` +
149
170
  ` slidemind read [presId|file.pptx] Pull the content of an open presentation (requires a host),\n` +
150
171
  ` or of a local .pptx path (read host-free from disk)\n` +
@@ -429,15 +450,86 @@ async function cmdImagen(rest) {
429
450
  );
430
451
  }
431
452
 
453
+ async function cmdInspectTemplateDeck(rest) {
454
+ const { deck, passthrough } = splitDeckArg(rest, "inspect-template-deck");
455
+ let packDir = null;
456
+ let json = false;
457
+ let allowUnrenderedFreeform = false;
458
+ for (let i = 0; i < passthrough.length; i++) {
459
+ const a = passthrough[i];
460
+ if (a === "--pack") {
461
+ packDir = passthrough[++i];
462
+ if (!packDir) fail("--pack expects a directory");
463
+ } else if (a === "--json") {
464
+ json = true;
465
+ } else if (a === "--allow-unrendered-freeform") {
466
+ allowUnrenderedFreeform = true;
467
+ } else if (a.startsWith("-")) {
468
+ fail(`unknown flag: ${a}`);
469
+ } else {
470
+ fail(`unexpected argument: ${a}`);
471
+ }
472
+ }
473
+ if (!packDir) fail("inspect-template-deck requires --pack <dir>");
474
+
475
+ const deckDir = resolveDeckArg(deck);
476
+ const resolveMod = await import(
477
+ pathToFileURL(path.join(SRC_DIR, "builders", "resolve-skill.mjs")).href
478
+ );
479
+ const skill = resolveMod.resolveSkillForDeck(deckDir, {
480
+ packDir: path.resolve(packDir),
481
+ });
482
+ if (skill.mode !== "fill-template") {
483
+ fail(`pack ${skill.skillDir} is mode ${skill.mode}, not fill-template`);
484
+ }
485
+ const engine = await import(
486
+ pathToFileURL(path.join(SRC_DIR, "pptx", "fill", "engine.mjs")).href
487
+ );
488
+ const result = engine.inspectFillTemplateDeck({
489
+ deckDir,
490
+ skill,
491
+ allowUnrenderedFreeform,
492
+ });
493
+ const payload = {
494
+ version: 1,
495
+ pack: {
496
+ name: skill.skillName,
497
+ mode: skill.mode,
498
+ directory: skill.skillDir,
499
+ template: result.templateKey,
500
+ locale: result.locale,
501
+ },
502
+ slides: result.sequence,
503
+ warnings: result.warnings,
504
+ problems: result.problems,
505
+ };
506
+ if (json) {
507
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
508
+ } else {
509
+ for (const item of result.sequence) {
510
+ const suffix = item.kind === "master" ? ` → template ${item.no}` : "";
511
+ process.stdout.write(`${item.file}: ${item.kind}${suffix}\n`);
512
+ }
513
+ for (const warning of result.warnings) process.stderr.write(` warning: ${warning}\n`);
514
+ }
515
+ if (result.problems.length) {
516
+ fail(`inspection failed:\n - ${result.problems.join("\n - ")}`);
517
+ }
518
+ }
519
+
432
520
  async function cmdBuildPptx(rest) {
433
521
  const { deck, passthrough } = splitDeckArg(rest, "build-pptx");
434
522
  let to = null;
435
523
  let outPath = null;
524
+ let packDir = null;
436
525
  for (let i = 0; i < passthrough.length; i++) {
437
526
  const a = passthrough[i];
438
527
  if (a === "--to") {
439
528
  to = passthrough[++i];
440
529
  if (!to) fail("--to expects a presId");
530
+ } else if (a === "--pack") {
531
+ packDir = passthrough[++i];
532
+ if (!packDir) fail("--pack expects a directory");
441
533
  } else if (a === "--out" || a === "-o") {
442
534
  outPath = passthrough[++i];
443
535
  if (!outPath) fail("--out expects a path");
@@ -460,8 +552,13 @@ async function cmdBuildPptx(rest) {
460
552
  const resolveMod = await import(
461
553
  pathToFileURL(path.join(SRC_DIR, "builders", "resolve-skill.mjs")).href
462
554
  );
463
- if (resolveMod.resolveDeckMode(deckDir).mode === "fill-template") {
464
- const skill = resolveMod.resolveSkillForDeck(deckDir);
555
+ const resolveOpts = packDir ? { packDir: path.resolve(packDir) } : {};
556
+ const deckMode = resolveMod.resolveDeckMode(deckDir, resolveOpts);
557
+ if (packDir && deckMode.mode !== "fill-template") {
558
+ fail(`explicit --pack ${path.resolve(packDir)} is mode ${deckMode.mode}, not fill-template`);
559
+ }
560
+ if (deckMode.mode === "fill-template") {
561
+ const skill = resolveMod.resolveSkillForDeck(deckDir, resolveOpts);
465
562
  if (to) {
466
563
  fail(
467
564
  "--to targets a PowerPoint window, but fill-template decks build " +
@@ -699,9 +796,169 @@ function writeSearchGrid(gridDataUri, searchId, resolveExt) {
699
796
  }
700
797
  }
701
798
 
799
+ // Where an engine op runs. Newer add-ins serve the engine surface off the CLI
800
+ // session itself (`/api/cli/engines`, `/api/cli/searches/*`), so discovery,
801
+ // search, download and extraction need no PowerPoint window at all; older ones
802
+ // relay every one of them through a task-pane webview, which is what
803
+ // requirePaneHost gates. The DIRECT path is preferred whenever it exists and the
804
+ // pane path stays as the compat fallback, so both add-in generations work.
805
+ //
806
+ // `--to <presId>` stops being a REQUIREMENT — the searchId is server-owned
807
+ // rather than bound to a window, so nothing has to be targeted — but it is still
808
+ // honoured, and naming a window means "relay through THAT pane". That is the
809
+ // escape hatch for the two things the pane-free routes refuse (`asset_needs_host`
810
+ // for SharePoint-hosted bytes, `recolor_needs_host` for an icon tint), both of
811
+ // which the pane can do; ignoring --to made their own remediation unreachable.
812
+ // `brandId` is the deck's bound design — the only thing that routes an engine op
813
+ // in a multi-environment session when no window is involved.
814
+ // No connected environment holds a server-owned search record. Authored once
815
+ // because every consumer of an opaque searchId reaches the same dead end and
816
+ // the same way out — the record's own environment is the only thing that can
817
+ // serve it, so a re-search is the fix regardless of who asked.
818
+ function expiredSearchMessage(searchId) {
819
+ return (
820
+ `search ${searchId} was not found on any connected environment — server-side ` +
821
+ "search records expire after ~2 hours. Re-run 'slidemind search' and pick again."
822
+ );
823
+ }
824
+
825
+ async function resolveEngineRouting(client, { to = null, brandId = null, pick = "one", searchId = null } = {}) {
826
+ // An opaque (server-minted) searchId pins the route by itself: the record
827
+ // lives on the environment that minted it, and the pane relay can never
828
+ // serve it — its handler only knows its own per-window numeric ids. So the
829
+ // record's origin outranks the deck's design AND --to, and when no connected
830
+ // environment holds the record the only honest answer is "search again",
831
+ // not a pane fallback that is guaranteed to 4xx.
832
+ if (client.isOpaqueSearchId?.(searchId)) {
833
+ if (to) {
834
+ fail(
835
+ `searchId ${searchId} is a pane-free search — it cannot be relayed through a window. ` +
836
+ `To go through the pane, re-run 'slidemind search "<query>" --to ${to}' and use ITS searchId.`,
837
+ );
838
+ }
839
+ let origin = null;
840
+ try {
841
+ origin = await client.resolveSearchBackend(searchId);
842
+ } catch (err) {
843
+ // The probe distinguishes "no environment holds this record" from the
844
+ // failures that only LOOK like it — a dead session, a stale snapshot —
845
+ // and hands the latter back for their own remediation.
846
+ await failOnEngineBlocker(client, err, { to });
847
+ }
848
+ if (origin) {
849
+ return { direct: true, backendURL: origin, presId: null, registry: null, entry: null };
850
+ }
851
+ fail(expiredSearchMessage(searchId));
852
+ }
853
+ let backendURL = null;
854
+ if (!to) {
855
+ backendURL = await client.resolveEngineBackend({ brandId });
856
+ }
857
+ if (backendURL) {
858
+ return { direct: true, backendURL, presId: null, registry: null, entry: null };
859
+ }
860
+ const registry = await client.requirePaneHost();
861
+ const entry =
862
+ pick === "any"
863
+ ? client.pickAnyPresentationEntry(registry, to)
864
+ : client.pickPresentationEntry(registry, to);
865
+ return {
866
+ direct: false,
867
+ backendURL: entry.backendURL ?? null,
868
+ presId: entry.presId,
869
+ registry,
870
+ entry,
871
+ };
872
+ }
873
+
874
+ // The design a deck is bound to (presFP == brandId), or null when it isn't bound
875
+ // / isn't a deck. Routing state only — never printed.
876
+ async function deckBrandId(deckDir) {
877
+ if (!deckDir) return null;
878
+ const bundleMod = await import(
879
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
880
+ );
881
+ return bundleMod.readDeckBundleRef(deckDir)?.presFP ?? null;
882
+ }
883
+
884
+ // A pane-ready window, or null — never a throw, and never a wait. Used by the
885
+ // stale-snapshot paths below, where an open pane is an OPTION rather than a
886
+ // precondition. `to` is honoured only when it names a window that is actually
887
+ // registered; a stale --to must not cost the caller its fallback.
888
+ async function openPaneEntry(client, to = null) {
889
+ const registry = (await client.paneReadyPresentations?.()) ?? [];
890
+ if (registry.length === 0) return null;
891
+ const explicit = registry.some((p) => p.presId === to) ? to : null;
892
+ try {
893
+ return { registry, entry: client.pickAnyPresentationEntry(registry, explicit) };
894
+ } catch {
895
+ // Several windows and no way to choose — no automatic fallback from here.
896
+ return null;
897
+ }
898
+ }
899
+
900
+ // The ONE rendering of a stale permission snapshot. The snapshot is the
901
+ // backend's cached copy of the user's group memberships and it backs the
902
+ // PANE-FREE path only: an open task pane signs every request with a live
903
+ // browser token, which is strictly fresher than any snapshot. So when a
904
+ // pane-ready window is already open, "open the slidemind add-in once" is wrong
905
+ // twice over — it IS open, and that window can serve a fresh search right now.
906
+ // Returns the message, or null when this isn't a stale-snapshot failure.
907
+ async function staleSnapshotMessage(client, err, { to = null } = {}) {
908
+ const blocker = client.permissionsStaleBlocker?.(err);
909
+ if (!blocker) return null;
910
+ const pane = await openPaneEntry(client, to);
911
+ if (!pane) return blocker.message;
912
+ const env = pane.entry.env ? ` [${pane.entry.env}]` : "";
913
+ return (
914
+ `${blocker.message} A slidemind pane is already open${env} and signs each request live, so a fresh ` +
915
+ `search through that window works now: slidemind search "<query>" --to ${pane.entry.presId}.`
916
+ );
917
+ }
918
+
919
+ // Engine-route failures the USER has to clear get one actionable line instead of
920
+ // a relay error. `permissions_stale` is the search permission snapshot having
921
+ // aged out — the server hands back the exact remediation ("open the slidemind
922
+ // add-in once…"), and only corpus search degrades; render and bundle don't.
923
+ // Anything else is re-thrown for the top-level handler.
924
+ async function failOnEngineBlocker(client, err, { to = null } = {}) {
925
+ const message = await staleSnapshotMessage(client, err, { to });
926
+ if (message) fail(message);
927
+ throw err;
928
+ }
929
+
930
+ // A stale snapshot that an OPEN PANE can simply route around. Only an operation
931
+ // that can be replayed pane-side qualifies — creating a NEW search, whose id the
932
+ // pane mints itself. A record-bound op on an opaque searchId cannot: the pane's
933
+ // handler only understands its own per-window numeric ids, so those get
934
+ // staleSnapshotMessage's extended blocker instead. Returns { registry, entry }
935
+ // or null.
936
+ async function paneRelayForStaleSnapshot(client, err, { to = null } = {}) {
937
+ if (!client.permissionsStaleBlocker?.(err)) return null;
938
+ return openPaneEntry(client, to);
939
+ }
940
+
941
+ // The pane-free routes refuse exactly two things outright: bytes that live in
942
+ // SharePoint (`asset_needs_host`, which needs a delegated token only a browser
943
+ // can mint) and an icon tint (`recolor_needs_host`, whose classifier is
944
+ // DOM-based). An open pane does both, so the remediation is the RELAY — name it,
945
+ // rather than echoing a 409 whose "retry" would take the identical route.
946
+ // Returns null for anything else.
947
+ function describeHostOnlyEngineFailure(err) {
948
+ const code = err?.code ?? err?.body?.code ?? null;
949
+ if (code !== "asset_needs_host" && code !== "recolor_needs_host") return null;
950
+ const detail =
951
+ typeof err?.body?.error === "string" ? err.body.error : (err?.message ?? "");
952
+ return (
953
+ `${detail} Run 'slidemind search "<query>" --to <presId>' against an open window and ` +
954
+ "re-pick, then repeat this download with the same --to — that relays it through the pane."
955
+ );
956
+ }
957
+
702
958
  async function cmdSearch(rest) {
703
959
  let asJson = false;
704
960
  let to = null;
961
+ let deck = null;
705
962
  let engineId = null;
706
963
  let resultType = null;
707
964
  let intent = null;
@@ -714,6 +971,9 @@ async function cmdSearch(rest) {
714
971
  } else if (a === "--to") {
715
972
  to = rest[++i];
716
973
  if (!to) fail("--to expects a presId");
974
+ } else if (a === "--deck") {
975
+ deck = rest[++i];
976
+ if (!deck) fail("--deck expects a deck name or path");
717
977
  } else if (a === "--engine" || a === "-e" || a === "--engineId") {
718
978
  engineId = rest[++i];
719
979
  if (!engineId) fail("--engine expects an engineId");
@@ -745,12 +1005,13 @@ async function cmdSearch(rest) {
745
1005
  const query = queryParts.join(" ").trim();
746
1006
  if (!query) {
747
1007
  fail(
748
- `usage: slidemind search "<query>" [--engine <id|scope:all|scope:sharepoint>] [--result-type slide|image] [--intent auto|content|visual] [--top <N>] [--to <presId>] [--json]`,
1008
+ `usage: slidemind search "<query>" [--engine <id|scope:all|scope:sharepoint>] [--result-type slide|image] [--intent auto|content|visual] [--top <N>] [--deck <deck>] [--to <presId>] [--json]`,
749
1009
  );
750
1010
  }
751
1011
 
752
- // Engine search runs against the add-in's live engine over the bridge — an
753
- // open host is required.
1012
+ // Engine search runs against the add-in's live engine over the bridge —
1013
+ // pane-free where the add-in serves the direct routes, relayed through an open
1014
+ // window (host required) where it doesn't.
754
1015
  const client = await import(
755
1016
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
756
1017
  );
@@ -760,14 +1021,23 @@ async function cmdSearch(rest) {
760
1021
  const { resolveAssetExt } = await import(
761
1022
  pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
762
1023
  );
763
- const registry = await client.requirePaneHost();
764
- // The engine is per-user, not window-bound — so search just attaches to any
765
- // connected window (preferring a live one). No "which window?" prompt; pass
766
- // --to only to pin a specific one. The chosen window is printed below so
767
- // follow-up insert/add-slide can target the same pane the searchId lives on.
768
- const entry = client.pickAnyPresentationEntry(registry, to);
769
- const presId = entry.presId;
770
- const backendURL = entry.backendURL ?? null;
1024
+ // The engine catalog is per-user, not window-bound. On the pane path search
1025
+ // attaches to any connected window (preferring a live one) and the chosen
1026
+ // window is printed below, because the searchId lives on that pane; on the
1027
+ // direct path the searchId is the backend's and no window is involved.
1028
+ const routing = await resolveEngineRouting(client, {
1029
+ to,
1030
+ brandId: await deckBrandId(deck ? resolveDeckArg(deck) : null),
1031
+ pick: "any",
1032
+ });
1033
+ const presId = routing.presId;
1034
+ const backendURL = routing.backendURL;
1035
+ // Mutable, because a stale permission snapshot can push a direct search onto
1036
+ // the pane relay below — and once it does, the searchId belongs to a WINDOW
1037
+ // and every next-step hint has to name it.
1038
+ let direct = routing.direct;
1039
+ let registry = routing.registry;
1040
+ let entry = routing.entry;
771
1041
 
772
1042
  // An explicit content/visual intent means "search slides", not imagery.
773
1043
  const slideIntent = intent === "content" || intent === "visual";
@@ -800,9 +1070,18 @@ async function cmdSearch(rest) {
800
1070
  // Best-effort: a bundle that can't be materialized (e.g. an incomplete
801
1071
  // one) must not replace the actionable "wire an image engine" message
802
1072
  // below with a bundle error — the live catalog is the real answer here.
803
- const mat = await bundle
804
- .materializeBundle(presId, { backendURL })
805
- .catch(() => null);
1073
+ // Pane path: fetch by the window's presId. Direct path: there is no
1074
+ // window, so only a bound deck (--deck) can name the design; without one
1075
+ // there is nothing to fall back to and the live catalog's answer stands.
1076
+ const brandId = presId
1077
+ ? null
1078
+ : await deckBrandId(deck ? resolveDeckArg(deck) : null);
1079
+ const mat =
1080
+ presId || brandId
1081
+ ? await bundle
1082
+ .materializeBundle(presId, { backendURL, brandId })
1083
+ .catch(() => null)
1084
+ : null;
806
1085
  if (mat) pick = bundle.pickImageEngine(bundle.readEnginesFromDir(mat.dir));
807
1086
  }
808
1087
  if (!pick) {
@@ -829,23 +1108,65 @@ async function cmdSearch(rest) {
829
1108
  resultType = null;
830
1109
  }
831
1110
 
832
- // Window-agnostic: tries the picked pane, then fails over to any other live
833
- // window on the same backend if it's suspended/frozen. The answering window
834
- // (res.presId) is what reuse must target — the searchId is bound to it.
835
- const res = await client.engineSearchAnyPane({
836
- registry,
837
- explicitPresId: to,
838
- engineId,
839
- query,
840
- subtype,
841
- resultType,
842
- intent: intent ?? null,
843
- top: top ?? null,
844
- });
845
- const ranPresId = res?.presId ?? presId;
846
- const ranEntry = registry.find((p) => p.presId === ranPresId) ?? entry;
1111
+ // Direct: one pane-free call against the backend that owns the searchId.
1112
+ // Pane: window-agnostic — tries the picked pane, then fails over to any other
1113
+ // live window on the same backend if it's suspended/frozen. The answering
1114
+ // window (res.presId) is what reuse must target there, since the searchId is
1115
+ // bound to it.
1116
+ const relaySearch = (reg) =>
1117
+ client.engineSearchAnyPane({
1118
+ registry: reg,
1119
+ explicitPresId: to,
1120
+ engineId,
1121
+ query,
1122
+ subtype,
1123
+ resultType,
1124
+ intent: intent ?? null,
1125
+ top: top ?? null,
1126
+ });
1127
+ let res;
1128
+ try {
1129
+ res = direct
1130
+ ? await client.engineSearch({
1131
+ engineId,
1132
+ query,
1133
+ subtype,
1134
+ resultType,
1135
+ intent: intent ?? null,
1136
+ top: top ?? null,
1137
+ backendURL,
1138
+ })
1139
+ : await relaySearch(routing.registry);
1140
+ } catch (err) {
1141
+ // A stale permission snapshot degrades the PANE-FREE path only — an open
1142
+ // pane signs each request live, which is strictly fresher than the
1143
+ // snapshot. Creating a new search is exactly the operation the pane can
1144
+ // replay (it mints its own searchId), so relay it rather than telling the
1145
+ // user to open an add-in that is already open.
1146
+ const relay = direct ? await paneRelayForStaleSnapshot(client, err, { to }) : null;
1147
+ if (!relay) await failOnEngineBlocker(client, err, { to });
1148
+ process.stderr.write(
1149
+ `note: this environment's search permission snapshot has aged out, so the search ran through the open ` +
1150
+ `slidemind pane${relay.entry.env ? ` [${relay.entry.env}]` : ""} instead (it signs each request live).\n`,
1151
+ );
1152
+ direct = false;
1153
+ registry = relay.registry;
1154
+ entry = relay.entry;
1155
+ try {
1156
+ res = await relaySearch(registry);
1157
+ } catch (relayErr) {
1158
+ await failOnEngineBlocker(client, relayErr, { to });
1159
+ }
1160
+ }
1161
+ const ranPresId = res?.presId ?? entry?.presId ?? presId;
1162
+ const ranEntry = direct
1163
+ ? { env: res?.env ?? null }
1164
+ : (registry.find((p) => p.presId === ranPresId) ?? entry);
847
1165
  if (asJson) {
848
- process.stdout.write(`${JSON.stringify(res, null, 2)}\n`);
1166
+ // The searchId rides the JSON so an interrupted agent can resume its picks
1167
+ // without re-searching. The backendURL never does — env label only.
1168
+ const { backendURL: _routingOnly, ...pub } = res ?? {};
1169
+ process.stdout.write(`${JSON.stringify(pub, null, 2)}\n`);
849
1170
  return;
850
1171
  }
851
1172
  if (!res?.ok) {
@@ -902,6 +1223,16 @@ async function cmdSearch(rest) {
902
1223
  `Contact sheet of all ${count} result(s) — LOOK AT THIS FILE to choose, ` +
903
1224
  `don't fetch previews one by one: ${gridPath}\n`,
904
1225
  );
1226
+ } else if (direct) {
1227
+ // The pane composes the collage in a DOM; the pane-free route may not
1228
+ // have one to hand back (`gridUnavailable`, or simply no grid field on an
1229
+ // add-in that hasn't grown it yet). Say where previews come from instead
1230
+ // — otherwise an agent that has learned to expect a contact sheet quietly
1231
+ // issues N thumbnail fetches to rebuild it.
1232
+ process.stdout.write(
1233
+ `No contact sheet on this path — preview candidates one at a time instead: ` +
1234
+ `slidemind view-slide --search ${res.searchId} --pick <n>\n`,
1235
+ );
905
1236
  }
906
1237
  }
907
1238
  // Tailor the next-step hint to the result kind: slide verbs (insert-slide /
@@ -910,8 +1241,24 @@ async function cmdSearch(rest) {
910
1241
  // add-ins that don't echo a top-level subtype.
911
1242
  const isSlideResults =
912
1243
  res.subtype === "slide" || results.some((r) => r.kind === "slide");
913
- const envTag = ranEntry.env ? ` [${ranEntry.env}]` : "";
914
- if (isSlideResults) {
1244
+ const envTag = ranEntry?.env ? ` [${ranEntry.env}]` : "";
1245
+ if (direct) {
1246
+ // The searchId belongs to the BACKEND here, not to a window: picks work with
1247
+ // PowerPoint closed, so the follow-up commands carry no --to. Live insert is
1248
+ // the one step that still names a window (it types into an open deck).
1249
+ if (isSlideResults) {
1250
+ process.stdout.write(
1251
+ `searchId ${res.searchId}${envTag} is held by the add-in — reuse it with no PowerPoint window:\n` +
1252
+ ` slidemind add-slide --search ${res.searchId} --pick <n> --deck <deck>\n` +
1253
+ ` slidemind insert-slide --search ${res.searchId} --pick <n> --to <presId> (live insert needs an open window)\n`,
1254
+ );
1255
+ } else {
1256
+ process.stdout.write(
1257
+ `searchId ${res.searchId}${envTag} is held by the add-in — download image result(s) into a deck (no window needed):\n` +
1258
+ ` slidemind download-result --search ${res.searchId} --pick <n[,n,...]> --deck <deck> [--icon-color <hex>]\n`,
1259
+ );
1260
+ }
1261
+ } else if (isSlideResults) {
915
1262
  // The searchId is bound to the window the search ran on — reuse must target
916
1263
  // that same window, so spell out its presId in the next-step commands.
917
1264
  process.stdout.write(
@@ -973,16 +1320,58 @@ async function cmdLs(rest) {
973
1320
  if (all && to) fail("--all cannot be combined with --to");
974
1321
  let backendURL = null;
975
1322
  if (!all) {
976
- const registry = await client.requirePaneHost();
977
- const entry = client.pickAnyPresentationEntry(registry, to);
978
- backendURL = entry.backendURL ?? null;
1323
+ // Pane-free where the add-in serves the direct routes; otherwise the catalog
1324
+ // is relayed through a window, so a live pane is still the precondition.
1325
+ try {
1326
+ backendURL = (await resolveEngineRouting(client, { to, pick: "any" }))
1327
+ .backendURL;
1328
+ } catch (err) {
1329
+ // Listing is the one engine op that can simply show EVERY environment
1330
+ // (each engine is tagged with its env), so an ambiguous multi-environment
1331
+ // session fans out here rather than demanding --backend.
1332
+ if (err?.code !== "engine_backend_ambiguous") throw err;
1333
+ backendURL = null;
1334
+ }
1335
+ }
1336
+ let engines;
1337
+ // The pane-free route's summary of the catalog BEFORE the type/subtype
1338
+ // filter; null on older add-ins and on the pane-relay path.
1339
+ let catalog = null;
1340
+ // Which environments answered. The catalog is POOLED, and pooling dedupes the
1341
+ // scopes every environment repeats — so a second environment can contribute
1342
+ // no row of its own, and the surviving rows can no longer be counted to learn
1343
+ // whether more than one answered.
1344
+ let envs = [];
1345
+ try {
1346
+ ({ engines, catalog, envs = [] } = await client.listEngines({
1347
+ type,
1348
+ subtype,
1349
+ backendURL,
1350
+ }));
1351
+ } catch (err) {
1352
+ // No pane fallback here: `GET /api/cli/engines` is the catalog route on both
1353
+ // add-in generations (newer ones answer it directly, older ones relay it
1354
+ // themselves), so there is no second route to retry on. The message does
1355
+ // name the open pane, because searching THROUGH it still works.
1356
+ await failOnEngineBlocker(client, err, { to });
979
1357
  }
980
- const { engines } = await client.listEngines({ type, subtype, backendURL });
981
1358
  if (asJson) {
982
- process.stdout.write(`${JSON.stringify(engines, null, 2)}\n`);
1359
+ process.stdout.write(`${JSON.stringify({ engines, catalog, envs }, null, 2)}\n`);
983
1360
  return;
984
1361
  }
985
1362
  if (engines.length === 0) {
1363
+ // A filter that matched nothing is NOT an empty deployment — the summary is
1364
+ // the only thing that tells the two apart, so say which one this is and the
1365
+ // next move follows (drop the filter vs. search the corpus unfiltered).
1366
+ const unfiltered = catalog ? catalog.totalEngines + catalog.totalScopes : 0;
1367
+ if (catalog?.filtered && unfiltered > 0) {
1368
+ process.stderr.write(
1369
+ `No engine or scope matched this filter, but ${catalog.totalEngines} engine(s) ` +
1370
+ `and ${catalog.totalScopes} scope(s) exist. Re-run 'slidemind ls engines' ` +
1371
+ "without --type/--subtype to see them.\n",
1372
+ );
1373
+ return;
1374
+ }
986
1375
  // Deliberately not "nothing to search": an engine is a saved folder filter
987
1376
  // over the add-in's shared corpus, so an empty catalog says only that nobody
988
1377
  // saved a filter. An older add-in that predates scopes lands here too.
@@ -997,8 +1386,11 @@ async function cmdLs(rest) {
997
1386
  );
998
1387
  return;
999
1388
  }
1000
- // Show the env tag only when more than one environment contributed engines.
1001
- const multiEnv = new Set(engines.map((e) => e.env)).size > 1;
1389
+ // Show the env tag only when more than one environment contributed. Two
1390
+ // environments can carry identically-named engines, and without the tag the
1391
+ // pooled list gives no way to tell them apart. Deduped scope rows carry no env
1392
+ // of their own (they exist on every environment), so they stay untagged.
1393
+ const multiEnv = envs.length > 1 || new Set(engines.map((e) => e.env)).size > 1;
1002
1394
  for (const e of engines) {
1003
1395
  const kind = [e.type, e.subtype].filter(Boolean).join("/");
1004
1396
  const env = multiEnv && e.env ? `[${e.env}] ` : "";
@@ -1009,12 +1401,28 @@ async function cmdLs(rest) {
1009
1401
  e.type === "scope"
1010
1402
  ? ` (needs --result-type ${(e.capabilities?.resultTypes ?? ["slide", "image"]).join("|")})`
1011
1403
  : "";
1404
+ // An engine that exists but has nothing indexed yet returns zero hits for
1405
+ // every query; say so here so an empty search isn't read as a bad engine id.
1406
+ const state =
1407
+ e.content?.state === "empty"
1408
+ ? " (nothing indexed yet)"
1409
+ : e.content?.state === "indexing"
1410
+ ? " (indexing)"
1411
+ : "";
1012
1412
  process.stdout.write(
1013
- `${env}${e.id} · ${e.label ?? e.id} · ${kind}${hint}${desc}\n`,
1413
+ `${env}${e.id} · ${e.label ?? e.id} · ${kind}${hint}${state}${desc}\n`,
1014
1414
  );
1015
1415
  }
1016
1416
  }
1017
1417
 
1418
+ // A searchId is a number when the pane minted it (the relay path) and an opaque
1419
+ // server-minted id on the pane-free engine routes, where the search record lives
1420
+ // on the backend. Accept both so a pick round-trips whichever search produced it.
1421
+ function parseSearchIdArg(raw, flag = "--search") {
1422
+ if (!raw || raw.startsWith("-")) fail(`${flag} expects a searchId, got '${raw}'`);
1423
+ return /^\d+$/.test(raw) ? parseInt(raw, 10) : raw;
1424
+ }
1425
+
1018
1426
  function parsePicks(raw, flag = "--pick") {
1019
1427
  if (!raw) fail(`${flag} expects a comma-separated list of selection numbers`);
1020
1428
  // Tolerate a trailing/empty segment ("1," or "1,,2") by dropping blank parts
@@ -1034,6 +1442,45 @@ function parsePicks(raw, flag = "--pick") {
1034
1442
  return picks;
1035
1443
  }
1036
1444
 
1445
+ /**
1446
+ * Turn a live-insert failure into the action that actually fixes it. The client
1447
+ * has already re-listed the registry and retried ONCE against the live window
1448
+ * (a pane reconnect churns the presId), so a pane error that reaches here is one
1449
+ * only the user can clear — and the way around it is the window-free fold.
1450
+ *
1451
+ * A stale permission snapshot is NOT among them: it is authored once, in
1452
+ * staleSnapshotMessage, which the caller consults first.
1453
+ */
1454
+ function describeInsertSlideFailure(err, searchId) {
1455
+ const code = err?.code ?? err?.body?.code ?? null;
1456
+ if (code === "unknown_search") {
1457
+ return `searchId ${searchId} is no longer available. Run 'slidemind search' again, then re-pick.`;
1458
+ }
1459
+ if (code === "searchId_required") {
1460
+ // The pane was handed an id it cannot resolve — its search context is
1461
+ // per-window and numeric, and a server-minted id was never in it.
1462
+ return (
1463
+ `searchId ${searchId} belongs to the add-in, not to that PowerPoint window. ` +
1464
+ `Re-run 'slidemind search' (the CLI picks the reuse route from where the search ran), then re-pick.`
1465
+ );
1466
+ }
1467
+ const paneAsleep =
1468
+ code === "pane_suspended" ||
1469
+ code === "pane_timeout" ||
1470
+ // A relay 404 about the presId itself carries prose and no machine code,
1471
+ // and bridgeFetch falls `code` back to that prose — so read the PARSED one.
1472
+ (err?.status === 404 &&
1473
+ (!err?.body?.code || err.body.code === "unknown_presentation"));
1474
+ if (paneAsleep) {
1475
+ return (
1476
+ "the slidemind task pane in that PowerPoint window is not responding (already retried once). " +
1477
+ "Foreground PowerPoint and click the slidemind pane to wake it, then re-run — or reuse the same " +
1478
+ `result with no window at all: slidemind add-slide --search ${searchId} --pick <n> --deck <deck>.`
1479
+ );
1480
+ }
1481
+ return `insert-slide failed: ${err?.message ?? "unknown error"}`;
1482
+ }
1483
+
1037
1484
  async function cmdInsertSlide(rest) {
1038
1485
  let searchId = null;
1039
1486
  let picks = null;
@@ -1042,10 +1489,7 @@ async function cmdInsertSlide(rest) {
1042
1489
  for (let i = 0; i < rest.length; i++) {
1043
1490
  const a = rest[i];
1044
1491
  if (a === "--search") {
1045
- const next = rest[++i];
1046
- const n = parseInt(next, 10);
1047
- if (!Number.isFinite(n)) fail(`--search expects a searchId, got '${next}'`);
1048
- searchId = n;
1492
+ searchId = parseSearchIdArg(rest[++i]);
1049
1493
  } else if (a === "--pick") {
1050
1494
  picks = parsePicks(rest[++i]);
1051
1495
  } else if (a === "--to") {
@@ -1070,20 +1514,97 @@ async function cmdInsertSlide(rest) {
1070
1514
  const client = await import(
1071
1515
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
1072
1516
  );
1517
+ // Live insert types into an OPEN presentation, so it stays window- and
1518
+ // pane-bound even where the rest of the engine surface has moved off the pane.
1073
1519
  const registry = await client.requirePaneHost();
1074
1520
  const entry = client.pickPresentationEntry(registry, to);
1075
- const res = await client.insertSlideLive({
1076
- presId: entry.presId,
1077
- searchId,
1078
- selections: picks,
1079
- at,
1080
- backendURL: entry.backendURL ?? null,
1081
- });
1521
+ // Window identity for the client's single retry: a pane reconnect mints a NEW
1522
+ // presId for the same window, and the design fingerprint re-matches it — but
1523
+ // only against windows we did NOT already see next to the target, or a second
1524
+ // deck on the same corporate design would take the insert.
1525
+ const identity = {
1526
+ fileName: entry.fileName ?? entry.title ?? null,
1527
+ brandId: entry.brandId ?? null,
1528
+ otherPresIds: registry
1529
+ .map((p) => p.presId)
1530
+ .filter((id) => id && id !== entry.presId),
1531
+ };
1532
+ // WHERE the searchId lives decides how the reuse runs. A number came from
1533
+ // that window's own pane search and the pane resolves it; an opaque id is the
1534
+ // backend's (the pane-free search) and the pane has never seen it — so those
1535
+ // picks are extracted pane-free and inserted as bytes instead.
1536
+ //
1537
+ // Which backend extracts them is the RECORD's, not the window's. Those are
1538
+ // different environments as soon as two are connected, and spending a
1539
+ // server-owned id against the window's origin 404s as `unknown_search` — read
1540
+ // as "the search expired", which sends the agent round a re-search loop that
1541
+ // can never resolve. The window stays the INSERT target either way.
1542
+ let engineBackendURL = null;
1543
+ if (client.isOpaqueSearchId?.(searchId)) {
1544
+ try {
1545
+ engineBackendURL = await client.resolveSearchBackend(searchId);
1546
+ } catch (err) {
1547
+ await failOnEngineBlocker(client, err, { to: entry.presId });
1548
+ }
1549
+ if (!engineBackendURL) fail(expiredSearchMessage(searchId));
1550
+ }
1551
+ let res;
1552
+ try {
1553
+ res = engineBackendURL
1554
+ ? await client.insertSlidesFromSearch({
1555
+ presId: entry.presId,
1556
+ searchId,
1557
+ selections: picks,
1558
+ at,
1559
+ backendURL: entry.backendURL ?? null,
1560
+ engineBackendURL,
1561
+ ...identity,
1562
+ })
1563
+ : await client.insertSlideLive({
1564
+ presId: entry.presId,
1565
+ searchId,
1566
+ selections: picks,
1567
+ at,
1568
+ backendURL: entry.backendURL ?? null,
1569
+ ...identity,
1570
+ });
1571
+ } catch (err) {
1572
+ fail(
1573
+ (await staleSnapshotMessage(client, err, { to: entry.presId })) ??
1574
+ describeInsertSlideFailure(err, searchId),
1575
+ );
1576
+ }
1082
1577
  if (!res?.ok) {
1083
1578
  fail(`insert-slide failed: ${res?.error ?? "unknown error"}`);
1084
1579
  }
1085
1580
  process.stdout.write(
1086
- `Inserted ${res.insertedCount ?? picks.length} slide(s) into presentation ${entry.presId}\n`,
1581
+ `Inserted ${res.insertedCount ?? picks.length} slide(s) into presentation ${res.presId ?? entry.presId}\n`,
1582
+ );
1583
+ }
1584
+
1585
+ // One line per written slide, plus the machine-readable twin. The searchId is
1586
+ // echoed on purpose: an agent interrupted between picks can resume from the JSON
1587
+ // without re-running the search (the record lives on the backend now).
1588
+ function writeAddSlideResult({ asJson, searchId, deckDir, deck, written, slides }) {
1589
+ if (asJson) {
1590
+ process.stdout.write(
1591
+ `${JSON.stringify(
1592
+ {
1593
+ ok: true,
1594
+ searchId,
1595
+ deck: path.relative(process.cwd(), deckDir),
1596
+ slides,
1597
+ },
1598
+ null,
1599
+ 2,
1600
+ )}\n`,
1601
+ );
1602
+ return;
1603
+ }
1604
+ process.stdout.write(
1605
+ `Added ${written.length} reused slide(s) to ${path.relative(process.cwd(), deckDir)}:\n` +
1606
+ written.map((w) => ` ${w}`).join("\n") +
1607
+ `\nThey are native .pptx in slides/ — re-run 'slidemind build-pptx ${deck}' or 'slidemind push ${deck}' to render the deck with them spliced in.\n`,
1087
1608
  );
1088
1609
  }
1089
1610
 
@@ -1094,13 +1615,13 @@ async function cmdAddSlide(rest) {
1094
1615
  let at = null;
1095
1616
  let to = null;
1096
1617
  let from = null;
1618
+ let asJson = false;
1097
1619
  for (let i = 0; i < rest.length; i++) {
1098
1620
  const a = rest[i];
1099
- if (a === "--search") {
1100
- const next = rest[++i];
1101
- const n = parseInt(next, 10);
1102
- if (!Number.isFinite(n)) fail(`--search expects a searchId, got '${next}'`);
1103
- searchId = n;
1621
+ if (a === "--json") {
1622
+ asJson = true;
1623
+ } else if (a === "--search") {
1624
+ searchId = parseSearchIdArg(rest[++i]);
1104
1625
  } else if (a === "--from") {
1105
1626
  from = rest[++i];
1106
1627
  if (!from) fail("--from expects a path to a .pptx file");
@@ -1173,21 +1694,30 @@ async function cmdAddSlide(rest) {
1173
1694
  .filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"));
1174
1695
  const targets = reused.reusedSlideTargets(existing, results, at);
1175
1696
  const written = [];
1697
+ const slides = [];
1176
1698
  results.forEach((r, i) => {
1177
1699
  const target = path.join(slidesDir, targets[i].filename);
1178
1700
  fs.writeFileSync(target, Buffer.from(r.bytes));
1179
1701
  written.push(path.relative(process.cwd(), target));
1702
+ slides.push({
1703
+ selection: picks[i] ?? null,
1704
+ slideId: r.slideId ?? null,
1705
+ file: path.relative(process.cwd(), target),
1706
+ });
1707
+ });
1708
+ writeAddSlideResult({
1709
+ asJson,
1710
+ searchId: null,
1711
+ deckDir,
1712
+ deck,
1713
+ written,
1714
+ slides,
1180
1715
  });
1181
- process.stdout.write(
1182
- `Added ${written.length} reused slide(s) to ${path.relative(process.cwd(), deckDir)}:\n` +
1183
- written.map((w) => ` ${w}`).join("\n") +
1184
- `\nThey are native .pptx in slides/ — re-run 'slidemind build-pptx ${deck}' or 'slidemind push ${deck}' to render the deck with them spliced in.\n`,
1185
- );
1186
1716
  return;
1187
1717
  }
1188
1718
 
1189
1719
  if (searchId === null || !picks || !deck) {
1190
- fail("usage: slidemind add-slide (--search <id> | --from <file.pptx>) --pick <n[,n,...]> --deck <deck> [--to <presId>] [--at <N>]");
1720
+ fail("usage: slidemind add-slide (--search <id> | --from <file.pptx>) --pick <n[,n,...]> --deck <deck> [--to <presId>] [--at <N>] [--json]");
1191
1721
  }
1192
1722
  const deckDir = resolveDeckArg(deck);
1193
1723
  const slidesDir = path.join(deckDir, "slides");
@@ -1196,12 +1726,17 @@ async function cmdAddSlide(rest) {
1196
1726
  const client = await import(
1197
1727
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
1198
1728
  );
1199
- const registry = await client.requirePaneHost();
1200
- // The searchId is presentation-scoped, so --to targets the window the search
1201
- // ran on. Without it, a registry with >1 open window is a dead-end.
1202
- const entry = client.pickPresentationEntry(registry, to);
1203
- const presId = entry.presId;
1204
- const backendURL = entry.backendURL ?? null;
1729
+ // Direct path: an opaque searchId routes to the environment that minted the
1730
+ // record; the deck's bound design only decides when the id can't. Pane path:
1731
+ // the searchId is presentation-scoped, so --to targets the window the search
1732
+ // ran on (without it, a registry with >1 open window is a dead-end).
1733
+ const routing = await resolveEngineRouting(client, {
1734
+ to,
1735
+ brandId: await deckBrandId(deckDir),
1736
+ searchId,
1737
+ });
1738
+ const presId = routing.presId;
1739
+ const backendURL = routing.backendURL;
1205
1740
 
1206
1741
  // Extract each picked slide's bytes over the bridge, then let the shared
1207
1742
  // helper compute the play-order filenames (NN-reused-<shortid>.pptx). The
@@ -1222,9 +1757,12 @@ async function cmdAddSlide(rest) {
1222
1757
  if (s.tempPath) fs.rmSync(s.tempPath, { force: true });
1223
1758
  }
1224
1759
  };
1760
+ // A searchId can be an opaque server id on the direct path — keep it usable as
1761
+ // a file name component.
1762
+ const searchKey = String(searchId).replace(/[^A-Za-z0-9._-]/g, "_");
1225
1763
  try {
1226
1764
  for (const pick of picks) {
1227
- const tempPath = path.join(slidesDir, `.reused-${searchId}-${pick}.part`);
1765
+ const tempPath = path.join(slidesDir, `.reused-${searchKey}-${pick}.part`);
1228
1766
  if (typeof client.extractSlideToFile === "function") {
1229
1767
  try {
1230
1768
  const res = await client.extractSlideToFile({
@@ -1240,11 +1778,17 @@ async function cmdAddSlide(rest) {
1240
1778
  continue;
1241
1779
  } catch (err) {
1242
1780
  fs.rmSync(tempPath, { force: true });
1781
+ const stale = await staleSnapshotMessage(client, err, { to });
1782
+ if (stale) {
1783
+ cleanupTemps();
1784
+ fail(stale);
1785
+ }
1243
1786
  // Route absent (bodyless 404 / 501) → this add-in predates the streaming
1244
- // route; fall through to the capped base64 ack path below.
1787
+ // route; fall through to the capped base64 ack path below. On the
1788
+ // pane-free path there is no ack path to fall back to.
1245
1789
  const routeMissing =
1246
1790
  (err?.status === 404 && !err?.code) || err?.status === 501;
1247
- if (!routeMissing) {
1791
+ if (!routeMissing || !presId) {
1248
1792
  cleanupTemps();
1249
1793
  fail(`add-slide failed for selection ${pick}: ${err.message}`);
1250
1794
  }
@@ -1271,6 +1815,7 @@ async function cmdAddSlide(rest) {
1271
1815
  .filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"));
1272
1816
  const targets = reused.reusedSlideTargets(existing, extracted, at);
1273
1817
  const written = [];
1818
+ const slides = [];
1274
1819
  extracted.forEach((slide, i) => {
1275
1820
  const target = path.join(slidesDir, targets[i].filename);
1276
1821
  if (slide.tempPath) {
@@ -1279,12 +1824,13 @@ async function cmdAddSlide(rest) {
1279
1824
  fs.writeFileSync(target, Buffer.from(slide.base64, "base64"));
1280
1825
  }
1281
1826
  written.push(path.relative(process.cwd(), target));
1827
+ slides.push({
1828
+ selection: slide.selection,
1829
+ slideId: slide.slideId ?? null,
1830
+ file: path.relative(process.cwd(), target),
1831
+ });
1282
1832
  });
1283
- process.stdout.write(
1284
- `Added ${written.length} reused slide(s) to ${path.relative(process.cwd(), deckDir)}:\n` +
1285
- written.map((w) => ` ${w}`).join("\n") +
1286
- `\nThey are native .pptx in slides/ — re-run 'slidemind build-pptx ${deck}' or 'slidemind push ${deck}' to render the deck with them spliced in.\n`,
1287
- );
1833
+ writeAddSlideResult({ asJson, searchId, deckDir, deck, written, slides });
1288
1834
  }
1289
1835
 
1290
1836
  // Map a `--icon-color` value onto the `--pick` list. Accepts either one colour
@@ -1318,10 +1864,7 @@ async function cmdViewSlide(rest) {
1318
1864
  for (let i = 0; i < rest.length; i++) {
1319
1865
  const a = rest[i];
1320
1866
  if (a === "--search") {
1321
- const next = rest[++i];
1322
- const n = parseInt(next, 10);
1323
- if (!Number.isFinite(n)) fail(`--search expects a searchId, got '${next}'`);
1324
- searchId = n;
1867
+ searchId = parseSearchIdArg(rest[++i]);
1325
1868
  } else if (a === "--pick") {
1326
1869
  const next = rest[++i];
1327
1870
  const n = parseInt(next, 10);
@@ -1342,7 +1885,7 @@ async function cmdViewSlide(rest) {
1342
1885
  }
1343
1886
  }
1344
1887
  if (searchId === null || pick === null) {
1345
- fail("usage: slidemind view-slide --search <id> --pick <n> --to <presId> [--out <dir>]");
1888
+ fail("usage: slidemind view-slide --search <id> --pick <n> [--to <presId>] [--out <dir>]");
1346
1889
  }
1347
1890
  const client = await import(
1348
1891
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
@@ -1350,16 +1893,18 @@ async function cmdViewSlide(rest) {
1350
1893
  const { resolveAssetExt } = await import(
1351
1894
  pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
1352
1895
  );
1353
- const registry = await client.requirePaneHost();
1354
- // The searchId is presentation-scoped, so --to targets the window the search
1355
- // ran on. Without it, a registry with >1 open window is a dead-end.
1356
- const entry = client.pickPresentationEntry(registry, to);
1357
- const res = await client.viewSlide({
1358
- presId: entry.presId,
1359
- searchId,
1360
- selection: pick,
1361
- backendURL: entry.backendURL ?? null,
1362
- });
1896
+ const routing = await resolveEngineRouting(client, { to, searchId });
1897
+ let res;
1898
+ try {
1899
+ res = await client.viewSlide({
1900
+ presId: routing.presId,
1901
+ searchId,
1902
+ selection: pick,
1903
+ backendURL: routing.backendURL,
1904
+ });
1905
+ } catch (err) {
1906
+ await failOnEngineBlocker(client, err, { to });
1907
+ }
1363
1908
  if (!res?.ok || !res.dataUri) {
1364
1909
  fail(`view-slide failed: ${res?.error ?? "no image returned"}`);
1365
1910
  }
@@ -1383,6 +1928,135 @@ async function cmdViewSlide(rest) {
1383
1928
  );
1384
1929
  }
1385
1930
 
1931
+ async function cmdReadSource(rest) {
1932
+ let searchId = null;
1933
+ let pick = null;
1934
+ let fromSlide = null;
1935
+ let toSlide = null;
1936
+ let to = null;
1937
+ let outFile = null;
1938
+ for (let i = 0; i < rest.length; i++) {
1939
+ const a = rest[i];
1940
+ if (a === "--search") {
1941
+ searchId = parseSearchIdArg(rest[++i]);
1942
+ } else if (a === "--pick") {
1943
+ const next = rest[++i];
1944
+ const n = parseInt(next, 10);
1945
+ if (!Number.isFinite(n) || n <= 0) {
1946
+ fail(`--pick expects a positive selection number, got '${next}'`);
1947
+ }
1948
+ pick = n;
1949
+ } else if (a === "--from-slide") {
1950
+ const next = rest[++i];
1951
+ const n = parseInt(next, 10);
1952
+ if (!Number.isFinite(n) || n <= 0) {
1953
+ fail(`--from-slide expects a positive slide number, got '${next}'`);
1954
+ }
1955
+ fromSlide = n;
1956
+ } else if (a === "--to-slide") {
1957
+ const next = rest[++i];
1958
+ const n = parseInt(next, 10);
1959
+ if (!Number.isFinite(n) || n <= 0) {
1960
+ fail(`--to-slide expects a positive slide number, got '${next}'`);
1961
+ }
1962
+ toSlide = n;
1963
+ } else if (a === "--to") {
1964
+ // Deliberately the WINDOW, matching every other search-scoped command.
1965
+ // The slide window is --from-slide/--to-slide.
1966
+ to = rest[++i];
1967
+ if (!to) fail("--to expects a presId");
1968
+ } else if (a === "--out" || a === "-o") {
1969
+ outFile = rest[++i];
1970
+ if (!outFile) fail("--out expects a file path");
1971
+ } else if (a.startsWith("-")) {
1972
+ fail(`unknown flag: ${a}`);
1973
+ } else {
1974
+ fail(`unexpected argument: ${a}`);
1975
+ }
1976
+ }
1977
+ if (searchId === null || pick === null) {
1978
+ fail(
1979
+ "usage: slidemind read-source --search <id> --pick <n> [--from-slide <n>] [--to-slide <n>] [--to <presId>] [--out <file>]",
1980
+ );
1981
+ }
1982
+ const client = await import(
1983
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
1984
+ );
1985
+ // Pane path: the searchId is presentation-scoped, so --to targets the window
1986
+ // the search ran on — same rule as view-slide. Direct path: the record is the
1987
+ // backend's, so no window is involved.
1988
+ const routing = await resolveEngineRouting(client, { to, searchId });
1989
+ let res;
1990
+ try {
1991
+ res = await client.readSourcePresentation({
1992
+ presId: routing.presId,
1993
+ searchId,
1994
+ selection: pick,
1995
+ fromSlide,
1996
+ toSlide,
1997
+ backendURL: routing.backendURL,
1998
+ });
1999
+ } catch (err) {
2000
+ // The stale-snapshot line is authored once and knows about an open pane;
2001
+ // everything else is this command's own vocabulary.
2002
+ fail(
2003
+ (await staleSnapshotMessage(client, err, { to })) ??
2004
+ describeReadSourceFailure(err, searchId, pick),
2005
+ );
2006
+ }
2007
+ if (!res?.markdown) {
2008
+ fail("read-source returned no content. Try the search again, then re-pick.");
2009
+ }
2010
+ if (outFile) {
2011
+ const target = path.resolve(process.cwd(), outFile);
2012
+ fs.mkdirSync(path.dirname(target), { recursive: true });
2013
+ fs.writeFileSync(target, res.markdown, "utf8");
2014
+ process.stdout.write(
2015
+ `Wrote the source presentation of result ${pick} (searchId ${searchId}) to ${target}\n`,
2016
+ );
2017
+ } else {
2018
+ process.stdout.write(res.markdown.endsWith("\n") ? res.markdown : `${res.markdown}\n`);
2019
+ }
2020
+ // Truncation is the one thing a reader must not miss: the body they just got
2021
+ // is a slide map, not the deck.
2022
+ if (res.truncated) {
2023
+ process.stderr.write(
2024
+ `note: this deck was too long to return in full — the output is its slide map. ` +
2025
+ `Re-run with --from-slide/--to-slide to read a range.\n`,
2026
+ );
2027
+ } else if (res.totalSlideCount && res.slideCount < res.totalSlideCount) {
2028
+ process.stderr.write(
2029
+ `note: showing ${res.slideCount} of ${res.totalSlideCount} slides.\n`,
2030
+ );
2031
+ }
2032
+ }
2033
+
2034
+ /**
2035
+ * Turn a source-presentation failure into the action that actually fixes it.
2036
+ * A stale permission snapshot is handled one level up, in staleSnapshotMessage
2037
+ * — it is the one blocker only the USER can clear and it reads identically
2038
+ * across every command.
2039
+ */
2040
+ function describeReadSourceFailure(err, searchId, pick) {
2041
+ const code = err?.code ?? err?.body?.code ?? null;
2042
+ switch (code) {
2043
+ case "unknown_search":
2044
+ return `searchId ${searchId} is no longer available on that window. Run 'slidemind search' again, then re-pick.`;
2045
+ case "wrong_result_type":
2046
+ return `result ${pick} has no source presentation — only slide search results come from a deck (image and collection results do not).`;
2047
+ case "access_denied":
2048
+ return "you do not have access to that result's source presentation.";
2049
+ case "no_indexed_content":
2050
+ return "that presentation has no indexed slides yet. It may still be syncing — try again later.";
2051
+ case "invalid_source":
2052
+ return `result ${pick} carries no source file reference. Re-run the search, or pick a different result.`;
2053
+ case "source_presentation_unsupported":
2054
+ return err.message;
2055
+ default:
2056
+ return `read-source failed: ${err?.message ?? "unknown error"}`;
2057
+ }
2058
+ }
2059
+
1386
2060
  async function cmdDownloadResult(rest) {
1387
2061
  let searchId = null;
1388
2062
  let picks = null;
@@ -1392,10 +2066,7 @@ async function cmdDownloadResult(rest) {
1392
2066
  for (let i = 0; i < rest.length; i++) {
1393
2067
  const a = rest[i];
1394
2068
  if (a === "--search") {
1395
- const next = rest[++i];
1396
- const n = parseInt(next, 10);
1397
- if (!Number.isFinite(n)) fail(`--search expects a searchId, got '${next}'`);
1398
- searchId = n;
2069
+ searchId = parseSearchIdArg(rest[++i]);
1399
2070
  } else if (a === "--pick") {
1400
2071
  picks = parsePicks(rest[++i]);
1401
2072
  } else if (a === "--deck") {
@@ -1415,7 +2086,7 @@ async function cmdDownloadResult(rest) {
1415
2086
  }
1416
2087
  if (searchId === null || !picks || !deck) {
1417
2088
  fail(
1418
- "usage: slidemind download-result --search <id> --pick <n[,n,...]> --deck <deck> --to <presId> [--icon-color <hex[,hex,...]>]",
2089
+ "usage: slidemind download-result --search <id> --pick <n[,n,...]> --deck <deck> [--to <presId>] [--icon-color <hex[,hex,...]>]",
1419
2090
  );
1420
2091
  }
1421
2092
  const iconColors = parseIconColors(iconColorsRaw, picks.length);
@@ -1430,10 +2101,15 @@ async function cmdDownloadResult(rest) {
1430
2101
  const { downloadOneResult } = await import(
1431
2102
  pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
1432
2103
  );
1433
- const registry = await client.requirePaneHost();
1434
- // The searchId is presentation-scoped, so --to targets the window the search
1435
- // ran on. Without it, a registry with >1 open window is a dead-end.
1436
- const entry = client.pickPresentationEntry(registry, to);
2104
+ // Direct path: an opaque searchId routes to the record's own environment,
2105
+ // else the deck's bound design decides; no window needed. Pane path: the
2106
+ // searchId is presentation-scoped, so --to targets the window the search ran
2107
+ // on (without it, >1 open window is a dead-end).
2108
+ const routing = await resolveEngineRouting(client, {
2109
+ to,
2110
+ brandId: await deckBrandId(deckDir),
2111
+ searchId,
2112
+ });
1437
2113
 
1438
2114
  // Concurrent — each download is its own bridge round-trip; allSettled so one
1439
2115
  // bad selection doesn't sink the rest of the batch.
@@ -1441,8 +2117,8 @@ async function cmdDownloadResult(rest) {
1441
2117
  picks.map((selection, i) =>
1442
2118
  downloadOneResult({
1443
2119
  client,
1444
- presId: entry.presId,
1445
- backendURL: entry.backendURL ?? null,
2120
+ presId: routing.presId,
2121
+ backendURL: routing.backendURL,
1446
2122
  searchId,
1447
2123
  assetsDir,
1448
2124
  selection,
@@ -1450,10 +2126,22 @@ async function cmdDownloadResult(rest) {
1450
2126
  }),
1451
2127
  ),
1452
2128
  );
2129
+ // One user-clearable blocker sinks the whole batch identically — every pick
2130
+ // would fail the same way, and the fix is one action, not N error lines.
2131
+ const stale = settled.find(
2132
+ (r) => r.status === "rejected" && client.permissionsStaleBlocker?.(r.reason),
2133
+ );
2134
+ if (stale) fail(await staleSnapshotMessage(client, stale.reason, { to }));
1453
2135
  const results = settled.map((r, i) =>
1454
2136
  r.status === "fulfilled"
1455
2137
  ? r.value
1456
- : { selection: picks[i], error: r.reason?.message ?? String(r.reason) },
2138
+ : {
2139
+ selection: picks[i],
2140
+ error:
2141
+ describeHostOnlyEngineFailure(r.reason) ??
2142
+ r.reason?.message ??
2143
+ String(r.reason),
2144
+ },
1457
2145
  );
1458
2146
  const { formatSize } = await import(
1459
2147
  pathToFileURL(path.join(SRC_DIR, "bridge", "image-dimensions.mjs")).href
@@ -1798,20 +2486,22 @@ function publicConnectionStatus(status, client) {
1798
2486
  };
1799
2487
  }
1800
2488
 
1801
- // The ordered connection-readiness ladder as a STRUCTURED blocker: the first
1802
- // unmet precondition, or null when the connection is fully ready. `code` is the
1803
- // machine-copeable reason an agent switches on; `actor` says who can clear it —
1804
- // only `cli_update_required` is the AGENT's to fix (it can run `slidemind
1805
- // update`), every other rung needs a human at the add-in. The one exception is
1806
- // a plugin-installed CLI: `/plugin` is the only updater that replaces the
1807
- // binary on the session's PATH, and the agent cannot drive it, so that rung
1808
- // hands back to the user (see src/install-channel.mjs). The `message` strings
1809
- // are otherwise BYTE-IDENTICAL to the old connectionNextAction ladder so
1810
- // doctor's printed output never churns (connectionNextAction is now a thin
1811
- // projection over this).
1812
- // `slidemind ready` layers `ambiguous_design` on top and treats `pane_suspended`
1813
- // as non-blocking (a render-ready-but-suspended window is still a design source).
1814
- function connectionBlocker(pub) {
2489
+ // The readiness ladder splits in two, because the two halves fail
2490
+ // independently: BACKEND AUTHORING readiness (a session, a reachable add-in
2491
+ // backend, a supported CLI — everything render, bundle and the pane-free engine
2492
+ // routes need) and LIVE POWERPOINT readiness (a registered window, render-ready
2493
+ // metadata, an awake pane — what push/insert-slide and reading an open deck
2494
+ // need). Keeping them apart is the whole point of doc 13: a dead pane is no
2495
+ // longer "cannot work", it only defers the last mile.
2496
+ //
2497
+ // Each rung is a STRUCTURED blocker: `code` is the machine-copeable reason an
2498
+ // agent switches on; `actor` says who can clear it — only `cli_update_required`
2499
+ // is the AGENT's to fix (it can run `slidemind update`), every other rung needs
2500
+ // a human at the add-in. The one exception is a plugin-installed CLI:
2501
+ // `/plugin` is the only updater that replaces the binary on the session's PATH,
2502
+ // and the agent cannot drive it, so that rung hands back to the user (see
2503
+ // src/install-channel.mjs).
2504
+ function backendBlocker(pub) {
1815
2505
  if (!pub.paired) {
1816
2506
  return {
1817
2507
  code: "not_paired",
@@ -1841,6 +2531,15 @@ function connectionBlocker(pub) {
1841
2531
  updateCliInstruction(),
1842
2532
  };
1843
2533
  }
2534
+ return null;
2535
+ }
2536
+
2537
+ // The live-PowerPoint half. Never gates render, bundle or the pane-free engine
2538
+ // routes — it only says whether the ops that genuinely type into an open deck
2539
+ // (push's insert, insert-slide, reading an unsaved presentation) can run now.
2540
+ // The `message` strings are BYTE-IDENTICAL to the pre-split ladder so doctor's
2541
+ // printed output never churns.
2542
+ function paneBlocker(pub) {
1844
2543
  if ((pub.presentations ?? []).length === 0) {
1845
2544
  return {
1846
2545
  code: "no_window",
@@ -1875,18 +2574,53 @@ function connectionBlocker(pub) {
1875
2574
  code: "pane_suspended",
1876
2575
  actor: "user",
1877
2576
  message:
1878
- "Rendering is available, but pane operations are suspended. Bring PowerPoint forward and click/reopen the slidemind pane before push/search/read.",
2577
+ "Rendering is available, but pane operations are suspended. Bring PowerPoint forward and click/reopen the slidemind pane before push, insert-slide, or reading this window.",
1879
2578
  };
1880
2579
  }
1881
2580
  return null;
1882
2581
  }
1883
2582
 
2583
+ // The two ladders end-to-end, in the order a user hits them: nothing about a
2584
+ // window matters while the backend half is unmet. `slidemind ready` layers
2585
+ // `ambiguous_design` on top and treats `pane_suspended` as non-blocking (a
2586
+ // render-ready-but-suspended window is still a design source).
2587
+ function connectionBlocker(pub) {
2588
+ return backendBlocker(pub) ?? paneBlocker(pub);
2589
+ }
2590
+
1884
2591
  // Back-compat projection for doctor: just the blocker's message, or "Ready."
1885
2592
  // when the ladder is clear. Keeps the ladder authored in ONE place.
1886
2593
  function connectionNextAction(pub) {
1887
2594
  return connectionBlocker(pub)?.message ?? "Ready.";
1888
2595
  }
1889
2596
 
2597
+ // The permission SNAPSHOT behind corpus search ages out when nobody opens the
2598
+ // add-in for a while. It is a DEGRADATION, never a blocker: render, bundle and
2599
+ // every already-materialized asset keep working, only corpus search stops — so
2600
+ // it must not sit on either ladder. Newer add-ins report it on the readiness
2601
+ // capabilities; the message is authored once, in the client, next to the live
2602
+ // 403 shape the engine routes return (client.permissionsStaleBlocker).
2603
+ // Which connected environments report an aged-out snapshot on
2604
+ // `/api/cli/readiness`. Newer add-ins emit the flag; older ones never do, and
2605
+ // there the condition only surfaces as a live 403 on a search.
2606
+ function permissionsStaleEnvs(pub) {
2607
+ return (pub.backends ?? [])
2608
+ .filter((b) => b.ok && b.capabilities?.permissionsStale === true)
2609
+ .map((b) => b.env);
2610
+ }
2611
+
2612
+ function permissionsStaleDegradation(pub, client) {
2613
+ const stale = (pub.backends ?? []).find(
2614
+ (b) => b.ok && b.capabilities?.permissionsStale === true,
2615
+ );
2616
+ if (!stale) return null;
2617
+ const action = stale.capabilities?.permissionsStaleAction;
2618
+ return client.permissionsStaleBlocker({
2619
+ code: "permissions_stale",
2620
+ body: typeof action === "string" ? { action } : {},
2621
+ });
2622
+ }
2623
+
1890
2624
  // ---------- one-command setup (npx bootstrap) ----------
1891
2625
  //
1892
2626
  // `npx --yes slidemind@latest setup <claim>` is the whole onboarding: the user
@@ -2121,6 +2855,7 @@ async function cmdReady(rest) {
2121
2855
  let asJson = false;
2122
2856
  let force = false;
2123
2857
  let connectOnly = false;
2858
+ let brand = null;
2124
2859
  const positional = [];
2125
2860
  for (let i = 0; i < rest.length; i++) {
2126
2861
  const a = rest[i];
@@ -2133,12 +2868,18 @@ async function cmdReady(rest) {
2133
2868
  force = true;
2134
2869
  } else if (a === "--connect-only") {
2135
2870
  connectOnly = true;
2871
+ } else if (a === "--brand") {
2872
+ brand = rest[++i];
2873
+ if (!brand) fail("--brand expects a design id (presFP)");
2136
2874
  } else if (a.startsWith("-")) {
2137
2875
  fail(`unknown flag: ${a}`);
2138
2876
  } else {
2139
2877
  positional.push(a);
2140
2878
  }
2141
2879
  }
2880
+ if (brand && to) {
2881
+ fail("--brand and --to are mutually exclusive (a design id or a window, not both)");
2882
+ }
2142
2883
 
2143
2884
  // --- Local: resolve the deck dir (never create it) and read its existing
2144
2885
  // bundle ref. deckPresFP feeds ONLY the mirror tie-break + `suggested`; it
@@ -2228,10 +2969,162 @@ async function cmdReady(rest) {
2228
2969
  return;
2229
2970
  }
2230
2971
 
2972
+ // --- Backend authoring readiness beyond the connection itself: which
2973
+ // environments serve the pane-free engine routes. Probed at most once (and
2974
+ // only when a payload actually reports it), and never fatal — an add-in
2975
+ // without them still authors, it just relays search through a window. ---
2976
+ let engineInfo = null;
2977
+ const engines = async () => {
2978
+ if (!engineInfo) {
2979
+ try {
2980
+ engineInfo = await client.engineReachability({
2981
+ backends: statusRaw.backends,
2982
+ });
2983
+ } catch {
2984
+ engineInfo = { available: false, envs: [] };
2985
+ }
2986
+ }
2987
+ return engineInfo;
2988
+ };
2989
+
2990
+ // --- A design resolved WITHOUT a window — the deck's own binding, an
2991
+ // explicit --brand, or the backend's brand list. Same payload either way:
2992
+ // ready, `windowless: true`, and a pane status that is INFORMATIONAL. What
2993
+ // the missing window actually costs depends on the engine routes: with them
2994
+ // only push/insert-slide and reading an open presentation need PowerPoint.
2995
+ //
2996
+ // "Windowless" is about how the DESIGN was resolved, not about the registry:
2997
+ // --brand runs before the registry is consulted at all, and the later rungs
2998
+ // fire on "nothing render-ready", which a connected-but-still-matching window
2999
+ // satisfies. So the pane status is DERIVED from the registry as it stands at
3000
+ // call time (the hydration wait reassigns it), and the prose only claims
3001
+ // "no PowerPoint window" when there genuinely is none. ---
3002
+ const finishWindowless = async (result) => {
3003
+ if (deckDir) {
3004
+ bundle.writeDeckBundleRef(deckDir, {
3005
+ presFP: result.presFP,
3006
+ etag: result.etag,
3007
+ });
3008
+ }
3009
+ const designName = readDesignName(result.dir);
3010
+ const env = result.backendURL ? cfg.deriveEnvLabel(result.backendURL) : null;
3011
+ const eng = await engines();
3012
+ const paneReady = registry.some(client.isPaneReadyPresentation);
3013
+ const noWindow = registry.length === 0;
3014
+ const nextAction = noWindow
3015
+ ? eng.available
3016
+ ? "Ready (no PowerPoint window — authoring, engine search and build-pptx all work; only push/insert-slide and reading an open presentation need one)."
3017
+ : "Ready (no PowerPoint window — build-pptx/build/review/view-draft work; push/search/read need a pane)."
3018
+ : paneReady
3019
+ ? "Ready (design resolved without targeting a window; a registered PowerPoint window is available for push/insert-slide)."
3020
+ : eng.available
3021
+ ? "Ready (design resolved without targeting a window; pane operations — push/insert-slide/read of a window — will wait for a pane click; engine search does not)."
3022
+ : "Ready (design resolved without targeting a window; pane operations — push/search/read — will wait for a pane click).";
3023
+ if (asJson) {
3024
+ process.stdout.write(
3025
+ JSON.stringify(
3026
+ {
3027
+ ready: true,
3028
+ windowless: true,
3029
+ deck: deckSlug,
3030
+ bound: Boolean(deckDir),
3031
+ env,
3032
+ presId: null,
3033
+ presFP: result.presFP,
3034
+ designName,
3035
+ bundleDir: result.dir,
3036
+ bundleEtag: result.etag ?? null,
3037
+ paneSuspended: !paneReady,
3038
+ engines: eng,
3039
+ warnings: result.warnings ?? [],
3040
+ nextAction,
3041
+ },
3042
+ null,
3043
+ 2,
3044
+ ) + "\n",
3045
+ );
3046
+ return;
3047
+ }
3048
+ const designLabel = designName
3049
+ ? `${designName} (${result.presFP})`
3050
+ : result.presFP;
3051
+ process.stdout.write(
3052
+ `Ready — design ${designLabel}${env ? ` [${env}]` : ""}${noWindow ? " (no PowerPoint window)" : ""}\n`,
3053
+ );
3054
+ process.stdout.write(` bundle: ${result.dir} (etag ${result.etag ?? "?"})\n`);
3055
+ if (deckDir) process.stdout.write(` deck ${deckSlug} bound\n`);
3056
+ process.stdout.write(
3057
+ noWindow
3058
+ ? eng.available
3059
+ ? " Note: authoring, engine search and build-pptx need no window; push/insert-slide and reading an open presentation do.\n"
3060
+ : " Note: build-pptx/build/review/view-draft work without a window; push/search/read need a pane.\n"
3061
+ : paneReady
3062
+ ? " Note: the design was resolved without targeting a window; a registered PowerPoint window is available for push/insert-slide.\n"
3063
+ : eng.available
3064
+ ? " Note: pane operations (push/insert-slide/read of a window) will wait for a pane click; engine search does not.\n"
3065
+ : " Note: pane operations (push/search/read) will wait for a pane click.\n",
3066
+ );
3067
+ for (const w of result.warnings ?? []) {
3068
+ process.stderr.write(` warning: ${w}\n`);
3069
+ }
3070
+ };
3071
+
3072
+ // Materialize a design by id over the pane-free brand route. Returns null on
3073
+ // any miss (old add-in, unknown/revoked design) so the caller can fall
3074
+ // through to the window ladder instead of dead-ending.
3075
+ const materializeBrand = async (brandId) => {
3076
+ try {
3077
+ return await bundle.materializeBundle(null, { force, brandId });
3078
+ } catch {
3079
+ return null;
3080
+ }
3081
+ };
3082
+
3083
+ // --- --brand <presFP>: the user (or the agent, after an ambiguous brand
3084
+ // list) named the design outright. It resolves with or without a window, so
3085
+ // it runs before the registry is consulted at all. ---
3086
+ if (brand) {
3087
+ const result = await materializeBrand(brand);
3088
+ if (!result) {
3089
+ finishBlocker({
3090
+ blocker: {
3091
+ code: "unknown_brand",
3092
+ actor: "user",
3093
+ message: `No connected slidemind add-in serves design '${brand}'. Run 'slidemind ready' with no --brand to see which designs are available.`,
3094
+ },
3095
+ });
3096
+ return;
3097
+ }
3098
+ await finishWindowless(result);
3099
+ return;
3100
+ }
3101
+
3102
+ const isRR = client.isRenderReadyPresentation;
3103
+
3104
+ // --- Bound-deck ladder: session → bundle freshness, no pane precondition.
3105
+ // The design identity (presFP == brandId) lives in the deck's
3106
+ // .bundle-ref.json and the bundle syncs over the pane-free brand route, so
3107
+ // with PowerPoint CLOSED (an empty registry) a bound deck readies straight
3108
+ // away instead of sitting out the hydration wait for a window that isn't
3109
+ // coming. A registry that holds windows still hydrates first — the open
3110
+ // add-in stays the source of truth whenever there IS one. Skipped for
3111
+ // --connect-only (which resolves no design at all) and for --to (an explicit
3112
+ // window must resolve through the registry); a miss (old add-in, legacy ref,
3113
+ // revoked grant) falls through to the window ladder. ---
3114
+ const boundDeckLadder = deckDir && deckPresFP && !to && !connectOnly;
3115
+ let boundRungTried = false;
3116
+ if (boundDeckLadder && registry.length === 0) {
3117
+ boundRungTried = true;
3118
+ const result = await materializeBrand(deckPresFP);
3119
+ if (result) {
3120
+ await finishWindowless(result);
3121
+ return;
3122
+ }
3123
+ }
3124
+
2231
3125
  // --- Bounded hydration wait when nothing is render-ready yet: covers the
2232
3126
  // just-clicked / just-woke pane still hydrating brand metadata. Polls even on
2233
3127
  // an empty registry (that's the flaky window this papers over). ---
2234
- const isRR = client.isRenderReadyPresentation;
2235
3128
  if (!registry.some(isRR)) {
2236
3129
  const waitMs =
2237
3130
  parseInt(process.env.SLIDEMIND_BRIDGE_PRESENTATIONS_WAIT_MS || "6000", 10) ||
@@ -2245,74 +3138,89 @@ async function cmdReady(rest) {
2245
3138
  }
2246
3139
  }
2247
3140
  if (!registry.some(isRR)) {
2248
- // --- Pane-free rung: a BOUND deck readies over the brand routes with no
2249
- // window at all — the design identity (presFP == brandId) lives in the
2250
- // deck's .bundle-ref.json, and the render half never needed the pane.
2251
- // Degraded: build-pptx/build/review/view-draft work; push/search/read
2252
- // still need a pane. Skipped for --connect-only (search needs a pane) and
2253
- // --to (an explicit window must resolve through the registry). Any miss
2254
- // (old add-in, legacy ref, revoked grant) falls through to the ladder.
2255
- if (deckDir && deckPresFP && !to && !connectOnly) {
2256
- let result = null;
3141
+ // --connect-only asks one question — "can I work?" — and since the engine
3142
+ // routes answer off the session, a backend that serves them IS the answer:
3143
+ // discovery, search and every per-result fetch run with PowerPoint closed.
3144
+ // Without them search is still relayed through a pane, so the window rung
3145
+ // below stands.
3146
+ if (connectOnly && (await engines()).available) {
3147
+ const eng = await engines();
3148
+ if (asJson) {
3149
+ process.stdout.write(
3150
+ JSON.stringify(
3151
+ {
3152
+ ready: true,
3153
+ connectOnly: true,
3154
+ windowless: true,
3155
+ engines: eng,
3156
+ nextAction:
3157
+ "Ready (no PowerPoint window — engine search runs off the connection; only insert-slide needs one).",
3158
+ backends: (pub.backends ?? []).map((b) => ({ env: b.env, ok: b.ok })),
3159
+ },
3160
+ null,
3161
+ 2,
3162
+ ) + "\n",
3163
+ );
3164
+ } else {
3165
+ process.stdout.write(
3166
+ `Connected. ${pub.backendCount} backend(s), 0 window(s) registered — ` +
3167
+ `engine search runs without one [${eng.envs.join(", ")}].\n`,
3168
+ );
3169
+ }
3170
+ return;
3171
+ }
3172
+ // The bound-deck ladder again, for the windows-registered-but-never-ready
3173
+ // case the pre-poll rung deliberately left to the hydration wait.
3174
+ if (boundDeckLadder && !boundRungTried) {
3175
+ boundRungTried = true;
3176
+ const result = await materializeBrand(deckPresFP);
3177
+ if (result) {
3178
+ await finishWindowless(result);
3179
+ return;
3180
+ }
3181
+ }
3182
+ // --- Unbound + no window: ask the backend which designs this user may
3183
+ // build against. One is the answer; several genuinely need a human choice,
3184
+ // which --brand records. A backend without the route (or a deck already
3185
+ // bound to a design the brand route just refused) falls through unchanged. ---
3186
+ if (!connectOnly && !to && !deckPresFP && !boundRungTried) {
3187
+ let brands = [];
2257
3188
  try {
2258
- result = await bundle.materializeBundle(null, {
2259
- force,
2260
- brandId: deckPresFP,
2261
- });
3189
+ brands = await client.listBrands();
2262
3190
  } catch {
2263
- /* fall through to the standard no_window / not_render_ready blocker */
3191
+ /* no brand list — the window ladder below is the answer */
2264
3192
  }
2265
- if (result) {
2266
- bundle.writeDeckBundleRef(deckDir, {
2267
- presFP: result.presFP,
2268
- etag: result.etag,
2269
- });
2270
- const designName = readDesignName(result.dir);
2271
- const env = result.backendURL
2272
- ? cfg.deriveEnvLabel(result.backendURL)
2273
- : null;
2274
- const nextAction =
2275
- "Ready (no PowerPoint window — build-pptx/build/review/view-draft work; push/search/read need a pane).";
2276
- if (asJson) {
2277
- process.stdout.write(
2278
- JSON.stringify(
2279
- {
2280
- ready: true,
2281
- windowless: true,
2282
- deck: deckSlug,
2283
- bound: true,
2284
- env,
2285
- presId: null,
2286
- presFP: result.presFP,
2287
- designName,
2288
- bundleDir: result.dir,
2289
- bundleEtag: result.etag ?? null,
2290
- paneSuspended: true,
2291
- warnings: result.warnings ?? [],
2292
- nextAction,
2293
- },
2294
- null,
2295
- 2,
2296
- ) + "\n",
2297
- );
3193
+ if (brands.length === 1) {
3194
+ const result = await materializeBrand(brands[0].presFP);
3195
+ if (result) {
3196
+ await finishWindowless(result);
2298
3197
  return;
2299
3198
  }
2300
- const designLabel = designName
2301
- ? `${designName} (${result.presFP})`
2302
- : result.presFP;
2303
- process.stdout.write(
2304
- `Ready — design ${designLabel}${env ? ` [${env}]` : ""} (no PowerPoint window)\n`,
2305
- );
2306
- process.stdout.write(
2307
- ` bundle: ${result.dir} (etag ${result.etag ?? "?"})\n`,
2308
- );
2309
- process.stdout.write(` deck ${deckSlug} bound\n`);
2310
- process.stdout.write(
2311
- " Note: build-pptx/build/review/view-draft work without a window; push/search/read need a pane.\n",
3199
+ } else if (brands.length > 1) {
3200
+ const rows = brands.map(
3201
+ (b) => ` ${b.presFP}${b.env ? ` [${b.env}]` : ""} ${b.name ?? ""}`.trimEnd(),
2312
3202
  );
2313
- for (const w of result.warnings ?? []) {
2314
- process.stderr.write(` warning: ${w}\n`);
2315
- }
3203
+ const blocker = {
3204
+ code: "ambiguous_brand",
3205
+ actor: "user",
3206
+ message:
3207
+ "Several corporate designs are available and no PowerPoint window says which — ask the user which to build against, then re-run: slidemind ready" +
3208
+ `${deckSlug ? ` ${deckSlug}` : ""} --brand <presFP>.`,
3209
+ };
3210
+ finishBlocker({
3211
+ blocker,
3212
+ extras: {
3213
+ brands: brands.map((b) => ({
3214
+ presFP: b.presFP,
3215
+ name: b.name ?? null,
3216
+ env: b.env ?? null,
3217
+ })),
3218
+ },
3219
+ humanLines:
3220
+ "slidemind: several corporate designs are available — ask the user which to build against:\n" +
3221
+ `${rows.join("\n")}\n` +
3222
+ `Then re-run: slidemind ready${deckSlug ? ` ${deckSlug}` : ""} --brand <presFP>`,
3223
+ });
2316
3224
  return;
2317
3225
  }
2318
3226
  }
@@ -2345,6 +3253,7 @@ async function cmdReady(rest) {
2345
3253
  {
2346
3254
  ready: true,
2347
3255
  connectOnly: true,
3256
+ engines: await engines(),
2348
3257
  nextAction: "Ready.",
2349
3258
  backends: (pub.backends ?? []).map((b) => ({ env: b.env, ok: b.ok })),
2350
3259
  },
@@ -2468,6 +3377,7 @@ async function cmdReady(rest) {
2468
3377
 
2469
3378
  const designName = readDesignName(result.dir);
2470
3379
  const paneSuspended = !client.isPaneReadyPresentation(entry);
3380
+ const engineInfoForWindow = await engines();
2471
3381
 
2472
3382
  if (asJson) {
2473
3383
  process.stdout.write(
@@ -2487,6 +3397,7 @@ async function cmdReady(rest) {
2487
3397
  fileName: entry.fileName ?? null,
2488
3398
  },
2489
3399
  paneSuspended,
3400
+ engines: engineInfoForWindow,
2490
3401
  warnings: result.warnings ?? [],
2491
3402
  nextAction: "Ready.",
2492
3403
  },
@@ -2511,7 +3422,9 @@ async function cmdReady(rest) {
2511
3422
  if (bound) process.stdout.write(` deck ${deckSlug} bound\n`);
2512
3423
  if (paneSuspended) {
2513
3424
  process.stdout.write(
2514
- " Note: pane operations (push/search/read) will wait for a pane click.\n",
3425
+ engineInfoForWindow.available
3426
+ ? " Note: pane operations (push/insert-slide/read of this window) will wait for a pane click; engine search does not.\n"
3427
+ : " Note: pane operations (push/search/read) will wait for a pane click.\n",
2515
3428
  );
2516
3429
  }
2517
3430
  for (const w of result.warnings ?? []) {
@@ -2530,11 +3443,14 @@ async function cmdDoctor(rest) {
2530
3443
  // still reaches the reader, as the `cli_update_required` rung of the ladder.
2531
3444
  client.setDiagnosticMode(true);
2532
3445
  if (sessions.length === 0) {
3446
+ const blocker = backendBlocker({ paired: false });
2533
3447
  const pub = {
2534
3448
  paired: false,
2535
3449
  backendCount: 0,
2536
3450
  backends: [],
2537
3451
  presentations: [],
3452
+ authoring: { ready: false, blocker, degraded: null, engines: null },
3453
+ host: { ready: false, blocker: paneBlocker({ presentations: [] }) },
2538
3454
  nextAction: connectionNextAction({ paired: false }),
2539
3455
  };
2540
3456
  process[asJson ? "stdout" : "stderr"].write(
@@ -2542,7 +3458,30 @@ async function cmdDoctor(rest) {
2542
3458
  );
2543
3459
  return;
2544
3460
  }
2545
- const status = publicConnectionStatus(await client.connectionStatus(), client);
3461
+ const statusRaw = await client.connectionStatus();
3462
+ const status = publicConnectionStatus(statusRaw, client);
3463
+ // The two ladders are INDEPENDENT: authoring (session → reachable backend →
3464
+ // CLI version → engine reachability) is what render, bundle and the pane-free
3465
+ // engine routes ride on, while the live-PowerPoint ladder only governs
3466
+ // push/insert-slide and reading an open presentation. Reporting them
3467
+ // separately is the point — a dead pane is not "cannot work".
3468
+ const authoringBlocker = backendBlocker(status);
3469
+ let engines = { available: false, envs: [] };
3470
+ if (!authoringBlocker) {
3471
+ try {
3472
+ engines = await client.engineReachability({ backends: statusRaw.backends });
3473
+ } catch {
3474
+ /* leave it as unavailable — never fail a diagnosis on a probe */
3475
+ }
3476
+ }
3477
+ status.authoring = {
3478
+ ready: !authoringBlocker,
3479
+ blocker: authoringBlocker,
3480
+ degraded: permissionsStaleDegradation(status, client),
3481
+ engines,
3482
+ };
3483
+ const hostBlocker = paneBlocker(status);
3484
+ status.host = { ready: !hostBlocker, blocker: hostBlocker };
2546
3485
  status.nextAction = connectionNextAction(status);
2547
3486
  if (asJson) {
2548
3487
  process.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
@@ -2556,6 +3495,32 @@ async function cmdDoctor(rest) {
2556
3495
  `\n`,
2557
3496
  );
2558
3497
  }
3498
+ process.stdout.write(
3499
+ `Backend authoring ready: ${status.authoring.ready ? "yes" : "no"}` +
3500
+ `${status.authoring.ready ? "" : ` — ${authoringBlocker.message}`}\n`,
3501
+ );
3502
+ // One condition, one cause, named the same way everywhere: when the readiness
3503
+ // capabilities already say the permission snapshot has aged out, this line
3504
+ // says so too, instead of reporting a healthy pane-free surface that a corpus
3505
+ // search is about to 403 on for a reason the user learns only then.
3506
+ const staleEnvs = permissionsStaleEnvs(status);
3507
+ process.stdout.write(
3508
+ ` engine search (no window needed): ${
3509
+ engines.available
3510
+ ? `yes [${engines.envs.join(", ")}]` +
3511
+ (staleEnvs.length
3512
+ ? ` — corpus search degraded on ${staleEnvs.join(", ")}: stale permission snapshot`
3513
+ : "")
3514
+ : "no — relayed through an open pane"
3515
+ }\n`,
3516
+ );
3517
+ if (status.authoring.degraded) {
3518
+ process.stdout.write(` degraded: ${status.authoring.degraded.message}\n`);
3519
+ }
3520
+ process.stdout.write(
3521
+ `Live PowerPoint ready: ${status.host.ready ? "yes" : "no"}` +
3522
+ `${status.host.ready ? "" : ` — ${hostBlocker.message}`}\n`,
3523
+ );
2559
3524
  if (status.presentations.length === 0) {
2560
3525
  process.stdout.write("Open presentations: none\n");
2561
3526
  } else {
@@ -3268,6 +4233,9 @@ async function main(rawArgv) {
3268
4233
  case "build-pptx":
3269
4234
  await cmdBuildPptx(rest);
3270
4235
  return 0;
4236
+ case "inspect-template-deck":
4237
+ await cmdInspectTemplateDeck(rest);
4238
+ return 0;
3271
4239
  case "view-draft":
3272
4240
  await cmdViewDraft(rest);
3273
4241
  return 0;
@@ -3280,6 +4248,9 @@ async function main(rawArgv) {
3280
4248
  case "view-slide":
3281
4249
  await cmdViewSlide(rest);
3282
4250
  return 0;
4251
+ case "read-source":
4252
+ await cmdReadSource(rest);
4253
+ return 0;
3283
4254
  case "insert-slide":
3284
4255
  await cmdInsertSlide(rest);
3285
4256
  return 0;