carouselbot 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -1,11 +1,15 @@
1
1
  # CarouselBot MCP
2
2
 
3
- Local-first MCP companion for the hosted [CarouselBot editor](https://carousel.bot). It exposes project, slide, text, image, layer, history, rendering, and export controls to any stdio MCP client while the editor remains in the browser.
3
+ Local-first MCP companion for the hosted [CarouselBot editor](https://carousel.bot). It exposes project, folder, slide, text, image, layer, history, rendering, and export controls to any stdio MCP client while the editor remains in the browser.
4
4
 
5
5
  ```bash
6
6
  npx carouselbot@latest setup
7
7
  ```
8
8
 
9
+ Setup pins that exact package version in the generated MCP configuration, refreshes
10
+ the agent skill, and automatically upgrades an older shared daemon. Rerun the
11
+ command when you intentionally want to update.
12
+
9
13
  For non-interactive agent setup, select the current client explicitly:
10
14
 
11
15
  ```bash
@@ -22,17 +26,33 @@ The same validated tool surface is available through the package CLI when a runn
22
26
  npx -y carouselbot@latest call get_design_guidance
23
27
  npx -y carouselbot@latest call list_editors
24
28
  npx -y carouselbot@latest call begin_edit_session --json '{"editorId":"EDITOR_ID","purpose":"Build my deck"}'
25
- npx -y carouselbot@latest call create_project --json '{"editSessionId":"SESSION_ID","name":"My presentation"}'
29
+ npx -y carouselbot@latest call create_project --json '{"editSessionId":"SESSION_ID","name":"My presentation","folderPath":"/campaigns"}'
26
30
  ```
27
31
 
28
32
  Every MCP tool name and JSON argument shape works with `call`. The CLI-only `list_tools` helper lists names compactly or returns selected schemas. `render_slide` writes image output to a temporary `previewPath` instead of dumping base64 into the terminal.
29
33
 
34
+ Folders use exact canonical slash paths such as `/campaigns` and are derived from project membership. Pass `folderPath` to `create_project` to create a project in a folder. Use `move_project` with another slash path to move it between folders, or with `folderPath: null` to move it back to the dashboard root. Embedded slashes are part of the virtual path and do not create a nested UI hierarchy. Because folders are implicit, moving the last project out removes the empty folder card automatically. `inspect_editor` returns each project's current folder path.
35
+
30
36
  Always use `list_editors` to check the browser connection. Do not open CarouselBot or click **Connect AI** through a sandboxed, remote, or agent-controlled browser: that is a different browser session and may not reach the local companion.
31
37
 
32
- Browser reconnection is automatic. Retry transient disconnects; use `restart` only for an explicit protocol mismatch or failed daemon health check. Hermes can refresh native tools with `/reload-mcp` and `/reload-skills`. Claude may need a new session to register a newly added server, but the CLI fallback works immediately.
38
+ Browser and MCP-process reconnection are automatic, including after a compatible daemon upgrade. The MCP checks the daemon's advertised internal actions rather than trusting the browser protocol number alone. Retry transient disconnects; use `restart` only when automatic recovery or `doctor` reports a failed daemon health check. An already-running host may still need to refresh its native tool catalog when a release adds entirely new tool names; the CLI fallback works immediately without waiting for that refresh.
33
39
 
34
40
  The companion binds only to `127.0.0.1`. There is no hosted relay: projects remain in browser IndexedDB and local images remain on the user's computer.
35
41
 
42
+ ## Installed fonts
43
+
44
+ CarouselBot can use fonts installed on the same Mac as the companion. Open a text layer's font control once and choose **Allow local fonts**. The permission is stored in that browser; until it is granted, agent calls return `FONT_PERMISSION_REQUIRED`.
45
+
46
+ Agents use project-scoped IDs rather than CSS family guesses:
47
+
48
+ ```text
49
+ list_local_fonts({ query: "Didot" })
50
+ import_font({ editSessionId, projectId, localFontId })
51
+ add_text({ editSessionId, projectId, slideId, text, fontId })
52
+ ```
53
+
54
+ `list_project_fonts` reports faces already embedded in a project. `add_text` and `update_text` accept the returned `fontId`, plus optional `fontWeight`, `fontStyle`, and variable-axis settings. The companion returns opaque local font IDs and never exposes font paths. A selected face is transferred over the authenticated loopback connection, persisted only in the browser's local IndexedDB project, and loaded before fitting or rendering. If the exact bytes are missing or invalid, rendering reports `FONT_UNAVAILABLE` instead of silently using a fallback.
55
+
36
56
  Any MCP client can launch it with:
37
57
 
38
58
  ```bash
@@ -20,6 +20,7 @@ Read this before creating or editing slides. Use it as a compact quality bar, th
20
20
  - Align related text layers consistently. Center is a safe default; use left alignment for editorial layouts.
21
21
  - Use rotation sparingly. Small intentional angles can add energy; arbitrary angles make carousels feel inconsistent.
22
22
  - Reuse a small palette and consistent type scale across the project.
23
+ - For an installed Mac font, select an exact face with `list_local_fonts`, import its opaque ID with `import_font`, and apply only the returned project `fontId`. Reuse project faces through `list_project_fonts`; never guess family strings.
23
24
 
24
25
  ## Working method
25
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carouselbot",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Local-first MCP companion for the hosted CarouselBot editor",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,6 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@modelcontextprotocol/server": "2.0.0",
31
+ "fontkit": "^2.0.4",
31
32
  "zod": "^4.4.3"
32
33
  },
33
34
  "publishConfig": {
@@ -16,18 +16,20 @@ Before using any browser or making edits, call `list_editors`. A registered edit
16
16
 
17
17
  Browser reconnection is automatic. After `EDITOR_DISCONNECTED`, `EDITOR_RELOADED`, a dropped browser request, or an empty `list_editors` result that follows a working connection, wait briefly and retry `list_editors` several times. Do not restart the companion for a transient browser disconnect: restarting invalidates every browser session and makes recovery slower. If the editor does not return, ask the user to keep or reload the real editor tab; preserve completed project work and begin a new edit session after it reconnects.
18
18
 
19
- Run `npx -y carouselbot@latest restart` only when the MCP explicitly reports an outdated companion protocol or `doctor` reports that the daemon itself is unhealthy. After a necessary restart, ask the user to reload their real editor tab and retry `list_editors`.
19
+ The MCP checks the shared daemon's actual internal capabilities before advertising tools. It automatically replaces an outdated daemon, keeps the MCP process alive, and lets the remembered browser connection reconnect. Do not ask the user to reload MCP or restart Hermes for daemon recovery.
20
+
21
+ If an advertised tool nevertheless returns `UNSUPPORTED_INTERNAL_ACTION` or `Unknown internal action`, stop after that first failure; do not fan out parallel retries that can trip the host's whole-server circuit breaker. Retry `list_editors` once so automatic recovery can finish, then retry the original tool once. If recovery itself reports that no compatible companion could start, run `npx -y carouselbot@latest doctor` and then `npx -y carouselbot@latest restart` once. Only ask the user to reload their real editor tab if it does not reconnect automatically. Never restart the companion for a transient browser disconnect.
20
22
 
21
23
  If the native MCP tools are not registered in the current session, do not stop or ask for a restart. Use the same validated tools through the local CLI fallback:
22
24
 
23
25
  ```bash
24
26
  npx -y carouselbot@latest call get_design_guidance
25
27
  npx -y carouselbot@latest call list_editors
26
- npx -y carouselbot@latest call list_tools --json '{"names":["add_slide","add_text"]}'
27
- npx -y carouselbot@latest call create_project --json '{"name":"My presentation"}'
28
+ npx -y carouselbot@latest call list_tools --json '{"names":["create_project","move_project","add_slide","add_text"]}'
29
+ npx -y carouselbot@latest call create_project --json '{"name":"My presentation","folderPath":"/campaigns"}'
28
30
  ```
29
31
 
30
- Every tool accepts the same JSON arguments as MCP. Use `list_tools` without arguments for compact discovery or pass `{"names":[...]}` to retrieve selected schemas. Prefer `apply_operations` for batches. `render_slide` writes its returned image to a temporary local `previewPath`; inspect that file and remove the temporary directory after the review. Hermes can load the native tools in place with `/reload-mcp` and refresh this skill with `/reload-skills`.
32
+ Every tool accepts the same JSON arguments as MCP. Use `list_tools` without arguments for compact discovery or pass `{"names":[...]}` to retrieve selected schemas. Prefer `apply_operations` for batches. `render_slide` writes its returned image to a temporary local `previewPath`; inspect that file and remove the temporary directory after the review. A daemon replacement never requires `/reload-mcp`. Hermes only needs `/reload-mcp` when a package update adds entirely new native tool names to an already-running agent session; use the CLI fallback immediately in that rare case. A newly installed skill becomes active in the next session or after `/reload-skills`, but correctness must never depend on the user doing that manually.
31
33
 
32
34
  Before the first mutation in a task, call `get_design_guidance`. The server intentionally rejects mutations until this guidance has been read.
33
35
 
@@ -46,6 +48,17 @@ With only one agent and one editor, the server can create an implicit session fo
46
48
 
47
49
  Inspect the assigned editor before editing and keep the returned IDs and revision. Work on the assigned project and slide. Pass `expectedRevision` for sensitive mutations. If `STALE_PROJECT` is returned, the browser has reloaded the canonical IndexedDB copy; inspect again and retry with current IDs. Prefer `apply_operations` for compact related changes while preserving logical order.
48
50
 
51
+ When the user asks for a font installed on their Mac, use the deterministic two-ID flow:
52
+
53
+ 1. Call `list_local_fonts` with a family/style query and choose an exact face from its metadata. Never guess a CSS family or construct a `localFontId`.
54
+ 2. Call `import_font` with that opaque `localFontId`. Keep the returned project-scoped `fontId`.
55
+ 3. Pass the returned `fontId` to `add_text` or `update_text`. Import and application are separate calls for a newly selected face; do not invent a batch placeholder.
56
+ 4. Render and inspect the result. Use `list_project_fonts` to reuse faces already embedded in the project.
57
+
58
+ If listing returns `FONT_PERMISSION_REQUIRED`, ask the user to open the real CarouselBot tab and choose **Allow local fonts** from the text font control, then retry. Do not bypass this with browser automation. Font paths and bytes are intentionally unavailable to agents. If rendering reports `FONT_UNAVAILABLE`, preserve the editable text, report the missing face, and ask the user to replace or re-import it rather than accepting fallback pixels.
59
+
60
+ Dashboard folders are implicit and use exact canonical slash paths such as `/campaigns`. Set `folderPath` when calling `create_project` to create the project inside a folder. Call `move_project` with another slash path to move it between folders, or with `folderPath: null` to move it back to the dashboard root. Embedded slashes remain part of one virtual path rather than creating a nested UI hierarchy. Moving the final project out of a folder removes that empty folder automatically. Use `inspect_editor` to read each project's current `folderPath`.
61
+
49
62
  Use readable role-based type ranges: title `92–124`, subtitle `68–84`, body `54–68`, caption `44–52`. Do not solve dense copy by dropping below the body range; shorten it or split it across slides. `add_text` and `update_text` automatically preserve width and fit height around every wrapped line with safe padding. For highlighted text, prefer `style: "boxed"` with `backgroundShape: "lines"`. Use `backgroundShape: "full"` only for a deliberate card. Call `fit_text_boxes` with `mode: "both"` only when you intentionally want the width to shrink too.
50
63
 
51
64
  After each meaningful composition or after a short batch, call `render_slide` and inspect the returned image. Fix clipping, spacing, contrast, unsafe overlay placement, and weak hierarchy before claiming the slide is finished. Use `export_slide` or `export_project` only when local files are requested; do not overwrite existing files unless authorized.
package/src/companion.mjs CHANGED
@@ -2,10 +2,15 @@ import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { fileURLToPath } from "node:url";
5
- import { BRIDGE_URL, DAEMON_STATE_PATH, PACKAGE_VERSION, PROTOCOL_VERSION } from "./config.mjs";
5
+ import {
6
+ BRIDGE_URL, DAEMON_API_VERSION, DAEMON_INTERNAL_ACTIONS, DAEMON_STATE_PATH,
7
+ PACKAGE_VERSION, PROTOCOL_VERSION,
8
+ } from "./config.mjs";
6
9
  import { preferHostAgent } from "./agent-identity.mjs";
7
10
 
8
11
  const DAEMON_ENTRY = fileURLToPath(new URL("daemon.mjs", import.meta.url));
12
+ const START_TIMEOUT_MS = 8_000;
13
+ const STOP_TIMEOUT_MS = 8_000;
9
14
 
10
15
  const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
11
16
 
@@ -23,62 +28,158 @@ async function daemonRequest(state, path, init = {}) {
23
28
  if (!response.ok) {
24
29
  const error = new Error(value.error || `Local companion returned ${response.status}.`);
25
30
  error.status = response.status;
31
+ if (value.code) error.code = value.code;
32
+ if (value.details) error.details = value.details;
26
33
  throw error;
27
34
  }
28
35
  return value;
29
36
  }
30
37
 
31
- async function healthyState() {
38
+ function compatibilityIssue(health, { requireCurrentVersion = false } = {}) {
39
+ if (health.protocolVersion !== PROTOCOL_VERSION) {
40
+ return `browser protocol ${health.protocolVersion ?? "unknown"} (requires ${PROTOCOL_VERSION})`;
41
+ }
42
+ const advertisedActions = new Set(Array.isArray(health.capabilities?.internalActions) ? health.capabilities.internalActions : []);
43
+ const missingActions = DAEMON_INTERNAL_ACTIONS.filter((action) => !advertisedActions.has(action));
44
+ if (missingActions.length) return `missing internal actions: ${missingActions.join(", ")}`;
45
+ const advertisedApiVersion = Number(health.daemonApiVersion);
46
+ if (!Number.isInteger(advertisedApiVersion) || advertisedApiVersion < DAEMON_API_VERSION) {
47
+ return `daemon API ${health.daemonApiVersion ?? "unknown"} (requires ${DAEMON_API_VERSION})`;
48
+ }
49
+ if (requireCurrentVersion && health.version !== PACKAGE_VERSION) {
50
+ return `package ${health.version || "unknown"} (requires ${PACKAGE_VERSION})`;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ function stateWithHealth(state, health) {
56
+ return {
57
+ ...state,
58
+ pid: health.pid || state.pid,
59
+ version: health.version || state.version || null,
60
+ protocolVersion: health.protocolVersion,
61
+ daemonApiVersion: health.daemonApiVersion,
62
+ capabilities: health.capabilities,
63
+ };
64
+ }
65
+
66
+ async function inspectDaemon(options = {}) {
32
67
  const state = await readState();
33
68
  if (!state?.secret || state.port == null) return null;
34
69
  try {
35
- const result = await daemonRequest(state, "/internal/health");
36
- if (result.protocolVersion !== PROTOCOL_VERSION) {
37
- const error = new Error(`Local companion ${result.version || "unknown"} uses protocol ${result.protocolVersion}; ${PACKAGE_VERSION} requires protocol ${PROTOCOL_VERSION}. Run \`npx -y carouselbot@latest restart\`, then reload the editor.`);
38
- error.code = "EPROTOCOL";
39
- throw error;
40
- }
41
- return state;
42
- } catch (error) {
43
- if (error.code === "EPROTOCOL") throw error;
70
+ const health = await daemonRequest(state, "/internal/health");
71
+ return { state: stateWithHealth(state, health), health, issue: compatibilityIssue(health, options) };
72
+ } catch {
44
73
  return null;
45
74
  }
