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 +23 -3
- package/guidance/design.md +1 -0
- package/package.json +2 -1
- package/skill/carouselbot/SKILL.md +17 -4
- package/src/companion.mjs +159 -44
- package/src/config.mjs +20 -0
- package/src/daemon.mjs +200 -12
- package/src/local-fonts.mjs +574 -0
- package/src/mcp-server.mjs +31 -11
- package/src/setup.mjs +5 -4
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
|
|
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
|
package/guidance/design.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
|
61
|
-
if (
|
|
122
|
+
const candidate = await inspectDaemon(options);
|
|
123
|
+
if (!candidate) continue;
|
|
124
|
+
if (!candidate.issue) return candidate.state;
|
|
125
|
+
lastIssue = candidate.issue;
|
|
62
126
|
}
|
|
63
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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() {
|
|
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
|
|
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
|
|
120
|
-
|
|
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 {
|
|
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}`;
|