slidemind 0.1.0 → 0.1.2

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.
@@ -5,67 +5,111 @@
5
5
  // backend runs render/bundle in-process and relays insert/replace/engine to the
6
6
  // open PowerPoint pane. No localhost, no shared loopback token, no cert.
7
7
 
8
- import fs from "node:fs";
9
8
  import http from "node:http";
10
- import os from "node:os";
11
- import path from "node:path";
12
9
  import https from "node:https";
13
10
 
14
- import { readSessionConfig } from "./config.mjs";
11
+ import {
12
+ deriveEnvLabel,
13
+ getSessionFor,
14
+ readSessions,
15
+ } from "./config.mjs";
15
16
 
16
17
  function isLoopbackHostname(hostname) {
17
18
  return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
18
19
  }
19
20
 
20
- export function bridgeStateDir() {
21
- if (process.platform === "win32") {
22
- const base = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
23
- return path.join(base, "slidemind");
24
- }
25
- const base = process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state");
26
- return path.join(base, "slidemind");
21
+ // The fan-out probe (listing presentations across every paired backend) uses a
22
+ // shorter timeout than ordinary calls so one dead/slow backend — a local relay
23
+ // that isn't running — can't stall the whole sweep for the full 35s default.
24
+ function listTimeoutMs() {
25
+ return (
26
+ parseInt(process.env.SLIDEMIND_BRIDGE_LIST_TIMEOUT_MS || "8000", 10) || 8000
27
+ );
27
28
  }
28
29
 
29
- export function bridgeTokenPath() {
30
- return path.join(bridgeStateDir(), "bridge-token");
30
+ const NOT_PAIRED_MESSAGE =
31
+ 'slidemind is not paired. In the slidemind add-in, click "Connect CLI" ' +
32
+ "and run the shown `slidemind pair …` command, then retry.";
33
+
34
+ // One transient blip — a pane mid-reconnect after a backend deploy, a stale
35
+ // registration the pane is about to refresh — should not fail a whole skill
36
+ // step. Idempotent reads retry a couple of times; mutations never do (the
37
+ // request may already have been delivered).
38
+ function transientRetryDelays() {
39
+ const raw = process.env.SLIDEMIND_BRIDGE_RETRY_DELAYS_MS || "1500,4000";
40
+ return raw
41
+ .split(",")
42
+ .map((s) => parseInt(s.trim(), 10))
43
+ .filter((n) => Number.isFinite(n) && n >= 0);
31
44
  }
32
45
 
33
- export function readBridgeToken() {
34
- const file = bridgeTokenPath();
35
- let raw;
36
- try {
37
- raw = fs.readFileSync(file, "utf8");
38
- } catch (err) {
39
- if (err.code === "ENOENT") {
46
+ function isTransientBridgeError(err) {
47
+ if (!err) return false;
48
+ // pane_suspended needs the user (foreground PowerPoint), not a blind retry —
49
+ // the relay already waited for a reconnect before returning it.
50
+ if (err.code === "pane_suspended") return false;
51
+ if (err.status === 404 || err.status === 502 || err.status === 503) return true;
52
+ if (err.status === 409 && err.code === "pane_disconnected") return true;
53
+ return /relay unreachable/i.test(String(err.message || ""));
54
+ }
55
+
56
+ async function withTransientRetry(fn) {
57
+ const delays = transientRetryDelays();
58
+ for (let attempt = 0; ; attempt += 1) {
59
+ try {
60
+ return await fn();
61
+ } catch (err) {
62
+ if (attempt >= delays.length || !isTransientBridgeError(err)) throw err;
63
+ await new Promise((resolve) => setTimeout(resolve, delays[attempt]));
64
+ }
65
+ }
66
+ }
67
+
68
+ // The paired backends in scope for this invocation. Defaults to every paired
69
+ // session (fan-out); `SLIDEMIND_BACKEND` narrows it to one (the CLI sets that
70
+ // env from a `--backend <url|env>` flag) for speed or to disambiguate. Throws
71
+ // the same actionable message as a missing pairing when nothing matches.
72
+ function sessionsInScope() {
73
+ const all = readSessions();
74
+ if (all.length === 0) throw new Error(NOT_PAIRED_MESSAGE);
75
+ const scope = process.env.SLIDEMIND_BACKEND;
76
+ if (scope && scope.trim()) {
77
+ const want = scope.trim();
78
+ const match = all.filter(
79
+ (s) =>
80
+ s.backendURL === want ||
81
+ s.label === want ||
82
+ deriveEnvLabel(s.backendURL) === want ||
83
+ s.backendURL.includes(want),
84
+ );
85
+ if (match.length === 0) {
40
86
  throw new Error(
41
- `slidemind bridge token not found at ${file}. ` +
42
- `Start the slidemind PowerPoint add-in first — it generates the token on first run.`,
87
+ `slidemind is not paired with '${want}'. Run 'slidemind whoami' to see paired environments.`,
43
88
  );
44
89
  }
45
- throw err;
46
- }
47
- const token = raw.trim();
48
- if (!token) {
49
- throw new Error(`slidemind bridge token at ${file} is empty.`);
90
+ return match;
50
91
  }
51
- return token;
92
+ return all;
52
93
  }
53
94
 
54
- // The CLI talks to the cloud relay endpoint stored by `slidemind pair`. The
55
- // session token is the credential — no localhost, no shared loopback token.
56
- function requireSession() {
57
- const session = readSessionConfig();
58
- if (!session) {
59
- throw new Error(
60
- 'slidemind is not paired. In the slidemind add-in, click "Connect CLI" ' +
61
- "and run the shown `slidemind pair …` command, then retry.",
62
- );
95
+ // Resolve the credential for one backend (or the default session when no
96
+ // backendURL is given — the back-compat single-session path).
97
+ function sessionForBackend(backendURL) {
98
+ if (backendURL) {
99
+ const s = getSessionFor(backendURL);
100
+ if (!s)
101
+ throw new Error(
102
+ `slidemind is not paired with '${deriveEnvLabel(backendURL)}'.`,
103
+ );
104
+ return s;
63
105
  }
64
- return session;
106
+ const all = readSessions();
107
+ if (all.length === 0) throw new Error(NOT_PAIRED_MESSAGE);
108
+ return all[0];
65
109
  }
66
110
 
67
- async function bridgeFetch(pathPart, init = {}) {
68
- const session = requireSession();
111
+ async function bridgeFetch(pathPart, init = {}, backendURL = null) {
112
+ const session = sessionForBackend(backendURL);
69
113
  const url = `${session.backendURL}${pathPart}`;
70
114
  const headers = {
71
115
  accept: "application/json",
@@ -79,7 +123,7 @@ async function bridgeFetch(pathPart, init = {}) {
79
123
  } catch (err) {
80
124
  if (err && /ECONNREFUSED|ECONNRESET|ENOTFOUND|self-signed|fetch failed/i.test(String(err.message || err))) {
81
125
  throw new Error(
82
- `slidemind relay unreachable at ${url}. Check your connection; ` +
126
+ `slidemind relay unreachable (${deriveEnvLabel(session.backendURL)}). Check your connection; ` +
83
127
  "if it persists, re-pair with `slidemind pair`.",
84
128
  );
85
129
  }
@@ -158,10 +202,11 @@ function nodeRequest(urlString, init = {}) {
158
202
  });
159
203
  },
160
204
  );
161
- // Default 35s > the add-in's 30s emitWithAck, so a suspended pane surfaces a
162
- // typed `pane_suspended` rather than a client timeout. A real timeout is
163
- // tagged `bridge_timeout` so non-idempotent callers (insert) know the request
164
- // may already have been delivered and must NOT auto-retry it.
205
+ // Default 35s > the relay's 25s wait-for-pane-reconnect, so a suspended
206
+ // pane surfaces a typed `pane_suspended` rather than a client timeout. A
207
+ // real timeout is tagged `bridge_timeout` so non-idempotent callers
208
+ // (insert) know the request may already have been delivered and must NOT
209
+ // auto-retry it.
165
210
  req.setTimeout(timeoutMs, () => {
166
211
  const err = new Error(
167
212
  `slidemind bridge request timed out after ${timeoutMs}ms`,
@@ -175,42 +220,159 @@ function nodeRequest(urlString, init = {}) {
175
220
  });
176
221
  }
177
222
 
223
+ // List one backend's open presentations, tagging each entry with its origin
224
+ // (backendURL + a short env label) so the merged registry can be routed and
225
+ // displayed per environment. Transient blips retry per-backend.
226
+ async function listPresentationsForBackend(backendURL) {
227
+ return withTransientRetry(async () => {
228
+ const res = await bridgeFetch(
229
+ "/api/cli/presentations",
230
+ { timeoutMs: listTimeoutMs() },
231
+ backendURL,
232
+ );
233
+ const data = await res.json();
234
+ const arr = Array.isArray(data?.presentations) ? data.presentations : [];
235
+ const env = deriveEnvLabel(backendURL);
236
+ return arr.map((p) => ({ ...p, backendURL, env }));
237
+ });
238
+ }
239
+
240
+ // Fan out across every backend in scope, concurrently. Never rejects: each
241
+ // backend's outcome is partitioned into merged presentations vs. a failure
242
+ // record, so one unreachable environment can't hide the others.
243
+ async function fanOutPresentations() {
244
+ const sessions = sessionsInScope();
245
+ const settled = await Promise.allSettled(
246
+ sessions.map((s) => listPresentationsForBackend(s.backendURL)),
247
+ );
248
+ const presentations = [];
249
+ const failures = [];
250
+ let fulfilledCount = 0;
251
+ settled.forEach((r, i) => {
252
+ const backendURL = sessions[i].backendURL;
253
+ if (r.status === "fulfilled") {
254
+ fulfilledCount += 1;
255
+ presentations.push(...r.value);
256
+ } else {
257
+ failures.push({
258
+ backendURL,
259
+ env: deriveEnvLabel(backendURL),
260
+ err: r.reason,
261
+ });
262
+ }
263
+ });
264
+ return { presentations, failures, fulfilledCount };
265
+ }
266
+
267
+ // Merged registry across all paired backends. Stays a flat array (every caller
268
+ // expects one). Preserves single-backend behaviour: when nothing is reachable
269
+ // AND no backend answered, the underlying bridge error propagates (so a typed
270
+ // code like `pane_suspended` or a 401 still surfaces) — for several failing
271
+ // backends it's an aggregate message.
178
272
  export async function listOpenPresentations() {
179
- const res = await bridgeFetch("/api/cli/presentations");
180
- const data = await res.json();
181
- return Array.isArray(data?.presentations) ? data.presentations : [];
273
+ const { presentations, failures, fulfilledCount } =
274
+ await fanOutPresentations();
275
+ if (presentations.length === 0 && fulfilledCount === 0 && failures.length > 0) {
276
+ if (failures.length === 1) throw failures[0].err;
277
+ const err = new Error(
278
+ "slidemind: every paired backend failed to list presentations:\n" +
279
+ failures
280
+ .map((f) => ` ${f.env}: ${f.err.message}`)
281
+ .join("\n"),
282
+ );
283
+ throw err;
284
+ }
285
+ return presentations;
182
286
  }
183
287
 
184
- // Probe the bridge for an open PowerPoint host. Returns the live registry, or
185
- // throws a single actionable message — the shared precondition for every
186
- // author/render command now that the design truth lives in the add-in.
288
+ // The user/agent-facing shape of a presentation entry. `backendURL` is internal
289
+ // routing state — every caller targets a presentation by `presId` (the backend
290
+ // is re-resolved server-side from it), so the URL must never leave the process
291
+ // in any printed line or MCP payload. Keeps presId, title, fileName, connected,
292
+ // suspendedAt, and the short `env` label (the only discriminator a user sees).
293
+ export function toPublicPresentation(p) {
294
+ const { backendURL, ...rest } = p;
295
+ return rest;
296
+ }
297
+
298
+ // Like listOpenPresentations but never throws on per-backend failures — returns
299
+ // the merged registry plus which backends were unreachable, for callers that
300
+ // want to report partial results (requireHost, whoami).
301
+ export async function listOpenPresentationsDetailed() {
302
+ const { presentations, failures } = await fanOutPresentations();
303
+ return {
304
+ presentations,
305
+ unreachable: failures.map((f) => ({
306
+ backendURL: f.backendURL,
307
+ env: f.env,
308
+ error: f.err?.message ? f.err.message : String(f.err),
309
+ })),
310
+ };
311
+ }
312
+
313
+ // Probe the bridge for an open PowerPoint host across every paired backend.
314
+ // Returns the merged registry, or throws a single actionable message — the
315
+ // shared precondition for every author/render command now that the design truth
316
+ // lives in the add-in. Suspended entries (task-pane socket dropped, e.g.
317
+ // PowerPoint backgrounded) count as registered: render + bundle work without
318
+ // the pane, and a push waits server-side for the pane to reconnect.
187
319
  export async function requireHost() {
188
- let registry;
189
- try {
190
- registry = await listOpenPresentations();
191
- } catch (err) {
320
+ const { presentations, unreachable } = await listOpenPresentationsDetailed();
321
+ if (Array.isArray(presentations) && presentations.length > 0) {
322
+ return presentations;
323
+ }
324
+ // Nothing registered. Distinguish "a backend was unreachable" from "backends
325
+ // answered but no window is open" so the user gets the right next step.
326
+ if (unreachable.length > 0) {
192
327
  throw new Error(
193
328
  "Open PowerPoint with the slidemind add-in and an active corporate design, then retry.\n" +
194
- ` (bridge: ${err.message})`,
329
+ unreachable
330
+ .map((u) => ` (bridge: ${u.env}: ${u.error})`)
331
+ .join("\n"),
195
332
  );
196
333
  }
197
- if (!Array.isArray(registry) || registry.length === 0) {
334
+ throw new Error(
335
+ "No PowerPoint window is registered with the slidemind add-in.\n" +
336
+ "If PowerPoint is already open, bring it to the foreground and click the slidemind pane once so it re-registers, then retry.\n" +
337
+ "Otherwise open PowerPoint with the slidemind add-in and an active corporate design, then retry.",
338
+ );
339
+ }
340
+
341
+ // Resolve which backend a presId lives on via a LIVE fan-out list (never a
342
+ // cached map — a presId can move backends on re-pair/redeploy). Throws an
343
+ // actionable message on a miss, or an ambiguity error when the same presId is
344
+ // open on more than one reachable backend.
345
+ export async function resolveBackendForPres(presId) {
346
+ if (!presId) throw new Error("resolveBackendForPres: presId is required");
347
+ const { presentations } = await fanOutPresentations();
348
+ const matches = presentations.filter((p) => p.presId === presId);
349
+ if (matches.length === 1) return matches[0].backendURL;
350
+ if (matches.length > 1) {
351
+ const envs = matches.map((m) => m.env).join(", ");
198
352
  throw new Error(
199
- "No PowerPoint window is registered with the slidemind add-in.\n" +
200
- "Open PowerPoint with the slidemind add-in and an active corporate design, then retry.",
353
+ `Presentation '${presId}' is open on multiple environments: ${envs}. ` +
354
+ "Disambiguate with --backend <env|label> (or SLIDEMIND_BACKEND).",
201
355
  );
202
356
  }
203
- return registry;
357
+ throw new Error(
358
+ `No open PowerPoint window registered with presId='${presId}' on any paired backend. ` +
359
+ "Run 'slidemind presentations' to see what's open.",
360
+ );
204
361
  }
205
362
 
206
363
  // GET the full design bundle (tokens, css, layouts, designRules, signed asset
207
364
  // URLs, engines) for the presentation's active corporate design.
208
- export async function getDesignBundle(presId) {
365
+ export async function getDesignBundle(presId, { backendURL = null } = {}) {
209
366
  if (!presId) throw new Error("getDesignBundle: presId is required");
210
- const res = await bridgeFetch(
211
- `/api/cli/presentations/${encodeURIComponent(presId)}/design-bundle`,
212
- );
213
- return res.json();
367
+ const be = backendURL || (await resolveBackendForPres(presId));
368
+ return withTransientRetry(async () => {
369
+ const res = await bridgeFetch(
370
+ `/api/cli/presentations/${encodeURIComponent(presId)}/design-bundle`,
371
+ {},
372
+ be,
373
+ );
374
+ return res.json();
375
+ });
214
376
  }
215
377
 
216
378
  // Server-side OOXML render: ship authored slide JSX (+ deck-local assets) and
@@ -221,11 +383,13 @@ export async function renderPptx({
221
383
  slides,
222
384
  deckAssets = null,
223
385
  etag = null,
386
+ backendURL = null,
224
387
  }) {
225
388
  if (!presId) throw new Error("renderPptx: presId is required");
226
389
  if (!slides || Object.keys(slides).length === 0) {
227
390
  throw new Error("renderPptx: slides is required");
228
391
  }
392
+ const be = backendURL || (await resolveBackendForPres(presId));
229
393
  const body = { slides };
230
394
  if (deckAssets && Object.keys(deckAssets).length > 0) {
231
395
  body.deckAssets = deckAssets;
@@ -237,7 +401,14 @@ export async function renderPptx({
237
401
  method: "POST",
238
402
  headers: { "content-type": "application/json" },
239
403
  body: JSON.stringify(body),
404
+ // Server-side OOXML render is a heavy operation — a full deck of richly
405
+ // composed slides routinely runs well past the 35s default. Treat it like
406
+ // a mutation: give it a generous default and let env override it.
407
+ timeoutMs:
408
+ parseInt(process.env.SLIDEMIND_BRIDGE_RENDER_TIMEOUT_MS || "300000", 10) ||
409
+ 300000,
240
410
  },
411
+ be,
241
412
  );
242
413
  return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
243
414
  }
@@ -245,17 +416,25 @@ export async function renderPptx({
245
416
  // Brand-engine search through the local bridge. New add-ins run this
246
417
  // server-side with the relayed user token; older add-ins may fall back to a
247
418
  // task-pane proxy. Returns a searchId + numbered result thumbnails.
248
- export async function engineSearch({ presId, engineId, query }) {
419
+ export async function engineSearch({ presId, engineId, query, backendURL = null }) {
249
420
  if (!presId) throw new Error("engineSearch: presId is required");
250
421
  if (!engineId) throw new Error("engineSearch: engineId is required");
251
422
  if (!query) throw new Error("engineSearch: query is required");
423
+ const be = backendURL || (await resolveBackendForPres(presId));
252
424
  const res = await bridgeFetch(
253
425
  `/api/cli/presentations/${encodeURIComponent(presId)}/engine/search`,
254
426
  {
255
427
  method: "POST",
256
428
  headers: { "content-type": "application/json" },
257
429
  body: JSON.stringify({ engineId, query }),
430
+ // Engine ops relay through the pane: the server may wait up to ~25s for
431
+ // a suspended pane to reconnect, then up to 60s for the pane's ack —
432
+ // stay above that sum so typed errors win over client timeouts.
433
+ timeoutMs:
434
+ parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
435
+ 90000,
258
436
  },
437
+ be,
259
438
  );
260
439
  return res.json(); // { ok, searchId, resultCount, gridDataUri, thumbnails }
261
440
  }
@@ -268,6 +447,7 @@ export async function engineDownload({
268
447
  searchId,
269
448
  selection,
270
449
  iconColor = null,
450
+ backendURL = null,
271
451
  }) {
272
452
  if (!presId) throw new Error("engineDownload: presId is required");
273
453
  if (typeof searchId !== "number") {
@@ -276,6 +456,7 @@ export async function engineDownload({
276
456
  if (typeof selection !== "number") {
277
457
  throw new Error("engineDownload: selection (number) is required");
278
458
  }
459
+ const be = backendURL || (await resolveBackendForPres(presId));
279
460
  const body = { searchId, selection };
280
461
  if (iconColor) body.iconColor = iconColor;
281
462
  const res = await bridgeFetch(
@@ -284,7 +465,13 @@ export async function engineDownload({
284
465
  method: "POST",
285
466
  headers: { "content-type": "application/json" },
286
467
  body: JSON.stringify(body),
468
+ // Same relay path as engine/search — outlast the server's reconnect
469
+ // wait + pane ack timeout.
470
+ timeoutMs:
471
+ parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
472
+ 90000,
287
473
  },
474
+ be,
288
475
  );
289
476
  return res.json(); // { ok, base64, mimeType, svgMarkup }
290
477
  }
@@ -295,9 +482,11 @@ export async function insertSlides({
295
482
  insertAt = null,
296
483
  sourceSlideIds = null,
297
484
  operationId = null,
485
+ backendURL = null,
298
486
  }) {
299
487
  if (!presId) throw new Error("insertSlides: presId is required");
300
488
  if (!base64) throw new Error("insertSlides: base64 is required");
489
+ const be = backendURL || (await resolveBackendForPres(presId));
301
490
  const body = { base64, insertAt, sourceSlideIds, operationId };
302
491
  const res = await bridgeFetch(
303
492
  `/api/cli/presentations/${encodeURIComponent(presId)}/insert`,
@@ -309,13 +498,20 @@ export async function insertSlides({
309
498
  parseInt(process.env.SLIDEMIND_BRIDGE_MUTATION_TIMEOUT_MS || "1805000", 10) ||
310
499
  1805000,
311
500
  },
501
+ be,
312
502
  );
313
503
  return res.json().catch(() => ({}));
314
504
  }
315
505
 
316
- export async function replaceSlides({ presId, base64, operationId = null }) {
506
+ export async function replaceSlides({
507
+ presId,
508
+ base64,
509
+ operationId = null,
510
+ backendURL = null,
511
+ }) {
317
512
  if (!presId) throw new Error("replaceSlides: presId is required");
318
513
  if (!base64) throw new Error("replaceSlides: base64 is required");
514
+ const be = backendURL || (await resolveBackendForPres(presId));
319
515
  const res = await bridgeFetch(
320
516
  `/api/cli/presentations/${encodeURIComponent(presId)}/replace`,
321
517
  {
@@ -326,23 +522,33 @@ export async function replaceSlides({ presId, base64, operationId = null }) {
326
522
  parseInt(process.env.SLIDEMIND_BRIDGE_MUTATION_TIMEOUT_MS || "1805000", 10) ||
327
523
  1805000,
328
524
  },
525
+ be,
329
526
  );
330
527
  return res.json().catch(() => ({}));
331
528
  }
332
529
 
333
- // Pick a target presentation given the live registry and an optional explicit
334
- // presId. Returns the presId to push to, or throws with a helpful message if
335
- // the choice is ambiguous.
336
- export function pickPresentation(registry, explicitPresId) {
530
+ // Pick a target presentation given the (possibly multi-backend) registry and an
531
+ // optional explicit presId. Returns the matched registry ENTRY (which carries
532
+ // `backendURL`/`env` after a fan-out list), or throws with a helpful message if
533
+ // the choice is ambiguous. Callers that already hold the entry can pass its
534
+ // `backendURL` straight to the targeted ops, skipping a second fan-out.
535
+ export function pickPresentationEntry(registry, explicitPresId) {
337
536
  if (explicitPresId) {
338
- const match = registry.find((p) => p.presId === explicitPresId);
339
- if (!match) {
537
+ const matches = registry.filter((p) => p.presId === explicitPresId);
538
+ if (matches.length === 0) {
340
539
  const ids = registry.map((p) => p.presId).join(", ") || "(none registered)";
341
540
  throw new Error(
342
541
  `No open PowerPoint window registered with presId='${explicitPresId}'. Live: ${ids}`,
343
542
  );
344
543
  }
345
- return match.presId;
544
+ if (matches.length > 1) {
545
+ const envs = matches.map((m) => m.env || "?").join(", ");
546
+ throw new Error(
547
+ `Presentation '${explicitPresId}' is open on multiple environments: ${envs}. ` +
548
+ "Disambiguate with --backend <env|label> (or SLIDEMIND_BACKEND).",
549
+ );
550
+ }
551
+ return matches[0];
346
552
  }
347
553
  if (registry.length === 0) {
348
554
  throw new Error(
@@ -350,11 +556,21 @@ export function pickPresentation(registry, explicitPresId) {
350
556
  "Open a presentation, ensure the add-in is loaded, and try again.",
351
557
  );
352
558
  }
353
- if (registry.length === 1) return registry[0].presId;
559
+ if (registry.length === 1) return registry[0];
354
560
  const list = registry
355
- .map((p) => ` ${p.presId}\t${p.title || p.fileName || "(untitled)"}`)
561
+ .map(
562
+ (p) =>
563
+ ` ${p.presId}\t${p.env ? `[${p.env}]\t` : ""}${p.title || p.fileName || "(untitled)"}` +
564
+ (p.connected === false || p.suspendedAt ? "\t(suspended — PowerPoint in background)" : ""),
565
+ )
356
566
  .join("\n");
357
567
  throw new Error(
358
568
  `Multiple PowerPoint windows are open. Pick one with --to <presId>:\n${list}`,
359
569
  );
360
570
  }
571
+
572
+ // Back-compat shim: returns just the presId string. Prefer
573
+ // pickPresentationEntry when you need the origin backend.
574
+ export function pickPresentation(registry, explicitPresId) {
575
+ return pickPresentationEntry(registry, explicitPresId).presId;
576
+ }