slidemind 0.3.20 → 0.4.1

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 (187) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +592 -16
  4. package/LICENSE +4 -0
  5. package/README.md +203 -8
  6. package/THIRD-PARTY-NOTICES.md +201 -0
  7. package/bin/slidemind.mjs +50 -2
  8. package/package.json +5 -3
  9. package/skills/create-slides/SKILL.md +484 -886
  10. package/skills/search/SKILL.md +46 -23
  11. package/src/bridge/build-and-push.mjs +548 -73
  12. package/src/bridge/bundle.mjs +100 -31
  13. package/src/bridge/capture.mjs +14 -17
  14. package/src/bridge/client/brands.mjs +157 -18
  15. package/src/bridge/client/engine-routing.mjs +48 -13
  16. package/src/bridge/client/engine-search.mjs +24 -1
  17. package/src/bridge/client/render.mjs +27 -1
  18. package/src/bridge/client/transport.mjs +499 -23
  19. package/src/bridge/client.mjs +10 -0
  20. package/src/bridge/config.mjs +67 -6
  21. package/src/bridge/engine-download.mjs +0 -10
  22. package/src/bridge/graph-auth.mjs +55 -3
  23. package/src/bridge/image-intent-envelope.mjs +239 -0
  24. package/src/bridge/package-preview.mjs +67 -0
  25. package/src/bridge/sharepoint.mjs +914 -0
  26. package/src/builders/asset-fetch.mjs +203 -0
  27. package/src/builders/block-dividers.mjs +83 -0
  28. package/src/builders/build.mjs +171 -87
  29. package/src/builders/card-height.mjs +294 -0
  30. package/src/builders/chart-hole.mjs +183 -0
  31. package/src/builders/chart-jsx.mjs +410 -0
  32. package/src/builders/chart-labels.mjs +326 -0
  33. package/src/builders/concept-geometry.mjs +370 -0
  34. package/src/builders/concept-intent.mjs +200 -0
  35. package/src/builders/concept-layout.mjs +79 -0
  36. package/src/builders/concept-lines.mjs +509 -0
  37. package/src/builders/concept-lists.mjs +270 -0
  38. package/src/builders/concept-marks.mjs +84 -0
  39. package/src/builders/concept-relationships.mjs +12 -0
  40. package/src/builders/concept-spec.mjs +645 -0
  41. package/src/builders/concept-text.mjs +140 -0
  42. package/src/builders/concept-waves.mjs +57 -0
  43. package/src/builders/copy-fit.mjs +139 -0
  44. package/src/builders/design-tokens.mjs +347 -0
  45. package/src/builders/draft-check.mjs +552 -340
  46. package/src/builders/draft-lines.mjs +369 -0
  47. package/src/builders/edit-route.mjs +29 -0
  48. package/src/builders/figure-sketch.mjs +375 -0
  49. package/src/builders/fold-sets.mjs +639 -0
  50. package/src/builders/gate-server.mjs +9 -4
  51. package/src/builders/gate-session.js +273 -12
  52. package/src/builders/gate-shell.js +1494 -0
  53. package/src/builders/image-intent-check.mjs +308 -0
  54. package/src/builders/image-use-reconcile.mjs +107 -0
  55. package/src/builders/kit-helpers.mjs +121 -0
  56. package/src/builders/layout-names.mjs +32 -0
  57. package/src/builders/legacy-reference-uses.mjs +179 -0
  58. package/src/builders/line-fit.mjs +224 -0
  59. package/src/builders/line-native.mjs +895 -0
  60. package/src/builders/line-relations.mjs +230 -0
  61. package/src/builders/lint-fix.mjs +117 -9
  62. package/src/builders/lint.mjs +25 -20
  63. package/src/builders/list-jsx.mjs +88 -0
  64. package/src/builders/placement.mjs +133 -0
  65. package/src/builders/plan-checks.mjs +135 -0
  66. package/src/builders/plan-comments.mjs +19 -0
  67. package/src/builders/plan-handlers.mjs +95 -0
  68. package/src/builders/plan-markdown-edit.mjs +3 -3
  69. package/src/builders/plan-overlay.js +1530 -1608
  70. package/src/builders/plan.mjs +689 -186
  71. package/src/builders/preset-definitions.json +1 -0
  72. package/src/builders/preset-eval.mjs +244 -0
  73. package/src/builders/preview.html +51 -120
  74. package/src/builders/red-thread-backbone.mjs +135 -36
  75. package/src/builders/repair.mjs +205 -0
  76. package/src/builders/repeat-groups.mjs +540 -0
  77. package/src/builders/resolve-skill.mjs +5 -4
  78. package/src/builders/review-overlay.js +296 -293
  79. package/src/builders/review.mjs +305 -84
  80. package/src/builders/rule-shift.mjs +96 -0
  81. package/src/builders/seat.mjs +354 -0
  82. package/src/builders/settle-driver.mjs +236 -0
  83. package/src/builders/settle.mjs +1448 -0
  84. package/src/builders/slide-prep.mjs +67 -0
  85. package/src/builders/spec-assets.mjs +236 -0
  86. package/src/builders/spec-match.mjs +56 -0
  87. package/src/builders/stack.mjs +413 -0
  88. package/src/builders/storyboard-markdown.mjs +70 -27
  89. package/src/builders/storyboard-schema.mjs +54 -114
  90. package/src/builders/text-blocks.mjs +212 -0
  91. package/src/builders/text-card-fit.mjs +552 -0
  92. package/src/builders/text-stack.mjs +276 -0
  93. package/src/builders/transcribe.mjs +1069 -0
  94. package/src/cli/assets.mjs +164 -0
  95. package/src/cli/common.mjs +173 -12
  96. package/src/cli/concept-spec-support.mjs +76 -0
  97. package/src/cli/concepts.mjs +1002 -103
  98. package/src/cli/deck.mjs +401 -65
  99. package/src/cli/diagnose.mjs +4 -2
  100. package/src/cli/engine-helpers.mjs +17 -10
  101. package/src/cli/engine-results.mjs +76 -3
  102. package/src/cli/help.mjs +232 -28
  103. package/src/cli/install.mjs +9 -0
  104. package/src/cli/maintenance.mjs +104 -0
  105. package/src/cli/read.mjs +2 -1
  106. package/src/cli/ready.mjs +6 -3
  107. package/src/cli/record.mjs +522 -0
  108. package/src/cli/remote.mjs +818 -0
  109. package/src/cli/revision.mjs +686 -0
  110. package/src/cli/search.mjs +30 -11
  111. package/src/cli/session.mjs +56 -13
  112. package/src/cli/setup.mjs +46 -2
  113. package/src/cli/status.mjs +142 -33
  114. package/src/cli/tasks.mjs +246 -0
  115. package/src/cli/transcribe.mjs +854 -0
  116. package/src/cli/workspace-ui.mjs +260 -0
  117. package/src/cli/workspace.mjs +212 -34
  118. package/src/install-channel.mjs +64 -7
  119. package/src/lib/adopt.mjs +346 -25
  120. package/src/lib/concept-spec-binding.mjs +96 -0
  121. package/src/lib/conversions.mjs +70 -6
  122. package/src/lib/convert-folded.mjs +107 -0
  123. package/src/lib/deck-paths.mjs +418 -0
  124. package/src/lib/durable-ids.mjs +47 -0
  125. package/src/lib/edit-operations.mjs +316 -0
  126. package/src/lib/edit-state.mjs +1244 -0
  127. package/src/lib/edit-transitions.mjs +120 -0
  128. package/src/lib/extensions.mjs +501 -0
  129. package/src/lib/image-assets.mjs +465 -0
  130. package/src/lib/image-derivatives.mjs +234 -0
  131. package/src/lib/image-intent-fingerprint.mjs +70 -0
  132. package/src/lib/image-intent-tags.mjs +176 -0
  133. package/src/lib/image-intent.mjs +802 -0
  134. package/src/lib/image-use-overrides.mjs +279 -0
  135. package/src/lib/inline-components.mjs +613 -0
  136. package/src/lib/lint-converted.mjs +68 -4
  137. package/src/lib/merge-json.mjs +163 -0
  138. package/src/lib/overwrite-guard.mjs +3 -3
  139. package/src/lib/presentation-context.mjs +531 -0
  140. package/src/lib/preservation-tags.mjs +157 -0
  141. package/src/lib/preservation-verdict.mjs +1342 -0
  142. package/src/lib/publish.mjs +1557 -0
  143. package/src/lib/rebase.mjs +945 -0
  144. package/src/lib/reconcile-ack.mjs +4 -6
  145. package/src/lib/reconcile.mjs +62 -24
  146. package/src/lib/record-client.mjs +175 -0
  147. package/src/lib/record-state.mjs +151 -0
  148. package/src/lib/remote.mjs +661 -0
  149. package/src/lib/revision.mjs +1210 -0
  150. package/src/lib/slide-guids.mjs +4 -6
  151. package/src/lib/slide-preservation.mjs +423 -0
  152. package/src/lib/slide-selection.mjs +9 -0
  153. package/src/lib/workspace-transaction.mjs +117 -0
  154. package/src/pptx/content-digest.mjs +334 -0
  155. package/src/pptx/deck-assemble.mjs +1278 -0
  156. package/src/pptx/fill/engine.mjs +2 -3
  157. package/src/pptx/fill/freeform.mjs +6 -3
  158. package/src/pptx/ooxml.mjs +20 -0
  159. package/src/pptx/package-evidence.mjs +383 -0
  160. package/src/pptx/tags.mjs +163 -11
  161. package/src/update-check.mjs +22 -4
  162. package/src/workspace/adapters.mjs +222 -0
  163. package/src/workspace/auth.mjs +256 -0
  164. package/src/workspace/autostart.mjs +204 -0
  165. package/src/workspace/daemon.mjs +360 -0
  166. package/src/workspace/discovery.mjs +180 -0
  167. package/src/workspace/edit.mjs +167 -0
  168. package/src/workspace/events.mjs +52 -0
  169. package/src/workspace/gate-client.mjs +290 -0
  170. package/src/workspace/handback.mjs +73 -0
  171. package/src/workspace/lifecycle.mjs +635 -0
  172. package/src/workspace/operations.mjs +200 -0
  173. package/src/workspace/pick-folder.mjs +152 -0
  174. package/src/workspace/registry.mjs +359 -0
  175. package/src/workspace/remote-links.mjs +397 -0
  176. package/src/workspace/remote-status.mjs +359 -0
  177. package/src/workspace/routes.mjs +1331 -0
  178. package/src/workspace/server.mjs +159 -0
  179. package/src/workspace/sync.mjs +1119 -0
  180. package/src/workspace/task-actions.mjs +215 -0
  181. package/src/workspace/tasks.mjs +507 -0
  182. package/src/workspace/ui/app.js +1886 -0
  183. package/src/workspace/ui/index.html +141 -0
  184. package/src/workspace/ui/plan-bridge.js +120 -0
  185. package/src/workspace/ui/sharepoint.svg +59 -0
  186. package/src/workspace/ui/styles.css +423 -0
  187. package/src/builders/visual-families.mjs +0 -46
