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
@@ -0,0 +1,1331 @@
1
+ // The workspace API (contracts §3) on server.mjs's `handle` seam.
2
+ //
3
+ // Every deck route resolves a REGISTRY id — a path never travels in a URL.
4
+ // Reads need a credential (CLI bearer or browser cookie); mutations also need
5
+ // the CSRF token plus same-origin evidence (auth.mjs). Bodies are JSON ≤ 2 MiB.
6
+ // Network work answers 202 + a job id; nothing here blocks a save on the wire.
7
+ //
8
+ // Seams the other lanes fill (`deps`, each lazily imported when absent):
9
+ // tasks src/workspace/tasks.mjs listTasks / requestTask / getTask / respondTask
10
+ // edit src/workspace/edit.mjs savePlanLocal
11
+ // remote the wave-3 lanes replace the 501 placeholders below
12
+
13
+ import { createHash, randomUUID } from "node:crypto";
14
+ import fs from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ import { previewBuiltFrom } from "../builders/build.mjs";
20
+ import { MIME } from "../builders/gate-server.mjs";
21
+ import { isEditRouteDeck } from "../builders/edit-route.mjs";
22
+ import { OVERLAY_PATH as REVIEW_OVERLAY_PATH, brandAssetsDirForDeck, injectReview, loadReviewState, normalizeReviewComments, previewHash, reviewLogoFile, routeBrandAssets } from "../builders/review.mjs";
23
+ import { splitPlanFrontmatter } from "../builders/resolve-skill.mjs";
24
+ import { deckFormat, planPageOwner, previewPath, resolveWorkspace } from "../lib/deck-paths.mjs";
25
+ import { pidAlive, readBusy } from "../lib/revision.mjs";
26
+ import { dispatchAdapter, resolveWorkspaceAdapter } from "./adapters.mjs";
27
+ import { deriveTaskSubject, invokeTaskAction, respondTaskAction, taskActionsFor, taskActor, taskEvidenceVerifier } from "./task-actions.mjs";
28
+ import { readJob, skip as skipDir, startDiscovery } from "./discovery.mjs";
29
+ import { openFile as defaultOpenFile, pickFolder as defaultPickFolder, revealFolder as defaultRevealFolder, trashFolder as defaultTrashFolder } from "./pick-folder.mjs";
30
+ import { events as defaultEvents } from "./events.mjs";
31
+ import { pasteMessage, receiveCommand, viewOf } from "./handback.mjs";
32
+ import { readOperationIntent, runDeckOperation } from "./operations.mjs";
33
+ // The async twins, not the spinning ones: this server has one event loop and a
34
+ // synchronous wait for the registry lock stalls SSE and every long poll with it.
35
+ import { addDeckAsync, getDeck, listDecks, listRoots, removeDeckAsync } from "./registry.mjs";
36
+ import { sendError, sendJson } from "./server.mjs";
37
+
38
+ export const PREFIX = "/api/v1";
39
+ export const MAX_BODY_BYTES = 2 * 1024 * 1024;
40
+ export const UI_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "ui");
41
+
42
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
43
+ const ACTION_METHOD = { "plan.validate": "validate", "workbook.import": "import-workbook" };
44
+ const STATUS = {
45
+ unauthorized: 401,
46
+ csrf_required: 403,
47
+ cross_origin: 403,
48
+ outside_roots: 403,
49
+ unknown_deck: 404,
50
+ unknown_task: 404,
51
+ unknown_job: 404,
52
+ not_found: 404,
53
+ missing_path: 404,
54
+ method_not_allowed: 405,
55
+ deck_busy: 409,
56
+ local_conflict: 409,
57
+ remote_conflict: 409,
58
+ stale_subject: 409,
59
+ already_answered: 409,
60
+ operation_mismatch: 409,
61
+ registry_busy: 409,
62
+ path_registered: 409,
63
+ payload_too_large: 413,
64
+ bad_request: 400,
65
+ bad_job_id: 400,
66
+ not_a_deck: 422,
67
+ unsupported_format: 409,
68
+ validation_failed: 422,
69
+ extension_missing: 424,
70
+ extension_incompatible: 424,
71
+ approval_requires_sync: 409,
72
+ approval_in_progress: 409,
73
+ conflict_requires_resolve: 409,
74
+ outcome_unknown: 409,
75
+ recovery_required: 423,
76
+ not_implemented: 501,
77
+ // remote association (thrown by remote-links / remote-status outside a job)
78
+ selection_expired: 410,
79
+ name_exists: 409,
80
+ cross_tenant: 409,
81
+ dirty_target: 409,
82
+ already_bound: 409,
83
+ backend_ambiguous: 409,
84
+ login_required: 401,
85
+ access_denied: 403,
86
+ remote_missing: 404,
87
+ not_a_folder: 422,
88
+ unsupported_link: 422,
89
+ trash_unavailable: 422,
90
+ offline: 503,
91
+ };
92
+ const RETRYABLE = new Set(["deck_busy", "registry_busy"]);
93
+
94
+ export class RouteError extends Error {
95
+ constructor(code, message, details) {
96
+ super(message);
97
+ this.code = code;
98
+ this.details = details;
99
+ }
100
+ }
101
+ const fail = (code, message, details) => new RouteError(code, message, details);
102
+
103
+ /** One JSON body, ≤ MAX_BODY_BYTES, or a typed failure. */
104
+ export function readJson(req, limit = MAX_BODY_BYTES) {
105
+ return new Promise((resolve, reject) => {
106
+ const declared = Number(req.headers["content-length"] ?? 0);
107
+ if (declared > limit) {
108
+ req.resume();
109
+ return reject(fail("payload_too_large", `body exceeds ${limit} bytes`));
110
+ }
111
+ const chunks = [];
112
+ let bytes = 0;
113
+ req.on("data", (c) => {
114
+ bytes += c.length;
115
+ if (bytes > limit) {
116
+ req.destroy();
117
+ reject(fail("payload_too_large", `body exceeds ${limit} bytes`));
118
+ return;
119
+ }
120
+ chunks.push(c);
121
+ });
122
+ req.on("error", reject);
123
+ req.on("end", () => {
124
+ const raw = Buffer.concat(chunks).toString("utf8");
125
+ if (!raw.trim()) return resolve({});
126
+ try {
127
+ const v = JSON.parse(raw);
128
+ if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("not an object");
129
+ resolve(v);
130
+ } catch (err) {
131
+ reject(fail("bad_request", `invalid JSON body: ${err.message}`));
132
+ }
133
+ });
134
+ });
135
+ }
136
+
137
+ const sha256 = (text) => `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
138
+
139
+ /** A file under `root`, by realpath containment; false when it is not one. */
140
+ export function serveContained(root, rel, res) {
141
+ let base;
142
+ try {
143
+ base = fs.realpathSync(root);
144
+ } catch {
145
+ return false;
146
+ }
147
+ const candidate = path.resolve(base, String(rel).replace(/^\/+/, ""));
148
+ let real;
149
+ try {
150
+ real = fs.realpathSync(candidate);
151
+ if (!fs.statSync(real).isFile()) return false;
152
+ } catch {
153
+ return false;
154
+ }
155
+ if (real !== base && !real.startsWith(base + path.sep)) return false;
156
+ res.writeHead(200, { "Content-Type": MIME[path.extname(real).toLowerCase()] || "application/octet-stream", "Cache-Control": "no-store" });
157
+ res.end(fs.readFileSync(real));
158
+ return true;
159
+ }
160
+
161
+ const lazy = (spec) => {
162
+ let mod;
163
+ return async () => {
164
+ if (mod !== undefined) return mod;
165
+ try {
166
+ mod = await import(spec);
167
+ } catch (err) {
168
+ if (err?.code !== "ERR_MODULE_NOT_FOUND") throw err;
169
+ mod = null;
170
+ }
171
+ return mod;
172
+ };
173
+ };
174
+
175
+ function resolveDeck(id) {
176
+ if (!UUID.test(String(id))) throw fail("unknown_deck", "deck ids are registry UUIDs");
177
+ const deck = getDeck(id);
178
+ if (!deck) throw fail("unknown_deck", `no registered deck ${id}`);
179
+ if (!fs.existsSync(deck.path)) throw fail("missing_path", `${deck.path} is not there any more`, { deckId: id, path: deck.path, name: deck.name ?? null });
180
+ return { ...deck, workspaceDir: resolveWorkspace(deck.path) };
181
+ }
182
+
183
+ /** The installed adapter a deck's plan owner names, or the blocker. Core-owned decks: null adapter. */
184
+ function deckAdapter(deck) {
185
+ const owner = planPageOwner(deck.workspaceDir);
186
+ if (owner.owner === "core") return { id: "core", entry: null, owner };
187
+ const resolved = resolveWorkspaceAdapter(owner.owner === "extension" ? null : owner.owner);
188
+ return { ...resolved, owner };
189
+ }
190
+
191
+ function adapterResult(r) {
192
+ if (r.status === "ok") return r.data;
193
+ throw fail(r.error?.code ?? (r.status === "blocked" ? "deck_busy" : "extension_incompatible"), r.error?.message ?? "adapter failed", r.error?.details);
194
+ }
195
+
196
+ function mapCode(err) {
197
+ const code = err?.code && STATUS[err.code] ? err.code : null;
198
+ return { status: code ? STATUS[code] : 500, code: code ?? "internal", retryable: RETRYABLE.has(code) };
199
+ }
200
+
201
+ /**
202
+ * Preview freshness for the deck view. `hash` is the source hash the last
203
+ * review round recorded, `sourceHash` what the slides hash to now; `stale`
204
+ * compares presentation.html's mtime with the newest source file, so a
205
+ * rebuilt-but-unreviewed preview still reads fresh.
206
+ */
207
+ /**
208
+ * One stat walk over the reviewed sources (slides/, assets/): the newest mtime,
209
+ * and a signature of every name, size and time. Cheap — no file is read.
210
+ */
211
+ function previewScan(ws) {
212
+ let newest = 0;
213
+ const parts = [];
214
+ const walk = (dir, rel) => {
215
+ let entries = [];
216
+ try {
217
+ entries = fs.readdirSync(dir, { withFileTypes: true });
218
+ } catch {
219
+ return;
220
+ }
221
+ for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
222
+ const p = path.join(dir, e.name);
223
+ if (e.isDirectory()) walk(p, `${rel}/${e.name}`);
224
+ else if (e.isFile()) {
225
+ const st = fs.statSync(p);
226
+ newest = Math.max(newest, st.mtimeMs);
227
+ parts.push(`${rel}/${e.name}:${st.size}:${st.mtimeMs}`);
228
+ }
229
+ }
230
+ };
231
+ for (const sub of ["slides", "assets"]) walk(path.join(ws, sub), sub);
232
+ return { newest, sig: parts.join("|") };
233
+ }
234
+
235
+ // The content hash reads every slide and asset; a page polls every 2 s. It is
236
+ // recomputed only when a stat signature moved. Display paths only — every
237
+ // decision hashes fresh (subjectOf, respondPreview).
238
+ const previewHashMemo = new Map(); // ws → { sig, hash }
239
+ function previewHashCached(ws, scan = previewScan(ws)) {
240
+ const memo = previewHashMemo.get(ws);
241
+ if (memo && memo.sig === scan.sig) return memo.hash;
242
+ const hash = previewHash(ws);
243
+ previewHashMemo.set(ws, { sig: scan.sig, hash });
244
+ return hash;
245
+ }
246
+
247
+ function previewState(ws, { scan = null, sourceHash: known = null } = {}) {
248
+ const file = previewPath(ws, "presentation.html");
249
+ let builtAt = null;
250
+ try {
251
+ builtAt = fs.statSync(file).mtimeMs;
252
+ } catch {
253
+ return null;
254
+ }
255
+ const seen = scan ?? previewScan(ws);
256
+ const newest = seen.newest;
257
+ const reviewed = loadReviewState(ws).deckHash;
258
+ let sourceHash = known;
259
+ if (!sourceHash) {
260
+ try {
261
+ sourceHash = previewHashCached(ws, seen);
262
+ } catch {
263
+ // no bundle resolvable: freshness by mtime only
264
+ }
265
+ }
266
+ // The stamp the build wrote (the hash it was built from) is the truth when
267
+ // present — a deleted or renamed slide keeps no newer mtime; file times are the
268
+ // fallback for a preview built before stamps existed.
269
+ const builtFrom = previewBuiltFrom(ws);
270
+ const stale = builtFrom && sourceHash ? builtFrom !== sourceHash : newest > builtAt;
271
+ return { hash: reviewed ? `sha256:${reviewed}` : null, sourceHash, builtFrom, builtAt: new Date(builtAt).toISOString(), newestSourceAt: newest, stale };
272
+ }
273
+
274
+ /**
275
+ * The deck's built .pptx (absolute path), or null. `<name>.pptx` wins; a deck
276
+ * that holds several takes the newest, which is what a build just wrote.
277
+ */
278
+ function deckPptx(deck) {
279
+ let names = [];
280
+ try {
281
+ names = fs.readdirSync(deck.path).filter((f) => /\.pptx$/i.test(f) && !f.startsWith("~$"));
282
+ } catch {
283
+ return null;
284
+ }
285
+ if (!names.length) return null;
286
+ const preferred = names.find((f) => f.toLowerCase() === `${String(deck.name ?? "").toLowerCase()}.pptx`);
287
+ const pick =
288
+ preferred ??
289
+ names.sort((a, b) => fs.statSync(path.join(deck.path, b)).mtimeMs - fs.statSync(path.join(deck.path, a)).mtimeMs)[0];
290
+ return path.join(deck.path, pick);
291
+ }
292
+
293
+ /** Unsettled local work: an operation intent on disk or a queue with work pending. */
294
+ function dirtyState(ws, sync) {
295
+ const intent = readOperationIntent(ws);
296
+ return Boolean(intent) || Boolean(sync && !sync.synced && sync.exists);
297
+ }
298
+
299
+ /**
300
+ * `createRoutes({ auth, deps })` → `handle(req, res, ctx)` for createServer.
301
+ * auth from auth.mjs
302
+ * deps { tasks?, edit?, events?, track?, restart?, uiRoot?, adapter? }
303
+ * tasks/edit default to the sibling modules when they exist;
304
+ * adapter(deck) overrides deckAdapter (tests); track(promise) holds the
305
+ * drain; restart() is the daemon's re-exec.
306
+ */
307
+ export function createRoutes({ auth, deps = {} } = {}) {
308
+ const loadTasks = deps.tasks ? async () => deps.tasks : lazy("./tasks.mjs");
309
+ const loadEdit = deps.edit ? async () => deps.edit : lazy("./edit.mjs");
310
+ // remote association (remote-links / remote-status); `deps.remote` is the
311
+ // override bag those modules take (a mock provider session in tests)
312
+ const loadLinks = lazy("./remote-links.mjs");
313
+ const loadStatus = lazy("./remote-status.mjs");
314
+ // autosync + the approval/conflict coordinator (sync.mjs: drainOnce, refreshOnce,
315
+ // resolveConflict, activateDestination, approvalInProgress, syncStatus);
316
+ // `deps.syncModule` injects a fake (`deps.sync` is the daemon loop's bag)
317
+ const loadSync = deps.syncModule ? async () => deps.syncModule : lazy("./sync.mjs");
318
+ const remoteDeps = deps.remote ?? {};
319
+ // a task answer: the coordinator entry point, or the injected fake store's respondTask
320
+ const respond = deps.tasks
321
+ ? (deckRoot, id, req, { adapter, owner, verify }) =>
322
+ deps.tasks.respondTask(deckRoot, id, req, { owner, verify, invoke: (task, response, { token }) => invokeTaskAction({ deckRoot, adapter, task, response, token, payload: req.payload ?? {} }) })
323
+ : respondTaskAction;
324
+
325
+ /** One export of a lazily loaded module, or a typed 501 when this build lacks it. */
326
+ const fn = async (loader, name, what) => {
327
+ const mod = await loader();
328
+ if (!mod || typeof mod[name] !== "function") throw fail("not_implemented", `${what} is not available in this build`);
329
+ return mod[name];
330
+ };
331
+ const syncCard = async (ws) => {
332
+ const s = await loadSync();
333
+ if (!s || typeof s.syncStatus !== "function") return null;
334
+ try {
335
+ return s.syncStatus(ws);
336
+ } catch (err) {
337
+ return { state: "recovery_required", synced: false, exists: true, lastError: { code: err.code ?? "recovery_required", message: err.message } };
338
+ }
339
+ };
340
+ const remoteCard = async (deck) => {
341
+ const st = await loadStatus();
342
+ const remote = st ? await st.remoteStatus(deck.path, remoteDeps) : null;
343
+ const sync = await syncCard(deck.workspaceDir);
344
+ if (!remote) return { remote: null, sync };
345
+ // the queue is the authority on "blocked": a conflict or an unknown outcome overrides the disk-only card
346
+ const blocked = sync && (sync.conflict || ["blocked", "outcome-unknown", "recovery_required"].includes(sync.state));
347
+ const plan = remote.plan.state === "shared" && blocked ? { ...remote.plan, state: "blocked", reason: sync.lastError?.code ?? (sync.conflict ? "remote_conflict" : sync.state), lastConfirmedAt: sync.lastConfirmedAt ?? remote.plan.lastConfirmedAt } : remote.plan;
348
+ return { remote: { ...remote, plan, sync }, sync };
349
+ };
350
+ const events = deps.events ?? defaultEvents;
351
+ const track = deps.track ?? ((p) => p);
352
+ const uiRoot = deps.uiRoot ?? UI_ROOT;
353
+ const adapterOf = deps.adapter ?? deckAdapter;
354
+
355
+ const need = async (loader, name) => {
356
+ const mod = await loader();
357
+ if (!mod) throw fail("not_implemented", `${name} is not available in this build`);
358
+ return mod;
359
+ };
360
+
361
+ /** A store without waitTask (an injected fake): poll it until the task settles. */
362
+ async function pollTask(tasksMod, deckRoot, taskId, timeoutMs, signal) {
363
+ const deadline = Date.now() + timeoutMs;
364
+ for (;;) {
365
+ const task = (await tasksMod.listTasks(deckRoot, {})).find((t) => t.id === taskId);
366
+ if (!task) throw fail("stale_subject", `no task ${taskId} on this deck`);
367
+ if (task.status !== "pending") return { task, pending: false };
368
+ if (signal.aborted || Date.now() >= deadline) return { task, pending: true };
369
+ await new Promise((r) => setTimeout(r, 200));
370
+ }
371
+ }
372
+
373
+ async function describe(deck) {
374
+ const a = adapterOf(deck);
375
+ if (a.blocker || !a.entry) return { adapter: a, views: null, actions: [] };
376
+ const data = adapterResult(await dispatchAdapter(a.entry, { method: "describe", deckRoot: deck.path }));
377
+ return { adapter: a, views: data?.views ?? null, actions: Array.isArray(data?.actions) ? data.actions : [], record: data?.record ?? null };
378
+ }
379
+
380
+ async function readPlan(deck) {
381
+ const a = adapterOf(deck);
382
+ if (a.blocker) throw fail(a.blocker.code, a.blocker.message);
383
+ if (!a.entry) {
384
+ const file = path.join(deck.workspaceDir, "plan.md");
385
+ let value = null;
386
+ try {
387
+ value = fs.readFileSync(file, "utf8");
388
+ } catch {
389
+ // a deck without a plan yet
390
+ }
391
+ return { value, hash: value === null ? null : sha256(value), format: "markdown", adapterId: "core", capabilities: { save: true }, sync: null };
392
+ }
393
+ const data = adapterResult(await dispatchAdapter(a.entry, { method: "read", deckRoot: deck.path }));
394
+ return { format: "json", adapterId: a.id, capabilities: {}, sync: null, ...data };
395
+ }
396
+
397
+ // GET /decks/:id/preview/<file>, the preview page's media and GET /ui/<file>.
398
+ // The page's `<base href="../">` resolves the deck's own `./assets/…` (engine
399
+ // downloads, converted pictures) to /decks/:id/assets/, and previewPageHtml
400
+ // points the brand's at /decks/:id/brand/assets/ — the two roots `slidemind
401
+ // review` serves the same page from. Nothing else of the workspace is served.
402
+ function serveFiles(pathname, res) {
403
+ const ui = pathname.match(/^\/ui\/(.+)$/);
404
+ if (ui) return serveContained(uiRoot, decodeURIComponent(ui[1]), res);
405
+ const pv = pathname.match(/^\/api\/v1\/decks\/([^/]+)\/(preview|assets|brand\/assets)\/(.+)$/);
406
+ if (pv) {
407
+ const ws = resolveDeck(pv[1]).workspaceDir;
408
+ const root = pv[2] === "preview" ? previewPath(ws) : pv[2] === "assets" ? path.join(ws, "assets") : brandAssetsDirForDeck(ws);
409
+ return !!root && serveContained(root, decodeURIComponent(pv[3]), res);
410
+ }
411
+ return false;
412
+ }
413
+
414
+ /** presentation.html as this server serves it: the bundle's file: URLs answered by the deck's brand route. */
415
+ function previewPageHtml(deck) {
416
+ const html = fs.readFileSync(previewPath(deck.workspaceDir, "presentation.html"), "utf8");
417
+ return routeBrandAssets(html, brandAssetsDirForDeck(deck.workspaceDir), `${PREFIX}/decks/${deck.id}/brand/assets`);
418
+ }
419
+
420
+ // The route table: [method, regex, handler(params, ctx)] → body | { status, body }
421
+ const pickFolder = deps.pickFolder ?? defaultPickFolder;
422
+ const revealFolder = deps.revealFolder ?? defaultRevealFolder;
423
+ const openFile = deps.openFile ?? defaultOpenFile;
424
+ const trashFolder = deps.trashFolder ?? defaultTrashFolder;
425
+ // The embedded gates (plan.html / the preview review inside the workspace
426
+ // page) POST their decision without the page's CSRF header: each rendered
427
+ // page carries a token in its endpoint URL instead, minted per deck. With a
428
+ // persisted auth (auth.gateTokenFor) the token outlives a daemon restart, so
429
+ // a page left open keeps working; the local map is the in-memory fallback.
430
+ const gateTokens = new Map(); // token → deckId
431
+ const gateTokenFor = (deckId) => {
432
+ if (typeof auth?.gateTokenFor === "function") return auth.gateTokenFor(deckId);
433
+ for (const [t, id] of gateTokens) if (id === deckId) return t;
434
+ const t = createHash("sha256").update(`${deckId}:${Math.random()}:${Date.now()}`).digest("base64url");
435
+ gateTokens.set(t, deckId);
436
+ return t;
437
+ };
438
+ const gateTokenValid = (token, deckId) =>
439
+ typeof auth?.gateTokenValid === "function" ? auth.gateTokenValid(token, deckId) : gateTokens.get(token) === deckId;
440
+
441
+ // Scripts waiting on a task (`slidemind tasks wait` long-polls GET …/wait; no
442
+ // agent does — every hand-back is a paste, doc 21). The page says nothing
443
+ // about them: whether some process happens to wait never changes what the
444
+ // user sees. Two things still read them: the daemon does not restart into new
445
+ // code under an open wait, and a wait's owner token lets the answer to THAT
446
+ // task re-enter the script's edit session instead of meeting deck_busy.
447
+ const waiters = new Map(); // "<deckId>:<taskId>" → { open, owner }
448
+ const ownerOn = (deckId, taskId) => {
449
+ const w = taskId ? waiters.get(`${deckId}:${taskId}`) : null;
450
+ return w && w.open > 0 && w.owner ? w.owner : null;
451
+ };
452
+ const openWaits = () => [...waiters.values()].reduce((n, w) => n + w.open, 0);
453
+ // questions an open page was told about: asking the same question again
454
+ // (the user said "continue" before answering) does not announce it twice
455
+ const attended = new Set();
456
+ /** Pending tasks of one kind, newest first. */
457
+ const pendingOf = async (deck, kind) => {
458
+ const tasksMod = await loadTasks();
459
+ return tasksMod ? (await tasksMod.listTasks(deck.path, { status: "pending" })).filter((t) => t.kind === kind).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))) : [];
460
+ };
461
+ /** Every task of one kind, newest first. */
462
+ const tasksOf = async (deck, kind) => {
463
+ const tasksMod = await loadTasks();
464
+ return tasksMod ? (await tasksMod.listTasks(deck.path, {})).filter((t) => t.kind === kind).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt))) : [];
465
+ };
466
+ /** The round a gate page belongs to: the reviewed content plus the task waiting on it. */
467
+ // The round = the reviewed content + the NEWEST task of that kind, whatever its
468
+ // status: answering a task must not read as a new round (the page would reload
469
+ // and drop its "agent revising" state), a fresh request on the same content must.
470
+ const gateRound = async (deck, kind, subjectHash) => {
471
+ const newest = (await tasksOf(deck, kind))[0] ?? null;
472
+ return createHash("sha256").update(`${subjectHash}|${newest?.id ?? ""}`).digest("hex").slice(0, 16);
473
+ };
474
+ const subjectOf = async (deck, kind) => {
475
+ if (kind === "preview-review") return previewHash(deck.workspaceDir);
476
+ const { planSubjectHash } = await import("../builders/plan.mjs");
477
+ return planSubjectHash(deck.workspaceDir);
478
+ };
479
+ /** Who holds the deck right now — an agent's edit session or a running command — or null. */
480
+ const busyOf = (deck) => {
481
+ let b = null;
482
+ try {
483
+ b = readBusy(deck.workspaceDir);
484
+ } catch {
485
+ return null;
486
+ }
487
+ if (!b) return null;
488
+ if (b.kind !== "session" && (b.pid === process.pid || !b.pid || !pidAlive(b.pid))) return null;
489
+ return { since: b.startedAt ?? null, command: b.command ?? null, session: b.kind === "session" };
490
+ };
491
+ /** When the agent last changed what this gate shows: plan.md, or the slides/assets and the built preview. */
492
+ const activityOf = (deck, kind, scan = null) => {
493
+ const ws = deck.workspaceDir;
494
+ let newest = 0;
495
+ const stat = (p) => {
496
+ try {
497
+ newest = Math.max(newest, fs.statSync(p).mtimeMs);
498
+ } catch {
499
+ // not there
500
+ }
501
+ };
502
+ stat(path.join(ws, "plan.md"));
503
+ if (kind === "preview-review" && scan) {
504
+ stat(previewPath(ws, "presentation.html"));
505
+ newest = Math.max(newest, scan.newest);
506
+ } else if (kind === "preview-review") {
507
+ stat(previewPath(ws, "presentation.html"));
508
+ for (const sub of ["slides", "assets"]) {
509
+ let names = [];
510
+ try {
511
+ names = fs.readdirSync(path.join(ws, sub));
512
+ } catch {
513
+ continue;
514
+ }
515
+ for (const n of names) stat(path.join(ws, sub, n));
516
+ }
517
+ }
518
+ return newest ? new Date(newest).toISOString() : null;
519
+ };
520
+ const approvedNow = async (deck, kind, subjectHash) => {
521
+ if (kind === "preview-review") {
522
+ const s = loadReviewState(deck.workspaceDir);
523
+ return s.decision === "approve" && `sha256:${s.deckHash}` === subjectHash;
524
+ }
525
+ const { planApprovedExactly } = await import("../builders/plan.mjs");
526
+ return planApprovedExactly(deck.workspaceDir);
527
+ };
528
+ /** What an embedded gate's heartbeat carries (contract: heartbeat). */
529
+ const gateBeat = async (deck, kind, service, subjectHash, { scan = null } = {}) => {
530
+ const all = await tasksOf(deck, kind);
531
+ const pending = all.find((t) => t.status === "pending") ?? null;
532
+ const done = all.find((t) => t.status === "completed") ?? null;
533
+ return {
534
+ service,
535
+ round: createHash("sha256").update(`${subjectHash}|${all[0]?.id ?? ""}`).digest("hex").slice(0, 16),
536
+ taskId: pending?.id ?? null,
537
+ subject: subjectHash,
538
+ approved: await approvedNow(deck, kind, subjectHash),
539
+ answered: done ? { taskId: done.id, action: done.response?.action ?? null, at: done.response?.at ?? null, pickedUpAt: done.pickedUpAt ?? null, subject: done.subject?.hash ?? null } : null,
540
+ busy: busyOf(deck),
541
+ activityAt: activityOf(deck, kind, scan),
542
+ ...(kind === "preview-review" ? { previewStale: previewState(deck.workspaceDir, { scan, sourceHash: subjectHash })?.stale === true } : {}),
543
+ };
544
+ };
545
+
546
+ /**
547
+ * The plan the human edited inside the gate, committed BEFORE the answer, the
548
+ * way the Markdown editor's save commits it (PUT /plan): under the deck lock,
549
+ * with the stale-base check, and the pending plan task FOLLOWS the edit — so
550
+ * the answer is about exactly the plan the human approved, the agent learns
551
+ * it was edited (`subject.requestedHash`), and the task can complete. Writing
552
+ * the edit inside the answer instead left the task pending with a hash that
553
+ * no longer matched (the agent's wait never returned).
554
+ */
555
+ async function commitGateEdit(deck, adapter, expectedHash, editedBody, owner) {
556
+ const file = path.join(deck.workspaceDir, "plan.md");
557
+ let text;
558
+ try {
559
+ text = fs.readFileSync(file, "utf8");
560
+ } catch {
561
+ throw fail("stale_subject", "this deck has no plan any more");
562
+ }
563
+ const current = sha256(text);
564
+ const { frontmatterRaw, body } = splitPlanFrontmatter(text);
565
+ if (editedBody === body) return null;
566
+ if (expectedHash && expectedHash !== current) {
567
+ throw fail("stale_subject", "your agent changed the plan while you were editing it", { current });
568
+ }
569
+ const syncMod = await loadSync();
570
+ if (syncMod?.approvalInProgress?.(deck.workspaceDir)) throw fail("approval_in_progress", "an approval of this plan is in flight; your edits are kept and can be saved once it settles");
571
+ const edit = await need(loadEdit, "plan editing");
572
+ const result = await edit.savePlanLocal({ workspaceDir: deck.workspaceDir, adapter, operationId: randomUUID(), base: { hash: current, value: text }, value: frontmatterRaw + editedBody, owner });
573
+ if (result?.code) throw fail(result.code, result.message ?? result.code, result);
574
+ // The agent wrote plan.md between our read and the lock: the save merged onto
575
+ // a plan the human never saw. Their edit is kept (a later version can show it),
576
+ // but this answer is not about what they looked at.
577
+ if (result?.ok && result.previous !== current) {
578
+ events.invalidate({ type: "plan", deckId: deck.id });
579
+ throw fail("stale_subject", "your agent changed the plan while you were editing it", { current: result.hash });
580
+ }
581
+ const tasksMod = await loadTasks();
582
+ if (result?.ok && result.previous !== result.hash) await tasksMod?.followPlanEdit?.(deck.path, { from: result.previous, to: result.hash }, { owner });
583
+ events.invalidate({ type: "plan", deckId: deck.id });
584
+ return { from: result.previous, to: result.hash };
585
+ }
586
+
587
+ const gateNoun = (kind) => (kind === "plan-approval" ? "plan" : "slides");
588
+ const gateCommand = (kind, deck) => `slidemind ${kind === "plan-approval" ? "plan" : "review"} ${JSON.stringify(deck.path)}`;
589
+
590
+ /**
591
+ * A decision posted by an embedded gate. Every answer lands on a TASK about
592
+ * exactly the content the human looked at (`expectedHash`): the pending one
593
+ * the agent asked, or — when the agent has not asked about this version (it
594
+ * never asked, or asked about an older one) — one asked on the human's behalf.
595
+ * The agent's next `slidemind plan|review` run hands it over — the command
596
+ * the ack's `message` names, which the page copies for the user to paste.
597
+ */
598
+ async function gateDecision(deck, kind, body) {
599
+ const adapter = adapterOf(deck);
600
+ if (adapter.blocker) throw fail(adapter.blocker.code, adapter.blocker.message);
601
+ const tasksMod = await need(loadTasks, "tasks");
602
+ const decision = body.decision === "comments" ? "comments" : "approve";
603
+ const operationId = UUID.test(String(body.operationId ?? "")) ? String(body.operationId) : randomUUID();
604
+ let expected = typeof body.expectedHash === "string" && /^sha256:[a-f0-9]{64}$/.test(body.expectedHash) ? body.expectedHash : null;
605
+ const all = await tasksOf(deck, kind);
606
+ // the same attempt again (a retry after a dropped response): the recorded answer, nothing new
607
+ const replay = all.find((t) => t.response?.operationId === operationId);
608
+ if (replay) return gateAck(deck, kind, replay, { decision: replay.response.action === "approve" ? "approve" : "comments", replayed: true });
609
+ const pending = all.find((t) => t.status === "pending") ?? null;
610
+ const owner = ownerOn(deck.id, pending?.id) ?? false;
611
+ let edited = false;
612
+ if (kind === "plan-approval" && !adapter.entry && typeof body.editedPlan === "string") {
613
+ const moved = await commitGateEdit(deck, adapter, expected, body.editedPlan, owner);
614
+ if (moved) {
615
+ edited = true;
616
+ expected = moved.to;
617
+ }
618
+ }
619
+ const current = await subjectOf(deck, kind);
620
+ // A preview older than its slides shows something other than what an approval would cover.
621
+ // a decision judges freshness on a FRESH hash, never the heartbeat's cached one
622
+ if (kind === "preview-review" && decision === "approve" && previewState(deck.workspaceDir, { sourceHash: current })?.stale === true) {
623
+ throw fail("stale_subject", "your agent changed the slides after this preview was made — it will show you the new version for review", { current, previewStale: true });
624
+ }
625
+ const fresh = (await tasksOf(deck, kind)).find((t) => t.status === "pending") ?? null;
626
+ if (expected && expected !== current) {
627
+ // the human's own earlier edits are theirs; anything else moved under the page
628
+ const own = fresh && fresh.subject?.hash === current && (fresh.subject?.followed ?? []).includes(expected);
629
+ if (!own) throw fail("stale_subject", `your agent changed the ${gateNoun(kind)} after this page was opened`, { current });
630
+ }
631
+ let task = fresh && fresh.subject?.hash === current ? fresh : null;
632
+ if (!task) {
633
+ // Already answered (another tab, a page left open): one answer per version. A
634
+ // new question about the same content (the agent asked again) is pending, so
635
+ // it does not land here.
636
+ const done = (await tasksOf(deck, kind)).find((t) => t.status === "completed" && (t.subject?.hash === current || t.evidence?.hash === current));
637
+ if (done) throw fail("already_answered", `this version of the ${gateNoun(kind)} was already answered`, { action: done.response?.action ?? null, at: done.response?.at ?? null, taskId: done.id });
638
+ // Nobody asked about exactly this version: ask now, on the human's behalf.
639
+ // A pending task about an older version is superseded by it; an agent
640
+ // waiting on that one re-asks and receives this answer at once.
641
+ const { adapterId, subject } = await deriveTaskSubject({ deckRoot: deck.path, adapter, kind });
642
+ if (subject.hash !== current) throw fail("stale_subject", `your agent changed the ${gateNoun(kind)} after this page was opened`, { current: subject.hash });
643
+ const asked = await tasksMod.requestTask(deck.path, { kind, adapterId, subject }, { owner, verify: taskEvidenceVerifier({ deckRoot: deck.path, adapter }) });
644
+ task = asked?.task ?? asked;
645
+ }
646
+ const comments = decision === "comments" ? (Array.isArray(body.comments) ? body.comments : []) : [];
647
+ // a design picked at the gate is applied with the answer (respondPlan)
648
+ const design = kind === "plan-approval" && !adapter.entry && typeof body.design === "string" ? body.design : null;
649
+ const payload = { comments, ...(edited ? { edited: true } : {}), ...(design ? { design } : {}) };
650
+ const action = decision === "comments" ? (task.actions ?? []).find((a) => a !== "approve") ?? "comment" : "approve";
651
+ const { actor, selfDeclared } = taskActor(payload);
652
+ const verify = taskEvidenceVerifier({ deckRoot: deck.path, adapter });
653
+ let answered;
654
+ try {
655
+ answered = await track(respond(deck.path, task.id, { operationId, expectedHash: task.subject?.hash ?? current, action, payload, actor, selfDeclared }, { adapter, owner: ownerOn(deck.id, task.id) ?? owner, verify }));
656
+ } catch (err) {
657
+ if (err?.code === "stale_subject" && err.details?.supersede) {
658
+ await tasksMod.supersedeTask?.(deck.path, task.id, { owner: false }).catch(() => {});
659
+ events.invalidate({ type: "tasks", deckId: deck.id, taskId: task.id });
660
+ throw fail("stale_subject", `your agent changed the ${gateNoun(kind)} after this page was opened`, { current: await subjectOf(deck, kind).catch(() => null) });
661
+ }
662
+ throw err;
663
+ }
664
+ events.invalidate({ type: "tasks", deckId: deck.id, taskId: task.id });
665
+ events.invalidate({ type: kind === "plan-approval" ? "plan" : "preview", deckId: deck.id });
666
+ return gateAck(deck, kind, answered?.task ?? task, { decision, edited, replayed: false });
667
+ }
668
+ /**
669
+ * The decision ack (contract): the message the page copies for the user to
670
+ * paste into their chat — the decision plus the one command that receives it,
671
+ * so it works in the same chat, after a summarization and in a new one.
672
+ */
673
+ function gateAck(deck, kind, task, { decision, edited = false, replayed }) {
674
+ const receive = gateCommand(kind, deck);
675
+ const message = pasteMessage({ kind, action: decision === "comments" ? "request-changes" : "approve", receive });
676
+ return { decided: decision, task: task?.id ?? null, receive, message, edited, replayed };
677
+ }
678
+ const realDir = (p) => {
679
+ const abs = path.resolve(p);
680
+ try {
681
+ return fs.realpathSync(abs);
682
+ } catch {
683
+ throw fail("missing_path", `${abs} does not exist`);
684
+ }
685
+ };
686
+ const routes = [
687
+ ["POST", /^\/bootstrap$/, async (_, { who }) => {
688
+ if (who?.kind !== "cli") throw fail("unauthorized", "only the CLI hands out sign-in links");
689
+ // the link key: the same in every link, valid every time it is clicked
690
+ return auth.linkToken();
691
+ }],
692
+ ["POST", /^\/restart$/, async (_, { res }) => {
693
+ if (!deps.restart) throw fail("not_implemented", "this workspace cannot restart itself");
694
+ res.on("finish", () => deps.restart());
695
+ return { status: 202, body: { restarting: true } };
696
+ }],
697
+ ["GET", /^\/decks$/, async (_, { url }) => {
698
+ const limit = Math.min(Math.max(Number(url.searchParams.get("limit")) || 100, 1), 100);
699
+ const offset = Math.max(Number(url.searchParams.get("cursor")) || 0, 0);
700
+ const { decks, total } = listDecks({ q: url.searchParams.get("q") ?? "", limit, offset });
701
+ const tasksMod = await loadTasks();
702
+ const st = await loadStatus();
703
+ const rows = [];
704
+ for (const d of decks) {
705
+ let taskCount = 0;
706
+ let remote = null;
707
+ if (!d.missing) {
708
+ try {
709
+ taskCount = tasksMod ? (await tasksMod.listTasks(d.path, { status: "pending" })).length : 0;
710
+ } catch {
711
+ taskCount = null; // a malformed store shows as unknown, the deck view says why
712
+ }
713
+ try {
714
+ const card = st ? await st.remoteStatus(d.path, remoteDeps) : null;
715
+ // a deck is "on SharePoint" when its plan, its PPTX or a destination folder lives there
716
+ const on = card && (card.plan.state !== "none" || card.pptx.state !== "none" || (card.destination && !card.destination.malformed));
717
+ remote = on ? { plan: { state: card.plan.state }, pptx: { state: card.pptx.state }, link: card.pptx.link ?? card.plan.link ?? card.destination?.webUrl ?? null } : null;
718
+ } catch {
719
+ remote = null;
720
+ }
721
+ }
722
+ let built = false;
723
+ if (!d.missing) {
724
+ try {
725
+ built = fs.readdirSync(d.path).some((f) => /\.pptx$/i.test(f));
726
+ } catch {
727
+ built = false;
728
+ }
729
+ }
730
+ rows.push({ ...d, taskCount, remote, built });
731
+ }
732
+ return { decks: rows, total, nextCursor: offset + decks.length < total ? String(offset + decks.length) : null, scanning: deps.scanning ? deps.scanning() : [], roots: listRoots(), home: process.env.HOME || os.homedir() };
733
+ }],
734
+ ["POST", /^\/decks$/, async (_, { body, who }) => {
735
+ if (typeof body.path !== "string" || !body.path) throw fail("bad_request", "path is required");
736
+ const entry = await addDeckAsync(realDir(body.path));
737
+ return { deckId: entry.id, deck: entry };
738
+ }],
739
+ // POST /fs/pick — open the OS folder dialog on this machine and answer the choice.
740
+ ["POST", /^\/fs\/pick$/, async () => {
741
+ let chosen;
742
+ try {
743
+ chosen = await pickFolder();
744
+ } catch (err) {
745
+ throw fail(err.code === "picker_unavailable" ? "picker_unavailable" : "picker_failed", err.message);
746
+ }
747
+ return { path: chosen ? realDir(chosen) : null };
748
+ }],
749
+ // POST /decks/:id/reveal — show the deck folder in the OS file manager.
750
+ // `parent: true` shows the folder ABOVE it: that is what the sidebar's group
751
+ // heading points at, and keying it to a registered deck keeps the reveal
752
+ // confined to paths the registry already knows (no bare path in a body).
753
+ ["POST", /^\/decks\/([^/]+)\/reveal$/, async ([id], { body }) => {
754
+ const deck = resolveDeck(id);
755
+ const dir = body?.parent ? path.dirname(deck.path) : deck.path;
756
+ try {
757
+ await revealFolder(dir);
758
+ } catch (err) {
759
+ throw fail(err.code === "reveal_unavailable" ? "reveal_unavailable" : "reveal_failed", err.message);
760
+ }
761
+ return { revealed: dir };
762
+ }],
763
+ // POST /decks/:id/open-pptx — hand the built deck to PowerPoint on this machine
764
+ ["POST", /^\/decks\/([^/]+)\/open-pptx$/, async ([id]) => {
765
+ const deck = resolveDeck(id);
766
+ const file = deckPptx(deck);
767
+ if (!file) throw fail("not_found", "this deck has no .pptx yet");
768
+ try {
769
+ await openFile(file);
770
+ } catch (err) {
771
+ throw fail(err.code === "open_unavailable" ? "open_unavailable" : "open_failed", err.message);
772
+ }
773
+ return { opened: file };
774
+ }],
775
+ ["POST", /^\/discovery$/, async (_, { body }) => {
776
+ if (typeof body.root !== "string" || !body.root) throw fail("bad_request", "root is required");
777
+ const { jobId, done } = startDiscovery(body.root);
778
+ track(done.then(() => events.invalidate({ type: "library" })));
779
+ return { status: 202, body: { jobId } };
780
+ }],
781
+ ["GET", /^\/decks\/([^/]+)$/, async ([id]) => {
782
+ const deck = resolveDeck(id);
783
+ const d = await describe(deck);
784
+ const tasksMod = await loadTasks();
785
+ const tasks = tasksMod ? await tasksMod.listTasks(deck.path, {}) : [];
786
+ const { remote, sync } = await remoteCard(deck);
787
+ // where the deck stands (the same ladder `slidemind ready <deck>` prints): intake → plan gate →
788
+ // concepts → sourcing → authoring → draft check → review → deliver; a read of the disk, never a guess
789
+ let stage = null;
790
+ try {
791
+ const { deckStage } = await import("../cli/status.mjs");
792
+ const st = await deckStage(deck.workspaceDir);
793
+ stage = { name: st.name, next: st.next, plan: st.plan, slides: st.slides, draftCheck: st.draftCheck, review: st.review, route: st.route };
794
+ } catch {
795
+ stage = null;
796
+ }
797
+ return {
798
+ deck: { id: deck.id, name: deck.name, path: deck.path, missing: false, lastSeenAt: deck.lastSeenAt, format: deckFormat(deck.path) },
799
+ adapter: { id: d.adapter.id, blocker: d.adapter.blocker ?? null, owner: d.adapter.owner.owner },
800
+ views: d.views,
801
+ actions: d.actions,
802
+ tasks,
803
+ stage,
804
+ dirty: dirtyState(deck.workspaceDir, sync),
805
+ preview: previewState(deck.workspaceDir),
806
+ pptx: (() => {
807
+ const f = deckPptx(deck);
808
+ return f ? path.basename(f) : null;
809
+ })(),
810
+ remote,
811
+ sync,
812
+ };
813
+ }],
814
+ // Drop the library entry only; the deck's files are never touched.
815
+ // DELETE /decks/:id — drop the registry entry. `files: "trash"` also moves the
816
+ // deck folder to the OS trash: destructive, so it takes the deck's OWN realpath
817
+ // from the registry (never a path from the page), refuses a filesystem root or
818
+ // the home dir, and runs as a deck operation so a live agent session answers
819
+ // `deck_busy` instead of losing its work. Nothing here ever calls fs.rm.
820
+ ["DELETE", /^\/decks\/([^/]+)$/, async ([id], { body }) => {
821
+ if (!UUID.test(String(id))) throw fail("unknown_deck", "deck ids are registry UUIDs");
822
+ if (body?.files !== "trash") return { removed: await removeDeckAsync(id) };
823
+ const deck = getDeck(id);
824
+ if (!deck) throw fail("unknown_deck", `no registered deck ${id}`);
825
+ if (!fs.existsSync(deck.path)) return { removed: await removeDeckAsync(id), trashed: null };
826
+ const real = fs.realpathSync(deck.path);
827
+ if (path.dirname(real) === real || real === os.homedir()) throw fail("not_a_deck", `${real} is not a deck folder`);
828
+ await runDeckOperation(resolveWorkspace(real), { kind: "trash-deck", command: "workspace delete", owner: false }, () => trashFolder(real));
829
+ return { removed: await removeDeckAsync(id), trashed: real };
830
+ }],
831
+ ["GET", /^\/decks\/([^/]+)\/plan$/, async ([id]) => readPlan(resolveDeck(id))],
832
+ // The pack's plan page for the sandboxed frame: HTML from the installed template only.
833
+ ["GET", /^\/decks\/([^/]+)\/render\/plan$/, async ([id], { res }) => {
834
+ const deck = resolveDeck(id);
835
+ const a = adapterOf(deck);
836
+ if (a.blocker) throw fail(a.blocker.code, a.blocker.message);
837
+ if (!a.entry) {
838
+ // Core-owned deck: the page IS the deck's plan.html, re-rendered from
839
+ // plan.md so it never lags a save. The gate's read-only banner names a
840
+ // CLI command; here the Markdown editor sits next to the page, so it goes.
841
+ const { writePlanPage, withPlanWriteChannel, resolvePlanLayouts, planDesignOptions, loadPlanState, normalizePlanComments, planSubjectHash, planApprovedExactly } = await import("../builders/plan.mjs");
842
+ const out = writePlanPage(deck.workspaceDir);
843
+ if (!out) throw fail("not_found", "this deck has no plan yet");
844
+ // the same live write channel `slidemind plan` hands the page, pointed at this daemon
845
+ const html = withPlanWriteChannel(fs.readFileSync(out, "utf8"), {
846
+ embedded: true,
847
+ endpoint: `${PREFIX}/decks/${deck.id}/plan-review?t=${gateTokenFor(deck.id)}`,
848
+ heartbeat: `${PREFIX}/decks/${deck.id}/plan-heartbeat`,
849
+ round: await gateRound(deck, "plan-approval", planSubjectHash(deck.workspaceDir)),
850
+ // the version this page shows: a decision names it, so an answer never covers a plan the human did not see
851
+ subjectHash: planSubjectHash(deck.workspaceDir),
852
+ deckName: deck.name ?? path.basename(deck.path),
853
+ deckId: deck.id,
854
+ // the picker's chrome thumbnails come off this daemon, not the gate's port
855
+ layouts: resolvePlanLayouts(deck.workspaceDir).layouts.map((l) => ({ ...l, previewUrl: l.previewUrl ? `${PREFIX}/decks/${deck.id}${l.previewUrl}` : null })),
856
+ // the design picker: every cached design, its palette's thumbnails off this daemon too;
857
+ // the rest of the menu (designs the backend serves) loads after the page does
858
+ designs: planDesignOptions(deck.workspaceDir, { mapUrl: (u) => `${PREFIX}/decks/${deck.id}${u}` }),
859
+ designsUrl: `${PREFIX}/decks/${deck.id}/plan-designs`,
860
+ designsGate: gateTokenFor(deck.id),
861
+ logoMark: null,
862
+ ledgerSeed: normalizePlanComments(loadPlanState(deck.workspaceDir).comments || []),
863
+ // this exact plan.md is approved (hash-bound): the gate mutes Approve and says so
864
+ approved: planApprovedExactly(deck.workspaceDir) ? { at: loadPlanState(deck.workspaceDir).at ?? null, by: loadPlanState(deck.workspaceDir).actor ?? null } : null,
865
+ // has the agent asked for this review? (a pending task) — before that, approving is premature
866
+ asked: (await pendingOf(deck, "plan-approval"))[0]?.id ?? null,
867
+ });
868
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
869
+ res.end(html);
870
+ return null;
871
+ }
872
+ const data = adapterResult(await dispatchAdapter(a.entry, { method: "render", deckRoot: deck.path, payload: { viewId: "plan" } }));
873
+ if (typeof data?.html !== "string") throw fail("extension_incompatible", "the adapter returned no html for the plan view");
874
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
875
+ res.end(data.html);
876
+ return null;
877
+ }],
878
+ // The embedded gates' wire (the standalone gates' /plan-review, /review and
879
+ // their heartbeats, deck-scoped): a decision lands on the waiting task.
880
+ ["POST", /^\/decks\/([^/]+)\/plan-review$/, async ([id], { body }) => gateDecision(resolveDeck(id), "plan-approval", body)],
881
+ // The plan gate's design picker: the whole menu (cached designs + every design
882
+ // a connected backend serves), and a picked one this machine has not
883
+ // downloaded yet — materialized here, outside any agent sandbox.
884
+ ["GET", /^\/decks\/([^/]+)\/plan-designs$/, async ([id]) => {
885
+ const deck = resolveDeck(id);
886
+ const { listPlanDesigns } = await import("../builders/plan.mjs");
887
+ return { designs: await listPlanDesigns(deck.workspaceDir, { mapUrl: (u) => `${PREFIX}/decks/${deck.id}${u}`, listBrands: deps.designs?.listBrands ?? null }) };
888
+ }],
889
+ ["POST", /^\/decks\/([^/]+)\/plan-designs\/([^/]+)$/, async ([id, presFP]) => {
890
+ const deck = resolveDeck(id);
891
+ const fp = decodeURIComponent(presFP);
892
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(fp)) throw fail("bad_request", "not a design id");
893
+ const { downloadPlanDesign } = await import("../builders/plan.mjs");
894
+ try {
895
+ return { design: await downloadPlanDesign(deck.workspaceDir, fp, { mapUrl: (u) => `${PREFIX}/decks/${deck.id}${u}`, materialize: deps.designs?.materialize ?? null }) };
896
+ } catch (err) {
897
+ if (err?.code === "unknown_design") throw fail("not_found", err.message);
898
+ throw err;
899
+ }
900
+ }],
901
+ ["POST", /^\/decks\/([^/]+)\/review$/, async ([id], { body }) => gateDecision(resolveDeck(id), "preview-review", body)],
902
+ // layout chrome thumbnails for the embedded plan gate (the gate's /brand/chrome/<file>,
903
+ // another cached design's with `?design=` for the design picker)
904
+ ["GET", /^\/decks\/([^/]+)\/brand\/chrome\/([^/]+)$/, async ([id, name], { res, url }) => {
905
+ const deck = resolveDeck(id);
906
+ const { planChromeDir } = await import("../builders/plan.mjs");
907
+ const chromeDir = planChromeDir(deck.workspaceDir, url.searchParams.get("design"));
908
+ const file = path.basename(name);
909
+ if (!chromeDir || !/^chrome-[\w-]+\.(png|webp|jpe?g)$/i.test(file)) throw fail("not_found", "no such chrome image");
910
+ let data;
911
+ try {
912
+ data = fs.readFileSync(path.join(chromeDir, file));
913
+ } catch {
914
+ throw fail("not_found", "no such chrome image");
915
+ }
916
+ res.writeHead(200, { "Content-Type": MIME[path.extname(file).toLowerCase()] ?? "application/octet-stream", "Cache-Control": "no-store" });
917
+ res.end(data);
918
+ return null;
919
+ }],
920
+ ["GET", /^\/decks\/([^/]+)\/plan-heartbeat$/, async ([id]) => {
921
+ const deck = resolveDeck(id);
922
+ const { planSubjectHash } = await import("../builders/plan.mjs");
923
+ return gateBeat(deck, "plan-approval", "slidemind-plan", planSubjectHash(deck.workspaceDir));
924
+ }],
925
+ ["GET", /^\/decks\/([^/]+)\/preview-heartbeat$/, async ([id]) => {
926
+ const deck = resolveDeck(id);
927
+ const scan = previewScan(deck.workspaceDir);
928
+ return gateBeat(deck, "preview-review", "slidemind-review", previewHashCached(deck.workspaceDir, scan), { scan });
929
+ }],
930
+ // GET /decks/:id/tasks/:taskId/wait?timeout=<s> — `slidemind tasks wait` (scripts) long-polls
931
+ // here; the response is the saved task once it settles. A CLI wait may carry its edit
932
+ // session's owner token (`X-Slidemind-Owner`): the answer to this task then re-enters that session.
933
+ ["GET", /^\/decks\/([^/]+)\/tasks\/([^/]+)\/wait$/, async ([id, taskId], { url, req, res, who }) => {
934
+ const deck = resolveDeck(id);
935
+ const tasksMod = await need(loadTasks, "tasks");
936
+ const asked = Number(url.searchParams.get("timeout"));
937
+ // 0 is a real answer ("tell me now"), not "use the default"
938
+ const timeoutMs = Math.min(Math.max(url.searchParams.has("timeout") && Number.isFinite(asked) ? asked : 45, 0), 3600) * 1000;
939
+ const key = `${deck.id}:${taskId}`;
940
+ const owner = who?.kind === "cli" && typeof req.headers["x-slidemind-owner"] === "string" ? req.headers["x-slidemind-owner"] : null;
941
+ const bump = (d) => {
942
+ const w = waiters.get(key) ?? { open: 0, owner: null };
943
+ w.open = Math.max(0, w.open + d);
944
+ if (d > 0 && owner) w.owner = owner;
945
+ if (w.open === 0) w.owner = null;
946
+ waiters.set(key, w);
947
+ };
948
+ const ac = new AbortController();
949
+ req.on("close", () => ac.abort());
950
+ bump(1);
951
+ let out;
952
+ try {
953
+ out = typeof tasksMod.waitTask === "function" ? await tasksMod.waitTask(deck.path, taskId, { timeoutMs, signal: ac.signal }) : await pollTask(tasksMod, deck.path, taskId, timeoutMs, ac.signal);
954
+ } catch (err) {
955
+ bump(-1);
956
+ throw err;
957
+ }
958
+ bump(-1);
959
+ if (ac.signal.aborted || res.writableEnded) return null;
960
+ sendJson(res, 200, { ok: true, task: out.task, pending: out.pending });
961
+ return null;
962
+ }],
963
+ ["PUT", /^\/decks\/([^/]+)\/plan$/, async ([id], { body }) => {
964
+ const deck = resolveDeck(id);
965
+ if (!body.operationId || !body.base || typeof body.base.hash !== "string" || body.value === undefined) {
966
+ throw fail("bad_request", "operationId, base:{hash,value} and value are required");
967
+ }
968
+ const edit = await need(loadEdit, "plan editing");
969
+ const adapter = adapterOf(deck);
970
+ if (adapter.blocker) throw fail(adapter.blocker.code, adapter.blocker.message);
971
+ const syncMod = await loadSync();
972
+ if (syncMod?.approvalInProgress?.(deck.workspaceDir)) throw fail("approval_in_progress", "an approval of this plan is in flight; your edits are kept and can be saved once it settles");
973
+ const result = await edit.savePlanLocal({ workspaceDir: deck.workspaceDir, adapter, operationId: body.operationId, base: body.base, value: body.value, owner: false });
974
+ if (result?.code) throw fail(result.code, result.message ?? result.code, result);
975
+ events.invalidate({ type: "plan", deckId: deck.id });
976
+ // An edit made here, on exactly the version the editor loaded, is the
977
+ // reviewer's own: a pending plan task follows it rather than going stale.
978
+ if (result?.ok && result.previous === body.base.hash) {
979
+ const tasksMod = await loadTasks();
980
+ const moved = (await tasksMod?.followPlanEdit?.(deck.path, { from: result.previous, to: result.hash }, { owner: false })) ?? [];
981
+ if (moved.length) events.invalidate({ type: "tasks", deckId: deck.id });
982
+ }
983
+ return result;
984
+ }],
985
+ ["POST", /^\/decks\/([^/]+)\/actions\/([^/]+)$/, async ([id, action], { body }) => {
986
+ const deck = resolveDeck(id);
987
+ const d = await describe(deck);
988
+ if (d.adapter.blocker) throw fail(d.adapter.blocker.code, d.adapter.blocker.message);
989
+ if (!d.actions.includes(action)) throw fail("not_found", `${action} is not an action this deck offers`);
990
+ const method = ACTION_METHOD[action];
991
+ if (!method) throw fail("not_implemented", `${action} has no workspace binding yet`);
992
+ const result = await track(
993
+ runDeckOperation(deck.workspaceDir, { operationId: body.operationId, kind: action, expected: body.expectedHash ?? null, owner: false }, (op) =>
994
+ dispatchAdapter(d.adapter.entry, {
995
+ method,
996
+ deckRoot: deck.path,
997
+ payload: { expectedHash: body.expectedHash ?? null, ...(body.payload ?? {}) },
998
+ operationId: op.intent.id,
999
+ ownerToken: op.token,
1000
+ }),
1001
+ ),
1002
+ );
1003
+ events.invalidate({ type: "plan", deckId: deck.id });
1004
+ return { result: adapterResult(result) };
1005
+ }],
1006
+ ["GET", /^\/tasks$/, async (_, { url }) => {
1007
+ const tasksMod = await need(loadTasks, "tasks");
1008
+ const status = url.searchParams.get("status") ?? undefined;
1009
+ const deckId = url.searchParams.get("deck");
1010
+ const decks = deckId ? [resolveDeck(deckId)] : listDecks({ limit: 100 }).decks.filter((d) => !d.missing);
1011
+ const tasks = [];
1012
+ for (const d of decks) for (const t of await tasksMod.listTasks(d.path, { status })) tasks.push({ ...t, deckId: d.id, deckName: d.name ?? null, deckPath: d.path });
1013
+ return { tasks };
1014
+ }],
1015
+ ["POST", /^\/decks\/([^/]+)\/tasks$/, async ([id], { body, url }) => {
1016
+ const deck = resolveDeck(id);
1017
+ if (!taskActionsFor(body.kind)) throw fail("bad_request", "kind is required (plan-edit, plan-approval, preview-review, sync-conflict)");
1018
+ if (deckFormat(deck.path) === "legacy") throw fail("unsupported_format", "this deck still uses the pre-workspace layout — its migration into .slidemind/ failed, see the workspace log");
1019
+ const tasksMod = await need(loadTasks, "tasks");
1020
+ const adapter = adapterOf(deck);
1021
+ if (adapter.blocker) throw fail(adapter.blocker.code, adapter.blocker.message);
1022
+ const { adapterId, subject } = await deriveTaskSubject({ deckRoot: deck.path, adapter, kind: body.kind });
1023
+ const out = await tasksMod.requestTask(deck.path, { kind: body.kind, adapterId, subject }, { owner: false, verify: taskEvidenceVerifier({ deckRoot: deck.path, adapter }) });
1024
+ const task = out?.task ?? out;
1025
+ events.invalidate({ type: "tasks", deckId: deck.id, taskId: task?.id });
1026
+ return { task, existing: out?.existing ?? false, url: `${url.origin}/deck/${deck.id}/${viewOf(task?.kind)}` };
1027
+ }],
1028
+ // POST /decks/:id/attention — the agent just asked (`slidemind plan|review`, `tasks request`):
1029
+ // tell an open page (SSE) so it shows the question. Nothing opens a browser: the user
1030
+ // follows the link the agent posted (doc 21).
1031
+ ["POST", /^\/decks\/([^/]+)\/attention$/, async ([id], { body, who }) => {
1032
+ if (who?.kind !== "cli") throw fail("unauthorized", "only the CLI announces a question");
1033
+ const deck = resolveDeck(id);
1034
+ // `slidemind workspace <deck>` with a page already open: show that deck there
1035
+ if (body.focus === true) {
1036
+ events.invalidate({ type: "focus", deckId: deck.id });
1037
+ return { pages: events.clientCount ?? 0, focused: true };
1038
+ }
1039
+ const kind = body.kind === "preview-review" ? "preview-review" : "plan-approval";
1040
+ const taskId = typeof body.taskId === "string" ? body.taskId : null;
1041
+ events.invalidate({ type: "tasks", deckId: deck.id, taskId });
1042
+ // The same question asked again (the user said "continue" before answering)
1043
+ // is told to the page once (it switches tab / shows a toast), never again.
1044
+ const key = taskId ?? deck.id;
1045
+ const first = !attended.has(key);
1046
+ if (first) {
1047
+ attended.add(key);
1048
+ events.invalidate({ type: "attention", deckId: deck.id, kind, taskId });
1049
+ }
1050
+ return { pages: events.clientCount ?? 0, first };
1051
+ }],
1052
+ // Put a question away without answering it: the human says it no longer applies.
1053
+ // "cancelled", not "superseded" — nothing replaced it, someone dismissed it.
1054
+ ["POST", /^\/decks\/([^/]+)\/tasks\/([^/]+)\/dismiss$/, async ([id, taskId]) => {
1055
+ const deck = resolveDeck(id);
1056
+ const tasksMod = await need(loadTasks, "tasks");
1057
+ const task = await tasksMod.cancelTask(deck.path, taskId, { owner: false });
1058
+ events.invalidate({ type: "tasks", deckId: deck.id, taskId });
1059
+ return { task };
1060
+ }],
1061
+ ["POST", /^\/decks\/([^/]+)\/tasks\/([^/]+)\/respond$/, async ([id, taskId], { body }) => {
1062
+ const deck = resolveDeck(id);
1063
+ if (!body.operationId || typeof body.action !== "string") throw fail("bad_request", "operationId and action are required");
1064
+ const tasksMod = await need(loadTasks, "tasks");
1065
+ const adapter = adapterOf(deck);
1066
+ if (adapter.blocker) throw fail(adapter.blocker.code, adapter.blocker.message);
1067
+ const payload = body.payload ?? {};
1068
+ const { actor, selfDeclared } = taskActor(payload);
1069
+ const verify = taskEvidenceVerifier({ deckRoot: deck.path, adapter });
1070
+ let result;
1071
+ try {
1072
+ result = await track(respond(deck.path, taskId, { operationId: body.operationId, expectedHash: body.expectedHash ?? null, action: body.action, payload, actor, selfDeclared }, { adapter, owner: ownerOn(deck.id, taskId) ?? false, verify }));
1073
+ } catch (err) {
1074
+ // the content moved on under the task: void it and tell the page to ask again about what is there now
1075
+ if (err?.code === "stale_subject" && err.details?.supersede) {
1076
+ await tasksMod.supersedeTask?.(deck.path, taskId, { owner: false });
1077
+ events.invalidate({ type: "tasks", deckId: deck.id, taskId });
1078
+ }
1079
+ throw err;
1080
+ }
1081
+ events.invalidate({ type: "tasks", deckId: deck.id, taskId });
1082
+ // what the page copies for the user to paste: the answer plus the command that receives it
1083
+ const answered = result?.task ?? null;
1084
+ const receive = receiveCommand({ kind: answered?.kind, adapterId: answered?.adapterId, deckPath: deck.path, taskId });
1085
+ const message = answered ? pasteMessage({ kind: answered.kind, action: body.action, receive }) : null;
1086
+ return { ...result, receive, message };
1087
+ }],
1088
+ ["GET", /^\/jobs\/([^/]+)$/, async ([id]) => {
1089
+ const job = readJob(id);
1090
+ if (!job) throw fail("unknown_job", `no job ${id}`);
1091
+ return { job };
1092
+ }],
1093
+ ["GET", /^\/events$/, async (_, { req, res }) => {
1094
+ events.subscribe(req, res);
1095
+ return null;
1096
+ }],
1097
+ // wave 3 (remote association / autosync) replaces these; the shapes are the contract
1098
+ // Link intake: every network step is a job (202 + id); the page polls GET /jobs/:id.
1099
+ ["POST", /^\/remote\/resolve$/, async (_, { body }) => {
1100
+ if (typeof body.url !== "string" || !body.url) throw fail("bad_request", "url is required");
1101
+ if (body.deckId) resolveDeck(body.deckId);
1102
+ const links = await need(loadLinks, "remote links");
1103
+ const job = await track(links.resolveRemoteLink({ url: body.url, backendURL: body.backendURL ?? null, deckId: body.deckId ?? null }, remoteDeps));
1104
+ if (job.result?.deckId) events.invalidate({ type: "library" });
1105
+ return { status: 202, body: { jobId: job.id, job } };
1106
+ }],
1107
+ ["GET", /^\/remote\/children$/, async (_, { url }) => {
1108
+ const selectionId = url.searchParams.get("selection");
1109
+ if (!selectionId) throw fail("bad_request", "selection is required");
1110
+ const links = await need(loadLinks, "remote links");
1111
+ return links.listSelectionChildren({ selectionId, cursor: url.searchParams.get("cursor") }, remoteDeps);
1112
+ }],
1113
+ ["POST", /^\/jobs\/([^/]+)\/select$/, async ([id], { body }) => {
1114
+ if (!body.selectionId || !body.choiceId) throw fail("bad_request", "selectionId and choiceId are required");
1115
+ if (body.localTarget) resolveDeck(body.localTarget);
1116
+ const links = await need(loadLinks, "remote links");
1117
+ const job = await track(links.selectChoice(id, { selectionId: body.selectionId, choiceId: body.choiceId, localTarget: body.localTarget ?? null }, remoteDeps));
1118
+ if (job.result?.deckId) events.invalidate({ type: "library" });
1119
+ return { status: 202, body: { jobId: job.id, job } };
1120
+ }],
1121
+ ["POST", /^\/decks\/([^/]+)\/remote\/connect$/, async ([id], { body }) => {
1122
+ const deck = resolveDeck(id);
1123
+ if (!body.folderRef || (typeof body.folderRef !== "string" && !(body.folderRef.driveId && body.folderRef.itemId))) {
1124
+ throw fail("bad_request", "folderRef is a link or {driveId,itemId}");
1125
+ }
1126
+ const st = await need(loadStatus, "remote destinations");
1127
+ const result = await track(st.connectDestination({ deckRoot: deck.path, folderRef: body.folderRef, pptxName: body.pptxName ?? null, backendURL: body.backendURL ?? null }, remoteDeps));
1128
+ events.invalidate({ type: "plan", deckId: deck.id });
1129
+ // an editable plan with writes enabled: the record is created in the background (7.7)
1130
+ let activation = null;
1131
+ if (result.shouldCreatePlan) {
1132
+ const syncMod = await loadSync();
1133
+ if (typeof syncMod?.activateDestination === "function") {
1134
+ activation = { started: true };
1135
+ track(syncMod.activateDestination(deck.workspaceDir, { adapter: adapterOf(deck), owner: false }).then(() => events.invalidate({ type: "plan", deckId: deck.id })));
1136
+ }
1137
+ }
1138
+ // a deck built before its folder was connected: that build goes up now;
1139
+ // every later build publishes itself (build-pptx)
1140
+ let upload = null;
1141
+ if (result.shouldPublish && typeof st.uploadToDestination === "function") {
1142
+ upload = { started: true };
1143
+ track(st.uploadToDestination(deck.path, remoteDeps).then(() => events.invalidate({ type: "plan", deckId: deck.id })));
1144
+ }
1145
+ return { ...result, activation, upload, remote: (await remoteCard(deck)).remote };
1146
+ }],
1147
+ // autosync: every network step is a 202; the page re-GETs the deck for the outcome
1148
+ ["POST", /^\/decks\/([^/]+)\/remote\/sync$/, async ([id]) => {
1149
+ const deck = resolveDeck(id);
1150
+ const drainOnce = await fn(loadSync, "drainOnce", "plan synchronization");
1151
+ track(drainOnce(deck.workspaceDir, { adapter: adapterOf(deck), owner: false }).then(() => events.invalidate({ type: "plan", deckId: deck.id })));
1152
+ return { status: 202, body: { sync: await syncCard(deck.workspaceDir) } };
1153
+ }],
1154
+ ["POST", /^\/decks\/([^/]+)\/remote\/refresh$/, async ([id]) => {
1155
+ const deck = resolveDeck(id);
1156
+ const refreshOnce = await fn(loadSync, "refreshOnce", "shared plan refresh");
1157
+ track(refreshOnce(deck.workspaceDir, { adapter: adapterOf(deck), owner: false }).then(() => events.invalidate({ type: "plan", deckId: deck.id })));
1158
+ return { status: 202, body: { sync: await syncCard(deck.workspaceDir) } };
1159
+ }],
1160
+ ["POST", /^\/decks\/([^/]+)\/remote\/activate$/, async ([id]) => {
1161
+ const deck = resolveDeck(id);
1162
+ const activateDestination = await fn(loadSync, "activateDestination", "shared plan creation");
1163
+ // a published PPTX without a saved folder implies one: its parent (one provider round, needs a session)
1164
+ const st = await loadStatus();
1165
+ if (st?.ensureDestination) {
1166
+ try {
1167
+ await st.ensureDestination(deck.path, {}, remoteDeps);
1168
+ } catch (err) {
1169
+ if (!err?.code) throw err;
1170
+ throw fail(err.code, err.message, err.details);
1171
+ }
1172
+ }
1173
+ track(activateDestination(deck.workspaceDir, { adapter: adapterOf(deck), owner: false }).then(() => events.invalidate({ type: "plan", deckId: deck.id })));
1174
+ return { status: 202, body: { sync: await syncCard(deck.workspaceDir) } };
1175
+ }],
1176
+ ["POST", /^\/decks\/([^/]+)\/conflicts\/resolve$/, async ([id], { body }) => {
1177
+ const deck = resolveDeck(id);
1178
+ if (typeof body.conflictId !== "string" || typeof body.expectedHash !== "string" || body.value === undefined) {
1179
+ throw fail("bad_request", "conflictId, expectedHash and value are required");
1180
+ }
1181
+ const resolveConflict = await fn(loadSync, "resolveConflict", "conflict resolution");
1182
+ const result = await track(resolveConflict(deck.workspaceDir, { adapter: adapterOf(deck), owner: false, conflictId: body.conflictId, expectedHash: body.expectedHash, value: body.value }));
1183
+ if (result?.ok === false || result?.code) throw fail(result.code, result.message ?? result.code, { ...result, supersede: result.supersede === true });
1184
+ events.invalidate({ type: "plan", deckId: deck.id });
1185
+ events.invalidate({ type: "tasks", deckId: deck.id });
1186
+ return { ...result, sync: await syncCard(deck.workspaceDir) };
1187
+ }],
1188
+ ];
1189
+
1190
+ const handle = async function handle(req, res, { url }) {
1191
+ const pathname = url.pathname;
1192
+ // Static surfaces (authenticated like everything else).
1193
+ const who = auth.authenticate(req);
1194
+ // The session slides inside authenticate() on ANY request — the page shell and
1195
+ // its assets included — so the cookie is re-sent right here, whatever answers:
1196
+ // an active user is never signed out on a date fixed at their first sign-in.
1197
+ if (who?.kind === "browser" && typeof auth.renewalHeader === "function") {
1198
+ const renew = auth.renewalHeader(req);
1199
+ if (renew) res.setHeader("Set-Cookie", renew);
1200
+ }
1201
+ const isApi = pathname.startsWith(PREFIX);
1202
+ if (!isApi) {
1203
+ if (req.method !== "GET") return false;
1204
+ // the app shell: `/`, `/deck/<id>…` — unauthenticated, it only holds the link-key exchange
1205
+ if (pathname === "/" || pathname.startsWith("/deck/")) return serveContained(uiRoot, "index.html", res) || false;
1206
+ if (!pathname.startsWith("/ui/")) return false;
1207
+ // the stylesheet is linked before the bootstrap exchange; it carries nothing private
1208
+ if (!who && pathname !== "/ui/styles.css") return sendError(res, 401, "unauthorized", "sign in through `slidemind workspace`"), true;
1209
+ return serveFiles(pathname, res);
1210
+ }
1211
+ const rel = pathname.slice(PREFIX.length) || "/";
1212
+ try {
1213
+ if (rel === "/session" && req.method === "POST") {
1214
+ const s = auth.exchange((await readJson(req)).bootstrap);
1215
+ if (!s) throw fail("unauthorized", "the link carries no key of this workspace");
1216
+ res.setHeader("Set-Cookie", auth.cookieHeader(s.cookie));
1217
+ return sendJson(res, 200, { ok: true, csrfToken: s.csrfToken }), true;
1218
+ }
1219
+ // A tab that holds the cookie but not this tab's CSRF token (a second tab,
1220
+ // a reload after sessionStorage was cleared) asks for it here; only the
1221
+ // cookie holder can read the answer, so nothing new is exposed.
1222
+ if (rel === "/session" && req.method === "GET") {
1223
+ if (who?.kind !== "browser") throw fail("unauthorized", "no browser session");
1224
+ return sendJson(res, 200, { ok: true, csrfToken: who.csrfToken }), true;
1225
+ }
1226
+ if (!who) throw fail("unauthorized", "missing or invalid credential");
1227
+ const previewPage = rel.match(/^\/decks\/([^/]+)\/preview\/presentation\.html$/);
1228
+ if (previewPage && req.method === "GET") {
1229
+ const deck = resolveDeck(previewPage[1]);
1230
+ let pageHtml;
1231
+ try {
1232
+ pageHtml = previewPageHtml(deck);
1233
+ } catch {
1234
+ return sendError(res, 404, "not_found", "no preview built yet"), true;
1235
+ }
1236
+ if (url.searchParams.has("thumb")) {
1237
+ // the rail's per-slide frames: the same page, without the gate
1238
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
1239
+ res.end(pageHtml);
1240
+ return true;
1241
+ }
1242
+ // the deck's own preview page, handed the live review gate (pins,
1243
+ // approve, comments) exactly as `slidemind review` serves it
1244
+ const logo = reviewLogoFile(deck.workspaceDir);
1245
+ const html = injectReview(
1246
+ pageHtml,
1247
+ {
1248
+ embedded: true,
1249
+ deck: deck.name ?? deck.id,
1250
+ ledgerSeed: normalizeReviewComments(loadReviewState(deck.workspaceDir).comments || []),
1251
+ endpoint: `${PREFIX}/decks/${deck.id}/review?t=${gateTokenFor(deck.id)}`,
1252
+ heartbeat: `${PREFIX}/decks/${deck.id}/preview-heartbeat`,
1253
+ // the deck passed its one review: the gate mutes Approve and says later changes go straight into the .pptx
1254
+ // (an adopted deck never needed one: settled the same way, never worded as an approval)
1255
+ approved: ((a) => (a ? { at: a.at, by: a.actor } : isEditRouteDeck(deck.workspaceDir) ? { at: null, by: null, notNeeded: true } : null))(loadReviewState(deck.workspaceDir).approval),
1256
+ asked: (await pendingOf(deck, "preview-review"))[0]?.id ?? null,
1257
+ // the version this page shows: a decision names it, so an approval never covers slides nobody saw
1258
+ subjectHash: previewBuiltFrom(deck.workspaceDir) ?? previewHash(deck.workspaceDir),
1259
+ deckName: deck.name ?? path.basename(deck.path),
1260
+ deckId: deck.id,
1261
+ // the slides changed after this preview was built: what the page shows is not what would be approved
1262
+ previewStale: previewState(deck.workspaceDir)?.stale === true,
1263
+ },
1264
+ fs.readFileSync(REVIEW_OVERLAY_PATH, "utf8"),
1265
+ logo ? `${PREFIX}/decks/${deck.id}/brand/assets/${logo}` : null,
1266
+ await gateRound(deck, "preview-review", previewHash(deck.workspaceDir)),
1267
+ );
1268
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
1269
+ res.end(html);
1270
+ return true;
1271
+ }
1272
+ if (rel.match(/^\/decks\/[^/]+\/(?:preview|assets|brand\/assets)\//)) return serveFiles(pathname, res) || (sendError(res, 404, "not_found", "no such file"), true);
1273
+ let matched = null;
1274
+ let params = null;
1275
+ let methodSeen = false;
1276
+ for (const [method, re, fn] of routes) {
1277
+ const m = rel.match(re);
1278
+ if (!m) continue;
1279
+ methodSeen = true;
1280
+ if (method !== req.method) continue;
1281
+ matched = fn;
1282
+ params = m.slice(1);
1283
+ break;
1284
+ }
1285
+ if (!matched) {
1286
+ if (methodSeen) throw fail("method_not_allowed", `${req.method} not allowed here`);
1287
+ return false;
1288
+ }
1289
+ const mutation = req.method !== "GET" && req.method !== "HEAD";
1290
+ const gateToken = url.searchParams.get("t");
1291
+ // Both halves must be REAL: an unknown `t` reads back undefined, and off the
1292
+ // two gate routes the captured deck id is undefined too — `undefined === undefined`
1293
+ // would wave any junk `?t=` past both the CSRF and the origin check on every
1294
+ // mutation route, so the deck id is required to exist before it can match.
1295
+ const gatedDeck = (rel.match(/^\/decks\/([^/]+)\/(?:plan-review|review|plan-designs\/[^/]+)$/) ?? [])[1];
1296
+ const gated = !!gateToken && gatedDeck !== undefined && gateTokenValid(gateToken, gatedDeck);
1297
+ if (mutation && !gated) {
1298
+ const denied = auth.allowMutation(req, who, `http://${req.headers.host}`);
1299
+ if (denied) throw fail(denied, denied === "csrf_required" ? "X-CSRF-Token missing or wrong" : "the request did not come from this workspace's page");
1300
+ }
1301
+ const body = mutation ? await readJson(req) : {};
1302
+ const out = await matched(params, { body, req, res, url: new URL(pathname + url.search, `http://${req.headers.host}`), who });
1303
+ if (out === null) return true; // the handler owns the response (SSE)
1304
+ if (out && typeof out === "object" && "status" in out && "body" in out && Object.keys(out).length === 2) {
1305
+ return sendJson(res, out.status, { ok: true, ...out.body }), true;
1306
+ }
1307
+ return sendJson(res, 200, { ok: true, ...out }), true;
1308
+ } catch (err) {
1309
+ if (res.headersSent) return true;
1310
+ const { status, code, retryable } = mapCode(err);
1311
+ const body = { ok: false, error: { code, message: err?.message ?? "internal error", retryable } };
1312
+ if (err?.details && typeof err.details === "object") body.error.details = err.details;
1313
+ // who holds the deck, so the page can say "your agent is working on it since …" and retry
1314
+ if (code === "deck_busy" && err?.busy && typeof err.busy === "object") {
1315
+ body.error.details = { ...(body.error.details ?? {}), since: err.busy.startedAt ?? null, command: err.busy.command ?? null, session: err.busy.kind === "session" };
1316
+ }
1317
+ sendJson(res, status, body);
1318
+ return true;
1319
+ }
1320
+ };
1321
+ // Every module a route imports lazily, loaded NOW: a background `npm install -g`
1322
+ // that swaps the package files under a running daemon cannot leave a route
1323
+ // half-loaded (ENOENT, or new code against old dependencies) — the daemon keeps
1324
+ // serving the code it started with until it restarts itself onto the new one.
1325
+ handle.preload = async () => {
1326
+ await Promise.all([loadTasks(), loadEdit(), loadLinks(), loadStatus(), loadSync(), import("../cli/status.mjs"), import("../builders/plan.mjs")].map((p) => Promise.resolve(p).catch(() => null)));
1327
+ };
1328
+ /** Agent waits open right now — the daemon restarts itself onto new code only when this is 0. */
1329
+ handle.openWaits = openWaits;
1330
+ return handle;
1331
+ }