46
75
  }
47
76
 
48
- async function ensureDaemon() {
49
- const existing = await healthyState();
50
- if (existing) return existing;
77
+ async function processIsRunning(pid) {
78
+ if (!Number.isInteger(pid) || pid <= 0) return false;
79
+ try { process.kill(pid, 0); return true; }
80
+ catch { return false; }
81
+ }
82
+
83
+ async function stopDaemon({ state, health }) {
84
+ const pid = Number(health?.pid || state?.pid);
85
+ if (!Number.isInteger(pid) || pid <= 0) return;
86
+ let shutdownAccepted = false;
87
+ try {
88
+ const response = await daemonRequest(state, "/internal/shutdown", { method: "POST", body: "{}" });
89
+ shutdownAccepted = response?.pid === pid;
90
+ } catch { /* Older companions may not expose graceful shutdown. */ }
91
+ if (!shutdownAccepted) {
92
+ try { process.kill(pid, "SIGTERM"); } catch { return; }
93
+ }
94
+ const deadline = Date.now() + STOP_TIMEOUT_MS;
95
+ while (Date.now() < deadline && await processIsRunning(pid)) await wait(100);
96
+ if (await processIsRunning(pid)) {
97
+ try { process.kill(pid, "SIGTERM"); } catch { /* It already stopped. */ }
98
+ const forcedDeadline = Date.now() + 2_000;
99
+ while (Date.now() < forcedDeadline && await processIsRunning(pid)) await wait(100);
100
+ }
101
+ }
102
+
103
+ let ensureQueue = Promise.resolve();
104
+
105
+ async function ensureDaemonOnce({ forceReplace = false, requireCurrentVersion = false } = {}) {
106
+ const options = { requireCurrentVersion };
107
+ const existing = await inspectDaemon(options);
108
+ if (existing && !forceReplace && !existing.issue) return existing.state;
109
+ if (existing) await stopDaemon(existing);
110
+
51
111
  const child = spawn(process.execPath, [DAEMON_ENTRY], {
52
112
  detached: true,
53
113
  stdio: "ignore",
54
114
  env: process.env,
55
115
  });
56
116
  child.unref();
57
- const deadline = Date.now() + 8000;
117
+
118
+ const deadline = Date.now() + START_TIMEOUT_MS;
119
+ let lastIssue = existing?.issue || null;
58
120
  while (Date.now() < deadline) {
59
121
  await wait(100);
60
- const state = await healthyState();
61
- if (state) return state;
122
+ const candidate = await inspectDaemon(options);
123
+ if (!candidate) continue;
124
+ if (!candidate.issue) return candidate.state;
125
+ lastIssue = candidate.issue;
62
126
  }
63
- throw new Error("Could not start the local CarouselBot companion. Run `npx carouselbot doctor` for details.");
127
+ const detail = lastIssue ? ` Last companion was incompatible: ${lastIssue}.` : "";
128
+ throw new Error(`Could not start a compatible local CarouselBot companion.${detail} Run \`npx -y carouselbot@latest doctor\` for details.`);
129
+ }
130
+
131
+ function ensureDaemon(options = {}) {
132
+ const operation = ensureQueue.then(() => ensureDaemonOnce(options));
133
+ ensureQueue = operation.catch(() => {});
134
+ return operation;
135
+ }
136
+
137
+ function unsupportedInternalAction(error) {
138
+ return error?.code === "UNSUPPORTED_INTERNAL_ACTION" || /Unknown internal action:/i.test(error?.message || "");
64
139
  }
65
140
 
66
141
  export async function createCompanion(initialName = "MCP agent", initialVersion = null) {
142
+ // Compatibility is resolved before the stdio server advertises its tools. A
143
+ // stale shared daemon is replaced in place; this MCP process stays alive and
144
+ // the browser's remembered loopback connection reconnects automatically.
67
145
  let state = await ensureDaemon();
68
146
  const clientId = randomUUID();
69
147
  let clientName = initialName;
70
148
  let clientVersion = initialVersion;
71
149
  let closed = false;
150
+ let unsupportedRecovery = null;
151
+ const repairedUnsupportedActions = new Set();
72
152
 
73
153
  const rawPost = (path, body) => daemonRequest(state, path, { method: "POST", body: JSON.stringify(body) });
74
154
  const register = () => rawPost("/internal/client/connect", { clientId, name: clientName, version: clientVersion });
155
+ const recoverUnsupportedAction = (action) => {
156
+ if (unsupportedRecovery) return unsupportedRecovery;
157
+ if (repairedUnsupportedActions.has(action)) return null;
158
+ repairedUnsupportedActions.add(action);
159
+ const recovery = (async () => {
160
+ state = await ensureDaemon({ forceReplace: true });
161
+ await register();
162
+ })();
163
+ const trackedRecovery = recovery.finally(() => {
164
+ if (unsupportedRecovery === trackedRecovery) unsupportedRecovery = null;
165
+ });
166
+ unsupportedRecovery = trackedRecovery;
167
+ return unsupportedRecovery;
168
+ };
75
169
  const post = async (path, body) => {
76
170
  try {
77
171
  return await rawPost(path, body);
78
172
  } catch (error) {
79
- if (closed || (error.status && error.status !== 401)) throw error;
80
- state = await ensureDaemon();
81
- await register();
173
+ const unsupportedAction = path === "/internal/call" && unsupportedInternalAction(error) ? body.action : null;
174
+ if (closed || (error.status && error.status !== 401 && !unsupportedAction)) throw error;
175
+ if (unsupportedAction) {
176
+ const recovery = recoverUnsupportedAction(unsupportedAction);
177
+ if (!recovery) throw error;
178
+ await recovery;
179
+ } else {
180
+ state = await ensureDaemon();
181
+ await register();
182
+ }
82
183
  return rawPost(path, body);
83
184
  }
84
185
  };
@@ -90,11 +191,20 @@ export async function createCompanion(initialName = "MCP agent", initialVersion
90
191
 
91
192
  return {
92
193
  clientId,
93
- get daemon() { return { pid: state.pid, url: BRIDGE_URL, version: state.version, packageVersion: PACKAGE_VERSION }; },
194
+ get daemon() {
195
+ return {
196
+ pid: state.pid,
197
+ url: BRIDGE_URL,
198
+ version: state.version,
199
+ packageVersion: PACKAGE_VERSION,
200
+ daemonApiVersion: state.daemonApiVersion,
201
+ capabilities: state.capabilities,
202
+ };
203
+ },
94
204
  async identify(name, version) {
95
205
  clientName = preferHostAgent(name, clientName);
96
206
  clientVersion = version || clientVersion;
97
- await register();
207
+ await post("/internal/client/connect", { clientId, name: clientName, version: clientVersion });
98
208
  },
99
209
  async call(action, body = {}) {
100
210
  const response = await post("/internal/call", { clientId, action, ...body });
@@ -112,29 +222,34 @@ export async function createCompanion(initialName = "MCP agent", initialVersion
112
222
  export async function companionDoctor() {
113
223
  const state = await ensureDaemon();
114
224
  const health = await daemonRequest(state, "/internal/health");
115
- return { ...health, url: BRIDGE_URL, stateFile: DAEMON_STATE_PATH };
225
+ return { ...health, packageVersion: PACKAGE_VERSION, compatible: true, url: BRIDGE_URL, stateFile: DAEMON_STATE_PATH };
226
+ }
227
+
228
+ export async function companionUpgrade() {
229
+ const previous = await inspectDaemon();
230
+ const state = await ensureDaemon({ requireCurrentVersion: true });
231
+ const health = await daemonRequest(state, "/internal/health");
232
+ return {
233
+ ...health,
234
+ packageVersion: PACKAGE_VERSION,
235
+ compatible: true,
236
+ upgraded: Boolean(previous && previous.health.version !== health.version),
237
+ previousPid: previous?.health.pid || null,
238
+ url: BRIDGE_URL,
239
+ stateFile: DAEMON_STATE_PATH,
240
+ };
116
241
  }
117
242
 
118
243
  export async function companionRestart() {
119
- const previous = await readState();
120
- if (previous?.secret && Number.isInteger(previous.pid) && previous.pid > 0) {
121
- const health = await daemonRequest(previous, "/internal/health").catch(() => null);
122
- if (health?.pid === previous.pid) {
123
- await daemonRequest(previous, "/internal/shutdown", { method: "POST", body: "{}" }).catch(() => {
124
- try { process.kill(previous.pid, "SIGTERM"); } catch { /* It already stopped. */ }
125
- });
126
- const deadline = Date.now() + 8000;
127
- while (Date.now() < deadline) {
128
- try {
129
- process.kill(previous.pid, 0);
130
- await wait(100);
131
- } catch {
132
- break;
133
- }
134
- }
135
- }
136
- }
137
- const state = await ensureDaemon();
244
+ const previous = await inspectDaemon();
245
+ const state = await ensureDaemon({ forceReplace: true });
138
246
  const health = await daemonRequest(state, "/internal/health");
139
- return { ...health, url: BRIDGE_URL, stateFile: DAEMON_STATE_PATH, previousPid: previous?.pid || null };
247
+ return {
248
+ ...health,
249
+ packageVersion: PACKAGE_VERSION,
250
+ compatible: true,
251
+ url: BRIDGE_URL,
252
+ stateFile: DAEMON_STATE_PATH,
253
+ previousPid: previous?.health.pid || null,
254
+ };
140
255
  }
package/src/config.mjs CHANGED
@@ -8,6 +8,26 @@ export const PACKAGE_JSON = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.
8
8
  export const PACKAGE_NAME = PACKAGE_JSON.name;
9
9
  export const PACKAGE_VERSION = PACKAGE_JSON.version;
10
10
  export const PROTOCOL_VERSION = 3;
11
+ // The browser protocol changes only when the hosted editor and companion can no
12
+ // longer communicate. Internal MCP-to-daemon actions evolve independently, so
13
+ // advertise them explicitly instead of treating a matching browser protocol as
14
+ // proof that two installed package versions are compatible.
15
+ export const DAEMON_API_VERSION = 1;
16
+ export const DAEMON_INTERNAL_ACTIONS = Object.freeze([
17
+ "batch",
18
+ "begin_edit_session",
19
+ "browser",
20
+ "end_edit_session",
21
+ "list_edit_sessions",
22
+ "list_editors",
23
+ "list_local_fonts",
24
+ "list_recent_operations",
25
+ "notify",
26
+ "prepare_font",
27
+ "prepare_media",
28
+ "select_editor",
29
+ "write_export",
30
+ ]);
11
31
  export const BRIDGE_HOST = "127.0.0.1";
12
32
  export const BRIDGE_PORT = Number(process.env.CAROUSELBOT_BRIDGE_PORT || process.env.SLIDE_STUDIO_BRIDGE_PORT) || 43117;
13
33
  export const BRIDGE_URL = `http://${BRIDGE_HOST}:${BRIDGE_PORT}`;