@@ -0,0 +1,360 @@
1
+ // The workspace process. Run detached by lifecycle.mjs (`node daemon.mjs`) or
2
+ // in-process for `--foreground`. Owns the descriptor for its lifetime, serves
3
+ // the loopback API, logs to a bounded, redacted file, and drains on stop.
4
+ //
5
+ // Stop = refuse new mutations, wait up to 5s for in-flight local commits, let
6
+ // registered hooks mark unsettled network work for readback, remove OUR
7
+ // descriptor (pid + nonce match — never someone else's), close the listener.
8
+ // The process then ends on its own: nothing here calls process.exit.
9
+ //
10
+ // The config dir is read ONCE, at start: the descriptor, the log, the session
11
+ // store and the remembered port all live there for this process's lifetime,
12
+ // whatever the environment says later (a test that restores XDG_CONFIG_HOME
13
+ // while an in-process daemon is still draining must not write the real one).
14
+ //
15
+ // It looks after itself while it runs:
16
+ // every 30 s the descriptor still names us — another live workspace owns it
17
+ // → drain and leave; missing or unreadable → write ours back;
18
+ // every 60 s (detached only) newer code on disk (an npm auto-update, a
19
+ // release) → restart into it once idle. Sessions and the port
20
+ // survive, so an open page reconnects by itself.
21
+
22
+ import { randomBytes, randomUUID } from "node:crypto";
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ import { scrub } from "../bridge/secrets.mjs";
28
+ import { readPackageJson } from "../cli/common.mjs";
29
+ import { planPageOwner, resolveWorkspace } from "../lib/deck-paths.mjs";
30
+ import { resolveWorkspaceAdapter } from "./adapters.mjs";
31
+ import { autoDiscover } from "./discovery.mjs";
32
+ import { codeStamp, compareCode, descriptorPath, lastPortPath, logPath, pidAlive, probe, readDescriptor, rotateLogs } from "./lifecycle.mjs";
33
+ import { listDecks, workspaceConfigDir, writeJsonAtomic } from "./registry.mjs";
34
+ import { createAuth } from "./auth.mjs";
35
+ import { events as defaultEvents } from "./events.mjs";
36
+ import { createRoutes } from "./routes.mjs";
37
+ import { createServer, homePort, PROTOCOL_VERSION } from "./server.mjs";
38
+ import { startSyncLoop } from "./sync.mjs";
39
+
40
+ const DRAIN_MS = 5_000;
41
+ // newer code this process already failed to restart into: not retried until the disk moves again
42
+ let failedStamp = null;
43
+ const SELF_CHECK_MS = 30_000;
44
+ const CODE_CHECK_MS = 60_000;
45
+ const REDACT = [
46
+ [/(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}/g, "$1«redacted»"],
47
+ [/([?&](?:sig|signature|token|access_token|tempauth|code)=)[^&\s"']+/gi, "$1«redacted»"],
48
+ [/(sk-|eyJ)[A-Za-z0-9._-]{16,}/g, "«redacted»"],
49
+ ];
50
+
51
+ /** Append one redacted line to the daemon log (`file`, default the current config dir's), rotating at the 5 MiB cap. */
52
+ export function log(line, file = logPath()) {
53
+ let text = scrub(String(line));
54
+ for (const [re, rep] of REDACT) text = text.replace(re, rep);
55
+ try {
56
+ rotateLogs(file);
57
+ fs.appendFileSync(file, `${new Date().toISOString()} ${text}\n`);
58
+ } catch {
59
+ // logging never takes the daemon down
60
+ }
61
+ }
62
+
63
+ function readLastPort(dir) {
64
+ try {
65
+ const port = JSON.parse(fs.readFileSync(lastPortPath(dir), "utf8"))?.port;
66
+ return Number.isInteger(port) ? port : null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Start the workspace in this process. Resolves `{ url, descriptor, stopped, stop() }`
74
+ * once listening; `stopped` resolves after a drain completes (stop route, SIGTERM/SIGINT,
75
+ * or `stop()`). Without `handle` the full API (routes.mjs) is served; `deps` are
76
+ * its injection points (tests). `restart()` drains, closes and re-execs a fresh
77
+ * detached process through lifecycle — modules are never reloaded in place.
78
+ * `deps.storeDir` (default the config dir; null = memory only) keeps browser
79
+ * sessions across restarts; `deps.watchCode` (default: only the detached
80
+ * child) restarts into newer code on disk; `deps.selfCheckMs` /
81
+ * `deps.codeCheckMs` tune the two timers.
82
+ */
83
+ export async function runDaemon({ handle = null, hooks = {}, deps = {} } = {}) {
84
+ const configDir = workspaceConfigDir();
85
+ const descriptorFile = descriptorPath(configDir);
86
+ const logFile = logPath(configDir);
87
+ const dlog = (line) => log(line, logFile);
88
+ const bus = deps.events ?? defaultEvents;
89
+ const inflight = new Set();
90
+ const stopHooks = [];
91
+ const timers = [];
92
+ const track = (p) => {
93
+ inflight.add(p);
94
+ // the caller handles the outcome; the tracker only counts
95
+ p.finally(() => inflight.delete(p)).catch(() => {});
96
+ return p;
97
+ };
98
+ let resolveStopped;
99
+ const stopped = new Promise((r) => (resolveStopped = r));
100
+ let draining = null;
101
+
102
+ const descriptor = {
103
+ schemaVersion: 1,
104
+ protocolVersion: PROTOCOL_VERSION,
105
+ pid: process.pid,
106
+ nonce: randomUUID(),
107
+ url: null,
108
+ startedAt: new Date().toISOString(),
109
+ cliVersion: readPackageJson().version,
110
+ code: deps.codeStamp ?? codeStamp(),
111
+ cliBearer: randomBytes(32).toString("base64url"),
112
+ };
113
+
114
+ const drain = async () => {
115
+ if (draining) return draining;
116
+ draining = (async () => {
117
+ // /health says so from now on: nobody reuses a workspace on its way out
118
+ api.state.draining = true;
119
+ for (const t of timers) clearInterval(t);
120
+ dlog(`stop requested; ${inflight.size} local operation(s) in flight`);
121
+ let cap;
122
+ const timer = new Promise((r) => (cap = setTimeout(r, DRAIN_MS)));
123
+ await Promise.race([Promise.allSettled([...inflight]), timer]);
124
+ clearTimeout(cap); // a pending cap would hold the process for its full 5 s after the drain
125
+ for (const hook of stopHooks) {
126
+ try {
127
+ await hook();
128
+ } catch (err) {
129
+ dlog(`stop hook failed: ${err?.message ?? err}`);
130
+ }
131
+ }
132
+ const current = readDescriptor(configDir);
133
+ if (current?.pid === descriptor.pid && current?.nonce === descriptor.nonce) {
134
+ fs.rmSync(descriptorFile, { force: true });
135
+ }
136
+ dlog("stopped");
137
+ })();
138
+ return draining;
139
+ };
140
+ // Signal / programmatic stop: drain, then close. The stop ROUTE drains via
141
+ // onStop and closes itself after its response has left.
142
+ const shutdown = () => drain().then(() => bus.close()).then(() => api.close());
143
+ // `{ resume }`: when the new process cannot start, the detached workspace
144
+ // comes back up on the code it already has loaded rather than leave the page
145
+ // with nothing (the code watcher passes it; a requested restart reports).
146
+ const restart = async ({ resume = false } = {}) => {
147
+ await shutdown();
148
+ // an in-process workspace whose environment moved (a test restoring its env)
149
+ // must not start a workspace for some other config dir
150
+ if (workspaceConfigDir() !== configDir) return dlog("restart skipped: the config dir changed under this process");
151
+ const { ensureRunning } = await import("./lifecycle.mjs");
152
+ try {
153
+ const next = await ensureRunning({ note: dlog });
154
+ dlog(`restarted as pid ${next.pid} at ${next.url}`);
155
+ } catch (err) {
156
+ dlog(`restart failed: ${err?.message ?? err}`);
157
+ if (!resume) return;
158
+ try {
159
+ // it still runs the old code: say so, and the next CLI replaces it when it can
160
+ await runDaemon({ handle, hooks, deps: { ...deps, codeStamp: descriptor.code } });
161
+ dlog("resumed on the code already loaded");
162
+ } catch (again) {
163
+ dlog(`could not resume: ${again?.message ?? again}`);
164
+ }
165
+ }
166
+ };
167
+
168
+ let auth;
169
+ try {
170
+ auth = createAuth({ bearer: descriptor.cliBearer, storeDir: deps.storeDir === undefined ? configDir : deps.storeDir });
171
+ } catch (err) {
172
+ // an unwritable store costs persistence, never the workspace
173
+ dlog(`session store unavailable (${err?.code ?? err?.message ?? err}); sessions last until this process ends`);
174
+ auth = createAuth({ bearer: descriptor.cliBearer });
175
+ }
176
+ const scanning = new Set(); // auto-discovery job ids still running (the library page says "Scanning…")
177
+ const routes = handle ?? createRoutes({ auth, deps: { track, restart, events: bus, scanning: () => [...scanning], ...deps } });
178
+ // Load every lazily imported route module now: a background `npm install -g`
179
+ // that swaps the files under a running workspace must not break its next request.
180
+ if (typeof routes.preload === "function") {
181
+ try {
182
+ await routes.preload();
183
+ } catch (err) {
184
+ dlog(`preload failed: ${err?.message ?? err}`);
185
+ }
186
+ }
187
+ const lastPort = readLastPort(configDir);
188
+ const api = createServer({
189
+ bearer: descriptor.cliBearer,
190
+ nonce: descriptor.nonce,
191
+ pid: descriptor.pid,
192
+ pendingJobs: () => (hooks.pendingJobs ? hooks.pendingJobs() : 0) + inflight.size,
193
+ pages: () => bus.clientCount,
194
+ onStop: drain,
195
+ handle: routes,
196
+ ports: [lastPort, homePort()],
197
+ });
198
+ api.server.on("close", () => resolveStopped());
199
+ // The sender for every registered pack-owned deck; a stop marks what is on
200
+ // the wire as outcome-unknown so the next start reads back before resending.
201
+ if (deps.sync !== false) {
202
+ // a drain that applied something, or settled a crashed approval, is news for an open page
203
+ const observe = (p) => {
204
+ p.then((r) => {
205
+ if (!r || typeof r !== "object") return;
206
+ if (r.action === "applied" || r.action === "recovered" || r.action === "created") bus.invalidate({ type: "plan" });
207
+ if (r.approval?.ok) bus.invalidate({ type: "tasks" });
208
+ }).catch(() => {});
209
+ return track(p);
210
+ };
211
+ const sync = startSyncLoop({
212
+ // the registry of OUR config dir only (see the header)
213
+ decks: () => (workspaceConfigDir() === configDir ? listDecks({ limit: 1000 }).decks.filter((d) => !d.missing).map((d) => resolveWorkspace(d.path)) : []),
214
+ adapterFor: (ws) => {
215
+ const owner = planPageOwner(ws).owner;
216
+ if (!owner || owner === "core") return null;
217
+ const r = resolveWorkspaceAdapter(owner === "extension" ? null : owner);
218
+ return r.entry ? r : null;
219
+ },
220
+ track: observe,
221
+ log: dlog,
222
+ ...(deps.sync ?? {}),
223
+ });
224
+ stopHooks.push(sync.stop);
225
+ }
226
+ descriptor.url = await api.listen();
227
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
228
+ writeJsonAtomic(descriptorFile, descriptor, { mode: 0o600 });
229
+ // The remembered port is the page's home: the browser's cookie and every link
230
+ // an agent already handed out name it. A start pushed off it (something held
231
+ // it for a moment) serves elsewhere but keeps the home, so the next start goes
232
+ // back; only a workspace with no home yet records where it landed.
233
+ const boundPort = Number(new URL(descriptor.url).port);
234
+ if (lastPort === null || boundPort === lastPort) {
235
+ try {
236
+ writeJsonAtomic(lastPortPath(configDir), { port: boundPort });
237
+ } catch {
238
+ // the next start just tries the preferred port first
239
+ }
240
+ } else {
241
+ dlog(`port ${lastPort} is taken; serving on ${boundPort} until the next start goes back to ${lastPort}`);
242
+ }
243
+ dlog(`listening on ${descriptor.url} (pid ${process.pid}, cli ${descriptor.cliVersion})`);
244
+ // A new CLI fills the library by itself: remembered roots + the decks root of
245
+ // the command that started us. Tests pass `autoDiscover:false`.
246
+ let discovery = null;
247
+ if (deps.autoDiscover !== false) {
248
+ discovery = autoDiscover({ version: descriptor.cliVersion, decksDir: deps.decksDir ?? process.env.SLIDEMIND_DECKS ?? null, log: dlog });
249
+ if (discovery) {
250
+ for (const id of discovery.jobIds) scanning.add(id);
251
+ track(
252
+ discovery.done.then((jobs) => {
253
+ scanning.clear();
254
+ dlog(`auto-discovery done: ${jobs.reduce((n, j) => n + j.found.length, 0)} deck(s) registered`);
255
+ bus.invalidate({ type: "library" });
256
+ }),
257
+ );
258
+ }
259
+ }
260
+
261
+ // Is the descriptor still ours? Another live workspace owning it means we are
262
+ // an orphan (a hung process that woke up after being replaced): leave. A
263
+ // missing or broken one is written back — the workspace is running, and every
264
+ // CLI finds it only through that file.
265
+ let checking = false;
266
+ const selfCheck = async () => {
267
+ if (draining || checking) return;
268
+ checking = true;
269
+ try {
270
+ const cur = readDescriptor(configDir);
271
+ if (!cur || cur.malformed) {
272
+ writeJsonAtomic(descriptorFile, descriptor, { mode: 0o600 });
273
+ dlog("descriptor was missing; wrote it back");
274
+ } else if (cur.nonce !== descriptor.nonce) {
275
+ const p = await probe(cur);
276
+ if (p.live) {
277
+ dlog(`another workspace (pid ${cur.pid}) owns the descriptor; stopping this one`);
278
+ await shutdown();
279
+ } else if (p.draining || (p.unreachable === "timeout" && (cur.pid === process.pid || pidAlive(cur.pid)))) {
280
+ // busy (a timeout is not an absence: it may be the newer workspace at work)
281
+ // or on its way out (it removes its own descriptor): look again next tick
282
+ } else if (!draining) {
283
+ // definitively gone: refused or reset, its pid dead, or something else answering
284
+ writeJsonAtomic(descriptorFile, descriptor, { mode: 0o600 });
285
+ dlog(`descriptor named a workspace that is gone (pid ${cur.pid}); wrote ours back`);
286
+ }
287
+ }
288
+ } catch (err) {
289
+ dlog(`self-check failed: ${err?.message ?? err}`);
290
+ } finally {
291
+ checking = false;
292
+ }
293
+ };
294
+ const every = (ms, fn) => {
295
+ const t = setInterval(fn, ms);
296
+ t.unref?.();
297
+ timers.push(t);
298
+ };
299
+ every(deps.selfCheckMs ?? SELF_CHECK_MS, selfCheck);
300
+ // Newer code on disk: restart into it once nothing is in flight and no agent
301
+ // is waiting on an answer through us. Only the detached process does this —
302
+ // a foreground or in-process workspace belongs to whoever runs it.
303
+ if (deps.watchCode ?? deps.isChild ?? false) {
304
+ let restarting = false;
305
+ let seen = null; // the newer stamp of the previous tick: an install still copying files moves it
306
+ every(deps.codeCheckMs ?? CODE_CHECK_MS, () => {
307
+ if (draining || restarting) return;
308
+ let disk;
309
+ try {
310
+ disk = codeStamp();
311
+ } catch {
312
+ return; // mid-install: the next tick sees the finished tree
313
+ }
314
+ if (compareCode(disk, descriptor.code) <= 0 || disk === failedStamp) return;
315
+ const settled = disk === seen;
316
+ seen = disk;
317
+ if (!settled) return; // the same newer tree on two ticks in a row, or wait
318
+ if (inflight.size > 0 || (routes.openWaits?.() ?? 0) > 0) return;
319
+ restarting = true;
320
+ failedStamp = disk; // should the new process fail to start, this process does not try the same tree again
321
+ dlog(`newer code on disk (${descriptor.code} → ${disk}); restarting into it`);
322
+ restart({ resume: true });
323
+ });
324
+ }
325
+
326
+ const onSignal = () => {
327
+ shutdown();
328
+ };
329
+ process.once("SIGTERM", onSignal);
330
+ process.once("SIGINT", onSignal);
331
+ stopped.then(() => {
332
+ for (const t of timers) clearInterval(t);
333
+ process.off("SIGTERM", onSignal);
334
+ process.off("SIGINT", onSignal);
335
+ });
336
+
337
+ return {
338
+ url: descriptor.url,
339
+ descriptor,
340
+ api,
341
+ track,
342
+ auth,
343
+ onStop: (fn) => stopHooks.push(fn),
344
+ discovery,
345
+ stop: shutdown,
346
+ restart,
347
+ stopped,
348
+ selfCheck,
349
+ };
350
+ }
351
+
352
+ // Child entry: `node src/workspace/daemon.mjs`. Uncaught errors go to the
353
+ // inherited log fd; a crash leaves the descriptor behind, and the next start
354
+ // finds it dead (health probe) and replaces it without touching any pid.
355
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
356
+ runDaemon({ deps: { isChild: true } }).catch((err) => {
357
+ log(`startup failed: ${err?.stack ?? err}`);
358
+ process.exitCode = 1;
359
+ });
360
+ }
@@ -0,0 +1,180 @@
1
+ // Bounded deck discovery under a configured root.
2
+ //
3
+ // Breadth-first, four directory reads in flight, never through a symlink,
4
+ // never into hidden directories (that covers `.git`, `.cache`, `.slidemind`),
5
+ // `node_modules` or cache dirs. A directory that stands as a deck (see
6
+ // deck-paths `isDeckDir`) is registered and NOT descended into — so a deck's
7
+ // `.slidemind/scratch` is never reached, and a pack's deck shows up exactly once.
8
+ // Ordinary `.pptx` files are not decks: only the existing markers count.
9
+ //
10
+ // Job records live at `<sessionConfigDir>/workspace/jobs/<id>.json` and carry
11
+ // no tokens. Cancelling keeps every entry already registered.
12
+
13
+ import { randomUUID } from "node:crypto";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+
17
+ import { isDeckDir } from "../lib/deck-paths.mjs";
18
+ import { addRoot, coded, readRegistry, recordRegistryVersion, registerDeckBestEffort, workspaceConfigDir, writeJsonAtomic } from "./registry.mjs";
19
+
20
+ const CONCURRENCY = 4;
21
+ // ponytail: fixed depth cap keeps a mistaken `discover ~` bounded; make it a root option if a real tree needs more.
22
+ const MAX_DEPTH = 8;
23
+ const SKIP_NAMES = new Set(["node_modules", "cache", "caches", "__pycache__", "dist", "build"]);
24
+
25
+ export function jobsDir() {
26
+ return path.join(workspaceConfigDir(), "jobs");
27
+ }
28
+
29
+ export function jobPath(id) {
30
+ if (!/^[0-9a-f-]{36}$/.test(String(id))) throw coded("bad_job_id", "job ids are UUIDs");
31
+ return path.join(jobsDir(), `${id}.json`);
32
+ }
33
+
34
+ export function readJob(id) {
35
+ try {
36
+ return JSON.parse(fs.readFileSync(jobPath(id), "utf8"));
37
+ } catch (err) {
38
+ if (err.code === "ENOENT") return null;
39
+ throw err;
40
+ }
41
+ }
42
+
43
+ function writeJob(job) {
44
+ writeJsonAtomic(jobPath(job.id), job);
45
+ return job;
46
+ }
47
+
48
+ /** Directories the scan and the folder picker both leave out. */
49
+ export function skip(entry) {
50
+ return (
51
+ !entry.isDirectory() ||
52
+ entry.isSymbolicLink() ||
53
+ entry.name.startsWith(".") ||
54
+ SKIP_NAMES.has(entry.name)
55
+ );
56
+ }
57
+
58
+ /**
59
+ * Remember `root` and scan it. Returns `{ jobId, done }`; `done` resolves to the
60
+ * final job record (`state` completed | cancelled | failed) and never rejects
61
+ * for a scan problem — those land in `warnings`. Pass an AbortSignal to cancel.
62
+ */
63
+ export function startDiscovery(root, { signal = null } = {}) {
64
+ const job = {
65
+ schemaVersion: 1,
66
+ id: randomUUID(),
67
+ kind: "discovery",
68
+ root: path.resolve(root),
69
+ state: "queued",
70
+ found: [],
71
+ warnings: [],
72
+ startedAt: new Date().toISOString(),
73
+ finishedAt: null,
74
+ };
75
+ let real;
76
+ try {
77
+ real = addRoot(root);
78
+ } catch (err) {
79
+ job.state = "failed";
80
+ job.finishedAt = job.startedAt;
81
+ job.warnings.push({ path: job.root, code: err.code ?? "inaccessible", message: err.message });
82
+ writeJob(job);
83
+ return { jobId: job.id, done: Promise.resolve(job) };
84
+ }
85
+ job.root = real;
86
+ job.state = "running";
87
+ writeJob(job);
88
+ return { jobId: job.id, done: scan(job, signal) };
89
+ }
90
+
91
+ async function scan(job, signal) {
92
+ const queue = [{ dir: job.root, depth: 0 }];
93
+ const seen = new Set();
94
+ let active = 0;
95
+ await new Promise((resolve) => {
96
+ const pump = () => {
97
+ if (signal?.aborted) {
98
+ if (active === 0) resolve();
99
+ return;
100
+ }
101
+ while (active < CONCURRENCY && queue.length) {
102
+ const item = queue.shift();
103
+ active++;
104
+ visit(item).finally(() => {
105
+ active--;
106
+ pump();
107
+ });
108
+ }
109
+ if (active === 0 && queue.length === 0) resolve();
110
+ };
111
+ const visit = async ({ dir, depth }) => {
112
+ let real;
113
+ try {
114
+ real = await fs.promises.realpath(dir);
115
+ } catch (err) {
116
+ job.warnings.push({ path: dir, code: err.code ?? "inaccessible", message: err.message });
117
+ return;
118
+ }
119
+ if (seen.has(real)) return;
120
+ seen.add(real);
121
+ // A deck is registered and its inside is never walked: `.slidemind/` and
122
+ // whatever lives under it (the scratch deck) belong to this host deck.
123
+ if (isDeckDir(real)) {
124
+ const entry = registerDeckBestEffort(real);
125
+ if (entry) job.found.push(entry.id);
126
+ return;
127
+ }
128
+ if (depth >= MAX_DEPTH) return;
129
+ let entries;
130
+ try {
131
+ entries = await fs.promises.readdir(real, { withFileTypes: true });
132
+ } catch (err) {
133
+ job.warnings.push({ path: real, code: err.code ?? "inaccessible", message: err.message });
134
+ return;
135
+ }
136
+ for (const e of entries) {
137
+ if (!skip(e)) queue.push({ dir: path.join(real, e.name), depth: depth + 1 });
138
+ }
139
+ };
140
+ pump();
141
+ });
142
+ job.state = signal?.aborted ? "cancelled" : "completed";
143
+ job.finishedAt = new Date().toISOString();
144
+ return writeJob(job);
145
+ }
146
+
147
+ /**
148
+ * The library fills itself on a new CLI: when the registry is missing or was
149
+ * populated by another version, record this one and scan every remembered
150
+ * root plus the invoking command's decks root (`decksDir`, lifecycle passes it
151
+ * as SLIDEMIND_DECKS). Roots nested under another are skipped — one scan
152
+ * covers them. Null when the library is current or the registry is
153
+ * unreadable (a malformed file is a recovery case, not a rescan).
154
+ * Returns `{ previousVersion, roots, jobIds, done }`.
155
+ */
156
+ export function autoDiscover({ version, decksDir = null, log = () => {} }) {
157
+ let reg;
158
+ try {
159
+ reg = readRegistry();
160
+ } catch (err) {
161
+ log(`auto-discovery skipped: ${err.message}`);
162
+ return null;
163
+ }
164
+ if (reg.cliVersion === version) return null;
165
+ const roots = new Set(reg.roots);
166
+ if (decksDir) {
167
+ try {
168
+ const real = fs.realpathSync(decksDir);
169
+ if (fs.statSync(real).isDirectory()) roots.add(real);
170
+ } catch {
171
+ // the invoking command had no decks dir yet
172
+ }
173
+ }
174
+ const sorted = [...roots].sort();
175
+ const top = sorted.filter((r) => !sorted.some((o) => o !== r && r.startsWith(o + path.sep)));
176
+ recordRegistryVersion(version);
177
+ const jobs = top.map((r) => startDiscovery(r));
178
+ log(`library populated by cli ${reg.cliVersion ?? "none"}, now ${version}: scanning ${top.length} root(s)`);
179
+ return { previousVersion: reg.cliVersion ?? null, roots: top, jobIds: jobs.map((j) => j.jobId), done: Promise.all(jobs.map((j) => j.done)) };
180
+ }