slidemind 0.3.13 → 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 +19 -0
  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 +11 -804
  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,805 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { envTag } from "../bridge/config.mjs";
5
+ import { fail, resolveDeckArg, loadBridgeModule, SRC_DIR } from "./common.mjs";
6
+ import { publicConnectionStatus, backendBlocker, connectionBlocker } from "./connection.mjs";
7
+ import { readDesignName, cachedDesigns, designRows, designsHumanLines, windowsHumanLine } from "./designs.mjs";
8
+ import { runDiagnose } from "./diagnose.mjs";
9
+ import { deckStage, stageHumanLines } from "./status.mjs";
10
+
11
+ // The one-command agent preflight. Collapses the old 4-call session start
12
+ // (list windows → pick → download-bundle → download-bundle-to-bind) into a
13
+ // single verb: check pairing/reachability/CLI-version/windows, resolve the OPEN
14
+ // design (the add-in is the source of truth), materialize its bundle, bind the
15
+ // deck, and print exactly one actionable blocker when it can't. Exit 0 iff
16
+ // ready; every blocker → exit 1 with a machine-copeable code. Never prints a
17
+ // backendURL (env label only).
18
+ export async function cmdReady(rest) {
19
+ // --- Local arg parse: unknown flags fail BEFORE any network (cli-dispatch
20
+ // convention). ---
21
+ let to = null;
22
+ let asJson = false;
23
+ let force = false;
24
+ let connectOnly = false;
25
+ let diagnose = false;
26
+ let brand = null;
27
+ const positional = [];
28
+ for (let i = 0; i < rest.length; i++) {
29
+ const a = rest[i];
30
+ if (a === "--to") {
31
+ to = rest[++i];
32
+ if (!to) fail("--to expects a presId");
33
+ } else if (a === "--json") {
34
+ asJson = true;
35
+ } else if (a === "--force") {
36
+ force = true;
37
+ } else if (a === "--connect-only") {
38
+ connectOnly = true;
39
+ } else if (a === "--diagnose") {
40
+ diagnose = true;
41
+ } else if (a === "--brand") {
42
+ brand = rest[++i];
43
+ if (!brand) fail("--brand expects a design id (presFP)");
44
+ } else if (a.startsWith("-")) {
45
+ fail(`unknown flag: ${a}`);
46
+ } else {
47
+ positional.push(a);
48
+ }
49
+ }
50
+ if (brand && to) {
51
+ fail("--brand and --to are mutually exclusive (a design id or a window, not both)");
52
+ }
53
+
54
+ // --diagnose: the read-only report — both ladders, every paired backend, the
55
+ // Microsoft 365 state and every registered window with its pane state. It
56
+ // resolves no design, materializes nothing and binds nothing, which is why it
57
+ // is a mode of `ready` rather than the separate `doctor` + `presentations`
58
+ // commands it replaced.
59
+ if (diagnose) {
60
+ await runDiagnose(rest);
61
+ return;
62
+ }
63
+
64
+ // --- Local: resolve the deck dir (never create it) and read its existing
65
+ // bundle ref. deckPresFP feeds ONLY the mirror tie-break + `suggested`; it
66
+ // never auto-picks a design. ---
67
+ const deckArg = positional[0] ?? null;
68
+ const bundle = await import(
69
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
70
+ );
71
+ let deckDir = null;
72
+ let deckSlug = null;
73
+ let deckPresFP = null;
74
+ if (deckArg) {
75
+ deckDir = resolveDeckArg(deckArg);
76
+ if (!fs.existsSync(deckDir)) {
77
+ fail(
78
+ `deck '${deckArg}' not found — run \`slidemind ready\` (no deck) to set up, then \`slidemind new ${deckArg}\``,
79
+ );
80
+ }
81
+ deckSlug = path.basename(deckDir);
82
+ deckPresFP = bundle.readDeckBundleRef(deckDir)?.presFP ?? null;
83
+ }
84
+
85
+ // Emit a structured blocker (JSON to stdout / human to stderr) and exit 1.
86
+ // `humanLines` overrides the default `slidemind: <message>` for the richer
87
+ // ambiguous / unknown-window shapes. Mirrors the diagnostic's stream conventions:
88
+ // machine JSON on stdout, human prose on stderr.
89
+ const finishBlocker = ({ blocker, extras = {}, humanLines = null }) => {
90
+ if (asJson) {
91
+ process.stdout.write(
92
+ JSON.stringify(
93
+ { ready: false, blocker, nextAction: blocker.message, ...extras },
94
+ null,
95
+ 2,
96
+ ) + "\n",
97
+ );
98
+ process.exit(1);
99
+ }
100
+ if (humanLines) {
101
+ process.stderr.write(
102
+ humanLines.endsWith("\n") ? humanLines : `${humanLines}\n`,
103
+ );
104
+ process.exit(1);
105
+ }
106
+ fail(blocker.message);
107
+ };
108
+
109
+ // --- First use: there is nothing this process can do on its own. Connecting
110
+ // starts in the add-in because the claim it mints carries the deployment's
111
+ // address, and an unconnected CLI has no way to learn which backend its user
112
+ // belongs to. So name the one action that works and stop. ---
113
+ const cfg = await loadBridgeModule("config");
114
+ if (cfg.readSessions().length === 0) {
115
+ finishBlocker({
116
+ blocker: {
117
+ code: "not_connected",
118
+ actor: "user",
119
+ message:
120
+ "Connect this CLI first: in PowerPoint open slidemind → Settings → General → Connect CLI, press the link button, copy the command it shows, and run it here.",
121
+ },
122
+ });
123
+ return;
124
+ }
125
+
126
+ const client = await import(
127
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
128
+ );
129
+ // Same opt-out as `--diagnose`: `ready` OWNS the cli_update_required blocker (it is
130
+ // terminal, `actor: "agent"`, with the update command as its nextAction), so
131
+ // the raw header throw would only replace a structured answer with a worse one.
132
+ client.setDiagnosticMode(true);
133
+
134
+ // --- Network (one parallel round): readiness ladder + the merged registry. ---
135
+ const [statusRaw, detailed] = await Promise.all([
136
+ client.connectionStatus(),
137
+ client.listOpenPresentationsDetailed(),
138
+ ]);
139
+ const pub = publicConnectionStatus(statusRaw, client);
140
+ let registry = detailed.presentations;
141
+
142
+ // Terminal blockers: waiting can't fix an unreachable backend or a stale CLI.
143
+ const terminal = connectionBlocker(pub);
144
+ if (
145
+ terminal &&
146
+ (terminal.code === "all_unreachable" || terminal.code === "cli_update_required")
147
+ ) {
148
+ finishBlocker({ blocker: terminal });
149
+ return;
150
+ }
151
+
152
+ // --- Backend authoring readiness beyond the connection itself: which
153
+ // environments serve the pane-free engine routes. Probed at most once (and
154
+ // only when a payload actually reports it), and never fatal — an add-in
155
+ // without them still authors, it just relays search through a window. ---
156
+ let engineInfo = null;
157
+ const engines = async () => {
158
+ if (!engineInfo) {
159
+ try {
160
+ engineInfo = await client.engineReachability({
161
+ backends: statusRaw.backends,
162
+ });
163
+ } catch {
164
+ engineInfo = { available: false, envs: [] };
165
+ }
166
+ }
167
+ return engineInfo;
168
+ };
169
+
170
+ // Microsoft 365 sign-in, surfaced HERE because ready is the agent's
171
+ // session-start preflight and browser SSO is the one step an agent cannot do
172
+ // itself — it has to ask its human, and this is where it learns to. Reported
173
+ // only when a backend advertises graphDelegated (older backends have no
174
+ // window-free SharePoint path, so there is nothing to sign in FOR).
175
+ // On a cliLoginV1 backend the sign-in is REQUIRED for the whole pane-free
176
+ // engine surface (catalog, search, downloads, extraction answer 401
177
+ // login_required without it) — so the degradation says that plainly and up
178
+ // front, before the agent's first search meets the 401. Render and bundle
179
+ // still never need it. null = no backend advertises it.
180
+ let m365Info;
181
+ const m365 = async () => {
182
+ if (m365Info !== undefined) return m365Info;
183
+ const advertising = (statusRaw.backends ?? []).filter(
184
+ (b) => b.ok && b.capabilities?.graphDelegated === true && b.backendURL,
185
+ );
186
+ if (advertising.length === 0) {
187
+ m365Info = null;
188
+ return m365Info;
189
+ }
190
+ const graph = await loadBridgeModule("graph-auth");
191
+ const signedOut = advertising.filter(
192
+ (b) => !graph.hasGraphSession(b.backendURL),
193
+ );
194
+ const signedOutEnvs = signedOut.map((b) => cfg.deriveEnvLabel(b.backendURL));
195
+ m365Info =
196
+ signedOut.length === 0
197
+ ? { signedIn: true, degradation: null, line: "Microsoft 365: signed in" }
198
+ : {
199
+ signedIn: false,
200
+ degradation: {
201
+ code: "login_required",
202
+ actor: "user",
203
+ envs: signedOutEnvs,
204
+ message:
205
+ "Microsoft 365: not signed in — engine search, downloads and slide " +
206
+ "extraction run as the USER. The first engine command opens a browser " +
207
+ "sign-in AUTOMATICALLY and waits; tell the user to complete it there " +
208
+ "(you cannot). Render, build and bundle work without it.",
209
+ },
210
+ line:
211
+ `Microsoft 365: not signed in${envTag(signedOutEnvs.join(", "))} — the next engine ` +
212
+ "command opens a browser sign-in for the user to complete (automatic; " +
213
+ "`slidemind login` also works any time).",
214
+ };
215
+ return m365Info;
216
+ };
217
+
218
+ const isRR = client.isRenderReadyPresentation;
219
+
220
+ // The render-ready windows as the payloads report them — after the zombie
221
+ // prune below, so a listed window is one PowerPoint actually has. One row
222
+ // per window: a presId mirrored across backends is still one window.
223
+ const publicWindows = () => {
224
+ const seen = new Set();
225
+ return registry.filter(isRR).filter((p) => !seen.has(p.presId) && seen.add(p.presId)).map((p) => {
226
+ const q = client.toPublicPresentation(p);
227
+ return {
228
+ presId: q.presId,
229
+ env: q.env ?? null,
230
+ title: q.title ?? null,
231
+ fileName: q.fileName ?? null,
232
+ presFP: p.brandId ?? null,
233
+ paneReady: client.isPaneReadyPresentation(p),
234
+ };
235
+ });
236
+ };
237
+
238
+ // The design menu: every design at hand — cached here, open in a verified
239
+ // window, or (when asked) listed by the backend — one row per presFP with
240
+ // name, theme colours and the windows on it. Success payloads and the
241
+ // ambiguous blocker report the same shape, so the agent always has the
242
+ // menu to put to the user.
243
+ const designMenu = async ({ current = null, withBackend = false } = {}) => {
244
+ const blank = (presFP) => ({
245
+ presFP,
246
+ name: null,
247
+ colors: null,
248
+ current: presFP === current,
249
+ cached: false,
250
+ windows: [],
251
+ });
252
+ const rows = new Map(
253
+ cachedDesigns(bundle, current).map((d) => [
254
+ d.presFP,
255
+ { ...d, cached: true, windows: [] },
256
+ ]),
257
+ );
258
+ for (const w of publicWindows()) {
259
+ if (!w.presFP) continue;
260
+ if (!rows.has(w.presFP)) rows.set(w.presFP, blank(w.presFP));
261
+ rows.get(w.presFP).windows.push(w);
262
+ }
263
+ if (withBackend) {
264
+ try {
265
+ for (const b of await client.listBrands()) {
266
+ if (!rows.has(b.presFP)) rows.set(b.presFP, blank(b.presFP));
267
+ rows.get(b.presFP).name ??= b.name ?? null;
268
+ }
269
+ } catch {
270
+ /* no brand list — the menu is what this machine knows */
271
+ }
272
+ }
273
+ return [...rows.values()];
274
+ };
275
+
276
+ // The one question ready cannot answer: several designs at hand and neither
277
+ // a deck binding, a --brand, nor a single verified window says which.
278
+ const finishAmbiguous = async () => {
279
+ const designs = await designMenu({ withBackend: true });
280
+ const suggested =
281
+ deckPresFP && designs.some((d) => d.presFP === deckPresFP) ? deckPresFP : null;
282
+ const rerun = `slidemind ready${deckSlug ? ` ${deckSlug}` : ""} --brand <presFP>`;
283
+ const blocker = {
284
+ code: "ambiguous_design",
285
+ actor: "user",
286
+ message: `Several corporate designs are at hand and nothing says which — ask the user which to build against (by name and colours), then re-run: ${rerun}.`,
287
+ };
288
+ let human =
289
+ "slidemind: several corporate designs are at hand — ask the user which to build against:\n" +
290
+ `${designRows(designs).join("\n")}\n`;
291
+ if (suggested) {
292
+ const s = designs.find((d) => d.presFP === suggested);
293
+ human += ` (this deck was last built against ${s?.name ?? suggested} (${suggested}))\n`;
294
+ }
295
+ human += `Then re-run: ${rerun}`;
296
+ finishBlocker({
297
+ blocker,
298
+ extras: { designs, suggested, windows: publicWindows() },
299
+ humanLines: human,
300
+ });
301
+ };
302
+
303
+ // --- A design resolved WITHOUT a window — the deck's own binding, an
304
+ // explicit --brand, or the backend's brand list. Same payload either way:
305
+ // ready, `windowless: true`, and a pane status that is INFORMATIONAL. What
306
+ // the missing window actually costs depends on the engine routes: with them
307
+ // only push/insert-slide and reading an open presentation need PowerPoint.
308
+ //
309
+ // "Windowless" is about how the DESIGN was resolved, not about the registry:
310
+ // --brand runs before the registry is consulted at all, and the later rungs
311
+ // fire on "nothing render-ready", which a connected-but-still-matching window
312
+ // satisfies. So the pane status is DERIVED from the registry as it stands at
313
+ // call time (the hydration wait reassigns it), and the prose only claims
314
+ // "no PowerPoint window" when there genuinely is none. ---
315
+ const finishWindowless = async (result) => {
316
+ if (deckDir) {
317
+ bundle.writeDeckBundleRef(deckDir, {
318
+ presFP: result.presFP,
319
+ etag: result.etag,
320
+ });
321
+ }
322
+ const designName = readDesignName(result.dir);
323
+ const env = result.backendURL ? cfg.deriveEnvLabel(result.backendURL) : null;
324
+ const eng = await engines();
325
+ const m = await m365();
326
+ const paneReady = registry.some(client.isPaneReadyPresentation);
327
+ const noWindow = registry.length === 0;
328
+ const stage = deckDir ? await deckStage(deckDir) : null;
329
+ const nextAction = noWindow
330
+ ? eng.available
331
+ ? "Ready (no PowerPoint window — authoring, engine search and build-pptx all work; only push/insert-slide and reading an open presentation need one)."
332
+ : "Ready (no PowerPoint window — build-pptx/build/review/view-draft work; push/search/read need a pane)."
333
+ : paneReady
334
+ ? "Ready (design resolved without targeting a window; a registered PowerPoint window is available for push/insert-slide)."
335
+ : eng.available
336
+ ? "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)."
337
+ : "Ready (design resolved without targeting a window; pane operations — push/search/read — will wait for a pane click).";
338
+ if (asJson) {
339
+ process.stdout.write(
340
+ JSON.stringify(
341
+ {
342
+ ready: true,
343
+ windowless: true,
344
+ deck: deckSlug,
345
+ bound: Boolean(deckDir),
346
+ env,
347
+ presId: null,
348
+ presFP: result.presFP,
349
+ designName,
350
+ designs: await designMenu({ current: result.presFP }),
351
+ windows: publicWindows(),
352
+ bundleDir: result.dir,
353
+ bundleEtag: result.etag ?? null,
354
+ paneSuspended: !paneReady,
355
+ engines: eng,
356
+ ...(m ? { m365: { signedIn: m.signedIn, degradation: m.degradation } } : {}),
357
+ warnings: result.warnings ?? [],
358
+ ...(stage ? { stage } : {}),
359
+ nextAction,
360
+ },
361
+ null,
362
+ 2,
363
+ ) + "\n",
364
+ );
365
+ return;
366
+ }
367
+ const designLabel = designName
368
+ ? `${designName} (${result.presFP})`
369
+ : result.presFP;
370
+ process.stdout.write(
371
+ `Ready — design ${designLabel}${envTag(env)}${noWindow ? " (no PowerPoint window)" : ""}\n`,
372
+ );
373
+ process.stdout.write(` bundle: ${result.dir} (etag ${result.etag ?? "?"})\n`);
374
+ if (deckDir) process.stdout.write(` deck ${deckSlug} bound\n`);
375
+ if (stage) process.stdout.write(stageHumanLines(stage));
376
+ process.stdout.write(windowsHumanLine(publicWindows()));
377
+ process.stdout.write(designsHumanLines(await designMenu({ current: result.presFP })));
378
+ process.stdout.write(
379
+ noWindow
380
+ ? eng.available
381
+ ? " Note: authoring, engine search and build-pptx need no window; push/insert-slide and reading an open presentation do.\n"
382
+ : " Note: build-pptx/build/review/view-draft work without a window; push/search/read need a pane.\n"
383
+ : paneReady
384
+ ? " Note: the design was resolved without targeting a window; a registered PowerPoint window is available for push/insert-slide.\n"
385
+ : eng.available
386
+ ? " Note: pane operations (push/insert-slide/read of a window) will wait for a pane click; engine search does not.\n"
387
+ : " Note: pane operations (push/search/read) will wait for a pane click.\n",
388
+ );
389
+ if (m) process.stdout.write(` ${m.line}\n`);
390
+ for (const w of result.warnings ?? []) {
391
+ process.stderr.write(` warning: ${w}\n`);
392
+ }
393
+ };
394
+
395
+ // Materialize a design by id over the pane-free brand route. Returns null on
396
+ // any miss (old add-in, unknown/revoked design) so the caller can fall
397
+ // through to the window ladder instead of dead-ending.
398
+ const materializeBrand = async (brandId) => {
399
+ try {
400
+ return await bundle.materializeBundle(null, { force, brandId });
401
+ } catch {
402
+ return null;
403
+ }
404
+ };
405
+
406
+ // --- --brand <presFP>: the user (or the agent, after an ambiguous brand
407
+ // list) named the design outright. It resolves with or without a window, so
408
+ // it runs before the registry is consulted at all. ---
409
+ if (brand) {
410
+ const result = await materializeBrand(brand);
411
+ if (!result) {
412
+ finishBlocker({
413
+ blocker: {
414
+ code: "unknown_brand",
415
+ actor: "user",
416
+ message: `No connected slidemind add-in serves design '${brand}'. Run 'slidemind ready' with no --brand to see which designs are available.`,
417
+ },
418
+ });
419
+ return;
420
+ }
421
+ await finishWindowless(result);
422
+ return;
423
+ }
424
+
425
+ // --- Bound-deck ladder: session → bundle freshness, no pane precondition.
426
+ // The design identity (presFP == brandId) lives in the deck's
427
+ // .bundle-ref.json and the bundle syncs over the pane-free brand route, so
428
+ // with PowerPoint CLOSED (an empty registry) a bound deck readies straight
429
+ // away instead of sitting out the hydration wait for a window that isn't
430
+ // coming. A registry that holds windows still hydrates first — the open
431
+ // add-in stays the source of truth whenever there IS one. Skipped for
432
+ // --connect-only (which resolves no design at all) and for --to (an explicit
433
+ // window must resolve through the registry); a miss (old add-in, legacy ref,
434
+ // revoked grant) falls through to the window ladder. ---
435
+ const boundDeckLadder = deckDir && deckPresFP && !to && !connectOnly;
436
+ let boundRungTried = false;
437
+ if (boundDeckLadder && registry.length === 0) {
438
+ boundRungTried = true;
439
+ const result = await materializeBrand(deckPresFP);
440
+ if (result) {
441
+ await finishWindowless(result);
442
+ return;
443
+ }
444
+ }
445
+
446
+ // --- Bounded hydration wait when nothing is render-ready yet: covers the
447
+ // just-clicked / just-woke pane still hydrating brand metadata. Polls even on
448
+ // an empty registry (that's the flaky window this papers over). ---
449
+ if (!registry.some(isRR)) {
450
+ const waitMs =
451
+ parseInt(process.env.SLIDEMIND_BRIDGE_PRESENTATIONS_WAIT_MS || "6000", 10) ||
452
+ 6000;
453
+ const pollMs =
454
+ parseInt(process.env.SLIDEMIND_BRIDGE_READY_POLL_MS || "1500", 10) || 1500;
455
+ const deadline = Date.now() + waitMs;
456
+ while (!registry.some(isRR) && Date.now() < deadline) {
457
+ await new Promise((r) => setTimeout(r, pollMs));
458
+ registry = (await client.listOpenPresentationsDetailed()).presentations;
459
+ }
460
+ }
461
+ // Everything below terminates the command: it is the whole "no render-ready
462
+ // window" resolution (connect-only engine answer → bound-deck rung → brand
463
+ // list → late blocker). A named closure because TWO paths land here: the
464
+ // registry genuinely holding no render-ready window, and the ping check
465
+ // below pruning the last one as a zombie.
466
+ const finishNoRenderReady = async () => {
467
+ // --connect-only asks one question — "can I work?" — and since the engine
468
+ // routes answer off the session, a backend that serves them IS the answer:
469
+ // discovery, search and every per-result fetch run with PowerPoint closed.
470
+ // Without them search is still relayed through a pane, so the window rung
471
+ // below stands.
472
+ if (connectOnly && (await engines()).available) {
473
+ const eng = await engines();
474
+ const m = await m365();
475
+ if (asJson) {
476
+ process.stdout.write(
477
+ JSON.stringify(
478
+ {
479
+ ready: true,
480
+ connectOnly: true,
481
+ windowless: true,
482
+ engines: eng,
483
+ ...(m ? { m365: { signedIn: m.signedIn, degradation: m.degradation } } : {}),
484
+ nextAction:
485
+ "Ready (no PowerPoint window — engine search runs off the connection; only insert-slide needs one).",
486
+ backends: (pub.backends ?? []).map((b) => ({ env: b.env, ok: b.ok })),
487
+ },
488
+ null,
489
+ 2,
490
+ ) + "\n",
491
+ );
492
+ } else {
493
+ process.stdout.write(
494
+ `Connected. ${pub.backendCount} backend(s), 0 window(s) registered — ` +
495
+ `engine search runs without one${envTag(eng.envs.join(", "))}.\n`,
496
+ );
497
+ if (m) process.stdout.write(` ${m.line}\n`);
498
+ }
499
+ return;
500
+ }
501
+ // The bound-deck ladder again, for the windows-registered-but-never-ready
502
+ // case the pre-poll rung deliberately left to the hydration wait.
503
+ if (boundDeckLadder && !boundRungTried) {
504
+ boundRungTried = true;
505
+ const result = await materializeBrand(deckPresFP);
506
+ if (result) {
507
+ await finishWindowless(result);
508
+ return;
509
+ }
510
+ }
511
+ // --- Unbound + no window: the ONE design cached here is the answer, else
512
+ // the ONE design the backend lists; several of either genuinely need a
513
+ // human choice, which --brand records. A backend without the brand route
514
+ // (or a deck already bound to a design it just refused) falls through
515
+ // unchanged — as does a connection with every backend down, where a
516
+ // design menu would only hide the real blocker. ---
517
+ if (!connectOnly && !to && !deckPresFP && !boundRungTried && !backendBlocker(pub)) {
518
+ const cached = bundle.listCachedBundles();
519
+ if (cached.length === 1) {
520
+ const result = await materializeBrand(cached[0].presFP);
521
+ if (result) {
522
+ await finishWindowless(result);
523
+ return;
524
+ }
525
+ }
526
+ let brands = [];
527
+ try {
528
+ brands = await client.listBrands();
529
+ } catch {
530
+ /* no brand list — the window ladder below is the answer */
531
+ }
532
+ if (cached.length === 0 && brands.length === 1) {
533
+ const result = await materializeBrand(brands[0].presFP);
534
+ if (result) {
535
+ await finishWindowless(result);
536
+ return;
537
+ }
538
+ } else if (cached.length > 1 || brands.length > 1) {
539
+ await finishAmbiguous();
540
+ return;
541
+ }
542
+ }
543
+ // Only the no-window / not-render-ready rungs are reachable here (terminal
544
+ // blockers already returned; a render-ready window would have exited the
545
+ // poll). Reuse the ladder's exact strings over the freshly-polled registry.
546
+ const latePub = {
547
+ paired: true,
548
+ backendCount: pub.backendCount,
549
+ backends: pub.backends,
550
+ presentations: registry.map(client.toPublicPresentation),
551
+ };
552
+ const b =
553
+ connectionBlocker(latePub) ?? {
554
+ code: "not_render_ready",
555
+ actor: "user",
556
+ message:
557
+ "PowerPoint is connected, but no window is render-ready. Wait for corporate design matching or activate/create a corporate design.",
558
+ };
559
+ finishBlocker({ blocker: b });
560
+ return;
561
+ };
562
+ // --- Zombie prune, BEFORE any resolution: a webview whose document was
563
+ // closed keeps its socket — and thus a "connected" registry lease — alive
564
+ // indefinitely, and only a host round-trip inside the pane can tell. Every
565
+ // window claiming a LIVE pane gets one truth ping (in parallel); a dead one
566
+ // leaves the registry, so it can neither win silently nor fake a second
567
+ // design. Narrow on purpose: an explicit --to is the user's call, a
568
+ // suspended window keeps its silent-adopt semantics (backgrounded
569
+ // PowerPoint must not start asking questions), and a backend without
570
+ // paneLivenessV1 fails open. ---
571
+ const backendAdvertisesPing = (entry) =>
572
+ (statusRaw.backends ?? []).some(
573
+ (b) =>
574
+ b.ok &&
575
+ b.backendURL === entry.backendURL &&
576
+ b.capabilities?.paneLivenessV1 === true,
577
+ );
578
+ const entryIsAlive = async (entry) => {
579
+ try {
580
+ const ack = await client.pingPresentation({
581
+ presId: entry.presId,
582
+ backendURL: entry.backendURL ?? null,
583
+ });
584
+ return ack?.ok !== false;
585
+ } catch (err) {
586
+ // Dead only on the typed pane verdicts; anything else (404 route on an
587
+ // older backend, a transport blip) fails open — adopt as before.
588
+ return ![
589
+ "pane_suspended",
590
+ "pane_timeout",
591
+ "pane_disconnected",
592
+ "bridge_timeout",
593
+ ].includes(err?.code);
594
+ }
595
+ };
596
+ if (!to) {
597
+ const suspects = registry.filter(
598
+ (p) => isRR(p) && client.isPaneReadyPresentation(p) && backendAdvertisesPing(p),
599
+ );
600
+ const alive = await Promise.all(suspects.map(entryIsAlive));
601
+ const dead = new Set(suspects.filter((_, i) => !alive[i]));
602
+ if (dead.size > 0) registry = registry.filter((p) => !dead.has(p));
603
+ }
604
+
605
+ if (!registry.some(isRR)) {
606
+ await finishNoRenderReady();
607
+ return;
608
+ }
609
+
610
+ // --- --connect-only: the connection is up with ≥1 render-ready window; the
611
+ // search skill needs nothing more (no design resolution, no bundle). ---
612
+ if (connectOnly) {
613
+ const m = await m365();
614
+ if (asJson) {
615
+ process.stdout.write(
616
+ JSON.stringify(
617
+ {
618
+ ready: true,
619
+ connectOnly: true,
620
+ engines: await engines(),
621
+ ...(m ? { m365: { signedIn: m.signedIn, degradation: m.degradation } } : {}),
622
+ nextAction: "Ready.",
623
+ backends: (pub.backends ?? []).map((b) => ({ env: b.env, ok: b.ok })),
624
+ },
625
+ null,
626
+ 2,
627
+ ) + "\n",
628
+ );
629
+ } else {
630
+ const windows = registry.filter(isRR).length;
631
+ process.stdout.write(
632
+ `Connected. ${pub.backendCount} backend(s), ${windows} window(s) registered.\n`,
633
+ );
634
+ if (m) process.stdout.write(` ${m.line}\n`);
635
+ }
636
+ return;
637
+ }
638
+
639
+ // --- Local: pick WHICH design. The order is the user's, not the window's:
640
+ // an explicit --to, else the deck's own binding (a window on that design is
641
+ // targeted, none at all readies windowless over the brand route), else the
642
+ // ONE design the verified windows agree on. Anything else is a choice only
643
+ // the user can make — the ambiguous blocker puts the menu in front of them. ---
644
+ let resolution;
645
+ if (to) {
646
+ resolution = client.resolveDesignSource(registry, {
647
+ explicitPresId: to,
648
+ deckPresFP,
649
+ });
650
+ } else {
651
+ const onDeckDesign = deckPresFP
652
+ ? registry.filter((p) => isRR(p) && p.brandId === deckPresFP)
653
+ : [];
654
+ if (boundDeckLadder && onDeckDesign.length === 0) {
655
+ boundRungTried = true;
656
+ const result = await materializeBrand(deckPresFP);
657
+ if (result) {
658
+ await finishWindowless(result);
659
+ return;
660
+ }
661
+ }
662
+ resolution = client.resolveDesignSource(
663
+ onDeckDesign.length > 0 ? onDeckDesign : registry,
664
+ { deckPresFP },
665
+ );
666
+ }
667
+
668
+ if (resolution.status === "none") {
669
+ // Defensive: a render-ready window exists (poll passed) so this is rare.
670
+ finishBlocker({
671
+ blocker: {
672
+ code: "not_render_ready",
673
+ actor: "user",
674
+ message:
675
+ "PowerPoint is connected, but no window is render-ready. Wait for corporate design matching or activate/create a corporate design.",
676
+ },
677
+ });
678
+ return;
679
+ }
680
+
681
+ if (resolution.status === "unknown_presid") {
682
+ const windows = registry.filter(isRR).map(client.toPublicPresentation);
683
+ const candidates = windows.map((p) => ({
684
+ presId: p.presId,
685
+ env: p.env ?? null,
686
+ title: p.title ?? null,
687
+ fileName: p.fileName ?? null,
688
+ }));
689
+ const blocker = {
690
+ code: "unknown_presid",
691
+ actor: "user",
692
+ message: `No render-ready PowerPoint window has presId='${to}'. Re-run 'slidemind ready --to <presId>' with one of the open windows.`,
693
+ };
694
+ const rows = windows.map(
695
+ (p) =>
696
+ ` ${p.presId} ${envTag(p.env)} ${p.title || p.fileName || "(untitled)"}`,
697
+ );
698
+ finishBlocker({
699
+ blocker,
700
+ extras: { candidates },
701
+ humanLines:
702
+ `slidemind: ${blocker.message}\n` +
703
+ `${rows.join("\n")}`,
704
+ });
705
+ return;
706
+ }
707
+
708
+ if (resolution.status === "ambiguous") {
709
+ await finishAmbiguous();
710
+ return;
711
+ }
712
+
713
+ // --- Network: materialize the resolved design's bundle. Always called —
714
+ // incremental etag-gated download makes an unchanged bundle near-free and a
715
+ // changed one refreshes. ---
716
+ const entry = resolution.entry;
717
+ const result = await bundle.materializeBundle(entry.presId, {
718
+ force,
719
+ backendURL: entry.backendURL ?? null,
720
+ preferBrandId: entry.brandId ?? null,
721
+ });
722
+
723
+ // --- Local: bind the deck (kills the second download-bundle). ---
724
+ let bound = false;
725
+ if (deckDir) {
726
+ bundle.writeDeckBundleRef(deckDir, {
727
+ presFP: result.presFP,
728
+ etag: result.etag,
729
+ });
730
+ bound = true;
731
+ }
732
+
733
+ const designName = readDesignName(result.dir);
734
+ const paneSuspended = !client.isPaneReadyPresentation(entry);
735
+ const engineInfoForWindow = await engines();
736
+ const m365ForWindow = await m365();
737
+ const stageForWindow = deckDir ? await deckStage(deckDir) : null;
738
+
739
+ if (asJson) {
740
+ process.stdout.write(
741
+ JSON.stringify(
742
+ {
743
+ ready: true,
744
+ deck: deckSlug,
745
+ bound,
746
+ env: entry.env ?? null,
747
+ presId: entry.presId,
748
+ presFP: result.presFP,
749
+ designName,
750
+ designs: await designMenu({ current: result.presFP }),
751
+ windows: publicWindows(),
752
+ bundleDir: result.dir,
753
+ bundleEtag: result.etag ?? null,
754
+ presentation: {
755
+ title: entry.title ?? null,
756
+ fileName: entry.fileName ?? null,
757
+ },
758
+ paneSuspended,
759
+ engines: engineInfoForWindow,
760
+ ...(m365ForWindow
761
+ ? {
762
+ m365: {
763
+ signedIn: m365ForWindow.signedIn,
764
+ degradation: m365ForWindow.degradation,
765
+ },
766
+ }
767
+ : {}),
768
+ warnings: result.warnings ?? [],
769
+ ...(stageForWindow ? { stage: stageForWindow } : {}),
770
+ nextAction: "Ready.",
771
+ },
772
+ null,
773
+ 2,
774
+ ) + "\n",
775
+ );
776
+ return;
777
+ }
778
+
779
+ // --- Human success: compact multi-line summary on stdout. ---
780
+ const entryEnv = envTag(entry.env);
781
+ const presLabel = entry.title || entry.fileName || "(untitled)";
782
+ const designLabel = designName
783
+ ? `${designName} (${result.presFP})`
784
+ : result.presFP;
785
+ process.stdout.write(`Ready — design ${designLabel}\n`);
786
+ process.stdout.write(` presentation: ${presLabel}${entryEnv}\n`);
787
+ process.stdout.write(
788
+ ` bundle: ${result.dir} (etag ${result.etag ?? "?"})\n`,
789
+ );
790
+ if (bound) process.stdout.write(` deck ${deckSlug} bound\n`);
791
+ if (stageForWindow) process.stdout.write(stageHumanLines(stageForWindow));
792
+ process.stdout.write(windowsHumanLine(publicWindows()));
793
+ process.stdout.write(designsHumanLines(await designMenu({ current: result.presFP })));
794
+ if (paneSuspended) {
795
+ process.stdout.write(
796
+ engineInfoForWindow.available
797
+ ? " Note: pane operations (push/insert-slide/read of this window) will wait for a pane click; engine search does not.\n"
798
+ : " Note: pane operations (push/search/read) will wait for a pane click.\n",
799
+ );
800
+ }
801
+ if (m365ForWindow) process.stdout.write(` ${m365ForWindow.line}\n`);
802
+ for (const w of result.warnings ?? []) {
803
+ process.stderr.write(` warning: ${w}\n`);
804
+ }
805
+ }