slidemind 0.3.12 → 0.3.14

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.
Files changed (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +86 -21
  4. package/README.md +14 -17
  5. package/bin/slidemind.mjs +65 -4969
  6. package/package.json +1 -1
  7. package/skills/create-slides/SKILL.md +113 -74
  8. package/skills/search/SKILL.md +25 -7
  9. package/src/bridge/build-and-push.mjs +108 -15
  10. package/src/bridge/bundle.mjs +69 -0
  11. package/src/bridge/capture.mjs +71 -8
  12. package/src/bridge/client/brands.mjs +622 -0
  13. package/src/bridge/client/engine-routing.mjs +237 -0
  14. package/src/bridge/client/engine-search.mjs +310 -0
  15. package/src/bridge/client/engine-slides.mjs +435 -0
  16. package/src/bridge/client/pane-ops.mjs +335 -0
  17. package/src/bridge/client/presentations.mjs +587 -0
  18. package/src/bridge/client/render.mjs +317 -0
  19. package/src/bridge/client/transport.mjs +695 -0
  20. package/src/bridge/client.mjs +80 -3452
  21. package/src/bridge/config.mjs +1 -1
  22. package/src/bridge/engine-download.mjs +15 -2
  23. package/src/builders/build.mjs +16 -669
  24. package/src/builders/draft-check.mjs +376 -0
  25. package/src/builders/lint.mjs +38 -2
  26. package/src/builders/preview.html +799 -0
  27. package/src/builders/review.mjs +23 -4
  28. package/src/builders/visual-families.mjs +6 -8
  29. package/src/cli/common.mjs +179 -0
  30. package/src/cli/concepts.mjs +310 -0
  31. package/src/cli/connection.mjs +147 -0
  32. package/src/cli/deck.mjs +608 -0
  33. package/src/cli/designs.mjs +113 -0
  34. package/src/cli/diagnose.mjs +141 -0
  35. package/src/cli/download-bundle.mjs +144 -0
  36. package/src/cli/engine-helpers.mjs +429 -0
  37. package/src/cli/engine-results.mjs +320 -0
  38. package/src/cli/help.mjs +201 -0
  39. package/src/cli/install.mjs +257 -0
  40. package/src/cli/read.mjs +120 -0
  41. package/src/cli/ready.mjs +805 -0
  42. package/src/cli/search.mjs +608 -0
  43. package/src/cli/session.mjs +177 -0
  44. package/src/cli/setup.mjs +282 -0
  45. package/src/cli/slide-reuse.mjs +334 -0
  46. package/src/cli/status.mjs +118 -0
  47. package/src/cli/workspace.mjs +302 -0
  48. package/src/installers/codex.mjs +1 -1
  49. package/src/update-check.mjs +231 -0
@@ -0,0 +1,587 @@
1
+ // The open-window registry: fan-out listing, readiness ladders, pane waits and
2
+ // the presId → backend routing every pane-bound op rides on.
3
+
4
+ import { deriveEnvLabel } from "../config.mjs";
5
+ import { cliVersion, compareCliVersions, listTimeoutMs, engineTimeoutMs, readinessTimeoutMs, READY_HINT, withTransientRetry, sessionsInScope, bridgeFetch, envInt, sleep } from "./transport.mjs";
6
+
7
+ // List one backend's open presentations, tagging each entry with its origin
8
+ // (backendURL + a short env label) so the merged registry can be routed and
9
+ // displayed per environment. Transient blips retry per-backend.
10
+ export async function listPresentationsForBackend(backendURL) {
11
+ return withTransientRetry(async () => {
12
+ const res = await bridgeFetch(
13
+ "/api/cli/presentations",
14
+ { timeoutMs: listTimeoutMs() },
15
+ backendURL,
16
+ );
17
+ const data = await res.json();
18
+ const arr = Array.isArray(data?.presentations) ? data.presentations : [];
19
+ const env = deriveEnvLabel(backendURL);
20
+ return arr.map((p) => ({ ...p, backendURL, env }));
21
+ });
22
+ }
23
+
24
+ export async function readinessForBackend(backendURL) {
25
+ const env = deriveEnvLabel(backendURL);
26
+ try {
27
+ // Retry only a 503: the add-in answers that (code `backend_unavailable`)
28
+ // while its session store cannot respond, and a 404 here means an older
29
+ // add-in without the readiness route, which the fallback below handles
30
+ // without waiting out the retry delays.
31
+ const res = await withTransientRetry(
32
+ () =>
33
+ bridgeFetch(
34
+ "/api/cli/readiness",
35
+ { timeoutMs: listTimeoutMs() },
36
+ backendURL,
37
+ ),
38
+ (err) => err?.status === 503 && err?.code !== "pane_suspended",
39
+ );
40
+ const data = (await res.json()) ?? {};
41
+ const presentations = Array.isArray(data.presentations)
42
+ ? data.presentations.map((p) => ({ ...p, backendURL, env }))
43
+ : [];
44
+ // Version-floor check for diagnostics (doctor / connection_status): newer
45
+ // add-ins advertise minCliVersion in the readiness capabilities, so the
46
+ // skew is visible BEFORE a render trips over it.
47
+ const minCliVersion =
48
+ typeof data.capabilities?.minCliVersion === "string"
49
+ ? data.capabilities.minCliVersion
50
+ : null;
51
+ return {
52
+ env,
53
+ backendURL,
54
+ ok: true,
55
+ readinessV1: data.readinessV1 === true,
56
+ capabilities: data.capabilities ?? null,
57
+ minCliVersion,
58
+ cliUpdateRequired: Boolean(
59
+ minCliVersion && compareCliVersions(cliVersion(), minCliVersion) < 0,
60
+ ),
61
+ presentations,
62
+ summary: data.summary ?? null,
63
+ };
64
+ } catch (err) {
65
+ if (err?.status !== 404) throw err;
66
+ // Older add-ins do not serve /readiness. Fall back to the legacy
67
+ // presentations route and mark the result as a compatibility response.
68
+ const presentations = await listPresentationsForBackend(backendURL);
69
+ return {
70
+ env,
71
+ backendURL,
72
+ ok: true,
73
+ readinessV1: false,
74
+ capabilities: null,
75
+ presentations,
76
+ summary: null,
77
+ };
78
+ }
79
+ }
80
+
81
+ export async function connectionStatus() {
82
+ const sessions = sessionsInScope();
83
+ const settled = await Promise.allSettled(
84
+ sessions.map((s) => readinessForBackend(s.backendURL)),
85
+ );
86
+ const backends = settled.map((r, i) => {
87
+ const backendURL = sessions[i].backendURL;
88
+ const env = deriveEnvLabel(backendURL);
89
+ if (r.status === "fulfilled") return r.value;
90
+ return {
91
+ env,
92
+ backendURL,
93
+ ok: false,
94
+ error: r.reason?.message ? r.reason.message : String(r.reason),
95
+ // Machine code of the failure (`backend_unavailable`, `session_invalid`,
96
+ // …) so the readiness ladder can tell "retry shortly" from "re-pair".
97
+ code: typeof r.reason?.code === "string" ? r.reason.code : null,
98
+ // The HTTP status too: a proxy's 503 carries no machine code (or only a
99
+ // prose `error`), and the ladder must still read it as "wait".
100
+ status: typeof r.reason?.status === "number" ? r.reason.status : null,
101
+ presentations: [],
102
+ };
103
+ });
104
+ return {
105
+ paired: sessions.length > 0,
106
+ backendCount: sessions.length,
107
+ backends,
108
+ presentations: backends.flatMap((b) => b.presentations ?? []),
109
+ };
110
+ }
111
+
112
+ // Fan out across every backend in scope, concurrently. Never rejects: each
113
+ // backend's outcome is partitioned into merged presentations vs. a failure
114
+ // record, so one unreachable environment can't hide the others.
115
+ export async function fanOutPresentations() {
116
+ const sessions = sessionsInScope();
117
+ const settled = await Promise.allSettled(
118
+ sessions.map((s) => listPresentationsForBackend(s.backendURL)),
119
+ );
120
+ const presentations = [];
121
+ const failures = [];
122
+ let fulfilledCount = 0;
123
+ settled.forEach((r, i) => {
124
+ const backendURL = sessions[i].backendURL;
125
+ if (r.status === "fulfilled") {
126
+ fulfilledCount += 1;
127
+ presentations.push(...r.value);
128
+ } else {
129
+ failures.push({
130
+ backendURL,
131
+ env: deriveEnvLabel(backendURL),
132
+ err: r.reason,
133
+ });
134
+ }
135
+ });
136
+ return { presentations, failures, fulfilledCount };
137
+ }
138
+
139
+ // Merged registry across all paired backends. Stays a flat array (every caller
140
+ // expects one). Preserves single-backend behaviour: when nothing is reachable
141
+ // AND no backend answered, the underlying bridge error propagates (so a typed
142
+ // code like `pane_suspended` or a 401 still surfaces) — for several failing
143
+ // backends it's an aggregate message.
144
+ export async function listOpenPresentations() {
145
+ const { presentations, failures, fulfilledCount } =
146
+ await fanOutPresentations();
147
+ if (presentations.length === 0 && fulfilledCount === 0 && failures.length > 0) {
148
+ if (failures.length === 1) throw failures[0].err;
149
+ const err = new Error(
150
+ "slidemind: every paired backend failed to list presentations:\n" +
151
+ failures
152
+ .map((f) => ` ${f.env}: ${f.err.message}`)
153
+ .join("\n"),
154
+ );
155
+ throw err;
156
+ }
157
+ return presentations;
158
+ }
159
+
160
+ // The user/agent-facing shape of a presentation entry. `backendURL` is internal
161
+ // routing state — every caller targets a presentation by `presId` (the backend
162
+ // is re-resolved server-side from it), so the URL must never leave the process
163
+ // in any printed line or command output. Keeps everything else — presId, title,
164
+ // fileName, connected, suspendedAt, the short `env` label, and the readiness
165
+ // fields (connectionState, designState, capabilities, nextAction) that agents
166
+ // parse from `presentations --json` to decide whether/how to target a pane.
167
+ export function toPublicPresentation(p) {
168
+ const { backendURL, ...rest } = p;
169
+ return rest;
170
+ }
171
+
172
+ // Like listOpenPresentations but never throws on per-backend failures — returns
173
+ // the merged registry plus which backends were unreachable, for callers that
174
+ // want to report partial results (requireHost, whoami).
175
+ export async function listOpenPresentationsDetailed() {
176
+ const { presentations, failures } = await fanOutPresentations();
177
+ return {
178
+ presentations,
179
+ unreachable: failures.map((f) => ({
180
+ backendURL: f.backendURL,
181
+ env: f.env,
182
+ error: f.err?.message ? f.err.message : String(f.err),
183
+ })),
184
+ };
185
+ }
186
+
187
+ // Probe the bridge for an open PowerPoint host across every paired backend.
188
+ // Returns the merged registry, or throws a single actionable message — the
189
+ // shared precondition for every author/render command now that the design truth
190
+ // lives in the add-in. Suspended entries (task-pane socket dropped, e.g.
191
+ // PowerPoint backgrounded) count as registered: render + bundle work without
192
+ // the pane, and a push waits server-side for the pane to reconnect.
193
+ export async function requireHost() {
194
+ const { presentations, unreachable } = await listOpenPresentationsDetailed();
195
+ if (Array.isArray(presentations) && presentations.length > 0) {
196
+ return presentations;
197
+ }
198
+ // Nothing registered. Distinguish "a backend was unreachable" from "backends
199
+ // answered but no window is open" so the user gets the right next step.
200
+ if (unreachable.length > 0) {
201
+ throw new Error(
202
+ "Open PowerPoint with the slidemind add-in and an active corporate design, then retry.\n" +
203
+ unreachable
204
+ .map((u) => ` (bridge: ${u.env}: ${u.error})`)
205
+ .join("\n") +
206
+ `\n${READY_HINT}`,
207
+ );
208
+ }
209
+ throw new Error(
210
+ "No PowerPoint window is registered with the slidemind add-in.\n" +
211
+ "If PowerPoint is already open, bring it to the foreground and click the slidemind pane once so it re-registers, then retry.\n" +
212
+ "Otherwise open PowerPoint with the slidemind add-in and an active corporate design, then retry.\n" +
213
+ READY_HINT,
214
+ );
215
+ }
216
+
217
+ export function hasReadinessFields(p) {
218
+ return (
219
+ p &&
220
+ (typeof p.connectionState === "string" ||
221
+ typeof p.designState === "string" ||
222
+ (p.capabilities && typeof p.capabilities === "object"))
223
+ );
224
+ }
225
+
226
+ export function isRenderReadyPresentation(p) {
227
+ if (!p) return false;
228
+ if (!hasReadinessFields(p)) return true;
229
+ if (p.capabilities?.render === true) return true;
230
+ if (p.designState === "ready" && p.capabilities?.render !== false) return true;
231
+ return false;
232
+ }
233
+
234
+ export function isPaneReadyPresentation(p) {
235
+ if (!p) return false;
236
+ if (!hasReadinessFields(p)) return p.connected !== false && !p.suspendedAt;
237
+ if (p.capabilities?.paneOps === true) return true;
238
+ return (
239
+ p.connectionState === "connected" &&
240
+ p.connected !== false &&
241
+ !p.suspendedAt &&
242
+ p.connectionState !== "stale"
243
+ );
244
+ }
245
+
246
+ export async function waitForReadyPresentation(predicate, { timeoutMs = null } = {}) {
247
+ const waitMs = timeoutMs ?? readinessTimeoutMs();
248
+ const pollMs = envInt("SLIDEMIND_BRIDGE_READY_POLL_MS", 1500);
249
+ const deadline = Date.now() + waitMs;
250
+ for (;;) {
251
+ const { presentations, unreachable } = await listOpenPresentationsDetailed();
252
+ if (presentations.some(predicate)) return presentations;
253
+ // Do not wait when nothing is registered at all. Waiting helps only for an
254
+ // already-open pane that is still hydrating brand/readiness metadata.
255
+ if (presentations.length === 0 || Date.now() >= deadline) {
256
+ return { presentations, unreachable, timedOut: presentations.length > 0 };
257
+ }
258
+ await sleep(pollMs);
259
+ }
260
+ }
261
+
262
+ // The bounded poll behind the window listings. Unlike
263
+ // waitForReadyPresentation it PURPOSELY does NOT early-return on an empty
264
+ // registry: the empty window right after a pane reload / PowerPoint wake is
265
+ // exactly the flaky case this papers over. Polls the merged registry every
266
+ // SLIDEMIND_BRIDGE_READY_POLL_MS (default 1.5s) up to
267
+ // SLIDEMIND_BRIDGE_PRESENTATIONS_WAIT_MS (default 6s), stopping as soon as at
268
+ // least one pane is pane-ready. A `timeoutMs` of 0 means single-shot — one
269
+ // fetch, no sleep. Returns { all, ready } so the caller can list EVERY entry
270
+ // (suspended included, with its status) yet still know which are live. Does not
271
+ // touch waitForReadyPresentation / requirePaneHost — agent target selection
272
+ // still uses only ready panes.
273
+ export async function listReadyPresentationsWithWait({ timeoutMs = null } = {}) {
274
+ const waitMs =
275
+ timeoutMs ?? envInt("SLIDEMIND_BRIDGE_PRESENTATIONS_WAIT_MS", 6000);
276
+ const pollMs = envInt("SLIDEMIND_BRIDGE_READY_POLL_MS", 1500);
277
+ const deadline = Date.now() + waitMs;
278
+ for (;;) {
279
+ const all = await listOpenPresentations();
280
+ const ready = all.filter(isPaneReadyPresentation);
281
+ if (ready.length > 0 || waitMs <= 0 || Date.now() >= deadline) {
282
+ return { all, ready };
283
+ }
284
+ await sleep(pollMs);
285
+ }
286
+ }
287
+
288
+ export async function requireRenderHost({ timeoutMs = null } = {}) {
289
+ const first = await listOpenPresentationsDetailed();
290
+ if (first.presentations.some(isRenderReadyPresentation)) {
291
+ return first.presentations;
292
+ }
293
+ if (first.presentations.length > 0) {
294
+ const waited = await waitForReadyPresentation(isRenderReadyPresentation, {
295
+ timeoutMs,
296
+ });
297
+ if (Array.isArray(waited)) return waited;
298
+ if (waited.presentations.some(isRenderReadyPresentation)) {
299
+ return waited.presentations;
300
+ }
301
+ throw new Error(
302
+ "PowerPoint is registered with the slidemind add-in, but no window is render-ready yet. " +
303
+ "Wait for the corporate design to finish matching, or pick/create a corporate design in the add-in, then retry. " +
304
+ READY_HINT,
305
+ );
306
+ }
307
+ return requireHost();
308
+ }
309
+
310
+ export async function requirePaneHost({ timeoutMs = null } = {}) {
311
+ const first = await listOpenPresentationsDetailed();
312
+ if (first.presentations.some(isPaneReadyPresentation)) {
313
+ return first.presentations.filter(isPaneReadyPresentation);
314
+ }
315
+ if (first.presentations.length > 0) {
316
+ const waited = await waitForReadyPresentation(isPaneReadyPresentation, {
317
+ timeoutMs,
318
+ });
319
+ if (Array.isArray(waited)) return waited.filter(isPaneReadyPresentation);
320
+ if (waited.presentations.some(isPaneReadyPresentation)) {
321
+ return waited.presentations.filter(isPaneReadyPresentation);
322
+ }
323
+ throw new Error(
324
+ "PowerPoint is registered with the slidemind add-in, but the task pane is not ready for pane operations. " +
325
+ "Bring PowerPoint forward and click or reopen the slidemind pane, then retry. " +
326
+ READY_HINT,
327
+ );
328
+ }
329
+ return requireHost();
330
+ }
331
+
332
+ // "Is a task pane available RIGHT NOW?" — the non-throwing twin of
333
+ // requirePaneHost, for paths where an open pane is an OPTION rather than a
334
+ // precondition (a stale permission snapshot can be routed around by relaying
335
+ // through a live pane, which signs each request with a fresh browser token).
336
+ // Never waits for hydration and never authors a blocker: an empty array means
337
+ // "no, carry on with what you were doing", not "stop".
338
+ export async function paneReadyPresentations() {
339
+ try {
340
+ const { presentations } = await listOpenPresentationsDetailed();
341
+ return presentations.filter(isPaneReadyPresentation);
342
+ } catch {
343
+ return [];
344
+ }
345
+ }
346
+
347
+ // Tie-break a multi-backend presId collision without bothering the user when
348
+ // only one candidate can actually serve the deck:
349
+ // (a) exactly one CONNECTED pane whose active CD matches the deck's bundle
350
+ // ref (`preferBrandId` — the deck's presFP, which equals the pane's
351
+ // brandId by the bundle-cache contract);
352
+ // (b) else exactly one connected pane with ANY active CD (brandId set) —
353
+ // e.g. a local dev backend connected without a CD next to prod with one.
354
+ // Returns the winning entry or null when genuinely ambiguous.
355
+ export function tieBreakMatches(matches, preferBrandId) {
356
+ const live = matches.filter(isPaneReadyPresentation);
357
+ if (preferBrandId) {
358
+ const want = live.filter((m) => m.brandId === preferBrandId);
359
+ if (want.length === 1) return want[0];
360
+ }
361
+ const withCd = live.filter((m) => m.brandId);
362
+ if (withCd.length === 1) return withCd[0];
363
+ return null;
364
+ }
365
+
366
+ // Resolve which backend a presId lives on via a LIVE fan-out list (never a
367
+ // cached map — a presId can move backends on re-pair/redeploy). Throws an
368
+ // actionable message on a miss, or an ambiguity error when the same presId is
369
+ // open on more than one reachable backend and no tie-break rule settles it.
370
+ export async function resolveBackendForPres(
371
+ presId,
372
+ { preferBrandId = null } = {},
373
+ ) {
374
+ if (!presId) throw new Error("resolveBackendForPres: presId is required");
375
+ const { presentations } = await fanOutPresentations();
376
+ const matches = presentations.filter((p) => p.presId === presId);
377
+ if (matches.length === 1) return matches[0].backendURL;
378
+ if (matches.length > 1) {
379
+ const winner = tieBreakMatches(matches, preferBrandId);
380
+ if (winner) return winner.backendURL;
381
+ const envs = matches.map((m) => m.env).join(", ");
382
+ throw new Error(
383
+ `Presentation '${presId}' is open on multiple environments: ${envs}, ` +
384
+ "and no single connected one has the deck's corporate design active. " +
385
+ "Disambiguate with --backend <env|label> (or SLIDEMIND_BACKEND).",
386
+ );
387
+ }
388
+ throw new Error(
389
+ `No open PowerPoint window registered with presId='${presId}' on any paired backend. ` +
390
+ "Run 'slidemind ready --diagnose' to see what's open.",
391
+ );
392
+ }
393
+
394
+ // Wait for a presentation's task pane to come back after a `pane_suspended`
395
+ // (PowerPoint backgrounded → webview suspended → socket dropped). Polls the
396
+ // live registry; succeeds on the same presId reconnecting, OR on a CONNECTED
397
+ // entry matching `fileName` + `brandId` — a pane reconnect can mint a NEW
398
+ // presId for the same window, so identity-by-content covers the churn. Returns
399
+ // the live registry entry (caller re-targets from it), or null on timeout.
400
+ // `onWait` fires once with the wait budget in seconds — the caller's single
401
+ // actionable "click the pane" line.
402
+ //
403
+ // `otherPresIds` are windows the caller already knows are NOT the target (the
404
+ // rest of the registry it picked from). They are excluded from the last-resort
405
+ // design-fingerprint match — see below.
406
+ export async function waitForPaneReady(
407
+ presId,
408
+ {
409
+ backendURL = null,
410
+ fileName = null,
411
+ brandId = null,
412
+ otherPresIds = null,
413
+ timeoutMs = null,
414
+ pollMs = null,
415
+ onWait = null,
416
+ } = {},
417
+ ) {
418
+ const notTheTarget = new Set(otherPresIds ?? []);
419
+ const waitMs =
420
+ timeoutMs ?? envInt("SLIDEMIND_BRIDGE_PANE_READY_TIMEOUT_MS", 120000);
421
+ const stepMs = pollMs ?? envInt("SLIDEMIND_BRIDGE_PANE_READY_POLL_MS", 3000);
422
+ const deadline = Date.now() + waitMs;
423
+ let notified = false;
424
+ for (;;) {
425
+ let list = [];
426
+ try {
427
+ list = backendURL
428
+ ? await listPresentationsForBackend(backendURL)
429
+ : (await fanOutPresentations()).presentations;
430
+ } catch {
431
+ /* transient list failure — poll again until the deadline */
432
+ }
433
+ const connected = list.filter(
434
+ (p) => isPaneReadyPresentation(p),
435
+ );
436
+ const same = connected.find((p) => p.presId === presId);
437
+ if (same) return same;
438
+ if (fileName || brandId) {
439
+ const rematch = connected.find(
440
+ (p) =>
441
+ (!fileName || p.fileName === fileName || p.title === fileName) &&
442
+ (!brandId || p.brandId === brandId),
443
+ );
444
+ if (rematch) return rematch;
445
+ // Last resort: match on the design fingerprint alone (presFP == brandId).
446
+ // A reconnect can report a different name for the same window — the user
447
+ // hit Save As, or the pane reports the title where it used to report the
448
+ // file name — but the active corporate design does not change with it.
449
+ // Only when EXACTLY one connected window carries it: two windows on the
450
+ // same design are indistinguishable, and pushing into the wrong one is
451
+ // worse than waiting.
452
+ //
453
+ // A window the caller ALREADY saw next to the target is not a churned
454
+ // target — it is a different presentation that merely shares the design
455
+ // (most tenants run exactly one), and retargeting to it would append the
456
+ // deck's slides to an unrelated file. Most tenants run a single corporate
457
+ // design, so brandId alone is not window identity.
458
+ if (fileName && brandId) {
459
+ const byBrand = connected.filter(
460
+ (p) => p.brandId === brandId && !notTheTarget.has(p.presId),
461
+ );
462
+ if (byBrand.length === 1) return byBrand[0];
463
+ }
464
+ }
465
+ if (Date.now() >= deadline) return null;
466
+ if (!notified) {
467
+ notified = true;
468
+ onWait?.(Math.round(waitMs / 1000));
469
+ }
470
+ await sleep(stepMs);
471
+ }
472
+ }
473
+
474
+ // The one actionable line a human needs when a pane op is waiting. stderr so
475
+ // it reaches the terminal from the CLI.
476
+ export function paneWaitNotice(seconds) {
477
+ process.stderr.write(
478
+ "task pane suspended — click the slidemind pane in PowerPoint to wake it " +
479
+ `(waiting up to ${seconds}s)…\n`,
480
+ );
481
+ }
482
+
483
+ // The window is gone from the registry but its pane may already be back under a
484
+ // new presId (churn on every reconnect) — nothing for the user to do here, so
485
+ // say what the CLI is doing rather than asking for a click.
486
+ export function paneRetargetNotice(seconds) {
487
+ process.stderr.write(
488
+ "the target PowerPoint window re-registered — re-matching it by corporate " +
489
+ `design (up to ${seconds}s)…\n`,
490
+ );
491
+ }
492
+
493
+ // A 404 the RELAY emitted about the presId itself (the window is no longer in
494
+ // the registry — a pane reconnect mints a new presId for the same window)
495
+ // rather than one a ROUTE emitted about something else it was asked for (a
496
+ // stale `unknown_search`, an unimplemented route). Only the former is worth
497
+ // re-targeting: the same window is probably back under a new id.
498
+ //
499
+ // Read the PARSED code, never `err.code`: bridgeFetch falls that back to the
500
+ // human error string, and the relay's vanished-presId 404 carries prose only
501
+ // ("No PowerPoint window registered for presId='X'") — so testing `err.code`
502
+ // saw a truthy, unmatched value and never retargeted anything.
503
+ export function isMissingPresentationError(err) {
504
+ if (err?.status !== 404) return false;
505
+ const code = err?.body?.code ?? null;
506
+ return !code || code === "unknown_presentation" || code === "presentation_not_found";
507
+ }
508
+
509
+ // Run a pane-relayed engine op; on `pane_suspended` (the server already waited
510
+ // ~25s for a reconnect) OR `pane_timeout` (the pane looked connected but never
511
+ // acked within the server's 60s window — the server marks it suspended on the
512
+ // way out, so the same wake applies) wait CLIENT-side for the pane to wake —
513
+ // one actionable line, then a single retry against the live entry (covers
514
+ // presId churn too). Engine ops are idempotent reads, and the one mutation
515
+ // (live insert) rides the SAME operationId through the retry so the pane-side
516
+ // dedup makes a re-send safe.
517
+ //
518
+ // `fileName`/`brandId` identify the WINDOW so the wait can re-match it after a
519
+ // presId churn (see waitForPaneReady). `retargetOnMissing` additionally treats a
520
+ // vanished presId as recoverable — opt-in, because for most engine ops a 404 is
521
+ // the search record talking, not the window: it re-lists the registry, re-matches
522
+ // by presFP and retries ONCE on a short budget before the caller surfaces the
523
+ // wake-PowerPoint blocker.
524
+ export async function engineOpWithPaneWait(
525
+ presId,
526
+ backendURL,
527
+ attempt,
528
+ {
529
+ fileName = null,
530
+ brandId = null,
531
+ otherPresIds = null,
532
+ retargetOnMissing = false,
533
+ } = {},
534
+ ) {
535
+ try {
536
+ return await attempt(presId, backendURL);
537
+ } catch (err) {
538
+ const suspended =
539
+ err?.code === "pane_suspended" || err?.code === "pane_timeout";
540
+ const churned = retargetOnMissing && isMissingPresentationError(err);
541
+ if (!suspended && !churned) throw err;
542
+ const live = await waitForPaneReady(presId, {
543
+ backendURL,
544
+ fileName,
545
+ brandId,
546
+ otherPresIds,
547
+ // A churned presId needs a re-list, not a user: the window is already
548
+ // there under a new id, so poll briefly instead of holding the full
549
+ // click-the-pane budget. A suspended pane keeps that budget.
550
+ timeoutMs: churned && !suspended ? envInt("SLIDEMIND_BRIDGE_PANE_RETARGET_TIMEOUT_MS", 15000) : null,
551
+ onWait: churned && !suspended ? paneRetargetNotice : paneWaitNotice,
552
+ });
553
+ if (!live) throw err;
554
+ return attempt(live.presId, live.backendURL ?? backendURL);
555
+ }
556
+ }
557
+
558
+ // Read the CURRENT content of an open presentation (the "pull content" path
559
+ // behind `slidemind read`). `mode` selects the
560
+ // depth: "summary" returns a per-slide outline (index, layout, title, subtitle);
561
+ // "raw" (default) returns each slide's full plain text — NOT the add-in's editing
562
+ // markup. Unlike engine ops, content is WINDOW-BOUND: it reads THAT presentation,
563
+ // so we resolve the origin backend from the presId and relay through that
564
+ // window's pane. It's an idempotent read, so engineOpWithPaneWait's single
565
+ // pane_suspended client-wait + retry against the live entry is safe (like viewSlide).
566
+ export async function getPresentationContent({
567
+ presId,
568
+ mode = "raw",
569
+ backendURL = null,
570
+ }) {
571
+ if (!presId) throw new Error("getPresentationContent: presId is required");
572
+ const be = backendURL || (await resolveBackendForPres(presId));
573
+ const body = { mode: mode === "summary" ? "summary" : "raw" };
574
+ return engineOpWithPaneWait(presId, be, async (pid, b) => {
575
+ const res = await bridgeFetch(
576
+ `/api/cli/presentations/${encodeURIComponent(pid)}/content`,
577
+ {
578
+ method: "POST",
579
+ headers: { "content-type": "application/json" },
580
+ body: JSON.stringify(body),
581
+ timeoutMs: engineTimeoutMs(),
582
+ },
583
+ b,
584
+ );
585
+ return res.json(); // { ok, mode, slideCount, slides }
586
+ });
587
+ }