slidemind 0.3.19 → 0.4.0

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 (186) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +591 -15
  4. package/README.md +202 -7
  5. package/bin/slidemind.mjs +50 -2
  6. package/package.json +2 -2
  7. package/skills/create-slides/SKILL.md +484 -886
  8. package/skills/search/SKILL.md +46 -23
  9. package/src/bridge/build-and-push.mjs +548 -73
  10. package/src/bridge/bundle.mjs +100 -31
  11. package/src/bridge/capture.mjs +14 -17
  12. package/src/bridge/client/brands.mjs +157 -18
  13. package/src/bridge/client/engine-routing.mjs +48 -13
  14. package/src/bridge/client/engine-search.mjs +24 -1
  15. package/src/bridge/client/engine-slides.mjs +1 -1
  16. package/src/bridge/client/render.mjs +27 -1
  17. package/src/bridge/client/transport.mjs +499 -23
  18. package/src/bridge/client.mjs +10 -0
  19. package/src/bridge/config.mjs +67 -6
  20. package/src/bridge/engine-download.mjs +0 -10
  21. package/src/bridge/graph-auth.mjs +55 -3
  22. package/src/bridge/image-intent-envelope.mjs +239 -0
  23. package/src/bridge/package-preview.mjs +67 -0
  24. package/src/bridge/sharepoint.mjs +914 -0
  25. package/src/builders/asset-fetch.mjs +203 -0
  26. package/src/builders/block-dividers.mjs +83 -0
  27. package/src/builders/build.mjs +171 -87
  28. package/src/builders/card-height.mjs +294 -0
  29. package/src/builders/chart-hole.mjs +183 -0
  30. package/src/builders/chart-jsx.mjs +410 -0
  31. package/src/builders/chart-labels.mjs +326 -0
  32. package/src/builders/concept-geometry.mjs +370 -0
  33. package/src/builders/concept-intent.mjs +200 -0
  34. package/src/builders/concept-layout.mjs +79 -0
  35. package/src/builders/concept-lines.mjs +509 -0
  36. package/src/builders/concept-lists.mjs +270 -0
  37. package/src/builders/concept-marks.mjs +84 -0
  38. package/src/builders/concept-relationships.mjs +12 -0
  39. package/src/builders/concept-spec.mjs +645 -0
  40. package/src/builders/concept-text.mjs +140 -0
  41. package/src/builders/concept-waves.mjs +57 -0
  42. package/src/builders/copy-fit.mjs +139 -0
  43. package/src/builders/design-tokens.mjs +347 -0
  44. package/src/builders/draft-check.mjs +552 -340
  45. package/src/builders/draft-lines.mjs +369 -0
  46. package/src/builders/edit-route.mjs +29 -0
  47. package/src/builders/figure-sketch.mjs +375 -0
  48. package/src/builders/fold-sets.mjs +639 -0
  49. package/src/builders/gate-server.mjs +9 -4
  50. package/src/builders/gate-session.js +273 -12
  51. package/src/builders/gate-shell.js +1494 -0
  52. package/src/builders/image-intent-check.mjs +308 -0
  53. package/src/builders/image-use-reconcile.mjs +107 -0
  54. package/src/builders/kit-helpers.mjs +121 -0
  55. package/src/builders/layout-names.mjs +32 -0
  56. package/src/builders/legacy-reference-uses.mjs +179 -0
  57. package/src/builders/line-fit.mjs +224 -0
  58. package/src/builders/line-native.mjs +895 -0
  59. package/src/builders/line-relations.mjs +230 -0
  60. package/src/builders/lint-fix.mjs +117 -9
  61. package/src/builders/lint.mjs +25 -20
  62. package/src/builders/list-jsx.mjs +88 -0
  63. package/src/builders/placement.mjs +133 -0
  64. package/src/builders/plan-checks.mjs +135 -0
  65. package/src/builders/plan-comments.mjs +19 -0
  66. package/src/builders/plan-handlers.mjs +95 -0
  67. package/src/builders/plan-markdown-edit.mjs +3 -3
  68. package/src/builders/plan-overlay.js +1530 -1608
  69. package/src/builders/plan.mjs +689 -186
  70. package/src/builders/preset-definitions.json +1 -0
  71. package/src/builders/preset-eval.mjs +244 -0
  72. package/src/builders/preview.html +51 -120
  73. package/src/builders/red-thread-backbone.mjs +135 -36
  74. package/src/builders/repair.mjs +205 -0
  75. package/src/builders/repeat-groups.mjs +540 -0
  76. package/src/builders/resolve-skill.mjs +5 -4
  77. package/src/builders/review-overlay.js +296 -293
  78. package/src/builders/review.mjs +305 -84
  79. package/src/builders/rule-shift.mjs +96 -0
  80. package/src/builders/seat.mjs +354 -0
  81. package/src/builders/settle-driver.mjs +236 -0
  82. package/src/builders/settle.mjs +1448 -0
  83. package/src/builders/slide-prep.mjs +67 -0
  84. package/src/builders/spec-assets.mjs +236 -0
  85. package/src/builders/spec-match.mjs +56 -0
  86. package/src/builders/stack.mjs +413 -0
  87. package/src/builders/storyboard-markdown.mjs +70 -27
  88. package/src/builders/storyboard-schema.mjs +54 -114
  89. package/src/builders/text-blocks.mjs +212 -0
  90. package/src/builders/text-card-fit.mjs +552 -0
  91. package/src/builders/text-stack.mjs +276 -0
  92. package/src/builders/transcribe.mjs +1069 -0
  93. package/src/cli/assets.mjs +164 -0
  94. package/src/cli/common.mjs +173 -12
  95. package/src/cli/concept-spec-support.mjs +76 -0
  96. package/src/cli/concepts.mjs +1002 -103
  97. package/src/cli/deck.mjs +401 -65
  98. package/src/cli/diagnose.mjs +4 -2
  99. package/src/cli/engine-helpers.mjs +17 -10
  100. package/src/cli/engine-results.mjs +76 -3
  101. package/src/cli/help.mjs +232 -28
  102. package/src/cli/install.mjs +9 -0
  103. package/src/cli/maintenance.mjs +104 -0
  104. package/src/cli/read.mjs +2 -1
  105. package/src/cli/ready.mjs +6 -3
  106. package/src/cli/record.mjs +522 -0
  107. package/src/cli/remote.mjs +818 -0
  108. package/src/cli/revision.mjs +686 -0
  109. package/src/cli/search.mjs +30 -11
  110. package/src/cli/session.mjs +56 -13
  111. package/src/cli/setup.mjs +46 -2
  112. package/src/cli/status.mjs +142 -33
  113. package/src/cli/tasks.mjs +246 -0
  114. package/src/cli/transcribe.mjs +854 -0
  115. package/src/cli/workspace-ui.mjs +260 -0
  116. package/src/cli/workspace.mjs +212 -34
  117. package/src/install-channel.mjs +64 -7
  118. package/src/lib/adopt.mjs +346 -25
  119. package/src/lib/concept-spec-binding.mjs +96 -0
  120. package/src/lib/conversions.mjs +70 -6
  121. package/src/lib/convert-folded.mjs +107 -0
  122. package/src/lib/deck-paths.mjs +418 -0
  123. package/src/lib/durable-ids.mjs +47 -0
  124. package/src/lib/edit-operations.mjs +316 -0
  125. package/src/lib/edit-state.mjs +1244 -0
  126. package/src/lib/edit-transitions.mjs +120 -0
  127. package/src/lib/extensions.mjs +501 -0
  128. package/src/lib/image-assets.mjs +465 -0
  129. package/src/lib/image-derivatives.mjs +234 -0
  130. package/src/lib/image-intent-fingerprint.mjs +70 -0
  131. package/src/lib/image-intent-tags.mjs +176 -0
  132. package/src/lib/image-intent.mjs +802 -0
  133. package/src/lib/image-use-overrides.mjs +279 -0
  134. package/src/lib/inline-components.mjs +613 -0
  135. package/src/lib/lint-converted.mjs +68 -4
  136. package/src/lib/merge-json.mjs +163 -0
  137. package/src/lib/overwrite-guard.mjs +3 -3
  138. package/src/lib/presentation-context.mjs +531 -0
  139. package/src/lib/preservation-tags.mjs +157 -0
  140. package/src/lib/preservation-verdict.mjs +1342 -0
  141. package/src/lib/publish.mjs +1557 -0
  142. package/src/lib/rebase.mjs +945 -0
  143. package/src/lib/reconcile-ack.mjs +4 -6
  144. package/src/lib/reconcile.mjs +62 -24
  145. package/src/lib/record-client.mjs +175 -0
  146. package/src/lib/record-state.mjs +151 -0
  147. package/src/lib/remote.mjs +661 -0
  148. package/src/lib/revision.mjs +1210 -0
  149. package/src/lib/slide-guids.mjs +4 -6
  150. package/src/lib/slide-preservation.mjs +423 -0
  151. package/src/lib/slide-selection.mjs +9 -0
  152. package/src/lib/workspace-transaction.mjs +117 -0
  153. package/src/pptx/content-digest.mjs +334 -0
  154. package/src/pptx/deck-assemble.mjs +1278 -0
  155. package/src/pptx/fill/engine.mjs +2 -3
  156. package/src/pptx/fill/freeform.mjs +6 -3
  157. package/src/pptx/ooxml.mjs +20 -0
  158. package/src/pptx/package-evidence.mjs +383 -0
  159. package/src/pptx/tags.mjs +163 -11
  160. package/src/update-check.mjs +22 -4
  161. package/src/workspace/adapters.mjs +222 -0
  162. package/src/workspace/auth.mjs +256 -0
  163. package/src/workspace/autostart.mjs +204 -0
  164. package/src/workspace/daemon.mjs +360 -0
  165. package/src/workspace/discovery.mjs +180 -0
  166. package/src/workspace/edit.mjs +167 -0
  167. package/src/workspace/events.mjs +52 -0
  168. package/src/workspace/gate-client.mjs +290 -0
  169. package/src/workspace/handback.mjs +73 -0
  170. package/src/workspace/lifecycle.mjs +635 -0
  171. package/src/workspace/operations.mjs +200 -0
  172. package/src/workspace/pick-folder.mjs +152 -0
  173. package/src/workspace/registry.mjs +359 -0
  174. package/src/workspace/remote-links.mjs +397 -0
  175. package/src/workspace/remote-status.mjs +359 -0
  176. package/src/workspace/routes.mjs +1331 -0
  177. package/src/workspace/server.mjs +159 -0
  178. package/src/workspace/sync.mjs +1119 -0
  179. package/src/workspace/task-actions.mjs +215 -0
  180. package/src/workspace/tasks.mjs +507 -0
  181. package/src/workspace/ui/app.js +1886 -0
  182. package/src/workspace/ui/index.html +141 -0
  183. package/src/workspace/ui/plan-bridge.js +120 -0
  184. package/src/workspace/ui/sharepoint.svg +59 -0
  185. package/src/workspace/ui/styles.css +423 -0
  186. package/src/builders/visual-families.mjs +0 -46
