slidemind 0.3.20 → 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 (185) 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/render.mjs +27 -1
  16. package/src/bridge/client/transport.mjs +499 -23
  17. package/src/bridge/client.mjs +10 -0
  18. package/src/bridge/config.mjs +67 -6
  19. package/src/bridge/engine-download.mjs +0 -10
  20. package/src/bridge/graph-auth.mjs +55 -3
  21. package/src/bridge/image-intent-envelope.mjs +239 -0
  22. package/src/bridge/package-preview.mjs +67 -0
  23. package/src/bridge/sharepoint.mjs +914 -0
  24. package/src/builders/asset-fetch.mjs +203 -0
  25. package/src/builders/block-dividers.mjs +83 -0
  26. package/src/builders/build.mjs +171 -87
  27. package/src/builders/card-height.mjs +294 -0
  28. package/src/builders/chart-hole.mjs +183 -0
  29. package/src/builders/chart-jsx.mjs +410 -0
  30. package/src/builders/chart-labels.mjs +326 -0
  31. package/src/builders/concept-geometry.mjs +370 -0
  32. package/src/builders/concept-intent.mjs +200 -0
  33. package/src/builders/concept-layout.mjs +79 -0
  34. package/src/builders/concept-lines.mjs +509 -0
  35. package/src/builders/concept-lists.mjs +270 -0
  36. package/src/builders/concept-marks.mjs +84 -0
  37. package/src/builders/concept-relationships.mjs +12 -0
  38. package/src/builders/concept-spec.mjs +645 -0
  39. package/src/builders/concept-text.mjs +140 -0
  40. package/src/builders/concept-waves.mjs +57 -0
  41. package/src/builders/copy-fit.mjs +139 -0
  42. package/src/builders/design-tokens.mjs +347 -0
  43. package/src/builders/draft-check.mjs +552 -340
  44. package/src/builders/draft-lines.mjs +369 -0
  45. package/src/builders/edit-route.mjs +29 -0
  46. package/src/builders/figure-sketch.mjs +375 -0
  47. package/src/builders/fold-sets.mjs +639 -0
  48. package/src/builders/gate-server.mjs +9 -4
  49. package/src/builders/gate-session.js +273 -12
  50. package/src/builders/gate-shell.js +1494 -0
  51. package/src/builders/image-intent-check.mjs +308 -0
  52. package/src/builders/image-use-reconcile.mjs +107 -0
  53. package/src/builders/kit-helpers.mjs +121 -0
  54. package/src/builders/layout-names.mjs +32 -0
  55. package/src/builders/legacy-reference-uses.mjs +179 -0
  56. package/src/builders/line-fit.mjs +224 -0
  57. package/src/builders/line-native.mjs +895 -0
  58. package/src/builders/line-relations.mjs +230 -0
  59. package/src/builders/lint-fix.mjs +117 -9
  60. package/src/builders/lint.mjs +25 -20
  61. package/src/builders/list-jsx.mjs +88 -0
  62. package/src/builders/placement.mjs +133 -0
  63. package/src/builders/plan-checks.mjs +135 -0
  64. package/src/builders/plan-comments.mjs +19 -0
  65. package/src/builders/plan-handlers.mjs +95 -0
  66. package/src/builders/plan-markdown-edit.mjs +3 -3
  67. package/src/builders/plan-overlay.js +1530 -1608
  68. package/src/builders/plan.mjs +689 -186
  69. package/src/builders/preset-definitions.json +1 -0
  70. package/src/builders/preset-eval.mjs +244 -0
  71. package/src/builders/preview.html +51 -120
  72. package/src/builders/red-thread-backbone.mjs +135 -36
  73. package/src/builders/repair.mjs +205 -0
  74. package/src/builders/repeat-groups.mjs +540 -0
  75. package/src/builders/resolve-skill.mjs +5 -4
  76. package/src/builders/review-overlay.js +296 -293
  77. package/src/builders/review.mjs +305 -84
  78. package/src/builders/rule-shift.mjs +96 -0
  79. package/src/builders/seat.mjs +354 -0
  80. package/src/builders/settle-driver.mjs +236 -0
  81. package/src/builders/settle.mjs +1448 -0
  82. package/src/builders/slide-prep.mjs +67 -0
  83. package/src/builders/spec-assets.mjs +236 -0
  84. package/src/builders/spec-match.mjs +56 -0
  85. package/src/builders/stack.mjs +413 -0
  86. package/src/builders/storyboard-markdown.mjs +70 -27
  87. package/src/builders/storyboard-schema.mjs +54 -114
  88. package/src/builders/text-blocks.mjs +212 -0
  89. package/src/builders/text-card-fit.mjs +552 -0
  90. package/src/builders/text-stack.mjs +276 -0
  91. package/src/builders/transcribe.mjs +1069 -0
  92. package/src/cli/assets.mjs +164 -0
  93. package/src/cli/common.mjs +173 -12
  94. package/src/cli/concept-spec-support.mjs +76 -0
  95. package/src/cli/concepts.mjs +1002 -103
  96. package/src/cli/deck.mjs +401 -65
  97. package/src/cli/diagnose.mjs +4 -2
  98. package/src/cli/engine-helpers.mjs +17 -10
  99. package/src/cli/engine-results.mjs +76 -3
  100. package/src/cli/help.mjs +232 -28
  101. package/src/cli/install.mjs +9 -0
  102. package/src/cli/maintenance.mjs +104 -0
  103. package/src/cli/read.mjs +2 -1
  104. package/src/cli/ready.mjs +6 -3
  105. package/src/cli/record.mjs +522 -0
  106. package/src/cli/remote.mjs +818 -0
  107. package/src/cli/revision.mjs +686 -0
  108. package/src/cli/search.mjs +30 -11
  109. package/src/cli/session.mjs +56 -13
  110. package/src/cli/setup.mjs +46 -2
  111. package/src/cli/status.mjs +142 -33
  112. package/src/cli/tasks.mjs +246 -0
  113. package/src/cli/transcribe.mjs +854 -0
  114. package/src/cli/workspace-ui.mjs +260 -0
  115. package/src/cli/workspace.mjs +212 -34
  116. package/src/install-channel.mjs +64 -7
  117. package/src/lib/adopt.mjs +346 -25
  118. package/src/lib/concept-spec-binding.mjs +96 -0
  119. package/src/lib/conversions.mjs +70 -6
  120. package/src/lib/convert-folded.mjs +107 -0
  121. package/src/lib/deck-paths.mjs +418 -0
  122. package/src/lib/durable-ids.mjs +47 -0
  123. package/src/lib/edit-operations.mjs +316 -0
  124. package/src/lib/edit-state.mjs +1244 -0
  125. package/src/lib/edit-transitions.mjs +120 -0
  126. package/src/lib/extensions.mjs +501 -0
  127. package/src/lib/image-assets.mjs +465 -0
  128. package/src/lib/image-derivatives.mjs +234 -0
  129. package/src/lib/image-intent-fingerprint.mjs +70 -0
  130. package/src/lib/image-intent-tags.mjs +176 -0
  131. package/src/lib/image-intent.mjs +802 -0
  132. package/src/lib/image-use-overrides.mjs +279 -0
  133. package/src/lib/inline-components.mjs +613 -0
  134. package/src/lib/lint-converted.mjs +68 -4
  135. package/src/lib/merge-json.mjs +163 -0
  136. package/src/lib/overwrite-guard.mjs +3 -3
  137. package/src/lib/presentation-context.mjs +531 -0
  138. package/src/lib/preservation-tags.mjs +157 -0
  139. package/src/lib/preservation-verdict.mjs +1342 -0
  140. package/src/lib/publish.mjs +1557 -0
  141. package/src/lib/rebase.mjs +945 -0
  142. package/src/lib/reconcile-ack.mjs +4 -6
  143. package/src/lib/reconcile.mjs +62 -24
  144. package/src/lib/record-client.mjs +175 -0
  145. package/src/lib/record-state.mjs +151 -0
  146. package/src/lib/remote.mjs +661 -0
  147. package/src/lib/revision.mjs +1210 -0
  148. package/src/lib/slide-guids.mjs +4 -6
  149. package/src/lib/slide-preservation.mjs +423 -0
  150. package/src/lib/slide-selection.mjs +9 -0
  151. package/src/lib/workspace-transaction.mjs +117 -0
  152. package/src/pptx/content-digest.mjs +334 -0
  153. package/src/pptx/deck-assemble.mjs +1278 -0
  154. package/src/pptx/fill/engine.mjs +2 -3
  155. package/src/pptx/fill/freeform.mjs +6 -3
  156. package/src/pptx/ooxml.mjs +20 -0
  157. package/src/pptx/package-evidence.mjs +383 -0
  158. package/src/pptx/tags.mjs +163 -11
  159. package/src/update-check.mjs +22 -4
  160. package/src/workspace/adapters.mjs +222 -0
  161. package/src/workspace/auth.mjs +256 -0
  162. package/src/workspace/autostart.mjs +204 -0
  163. package/src/workspace/daemon.mjs +360 -0
  164. package/src/workspace/discovery.mjs +180 -0
  165. package/src/workspace/edit.mjs +167 -0
  166. package/src/workspace/events.mjs +52 -0
  167. package/src/workspace/gate-client.mjs +290 -0
  168. package/src/workspace/handback.mjs +73 -0
  169. package/src/workspace/lifecycle.mjs +635 -0
  170. package/src/workspace/operations.mjs +200 -0
  171. package/src/workspace/pick-folder.mjs +152 -0
  172. package/src/workspace/registry.mjs +359 -0
  173. package/src/workspace/remote-links.mjs +397 -0
  174. package/src/workspace/remote-status.mjs +359 -0
  175. package/src/workspace/routes.mjs +1331 -0
  176. package/src/workspace/server.mjs +159 -0
  177. package/src/workspace/sync.mjs +1119 -0
  178. package/src/workspace/task-actions.mjs +215 -0
  179. package/src/workspace/tasks.mjs +507 -0
  180. package/src/workspace/ui/app.js +1886 -0
  181. package/src/workspace/ui/index.html +141 -0
  182. package/src/workspace/ui/plan-bridge.js +120 -0
  183. package/src/workspace/ui/sharepoint.svg +59 -0
  184. package/src/workspace/ui/styles.css +423 -0
  185. package/src/builders/visual-families.mjs +0 -46