package/src/pptx/tags.mjs CHANGED
@@ -18,6 +18,8 @@ import {
18
18
  relsFor,
19
19
  relsPathFor,
20
20
  resolvePartPath,
21
+ tagAttr,
22
+ tagElements,
21
23
  } from "./ooxml.mjs";
22
24
 
23
25
  export const CHUNK_BYTES = 12288;
@@ -75,7 +77,34 @@ function xmlEsc(s) {
75
77
  // input: { name, jsx, guid, bundle:{presFP,etag}, renderer, assets:[{name,bytes}],
76
78
  // baseline?, pushedAt, metaExtra? }
77
79
  // `metaExtra` merges extra properties into SRC_META (e.g. kind:"template-fill");
78
- // decoders ignore unknown properties.
80
+ // decoders ignore unknown properties. Two keys are in use:
81
+ // kind: "template-fill" — the pack-filled dialect (src/pptx/fill/engine.mjs)
82
+ // imageIntent: {v, uses, assets}
83
+ // the slide's approved image obligations and the registry entries they name
84
+ // (src/lib/image-intent-tags.mjs). Their BYTES need no arrangement here: the
85
+ // jsx references `./assets/local/<assetId>.<ext>`, so `referencedAssets`
86
+ // already embeds them like any other deck asset. A slide with no image use
87
+ // carries the key not at all, so its tags are unchanged.
88
+ // ONE writer, on purpose (decided 2026-09-21, spec §2.2): the server bakes
89
+ // this deck's tags on the render path but only ever receives the TRANSPORT
90
+ // projection of an asset, which omits `provenance` BY DESIGN (design
91
+ // decision 5: the envelope is a projection, not the registry — `provenance`
92
+ // is client-side knowledge no model endpoint has a use for, and adding it
93
+ // would churn the frozen contract fixture in both repos for a field only
94
+ // the client reads back). So the server's `assets` would be lossy exactly
95
+ // where `adopt` needs them. Do NOT fix that by teaching the server to write
96
+ // a provenance-less copy: the CLI holds the registry, so the CLI writes the
97
+ // key on BOTH bake paths, topping the server's meta up through
98
+ // `augmentSourceMeta` rather than letting two writers answer one key
99
+ // differently.
100
+ // preservation: the slide's `state/slide-preservation.json` entry
101
+ // — what it must still contain, its `active` requirements AND the ones an
102
+ // operation superseded (src/lib/preservation-tags.mjs). The state file does
103
+ // not travel and the .pptx does, so without this a deck adopted into a fresh
104
+ // workspace comes back owing nothing, and a deletion the user asked for
105
+ // comes back owed again. Restored only for a slide adopt proved current, and
106
+ // never over a record the target deck already holds. A slide with no record
107
+ // carries the key not at all.
79
108
  export function encodeSourceTags(input) {
80
109
  const tags = { [TAG_GUID]: input.guid };
81
110
 
@@ -263,9 +292,9 @@ export function readSlideTags(files, slidePath) {
263
292
  const partPath = tagRels[ref[1]];
264
293
  const tagXml = partPath ? readText(files, partPath) : null;
265
294
  if (!tagXml) continue;
266
- for (const t of tagXml.matchAll(/<p:tag\b([^>]*)\/>/g)) {
267
- const name = t[1].match(/\bname="([^"]*)"/)?.[1];
268
- const val = t[1].match(/\bval="([^"]*)"/)?.[1];
295
+ for (const t of tagElements(tagXml)) {
296
+ const name = tagAttr(t[1], "name");
297
+ const val = tagAttr(t[1], "val");
269
298
  if (name) out[decodeXmlEntities(name)] = decodeXmlEntities(val ?? "");
270
299
  }
271
300
  }
@@ -273,6 +302,76 @@ export function readSlideTags(files, slidePath) {
273
302
  return out;
274
303
  }
275
304
 
305
+ /**
306
+ * Rewrite ONE existing slide tag's value, in the part that already holds it.
307
+ *
308
+ * The bake next door is additive: handed a tag name a slide already carries, it
309
+ * appends a SECOND `<p:tag>` under the same name. For a fresh slide that is
310
+ * right — nothing is ever renumbered or overwritten (rule C1). For amending a
311
+ * tag the SERVER wrote it is not: two `SLIDEMIND_SRC_META` tags in one tagLst
312
+ * let one reader take the first and another the last, so the same file says two
313
+ * different things. This rewrites the value where it stands instead.
314
+ *
315
+ * Attribute-order tolerant on purpose — a PowerPoint round trip may reorder
316
+ * them, and the server's writer is a different implementation of the same
317
+ * format. Returns true when a tag was found and rewritten.
318
+ */
319
+ export function updateSlideTag(files, slidePath, name, value) {
320
+ const xml = readText(files, slidePath);
321
+ if (!xml) return false;
322
+ const dir = slidePath.slice(0, slidePath.lastIndexOf("/"));
323
+ const tagRels = {};
324
+ for (const rel of relsFor(files, slidePath)) {
325
+ if (rel.id && rel.type === TAGS_REL_TYPE) tagRels[rel.id] = resolvePartPath(dir, rel.target);
326
+ }
327
+ for (const cust of xml.matchAll(/<p:custDataLst\b[\s\S]*?<\/p:custDataLst>/g)) {
328
+ for (const ref of cust[0].matchAll(/<p:tags\b[^>]*\br:id="([^"]+)"/g)) {
329
+ const partPath = tagRels[ref[1]];
330
+ const tagXml = partPath ? readText(files, partPath) : null;
331
+ if (!tagXml) continue;
332
+ for (const t of tagElements(tagXml)) {
333
+ if (decodeXmlEntities(tagAttr(t[1], "name") ?? "") !== name) continue;
334
+ const replaced = t[0].replace(/\bval=(?:"[^"]*"|'[^']*')/, `val="${xmlEsc(value)}"`);
335
+ files[partPath] = enc(tagXml.replace(t[0], replaced));
336
+ return true;
337
+ }
338
+ }
339
+ }
340
+ return false;
341
+ }
342
+
343
+ /**
344
+ * Add SRC_META extras to a slide whose source was baked by SOMEONE ELSE.
345
+ *
346
+ * The server bakes the tags on the render path, and there are extras it cannot
347
+ * write: `imageIntent` needs the deck's registry (the transport descriptor the
348
+ * server holds deliberately carries no `provenance`), and the local records
349
+ * beside it are the CLI's own. Rather than a second writer emitting a lossy
350
+ * payload under the same key, the CLI tops the server's meta up in place.
351
+ *
352
+ * Additive and idempotent: only the named keys are touched, an extra already
353
+ * equal to what is there is not rewritten, and a slide with no SRC_META at all
354
+ * is left alone. Returns true when the part changed.
355
+ */
356
+ export function augmentSourceMeta(files, slidePath, extras) {
357
+ if (!extras || Object.keys(extras).length === 0) return false;
358
+ const raw = readSlideTags(files, slidePath)[TAG_SRC_META];
359
+ if (!raw) return false;
360
+ let meta;
361
+ try {
362
+ meta = JSON.parse(raw);
363
+ } catch {
364
+ return false; // not ours to repair — a corrupt payload stays corrupt
365
+ }
366
+ let changed = false;
367
+ for (const [key, value] of Object.entries(extras)) {
368
+ if (JSON.stringify(meta[key]) === JSON.stringify(value)) continue;
369
+ meta[key] = value;
370
+ changed = true;
371
+ }
372
+ return changed ? updateSlideTag(files, slidePath, TAG_SRC_META, JSON.stringify(meta)) : false;
373
+ }
374
+
276
375
  // True when a slide part carries any tag key OUTSIDE the SLIDEMIND_ namespace
277
376
  // (another add-in marked it — think-cell tags every shape it owns).
278
377
  export function slideHasForeignTags(files, slidePath) {
@@ -345,6 +444,24 @@ export function bakeSlideSourceTags(files, entries) {
345
444
  const slideXml = readText(files, entry.partName);
346
445
  if (!slideXml || !slideXml.includes("</p:cSld>")) continue;
347
446
 
447
+ // One p:tags per customer-data list (CT_CustomerDataList): a slide that
448
+ // already carries one gets our tags merged into that part.
449
+ const sharedRef = slideXml.match(/<\/p:spTree>\s*<p:custDataLst\b[^>]*>[\s\S]*?<p:tags\b[^>]*\br:id="([^"]+)"/);
450
+ if (sharedRef) {
451
+ const slideDir = entry.partName.slice(0, entry.partName.lastIndexOf("/"));
452
+ const rel = relsFor(files, entry.partName).find((r) => r.id === sharedRef[1] && r.type === TAGS_REL_TYPE);
453
+ const sharedPart = rel ? resolvePartPath(slideDir, rel.target) : null;
454
+ const sharedXml = sharedPart ? readText(files, sharedPart) : null;
455
+ if (sharedXml && sharedXml.includes("</p:tagLst>")) {
456
+ const ours = Object.entries(entry.tags)
457
+ .map(([name, val]) => `<p:tag name="${xmlEsc(name)}" val="${xmlEsc(val)}"/>`)
458
+ .join("");
459
+ files[sharedPart] = enc(sharedXml.replace("</p:tagLst>", `${ours}</p:tagLst>`));
460
+ changed = true;
461
+ continue;
462
+ }
463
+ }
464
+
348
465
  const partName = `ppt/tags/tag${tagIndex}.xml`;
349
466
  files[partName] = enc(tagPartXml(entry.tags));
350
467
  if (!contentTypes.includes(`PartName="/${partName}"`)) {
@@ -448,7 +565,13 @@ const TOP_SHAPE_RE =
448
565
  const UNIT_BLOCK_KIND = "graphicFrame";
449
566
 
450
567
  // Split the spTree inner XML into top-level shape subtrees (depth-aware).
451
- function topLevelShapes(spTreeInner) {
568
+ //
569
+ // Exported for `src/pptx/package-evidence.mjs`, which needs the same units this
570
+ // projection reports — in the same order, with the same MC-block rule — to bind
571
+ // each one to its `p:cNvPr/@id` and its package dependencies. Re-deriving the
572
+ // split there would be a second normalizer that WILL drift from this one. This
573
+ // export adds no behaviour, so the cross-repo parity fixture is untouched.
574
+ export function topLevelShapes(spTreeInner) {
452
575
  const shapes = [];
453
576
  let depth = 0;
454
577
  let start = -1;
@@ -630,9 +753,9 @@ function parseProv(files, slidePath, shapeXml) {
630
753
  if (!rel?.type.endsWith("/tags")) continue;
631
754
  const tagXml = readText(files, resolvePartPath(dir, rel.target));
632
755
  if (!tagXml) continue;
633
- for (const t of tagXml.matchAll(/<p:tag\b([^>]*)\/>/g)) {
634
- const name = t[1].match(/\bname="([^"]+)"/)?.[1];
635
- const val = t[1].match(/\bval="([^"]*)"/)?.[1];
756
+ for (const t of tagElements(tagXml)) {
757
+ const name = tagAttr(t[1], "name");
758
+ const val = tagAttr(t[1], "val");
636
759
  if (name) tags[xmlUnescape(name)] = xmlUnescape(val ?? "");
637
760
  }
638
761
  }
@@ -649,6 +772,34 @@ function parseProv(files, slidePath, shapeXml) {
649
772
  return null;
650
773
  }
651
774
 
775
+ // Every leaf shape inside a group subtree, document order (nested groups walked).
776
+ function groupLeaves(groupXml) {
777
+ const inner = groupXml.slice(groupXml.indexOf(">") + 1, groupXml.lastIndexOf("</p:grpSp>"));
778
+ const out = [];
779
+ for (const child of topLevelShapes(inner)) {
780
+ if (child.kind === "grpSp") out.push(...groupLeaves(child.xml));
781
+ else out.push(child);
782
+ }
783
+ return out;
784
+ }
785
+
786
+ // A group's parts (`ProjectionShape.parts`): a card moved or recoloured INSIDE
787
+ // a group is a manual edit the group's own box and first fill cannot show.
788
+ // LOCKSTEP with the add-in's `slide-source-projection.ts` `groupParts`.
789
+ function groupParts(raw) {
790
+ return groupLeaves(raw.xml).map((leaf) => {
791
+ const paint = parseFillAndLine(leaf.xml);
792
+ const runs = parseTextRuns(leaf.xml);
793
+ return {
794
+ kind: leaf.kind,
795
+ geom: parseXfrm(leaf.xml),
796
+ fill: paint.fill,
797
+ line: paint.line,
798
+ t: runs ? runs.map((r) => r.t).join("") : null,
799
+ };
800
+ });
801
+ }
802
+
652
803
  // Structural projection of a slide in an unzipped package (the FIRST slide when
653
804
  // `slidePathOverride` is omitted — the single-slide case). Deterministic;
654
805
  // excludes every id/name/theme-resolved churn source. Groups are one entry each
@@ -675,6 +826,7 @@ export function computeProjection(files, slidePathOverride) {
675
826
  // are byte-compared); the VALUE is the one thing this mirror cannot
676
827
  // reproduce.
677
828
  chart: null,
829
+ ...(raw.kind === "grpSp" ? { parts: groupParts(raw) } : {}),
678
830
  });
679
831
  }
680
832
  return { v: 1, shapes };
@@ -701,9 +853,9 @@ export function computeProjection(files, slidePathOverride) {
701
853
  // or styling changed. Everything else about the slide — geometry, text, fill,
702
854
  // line, media — still compares exactly. The consumers below therefore drop the
703
855
  // field from BOTH sides before comparing, so a chart can never produce a FALSE
704
- // "someone edited this" refusal; the price is that a chart-ONLY edit is
705
- // invisible to the CLI's guard (the add-in's own drift check, which does have
706
- // the reader, still catches it on the next push).
856
+ // "someone edited this" refusal. A chart-ONLY edit is invisible to that
857
+ // comparison, so manual-edit reconcile (src/lib/reconcile.mjs) sends every
858
+ // chart-holding slide to the add-in as its package and lets its reader decide.
707
859
  //
708
860
  // Closing it properly means giving this repo the reader as a GENERATED artifact
709
861
  // built from the add-in's TypeScript — never a hand-written copy.
@@ -19,7 +19,11 @@ import { fileURLToPath } from "node:url";
19
19
 
20
20
  import { compareCliVersions } from "./bridge/client.mjs";
21
21
  import { sessionConfigDir } from "./bridge/config.mjs";
22
- import { isClaudePluginInstall, updateCliInstruction } from "./install-channel.mjs";
22
+ import {
23
+ globalInstallBlock,
24
+ isClaudePluginInstall,
25
+ updateCliInstruction,
26
+ } from "./install-channel.mjs";
23
27
 
24
28
  const PACKAGE_ROOT = path.resolve(
25
29
  path.dirname(fileURLToPath(import.meta.url)),
@@ -117,9 +121,15 @@ function autoUpdateEnabled(channel) {
117
121
 
118
122
  // The message, as one pure function — everything above it decides WHETHER to
119
123
  // call it, this decides WHAT it says. `auto` is what we actually kicked off.
120
- export function updateNotice({ current, latest, channel, auto, stalled }) {
124
+ export function updateNotice({ current, latest, channel, auto, stalled, blocked }) {
121
125
  if (!latest || compareCliVersions(current, latest) >= 0) return null;
122
126
  const head = `slidemind ${current} is out of date — ${latest} is on npm.`;
127
+ // The running process can't replace the global install itself (a sandbox, or
128
+ // a root-owned prefix): say so and ask for escalated rights, never claim a
129
+ // background update that silently failed.
130
+ if (blocked) {
131
+ return `${head} ${updateCliInstruction(blocked)}`;
132
+ }
123
133
  if (stalled) {
124
134
  return (
125
135
  `${head} A background auto-update to ${latest} was started earlier but this ` +
@@ -207,7 +217,14 @@ export async function announceUpdate({ command, current, force = false }) {
207
217
  prior?.version === latest &&
208
218
  Date.now() - (Number(prior.at) || 0) < intervalMs();
209
219
  let auto = false;
210
- if (autoUpdateEnabled(channel) && !attempted) {
220
+ // Will a background `npm install -g` even stick? In a Codex sandbox (and
221
+ // behind a root-owned prefix) it cannot: the install fails, and with the
222
+ // config dir unwritable too the attempt record never persists — so a doomed
223
+ // install would respawn on every single command while the nudge kept
224
+ // claiming success. Pre-flight it. When the global install is unwritable we
225
+ // don't spawn; the notice asks for escalated rights instead.
226
+ const blocked = autoUpdateEnabled(channel) ? globalInstallBlock() : null;
227
+ if (autoUpdateEnabled(channel) && !blocked && !attempted) {
211
228
  auto = startBackgroundUpdate(latest);
212
229
  if (auto) {
213
230
  writeCache({
@@ -222,7 +239,8 @@ export async function announceUpdate({ command, current, force = false }) {
222
239
  latest,
223
240
  channel,
224
241
  auto,
225
- stalled: attempted,
242
+ stalled: attempted && !blocked,
243
+ blocked,
226
244
  });
227
245
  if (notice) process.stderr.write(`slidemind: ${notice}\n`);
228
246
  } catch {
@@ -0,0 +1,222 @@
1
+ // Trusted workspace adapter — protocol `slidemind.workspace-adapter` v1.
2
+ //
3
+ // The persistent workspace shows a pack deck's plan through the pack that owns
4
+ // it. The pack is a separate, privately installed program; this is the wire.
5
+ // The single rule, inherited from src/lib/extensions.mjs: **the deck names an
6
+ // adapter id, this machine's registry names what runs.** No deck file, remote
7
+ // record or HTTP request ever supplies an executable or an argument.
8
+ //
9
+ // One JSON request on stdin, one JSON response on stdout, stderr is the pack's
10
+ // diagnostics. Bounded in time (30s, 120s for a workbook operation) and in
11
+ // output (8 MiB — read incrementally and killed past the bound, so the daemon
12
+ // never buffers an unbounded child). A failure on the other side comes back
13
+ // as `{ status: "error", error }`, never as an exception: the caller decides
14
+ // what a missing pack means for THIS view, and it is not a stack trace.
15
+
16
+ import { spawn } from "node:child_process";
17
+ import path from "node:path";
18
+
19
+ import { readRegistry } from "../lib/extensions.mjs";
20
+
21
+ export const PROTOCOL = "slidemind.workspace-adapter";
22
+ export const PROTOCOL_VERSION = 1;
23
+ export const METHODS = ["describe", "read", "render", "save-local", "import-workbook", "validate", "approve", "apply-remote"];
24
+ export const STATUSES = ["ok", "blocked", "error"];
25
+
26
+ export const DEFAULT_TIMEOUT_MS = 30_000;
27
+ export const WORKBOOK_TIMEOUT_MS = 120_000;
28
+ export const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
29
+ const MAX_STDERR_BYTES = 64 * 1024;
30
+ // How long after the process exited we still wait for its pipes to reach EOF
31
+ // before answering anyway. Real drain is immediate; only a leaked helper holding
32
+ // the adapter's stdout takes the whole grace.
33
+ const EXIT_GRACE_MS = 250;
34
+
35
+ const error = (code, message, { retryable = false, details } = {}) => ({
36
+ status: "error",
37
+ data: null,
38
+ error: { code, message, retryable, ...(details ? { details } : {}) },
39
+ });
40
+
41
+ /**
42
+ * Which installed adapter serves an id. `{ id, entry }` when usable —
43
+ * `entry.workspace.command` is the argv — or `{ id, blocker }` with the
44
+ * remedy spelled out: `extension_missing` (install it) vs
45
+ * `extension_incompatible` (the installed copy predates the workspace, or
46
+ * speaks another protocol version).
47
+ */
48
+ export function resolveWorkspaceAdapter(id, registry = readRegistry(), { protocolVersion = PROTOCOL_VERSION } = {}) {
49
+ if (typeof id !== "string" || !id) {
50
+ return { id: null, blocker: { code: "extension_incompatible", message: "no adapter id", actor: "user" } };
51
+ }
52
+ const entry = registry.extensions[id];
53
+ if (!entry) {
54
+ return {
55
+ id,
56
+ blocker: {
57
+ code: "extension_missing",
58
+ message: `this deck's plan is owned by the '${id}' pack, which is not installed on this machine. Install it to edit the plan here; the deck's files are untouched.`,
59
+ actor: "user",
60
+ },
61
+ };
62
+ }
63
+ if (!entry.workspace) {
64
+ return {
65
+ id,
66
+ blocker: {
67
+ code: "extension_incompatible",
68
+ message: `the installed '${id}' pack has no workspace adapter. Update it to a version that registers one.`,
69
+ actor: "user",
70
+ },
71
+ };
72
+ }
73
+ if (!entry.workspace.protocolVersions.includes(protocolVersion)) {
74
+ return {
75
+ id,
76
+ blocker: {
77
+ code: "extension_incompatible",
78
+ message: `the workspace speaks '${id}' adapter protocol v${protocolVersion}; the installed copy speaks v${entry.workspace.protocolVersions.join(", v")}. Update one of them.`,
79
+ actor: "user",
80
+ },
81
+ };
82
+ }
83
+ return { id, entry };
84
+ }
85
+
86
+ export function buildRequest({ method, deckRoot, payload = {}, operationId = null }) {
87
+ return { protocol: PROTOCOL, version: PROTOCOL_VERSION, operationId, method, deckRoot, payload };
88
+ }
89
+
90
+ /**
91
+ * Run one adapter method. Resolves to `{ status, data, error }`; rejects only
92
+ * for a programming error (unknown method, entry without a workspace command).
93
+ *
94
+ * `ownerToken` is the deck lock token of the operation this call executes
95
+ * inside (src/workspace/operations.mjs) — it goes to the child as
96
+ * `SLIDEMIND_OWNER` and nowhere else. Without one the child gets NO token, not
97
+ * the daemon's inherited environment. `SLIDEMIND_WORKSPACE_ADAPTER=1` marks
98
+ * the child so the pack can refuse to call back into the daemon.
99
+ */
100
+ export function dispatchAdapter(entry, { method, deckRoot, payload = {}, operationId = null, ownerToken = null, timeoutMs = null } = {}) {
101
+ if (!METHODS.includes(method)) throw new Error(`unknown workspace adapter method: ${method}`);
102
+ const argv = entry?.workspace?.command;
103
+ if (!Array.isArray(argv) || argv.length === 0) throw new Error("adapter entry has no workspace command");
104
+ if (typeof deckRoot !== "string" || !path.isAbsolute(deckRoot)) {
105
+ return Promise.resolve(error("validation_failed", "deckRoot must be an absolute local path"));
106
+ }
107
+ const timeout = timeoutMs ?? (method === "import-workbook" ? WORKBOOK_TIMEOUT_MS : DEFAULT_TIMEOUT_MS);
108
+ const request = buildRequest({ method, deckRoot, payload, operationId });
109
+
110
+ const env = { ...process.env, SLIDEMIND_WORKSPACE_ADAPTER: "1" };
111
+ delete env.SLIDEMIND_OWNER;
112
+ if (ownerToken) env.SLIDEMIND_OWNER = ownerToken;
113
+
114
+ return new Promise((resolve) => {
115
+ let child;
116
+ try {
117
+ child = spawn(argv[0], argv.slice(1), { env, shell: false, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
118
+ } catch (err) {
119
+ resolve(error("extension_incompatible", `the adapter could not be started (${err.code ?? err.message})`));
120
+ return;
121
+ }
122
+ const out = [];
123
+ let outBytes = 0;
124
+ let err = "";
125
+ let settled = false;
126
+ let why = null; // "timeout" | "oversize" once we killed it
127
+ let exitGrace = null;
128
+ const finish = (value) => {
129
+ if (settled) return;
130
+ settled = true;
131
+ clearTimeout(timer);
132
+ clearTimeout(exitGrace);
133
+ resolve(value);
134
+ };
135
+ const kill = (reason) => {
136
+ why = reason;
137
+ child.kill("SIGKILL");
138
+ };
139
+ const timer = setTimeout(() => kill("timeout"), timeout);
140
+
141
+ child.on("error", (e) =>
142
+ finish(error("extension_incompatible", `the adapter could not be run (${e.code ?? e.message})`)),
143
+ );
144
+ child.stdout.on("data", (chunk) => {
145
+ outBytes += chunk.length;
146
+ if (outBytes > MAX_OUTPUT_BYTES) {
147
+ out.length = 0;
148
+ kill("oversize");
149
+ return;
150
+ }
151
+ out.push(chunk);
152
+ });
153
+ child.stderr.on("data", (chunk) => {
154
+ if (err.length < MAX_STDERR_BYTES) err += chunk.toString("utf8");
155
+ });
156
+ const done = (code, signal) => {
157
+ const lastLine = err.trim().split("\n").filter(Boolean).slice(-1)[0] ?? "";
158
+ if (why === "timeout") {
159
+ finish(error("extension_incompatible", `the adapter did not answer ${method} within ${timeout} ms`, { retryable: true }));
160
+ return;
161
+ }
162
+ if (why === "oversize") {
163
+ finish(error("extension_incompatible", `the adapter's ${method} answer exceeded ${MAX_OUTPUT_BYTES} bytes`));
164
+ return;
165
+ }
166
+ if (code !== 0) {
167
+ finish(error("extension_incompatible", `the adapter exited ${code ?? signal}${lastLine ? `: ${lastLine}` : ""}`));
168
+ return;
169
+ }
170
+ finish(parseResponse(Buffer.concat(out).toString("utf8"), method));
171
+ };
172
+ child.on("close", done);
173
+ // `close` needs stdio EOF, which never arrives when the adapter left a helper
174
+ // holding its stdout open — not even after SIGKILL, since the pipe belongs to
175
+ // the helper. This promise would then never settle, so `runDeckOperation` would
176
+ // never release the deck's busy.json and every later save, task and sync on that
177
+ // deck would answer `deck_busy` until the daemon restarts. The process itself is
178
+ // gone here: give the pipes a moment to drain, then answer with what arrived.
179
+ child.on("exit", (code, signal) => {
180
+ if (settled || exitGrace) return;
181
+ exitGrace = setTimeout(() => done(code, signal), EXIT_GRACE_MS);
182
+ exitGrace.unref?.();
183
+ });
184
+ child.stdin.on("error", () => {}); // child gone before it read: `close` reports it
185
+ child.stdin.end(JSON.stringify(request));
186
+ });
187
+ }
188
+
189
+ /** One JSON object, a known status, data as an object, version 1 when stated. */
190
+ export function parseResponse(text, method) {
191
+ let parsed;
192
+ try {
193
+ parsed = JSON.parse(text);
194
+ } catch {
195
+ return error("extension_incompatible", `the adapter did not answer ${method} with JSON`);
196
+ }
197
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
198
+ return error("extension_incompatible", `the adapter's ${method} answer is not an object`);
199
+ }
200
+ if (parsed.version !== undefined && parsed.version !== PROTOCOL_VERSION) {
201
+ return error("extension_incompatible", `the adapter answered with protocol version ${parsed.version}; this workspace speaks v${PROTOCOL_VERSION}`);
202
+ }
203
+ if (parsed.protocol !== undefined && parsed.protocol !== PROTOCOL) {
204
+ return error("extension_incompatible", `the adapter answered protocol '${parsed.protocol}'`);
205
+ }
206
+ if (!STATUSES.includes(parsed.status)) {
207
+ return error("extension_incompatible", `the adapter's ${method} status '${parsed.status}' is not ok/blocked/error`);
208
+ }
209
+ const data = parsed.data && typeof parsed.data === "object" ? parsed.data : null;
210
+ if (parsed.status === "ok") return { status: "ok", data, error: null };
211
+ const e = parsed.error && typeof parsed.error === "object" ? parsed.error : {};
212
+ return {
213
+ status: parsed.status,
214
+ data,
215
+ error: {
216
+ code: typeof e.code === "string" ? e.code : parsed.status === "blocked" ? "blocked" : "adapter_error",
217
+ message: typeof e.message === "string" ? e.message : `${method} did not pass`,
218
+ retryable: e.retryable === true,
219
+ ...(e.details !== undefined ? { details: e.details } : {}),
220
+ },
221
+ };
222
+ }