@@ -0,0 +1,635 @@
1
+ // Start / reuse / stop the workspace process.
2
+ //
3
+ // The descriptor (`<sessionConfigDir>/workspace/daemon.json`) is the ONLY
4
+ // evidence of a running workspace, and it is trusted only after a live probe:
5
+ // GET /health for the protocol (and whether it is draining), then the
6
+ // bearer-authenticated status route, whose `nonce` must match the descriptor.
7
+ // Port occupancy proves nothing — an unrelated service on 57103 (or on the
8
+ // workspace's remembered port) is neither reused nor terminated.
9
+ //
10
+ // A workspace that accepts the connection and then says nothing (hung — a
11
+ // refused one is simply gone) is asked once more, patiently; still silent, its
12
+ // pid is signalled only when `ps` proves it is this install's daemon AND the
13
+ // process that wrote the descriptor (POSIX). Anything less certain — a dead
14
+ // pid, one the OS reused, Windows — is never signalled: a fresh workspace
15
+ // starts beside it instead, and nothing here can block every start.
16
+ //
17
+ // Who serves: the newer code. A CLI newer than the running workspace (semver,
18
+ // then the source mtime of a dev checkout) drains and replaces it; an older CLI
19
+ // reuses the newer workspace, so two installs never take turns replacing each
20
+ // other. A workspace on a newer protocol is `cli_update_required`.
21
+ //
22
+ // Concurrent starts serialize on an exclusive `start.lock` directory that names
23
+ // its owner pid; a lock whose owner died is reclaimed at once, any other after
24
+ // 30 s. The loser finds the winner's live descriptor.
25
+
26
+ import { execFileSync, spawn } from "node:child_process";
27
+ import { randomBytes } from "node:crypto";
28
+ import fs from "node:fs";
29
+ import path from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+
32
+ import { compareCliVersions } from "../bridge/client/transport.mjs";
33
+ import { readPackageJson, resolveDecksDir } from "../cli/common.mjs";
34
+ import { updateCliInstruction } from "../install-channel.mjs";
35
+ import { addDeck, coded, workspaceConfigDir } from "./registry.mjs";
36
+ import { PROTOCOL_VERSION } from "./server.mjs";
37
+
38
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
+ const DAEMON_ENTRY = path.join(__dirname, "daemon.mjs");
40
+
41
+ export const START_DEADLINE_MS = 10_000;
42
+ const START_LOCK_STALE_MS = 30_000;
43
+ const PROBE_TIMEOUT_MS = 1_500;
44
+ // a workspace that missed the first probe gets this long before it counts as hung
45
+ const REPROBE_TIMEOUT_MS = 4_000;
46
+ // how long a stopping workspace may take to leave (drain is capped at 5 s)
47
+ const EXIT_WAIT_MS = 8_000;
48
+ const TERM_GRACE_MS = 4_000;
49
+ const LOG_CAP = 5 * 1024 * 1024;
50
+ const LOG_KEEP = 2;
51
+
52
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
53
+
54
+ /**
55
+ * What code a daemon runs: the CLI version plus the newest mtime under `src/`.
56
+ * A daemon outlives the checkout it started from (an upgrade, a local edit);
57
+ * `ensureRunning` compares stamps to decide which side is newer.
58
+ */
59
+ export function codeStamp() {
60
+ const src = path.join(__dirname, "..");
61
+ let newest = 0;
62
+ for (const f of fs.readdirSync(src, { recursive: true })) {
63
+ if (f.endsWith(".mjs") || f.endsWith(".js")) newest = Math.max(newest, fs.statSync(path.join(src, f)).mtimeMs);
64
+ }
65
+ return `${readPackageJson().version}+${Math.floor(newest)}`;
66
+ }
67
+
68
+ function parseStamp(code) {
69
+ const m = /^(.*)\+(\d+)$/.exec(String(code ?? ""));
70
+ return m ? { version: m[1], mtime: Number(m[2]) } : { version: String(code ?? "0"), mtime: 0 };
71
+ }
72
+
73
+ /** >0 when stamp `a` is newer code than `b`: the version first, the source mtime on a tie. */
74
+ export function compareCode(a, b) {
75
+ const x = parseStamp(a);
76
+ const y = parseStamp(b);
77
+ return compareCliVersions(x.version, y.version) || Math.sign(x.mtime - y.mtime);
78
+ }
79
+
80
+ /** The code stamp a descriptor claims; a descriptor from before stamps counts as its version alone. */
81
+ const descriptorCode = (d) => d?.code ?? `${d?.cliVersion ?? "0"}+0`;
82
+
83
+ export function descriptorPath(dir = workspaceConfigDir()) {
84
+ return path.join(dir, "daemon.json");
85
+ }
86
+
87
+ export function logPath(dir = workspaceConfigDir()) {
88
+ return path.join(dir, "daemon.log");
89
+ }
90
+
91
+ /** Where a daemon remembers its port, so a restart keeps the page's origin (and its cookie). */
92
+ export function lastPortPath(dir = workspaceConfigDir()) {
93
+ return path.join(dir, "last-port.json");
94
+ }
95
+
96
+ /** `daemon.log` over 5 MiB rolls to `.1`, `.1` to `.2`, `.2` is dropped. */
97
+ export function rotateLogs(file = logPath()) {
98
+ let size = 0;
99
+ try {
100
+ size = fs.statSync(file).size;
101
+ } catch {
102
+ return;
103
+ }
104
+ if (size < LOG_CAP) return;
105
+ fs.rmSync(`${file}.${LOG_KEEP}`, { force: true });
106
+ for (let i = LOG_KEEP - 1; i >= 1; i--) {
107
+ try {
108
+ fs.renameSync(`${file}.${i}`, `${file}.${i + 1}`);
109
+ } catch {
110
+ // no such rotation yet
111
+ }
112
+ }
113
+ fs.renameSync(file, `${file}.1`);
114
+ }
115
+
116
+ /** The descriptor as on disk: null when absent, `{ malformed: true, file }` when unreadable. */
117
+ export function readDescriptor(dir = workspaceConfigDir()) {
118
+ const file = descriptorPath(dir);
119
+ let raw;
120
+ try {
121
+ raw = fs.readFileSync(file, "utf8");
122
+ } catch {
123
+ return null;
124
+ }
125
+ try {
126
+ const d = JSON.parse(raw);
127
+ if (d && d.schemaVersion === 1 && typeof d.url === "string" && typeof d.cliBearer === "string") return d;
128
+ } catch {
129
+ // fall through
130
+ }
131
+ return { malformed: true, file };
132
+ }
133
+
134
+ /** Is `pid` a running process? EPERM means it exists under another user — alive, and not ours. */
135
+ export function pidAlive(pid) {
136
+ if (!Number.isInteger(pid) || pid <= 0) return false;
137
+ try {
138
+ process.kill(pid, 0);
139
+ return true;
140
+ } catch (err) {
141
+ return err.code === "EPERM";
142
+ }
143
+ }
144
+
145
+ /**
146
+ * May this pid be signalled as the hung workspace this descriptor names? Only
147
+ * when BOTH hold: the process is this install's detached daemon (argv is
148
+ * `<node> <DAEMON_ENTRY>`, the exact path — an editor, a `tail`, another
149
+ * install's daemon or a `--foreground` workspace never match), and it was
150
+ * already running when the descriptor was written (a pid the OS handed to a
151
+ * later process is not the one that wrote it). "unknown" when this system
152
+ * cannot tell (Windows, no `ps`) — never signalled either.
153
+ */
154
+ function pidOwner(d) {
155
+ if (process.platform === "win32") return "unknown";
156
+ let out;
157
+ try {
158
+ out = execFileSync("ps", ["-ww", "-o", "etime=,command=", "-p", String(d.pid)], { encoding: "utf8", timeout: 2_000, stdio: ["ignore", "pipe", "ignore"] }).trim();
159
+ } catch {
160
+ return "unknown";
161
+ }
162
+ // etime: [[dd-]hh:]mm:ss
163
+ const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)\s+(.+)$/.exec(out);
164
+ if (!m) return "unknown";
165
+ const elapsedS = ((Number(m[1] ?? 0) * 24 + Number(m[2] ?? 0)) * 60 + Number(m[3])) * 60 + Number(m[4]);
166
+ const cmd = m[5];
167
+ const tail = ` ${DAEMON_ENTRY}`;
168
+ if (!cmd.endsWith(tail) || !/^node[\d.]*(\.exe)?$/i.test(path.basename(cmd.slice(0, -tail.length)))) return "other";
169
+ const wrote = Date.parse(d.startedAt);
170
+ // started before it wrote its descriptor (etime has 1 s resolution)
171
+ return Number.isFinite(wrote) && Date.now() - elapsedS * 1000 <= wrote + 2_000 ? "ours" : "other";
172
+ }
173
+
174
+ /** A Codex sandbox: no background process survives it, and loopback may be cut. */
175
+ export function sandboxed() {
176
+ return Boolean(process.env.CODEX_SANDBOX || process.env.CODEX_SANDBOX_NETWORK_DISABLED);
177
+ }
178
+
179
+ const SANDBOX_CODES = new Set(["EPERM", "EACCES", "EROFS"]);
180
+ const sandboxError = (detail) => coded("workspace_sandboxed", `the workspace cannot start from this session (${detail}) — it looks sandboxed`);
181
+
182
+ // A pooled keep-alive socket to a workspace that restarted on the same port is
183
+ // dead on first use; one fresh attempt is not a retry of anything that landed.
184
+ const STALE_SOCKET = new Set(["ECONNRESET", "EPIPE", "UND_ERR_SOCKET"]);
185
+
186
+ async function fetchJson(url, { bearer = null, method = "GET", timeoutMs = PROBE_TIMEOUT_MS, body = null, headers = {} } = {}, attempt = 0) {
187
+ let res;
188
+ try {
189
+ res = await fetch(url, {
190
+ method,
191
+ headers: { ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}), ...(body !== null ? { "Content-Type": "application/json" } : {}), ...headers },
192
+ body: body !== null ? JSON.stringify(body) : undefined,
193
+ signal: AbortSignal.timeout(timeoutMs),
194
+ });
195
+ } catch (err) {
196
+ if (attempt === 0 && STALE_SOCKET.has(err?.cause?.code)) return fetchJson(url, { bearer, method, timeoutMs, body, headers }, 1);
197
+ throw err;
198
+ }
199
+ return { status: res.status, body: await res.json().catch(() => null) };
200
+ }
201
+
202
+ /**
203
+ * One CLI → workspace API call: `path` is relative to `/api/v1` ("/bootstrap",
204
+ * "/decks/<id>/tasks/…"), sent with the descriptor's CLI bearer and `body` as
205
+ * JSON. Resolves `{ status, body }` whatever the HTTP status (callers read the
206
+ * error envelope themselves); a dead pooled socket is retried once. Throws
207
+ * coded `workspace_down` when there is no usable descriptor, else the network
208
+ * error itself (`err.cause` kept, a timeout is a TimeoutError).
209
+ */
210
+ export async function daemonRequest(path, { method = "GET", body = null, timeoutMs = 5000, headers = {} } = {}) {
211
+ const d = readDescriptor();
212
+ if (!d || d.malformed) throw coded("workspace_down", "the workspace is not running");
213
+ return fetchJson(`${d.url}/api/v1${path}`, { bearer: d.cliBearer, method, timeoutMs, body, headers });
214
+ }
215
+
216
+ const unreachableKind = (err) => (err?.name === "TimeoutError" || err?.cause?.name === "TimeoutError" ? "timeout" : "refused");
217
+
218
+ /**
219
+ * Is this descriptor a live workspace of ours?
220
+ * `{ live, protocolVersion?, status?, draining?, unreachable?: "timeout"|"refused", foreign? }`.
221
+ * Only a health answer AND a status answer carrying the descriptor's nonce
222
+ * count; a draining workspace is never live for reuse.
223
+ */
224
+ export async function probe(descriptor, { timeoutMs = PROBE_TIMEOUT_MS } = {}) {
225
+ if (!descriptor || descriptor.malformed) return { live: false };
226
+ let health;
227
+ try {
228
+ health = await fetchJson(`${descriptor.url}/health`, { timeoutMs });
229
+ } catch (err) {
230
+ return { live: false, unreachable: unreachableKind(err) };
231
+ }
232
+ if (health.status !== 200 || typeof health.body?.protocolVersion !== "number") return { live: false, foreign: true };
233
+ if (health.body.draining === true) return { live: false, draining: true, protocolVersion: health.body.protocolVersion };
234
+ let status;
235
+ try {
236
+ status = await fetchJson(`${descriptor.url}/api/v1/status`, { bearer: descriptor.cliBearer, timeoutMs });
237
+ } catch (err) {
238
+ return { live: false, unreachable: unreachableKind(err) };
239
+ }
240
+ if (status.status !== 200 || status.body?.nonce !== descriptor.nonce) return { live: false, foreign: true };
241
+ if (status.body.draining === true) return { live: false, draining: true, protocolVersion: health.body.protocolVersion };
242
+ return { live: true, protocolVersion: health.body.protocolVersion, status: status.body };
243
+ }
244
+
245
+ /** Does anything still answer on this url? (an in-process workspace has no pid of its own to watch) */
246
+ async function answers(url) {
247
+ try {
248
+ await fetchJson(`${url}/health`, { timeoutMs: 500 });
249
+ return true;
250
+ } catch {
251
+ return false;
252
+ }
253
+ }
254
+
255
+ /** Wait until the descriptor's workspace has left: its pid exited (or, in-process, its listener closed). */
256
+ async function awaitGone(d, ms = EXIT_WAIT_MS) {
257
+ const deadline = Date.now() + ms;
258
+ while (Date.now() < deadline) {
259
+ if (d.pid !== process.pid ? !pidAlive(d.pid) : !(await answers(d.url))) return true;
260
+ await wait(100);
261
+ }
262
+ return false;
263
+ }
264
+
265
+ async function terminate(pid) {
266
+ try {
267
+ process.kill(pid, "SIGTERM");
268
+ // a stopped process holds SIGTERM until it runs again: let it drain
269
+ process.kill(pid, "SIGCONT");
270
+ } catch {
271
+ // already gone
272
+ }
273
+ if (await awaitGone({ pid }, TERM_GRACE_MS)) return;
274
+ try {
275
+ process.kill(pid, "SIGKILL");
276
+ } catch {
277
+ // already gone
278
+ }
279
+ await awaitGone({ pid }, 2_000);
280
+ }
281
+
282
+ /**
283
+ * The descriptor did not probe live. Draining → wait for it to leave. Hung —
284
+ * the connection is ACCEPTED and then nothing comes back, twice, the second
285
+ * time with a longer timeout — → terminate it, but only when `pidOwner` proves
286
+ * it is the daemon that wrote this descriptor. Everything else (refused, reset,
287
+ * someone else answering, a pid that cannot be verified) leaves the pid alone
288
+ * and a fresh workspace starts; it takes another port if the old one is still
289
+ * held, and an old one that wakes up later finds the descriptor is no longer
290
+ * its own and stops itself. Never throws: nothing here may block every start.
291
+ */
292
+ async function recover(d, p) {
293
+ if (!d || d.malformed || !Number.isInteger(d.pid)) return p;
294
+ if (p.draining) {
295
+ await awaitGone(d); // a drain that overruns is left to finish beside the new one
296
+ return { live: false };
297
+ }
298
+ // in-process (foreground, tests): never signal ourselves
299
+ if (d.pid === process.pid) return p;
300
+ // refused or reset: nothing listens there any more, whatever holds the pid now
301
+ if (p.unreachable !== "timeout" || !pidAlive(d.pid)) return p;
302
+ // a busy moment: ask once more, patiently
303
+ const again = await probe(d, { timeoutMs: REPROBE_TIMEOUT_MS });
304
+ if (again.live) return again;
305
+ if (again.draining) {
306
+ await awaitGone(d);
307
+ return { live: false };
308
+ }
309
+ if (again.unreachable === "timeout" && pidOwner(d) === "ours") await terminate(d.pid);
310
+ return { live: false };
311
+ }
312
+
313
+ /** `{ running, url?, protocolVersion?, pid?, pendingJobs, pages, stale?, reason? }` for `workspace status`. */
314
+ export async function workspaceStatus() {
315
+ const d = readDescriptor();
316
+ const idle = { running: false, pendingJobs: 0, pages: 0 };
317
+ if (!d) return { ...idle, reason: sandboxed() ? "sandboxed" : "not_started" };
318
+ if (d.malformed) return { ...idle, reason: "crashed", malformedDescriptor: d.file };
319
+ const p = await probe(d);
320
+ if (p.live) {
321
+ return {
322
+ running: true,
323
+ url: d.url,
324
+ protocolVersion: p.protocolVersion,
325
+ pid: d.pid,
326
+ pendingJobs: p.status.pendingJobs ?? 0,
327
+ pages: p.status.pages ?? 0,
328
+ // the next ensureRunning from this CLI replaces it
329
+ stale: p.protocolVersion < PROTOCOL_VERSION || compareCode(codeStamp(), descriptorCode(d)) > 0,
330
+ };
331
+ }
332
+ let reason;
333
+ if (p.draining) reason = "draining";
334
+ else if (sandboxed()) reason = "sandboxed";
335
+ else if (Number.isInteger(d.pid) && d.pid !== process.pid && !pidAlive(d.pid)) reason = "crashed";
336
+ else reason = p.unreachable === "timeout" ? "unresponsive" : "unreachable";
337
+ return { ...idle, reason };
338
+ }
339
+
340
+ /**
341
+ * Take the start lock or return null (someone else is starting). The lock is a
342
+ * directory naming its owner (pid + a token). A lock judged abandoned — its
343
+ * owner died, or it is older than 30 s — is reclaimed ATOMICALLY: renamed to a
344
+ * tombstone first, and removed only when the tombstone is the very lock that
345
+ * was judged (two starters judging the same stale lock: only one rename wins;
346
+ * a fresh lock created in between is put back, never removed). Release removes
347
+ * the lock only while it is still ours.
348
+ */
349
+ async function withStartLock(dir, fn) {
350
+ const lock = path.join(dir, "start.lock");
351
+ try {
352
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
353
+ } catch (err) {
354
+ if (SANDBOX_CODES.has(err.code)) throw sandboxError(`${err.code} on ${dir}`);
355
+ throw err;
356
+ }
357
+ const token = randomBytes(8).toString("hex");
358
+ for (let attempt = 0; attempt < 3; attempt++) {
359
+ try {
360
+ fs.mkdirSync(lock);
361
+ } catch (err) {
362
+ if (SANDBOX_CODES.has(err.code)) throw sandboxError(`${err.code} on ${dir}`);
363
+ if (err.code !== "EEXIST") throw err;
364
+ const judged = lockAbandoned(lock);
365
+ if (!judged) return null; // someone else is starting it
366
+ if (!reclaimLock(lock, judged)) return null;
367
+ continue;
368
+ }
369
+ let named = true;
370
+ try {
371
+ fs.writeFileSync(path.join(lock, "owner.json"), JSON.stringify({ pid: process.pid, token, at: new Date().toISOString() }));
372
+ } catch {
373
+ named = false; // the age fallback still covers it
374
+ }
375
+ try {
376
+ return await fn();
377
+ } finally {
378
+ const owner = lockOwner(lock);
379
+ if (named ? owner?.token === token : !owner) fs.rmSync(lock, { recursive: true, force: true });
380
+ }
381
+ }
382
+ return null;
383
+ }
384
+
385
+ function lockOwner(lock) {
386
+ try {
387
+ return JSON.parse(fs.readFileSync(path.join(lock, "owner.json"), "utf8"));
388
+ } catch {
389
+ return null; // just created, or from an older CLI
390
+ }
391
+ }
392
+
393
+ /** The lock as judged abandoned — `{ token, mtimeMs }` — or null while it is someone's live lock. */
394
+ function lockAbandoned(lock) {
395
+ const owner = lockOwner(lock);
396
+ let mtimeMs;
397
+ try {
398
+ mtimeMs = fs.statSync(lock).mtimeMs;
399
+ } catch {
400
+ return { token: undefined, mtimeMs: null }; // released meanwhile: just try again
401
+ }
402
+ const snapshot = { token: owner?.token ?? null, mtimeMs };
403
+ if (Number.isInteger(owner?.pid) && owner.pid !== process.pid && !pidAlive(owner.pid)) return snapshot; // its holder was killed
404
+ return Date.now() - mtimeMs > START_LOCK_STALE_MS ? snapshot : null;
405
+ }
406
+
407
+ /** Remove the judged lock and nothing else. False when a live lock turned up in its place. */
408
+ function reclaimLock(lock, judged) {
409
+ if (judged.mtimeMs === null) return true; // it was released: retry acquisition
410
+ const tomb = `${lock}.${process.pid}.${randomBytes(4).toString("hex")}.stale`;
411
+ try {
412
+ fs.renameSync(lock, tomb);
413
+ } catch (err) {
414
+ if (err.code === "ENOENT") return true; // someone else reclaimed or released it: retry acquisition
415
+ throw err;
416
+ }
417
+ let mtimeMs = null;
418
+ try {
419
+ mtimeMs = fs.statSync(tomb).mtimeMs;
420
+ } catch {
421
+ // vanished: nothing to put back
422
+ }
423
+ if ((lockOwner(tomb)?.token ?? null) === judged.token && mtimeMs === judged.mtimeMs) {
424
+ fs.rmSync(tomb, { recursive: true, force: true });
425
+ return true;
426
+ }
427
+ // a fresh lock was created between the judgement and the rename: hand it back
428
+ try {
429
+ fs.renameSync(tomb, lock);
430
+ } catch {
431
+ // another starter already holds start.lock; leave the tombstone to the age rule
432
+ }
433
+ return false;
434
+ }
435
+
436
+ /** Test seam: the start lock's judge and reclaimer, to drive their races deterministically. */
437
+ export const startLockInternals = { lockAbandoned, reclaimLock, lockOwner };
438
+
439
+ const startFailed = (detail, dir) => coded("workspace_start_failed", `the workspace could not start in the background (${detail}) — see ${logPath(dir)}`);
440
+
441
+ // Poll until a live descriptor appears (optionally for a specific pid).
442
+ async function awaitLive({ dir, pid = null, deadline, child = null }) {
443
+ while (Date.now() < deadline) {
444
+ if (child && child.exitCode !== null) throw startFailed(`it exited with code ${child.exitCode} before it was ready`, dir);
445
+ const d = readDescriptor(dir);
446
+ if (d && !d.malformed && (pid === null || d.pid === pid)) {
447
+ const p = await probe(d);
448
+ if (p.live) return { descriptor: d, probe: p };
449
+ }
450
+ await wait(100);
451
+ }
452
+ throw startFailed(`it did not become ready within ${START_DEADLINE_MS / 1000}s`, dir);
453
+ }
454
+
455
+ // The decks root of the command that starts the workspace, for the daemon's
456
+ // auto-discovery — only when it exists; SLIDEMIND_DECKS already set wins.
457
+ function decksDirEnv() {
458
+ if (process.env.SLIDEMIND_DECKS) return {};
459
+ const dir = resolveDecksDir();
460
+ return fs.existsSync(dir) ? { SLIDEMIND_DECKS: dir } : {};
461
+ }
462
+
463
+ /**
464
+ * Reuse or replace a live workspace: "reuse" | "replace". A newer protocol is
465
+ * this CLI's problem (`cli_update_required`); an older one, or older code, is
466
+ * replaced; newer code is reused with a note — never replaced by an older CLI.
467
+ */
468
+ function judge(d, p, note) {
469
+ if (p.protocolVersion > PROTOCOL_VERSION) {
470
+ throw coded(
471
+ "cli_update_required",
472
+ `a newer slidemind workspace is running (protocol ${p.protocolVersion}); this CLI ${readPackageJson().version} speaks ${PROTOCOL_VERSION}. ${updateCliInstruction()}`,
473
+ );
474
+ }
475
+ if (p.protocolVersion < PROTOCOL_VERSION) return "replace";
476
+ const cmp = compareCode(codeStamp(), descriptorCode(d));
477
+ if (cmp > 0) return "replace";
478
+ if (cmp < 0 && d.cliVersion && compareCliVersions(d.cliVersion, readPackageJson().version) > 0) {
479
+ note(`slidemind: the workspace runs a newer slidemind (${d.cliVersion}) than this CLI (${readPackageJson().version}); using it.`);
480
+ }
481
+ return "reuse";
482
+ }
483
+
484
+ const reused = (d, p) => ({ url: d.url, started: false, protocolVersion: p.protocolVersion, pid: d.pid });
485
+
486
+ /**
487
+ * Reuse the live workspace or start one. Resolves
488
+ * `{ url, started, protocolVersion, pid, daemon? }` — `daemon` only in
489
+ * foreground mode, where the workspace runs in THIS process and
490
+ * `daemon.stopped` resolves when it has drained. Throws coded
491
+ * `workspace_sandboxed`, `workspace_start_failed`, `cli_update_required` or
492
+ * (replacing an older workspace that will not stop) `workspace_unresponsive`;
493
+ * callers that have another way (the standalone gate) fall back on any of them.
494
+ */
495
+ export async function ensureRunning({ foreground = false, note = (m) => process.stderr.write(`${m}\n`), _round = 0 } = {}) {
496
+ const dir = workspaceConfigDir();
497
+ const sandbox = sandboxed();
498
+ let d = readDescriptor(dir);
499
+ let p = await probe(d);
500
+ // a sandboxed session neither signals nor starts anything: it only reuses
501
+ if (!p.live && !sandbox) p = await recover(d, p);
502
+ if (p.live) {
503
+ const verdict = judge(d, p, note);
504
+ if (verdict === "reuse") return reused(d, p);
505
+ if (sandbox) {
506
+ if (p.protocolVersion !== PROTOCOL_VERSION) throw sandboxError("the running workspace is too old and cannot be replaced from here");
507
+ return reused(d, p);
508
+ }
509
+ await stopWorkspace();
510
+ }
511
+ if (sandbox) throw sandboxError(process.env.CODEX_SANDBOX_NETWORK_DISABLED ? "CODEX_SANDBOX_NETWORK_DISABLED" : "CODEX_SANDBOX");
512
+ const deadline = Date.now() + START_DEADLINE_MS;
513
+ const result = await withStartLock(dir, async () => {
514
+ // Re-check under the lock: the previous holder may have just finished.
515
+ const current = readDescriptor(dir);
516
+ const again = await probe(current);
517
+ if (again.live) {
518
+ // another CLI started one meanwhile: the same rule as above decides
519
+ if (judge(current, again, note) === "reuse") return reused(current, again);
520
+ await stopWorkspace();
521
+ } else if (again.draining) await awaitGone(current);
522
+ if (foreground) {
523
+ Object.assign(process.env, decksDirEnv());
524
+ const { runDaemon } = await import("./daemon.mjs");
525
+ const daemon = await runDaemon();
526
+ return { url: daemon.url, started: true, protocolVersion: PROTOCOL_VERSION, pid: process.pid, daemon };
527
+ }
528
+ try {
529
+ rotateLogs(logPath(dir));
530
+ } catch {
531
+ // an unrotated log is still a log
532
+ }
533
+ let fd;
534
+ try {
535
+ fd = fs.openSync(logPath(dir), "a");
536
+ } catch (err) {
537
+ if (SANDBOX_CODES.has(err.code)) throw sandboxError(`${err.code} on ${logPath(dir)}`);
538
+ throw startFailed(err.message, dir);
539
+ }
540
+ // The daemon is nobody's agent session: it never inherits a deck owner token.
541
+ const env = { ...process.env, ...decksDirEnv() };
542
+ delete env.SLIDEMIND_OWNER;
543
+ let child;
544
+ try {
545
+ child = spawn(process.execPath, [DAEMON_ENTRY], {
546
+ detached: true,
547
+ stdio: ["ignore", fd, fd],
548
+ env,
549
+ windowsHide: true,
550
+ });
551
+ } catch (err) {
552
+ throw startFailed(err.message, dir);
553
+ } finally {
554
+ fs.closeSync(fd);
555
+ }
556
+ child.unref();
557
+ const { descriptor, probe: live } = await awaitLive({ dir, pid: child.pid, deadline, child });
558
+ return { url: descriptor.url, started: true, protocolVersion: live.protocolVersion, pid: descriptor.pid };
559
+ });
560
+ if (result) return result;
561
+ // Another process holds the start lock: wait for its workspace, then judge
562
+ // it like any other — an older CLI starting at the same moment must not
563
+ // leave this newer one talking to its routes.
564
+ const { descriptor, probe: live } = await awaitLive({ dir, deadline });
565
+ if (judge(descriptor, live, note) === "reuse" || _round > 0) return reused(descriptor, live);
566
+ await stopWorkspace();
567
+ return ensureRunning({ foreground, note, _round: _round + 1 });
568
+ }
569
+
570
+ /**
571
+ * A browser entry URL for the live workspace: `<url>[/deck/<id>]#bootstrap=<key>`.
572
+ * The key is the workspace's link key, asked for with the CLI bearer and valid
573
+ * every time; the page exchanges it for its cookie and drops the fragment. Null
574
+ * when no workspace is live.
575
+ */
576
+ export async function bootstrapUrl(deckId = null) {
577
+ const d = readDescriptor();
578
+ if (!d || d.malformed) return null;
579
+ let res;
580
+ try {
581
+ res = await fetchJson(`${d.url}/api/v1/bootstrap`, { method: "POST", bearer: d.cliBearer });
582
+ } catch (err) {
583
+ throw coded(unreachableKind(err) === "timeout" ? "workspace_unresponsive" : "workspace_start_failed", `the workspace did not answer a sign-in request (${unreachableKind(err)})`);
584
+ }
585
+ if (res.status !== 200 || !res.body?.token) throw coded("workspace_start_failed", `workspace refused a sign-in link (HTTP ${res.status})`);
586
+ return `${d.url}${deckId ? `/deck/${deckId}` : "/"}#bootstrap=${res.body.token}`;
587
+ }
588
+
589
+ /**
590
+ * The deck's library entry `{ id, path, … }`: written here, or — when this
591
+ * process may not write the library (an agent's sandbox answers EPERM on the
592
+ * registry lock) — by the live workspace, which owns that file and runs outside
593
+ * the sandbox. Without the second path a sandboxed agent's task carried no link
594
+ * and its deck never appeared in the workspace. Throws the local error when the
595
+ * workspace cannot register it either; `not_a_deck`/`missing_path` throw at once.
596
+ * `add` is the test seam for the local write.
597
+ */
598
+ export async function registerDeck(p, { add = addDeck } = {}) {
599
+ const abs = path.resolve(p);
600
+ try {
601
+ return add(abs);
602
+ } catch (err) {
603
+ if (err?.code === "not_a_deck" || err?.code === "missing_path") throw err;
604
+ let answer;
605
+ try {
606
+ answer = await daemonRequest("/decks", { method: "POST", body: { path: abs } });
607
+ } catch {
608
+ throw err;
609
+ }
610
+ if (answer.status === 200 && answer.body?.deck?.id) return answer.body.deck;
611
+ throw err;
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Drain and stop the live workspace; resolves once its process has left (or,
617
+ * in-process, its listener closed). `{ stopped, pendingJobs, exited }`; not
618
+ * running → `stopped:false`. A workspace already draining is waited out.
619
+ */
620
+ export async function stopWorkspace() {
621
+ const d = readDescriptor();
622
+ const p = await probe(d);
623
+ if (!p.live) {
624
+ if (p.draining) return { stopped: true, pendingJobs: 0, exited: await awaitGone(d) };
625
+ return { stopped: false, running: false, pendingJobs: 0 };
626
+ }
627
+ let res;
628
+ try {
629
+ res = await fetchJson(`${d.url}/api/v1/stop`, { method: "POST", bearer: d.cliBearer, timeoutMs: 8_000 });
630
+ } catch (err) {
631
+ throw coded("workspace_unresponsive", `the workspace did not confirm the stop (${unreachableKind(err)}) — see ${logPath()}`);
632
+ }
633
+ if (res.status !== 200) throw coded("workspace_stop_failed", `workspace refused to stop (HTTP ${res.status})`);
634
+ return { stopped: true, pendingJobs: res.body?.pendingJobs ?? 0, exited: await awaitGone(d) };
635
+ }