carouselbot 0.3.0 → 0.3.2
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 +6 -3
- package/package.json +1 -1
- package/skill/carouselbot/SKILL.md +4 -2
- package/src/companion.mjs +159 -44
- package/src/config.mjs +20 -0
- package/src/daemon.mjs +34 -6
- package/src/mcp-server.mjs +1 -1
- package/src/setup.mjs +43 -12
package/README.md
CHANGED
|
@@ -6,8 +6,11 @@ Local-first MCP companion for the hosted [CarouselBot editor](https://carousel.b
|
|
|
6
6
|
npx carouselbot@latest setup
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
Setup pins that exact package version in the generated MCP configuration
|
|
10
|
-
the
|
|
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. When setup runs inside a named
|
|
12
|
+
Hermes profile, it also installs the skill into that profile's active
|
|
13
|
+
HERMES_HOME and non-interactively enables the discovered CarouselBot tools.
|
|
11
14
|
|
|
12
15
|
For non-interactive agent setup, select the current client explicitly:
|
|
13
16
|
|
|
@@ -34,7 +37,7 @@ Folders use exact canonical slash paths such as `/campaigns` and are derived fro
|
|
|
34
37
|
|
|
35
38
|
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.
|
|
36
39
|
|
|
37
|
-
Browser reconnection
|
|
40
|
+
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.
|
|
38
41
|
|
|
39
42
|
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.
|
|
40
43
|
|
package/package.json
CHANGED
|
@@ -16,7 +16,9 @@ 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
|
|
|
@@ -27,7 +29,7 @@ npx -y carouselbot@latest call list_tools --json '{"names":["create_project","mo
|
|
|
27
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
|
|
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}`;
|
package/src/daemon.mjs
CHANGED
|
@@ -5,7 +5,8 @@ import { appendFile, mkdir, open, readFile, rename, stat, unlink, writeFile } fr
|
|
|
5
5
|
import { basename, delimiter, extname } from "node:path";
|
|
6
6
|
import {
|
|
7
7
|
ALLOWED_ORIGINS, AUDIT_LOG_PATH, BRIDGE_HOST, BRIDGE_PORT, BRIDGE_URL, DAEMON_LOCK_PATH,
|
|
8
|
-
|
|
8
|
+
DAEMON_API_VERSION, DAEMON_INTERNAL_ACTIONS, DAEMON_STATE_PATH, PACKAGE_NAME, PACKAGE_VERSION,
|
|
9
|
+
PROTOCOL_VERSION, STATE_DIRECTORY,
|
|
9
10
|
} from "./config.mjs";
|
|
10
11
|
import { createLocalFontService } from "./local-fonts.mjs";
|
|
11
12
|
|
|
@@ -78,6 +79,19 @@ function codedError(code, message, details = {}) {
|
|
|
78
79
|
return error;
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
function daemonHealth(details = {}) {
|
|
83
|
+
return {
|
|
84
|
+
ok: true,
|
|
85
|
+
service: PACKAGE_NAME,
|
|
86
|
+
pid: process.pid,
|
|
87
|
+
version: PACKAGE_VERSION,
|
|
88
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
89
|
+
daemonApiVersion: DAEMON_API_VERSION,
|
|
90
|
+
capabilities: { internalActions: [...DAEMON_INTERNAL_ACTIONS] },
|
|
91
|
+
...details,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
81
95
|
function publicSession(session) {
|
|
82
96
|
return {
|
|
83
97
|
id: session.id,
|
|
@@ -546,7 +560,10 @@ async function handleInternalCall(body) {
|
|
|
546
560
|
for (const item of body.items) results.push(await callBrowser(body.clientId, item.toolName || "apply_operations", item.operation, item.label, { editSessionId: body.editSessionId, mutating: true }));
|
|
547
561
|
return { applied: results.length, results };
|
|
548
562
|
}
|
|
549
|
-
throw
|
|
563
|
+
throw codedError("UNSUPPORTED_INTERNAL_ACTION", `Unknown internal action: ${body.action}`, {
|
|
564
|
+
action: body.action,
|
|
565
|
+
supportedActions: [...DAEMON_INTERNAL_ACTIONS],
|
|
566
|
+
});
|
|
550
567
|
}
|
|
551
568
|
|
|
552
569
|
const server = createServer(async (request, response) => {
|
|
@@ -564,13 +581,13 @@ const server = createServer(async (request, response) => {
|
|
|
564
581
|
}
|
|
565
582
|
if (url.pathname === "/health" && request.method === "GET") {
|
|
566
583
|
if (origin && !cors) return sendJson(response, 403, { error: "Origin not allowed." });
|
|
567
|
-
return sendJson(response, 200, {
|
|
584
|
+
return sendJson(response, 200, daemonHealth({ editors: activeEditors().length, agents: activeClients().length }), cors || {});
|
|
568
585
|
}
|
|
569
586
|
|
|
570
587
|
try {
|
|
571
588
|
if (url.pathname.startsWith("/internal/")) {
|
|
572
589
|
if (!requireInternal(request, response)) return;
|
|
573
|
-
if (url.pathname === "/internal/health" && request.method === "GET") return sendJson(response, 200,
|
|
590
|
+
if (url.pathname === "/internal/health" && request.method === "GET") return sendJson(response, 200, daemonHealth());
|
|
574
591
|
if (url.pathname === "/internal/shutdown" && request.method === "POST") {
|
|
575
592
|
sendJson(response, 202, { ok: true, pid: process.pid });
|
|
576
593
|
setImmediate(() => void shutdown());
|
|
@@ -772,7 +789,11 @@ const server = createServer(async (request, response) => {
|
|
|
772
789
|
: ["EACCES", "FONT_PERMISSION_REQUIRED"].includes(error.code)
|
|
773
790
|
? 403
|
|
774
791
|
: error.code === "FONT_TRANSFER_LIMIT" ? 429 : 400;
|
|
775
|
-
return sendJson(response, statusCode, {
|
|
792
|
+
return sendJson(response, statusCode, {
|
|
793
|
+
error: error.message,
|
|
794
|
+
...(error.code ? { code: error.code } : {}),
|
|
795
|
+
...(error.details && Object.keys(error.details).length ? { details: error.details } : {}),
|
|
796
|
+
}, headers);
|
|
776
797
|
}
|
|
777
798
|
});
|
|
778
799
|
|
|
@@ -802,7 +823,14 @@ async function acquireDaemonLock() {
|
|
|
802
823
|
|
|
803
824
|
async function writeDaemonState() {
|
|
804
825
|
const temporary = `${DAEMON_STATE_PATH}.${process.pid}.tmp`;
|
|
805
|
-
await writeFile(temporary, JSON.stringify({
|
|
826
|
+
await writeFile(temporary, JSON.stringify({
|
|
827
|
+
pid: process.pid,
|
|
828
|
+
port: BRIDGE_PORT,
|
|
829
|
+
secret: daemonSecret,
|
|
830
|
+
version: PACKAGE_VERSION,
|
|
831
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
832
|
+
daemonApiVersion: DAEMON_API_VERSION,
|
|
833
|
+
}), { mode: 0o600 });
|
|
806
834
|
await rename(temporary, DAEMON_STATE_PATH);
|
|
807
835
|
}
|
|
808
836
|
|
package/src/mcp-server.mjs
CHANGED
|
@@ -115,7 +115,7 @@ export async function createCarouselBotMcpServer(companion) {
|
|
|
115
115
|
let guidanceRead = false;
|
|
116
116
|
let identifiedAs = null;
|
|
117
117
|
const server = new McpServer({ name: PACKAGE_NAME, version: PACKAGE_VERSION }, {
|
|
118
|
-
instructions: `First call list_editors and use the registered local browser tab. Never open or connect CarouselBot through a sandboxed agent browser. If no editor is listed, retry briefly because browser reconnection is automatic, then ask the user to open ${EDITOR_URL} in their normal browser and click Connect AI.
|
|
118
|
+
instructions: `First call list_editors and use the registered local browser tab. Never open or connect CarouselBot through a sandboxed agent browser. If no editor is listed, retry briefly because browser reconnection is automatic, then ask the user to open ${EDITOR_URL} in their normal browser and click Connect AI. Companion compatibility and reconnects are automatic. Do not parallel-retry an action that reports an unsupported internal action; retry once after list_editors so automatic recovery can finish. Never restart a healthy companion for a transient editor disconnect. Before edits call get_design_guidance, then begin_edit_session; pass editSessionId to every edit and end it in cleanup. Parallel editing workers require distinct editor sessions. Use render_slide to inspect actual pixels.`,
|
|
119
119
|
capabilities: { tools: {}, resources: {} },
|
|
120
120
|
});
|
|
121
121
|
|
package/src/setup.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { cp, mkdir } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
5
|
import { createInterface } from "node:readline/promises";
|
|
6
6
|
import { EDITOR_URL, PACKAGE_NAME, PACKAGE_ROOT, PACKAGE_VERSION } from "./config.mjs";
|
|
7
|
+
import { companionUpgrade } from "./companion.mjs";
|
|
7
8
|
|
|
8
9
|
const supported = ["claude", "codex", "hermes", "opencode", "openclaw"];
|
|
9
10
|
const serverName = "carouselbot";
|
|
@@ -43,13 +44,35 @@ function openCodeSnippet(specifier, version = commandVersion("opencode")) {
|
|
|
43
44
|
: { mcp: { [serverName]: { ...server, enabled: true } } }, null, 2);
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
const source = join(PACKAGE_ROOT, "skill", "carouselbot");
|
|
47
|
+
export function setupSkillTargets(homeDirectory = homedir(), hermesHome = process.env.HERMES_HOME) {
|
|
48
48
|
const targets = [
|
|
49
|
-
join(
|
|
50
|
-
join(
|
|
51
|
-
join(
|
|
49
|
+
join(homeDirectory, ".agents", "skills", "carouselbot"),
|
|
50
|
+
join(homeDirectory, ".claude", "skills", "carouselbot"),
|
|
51
|
+
join(homeDirectory, ".hermes", "skills", "carouselbot"),
|
|
52
52
|
];
|
|
53
|
+
if (hermesHome?.trim()) targets.push(join(resolve(hermesHome.trim()), "skills", "carouselbot"));
|
|
54
|
+
return [...new Set(targets)];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function clientSpawnOptions(client) {
|
|
58
|
+
if (client === "hermes") {
|
|
59
|
+
// Hermes tool discovery prompts even after the caller approved CarouselBot
|
|
60
|
+
// setup, and an existing entry adds an overwrite prompt first. Feed both
|
|
61
|
+
// approvals explicitly; EOF otherwise looks like a successful cancel.
|
|
62
|
+
return { input: "y\ny\n", stdio: ["pipe", "inherit", "inherit"] };
|
|
63
|
+
}
|
|
64
|
+
return { stdio: "inherit" };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function clientConfigurationPresent(client) {
|
|
68
|
+
if (client !== "hermes") return true;
|
|
69
|
+
const result = spawnSync("hermes", ["mcp", "list"], { encoding: "utf8" });
|
|
70
|
+
return result.status === 0 && /(?:^|\s)carouselbot(?:\s|$)/m.test(String(result.stdout || "") + String(result.stderr || ""));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function installSkill() {
|
|
74
|
+
const source = join(PACKAGE_ROOT, "skill", "carouselbot");
|
|
75
|
+
const targets = setupSkillTargets();
|
|
53
76
|
for (const target of targets) {
|
|
54
77
|
await mkdir(target, { recursive: true });
|
|
55
78
|
await cp(source, target, { recursive: true, force: true });
|
|
@@ -92,15 +115,23 @@ export async function runSetup(arguments_) {
|
|
|
92
115
|
for (const client of clients) {
|
|
93
116
|
const command = shellCommand(client, specifier);
|
|
94
117
|
if (!command) continue;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
118
|
+
if (client !== "hermes") {
|
|
119
|
+
for (const remove of removeCommands(client)) spawnSync(remove[0], remove.slice(1), { stdio: "ignore" });
|
|
120
|
+
}
|
|
121
|
+
const result = spawnSync(command[0], command.slice(1), clientSpawnOptions(client));
|
|
122
|
+
if (result.status === 0 && clientConfigurationPresent(client)) {
|
|
123
|
+
configured.push(client);
|
|
124
|
+
if (client === "hermes") {
|
|
125
|
+
const legacyRemove = removeCommands(client).find((remove) => remove.at(-1) === legacyServerName);
|
|
126
|
+
if (legacyRemove) spawnSync(legacyRemove[0], legacyRemove.slice(1), { input: "y\n", stdio: ["pipe", "ignore", "ignore"] });
|
|
127
|
+
}
|
|
128
|
+
} else process.stderr.write(`Could not verify ${client}'s CarouselBot config; the existing config was preserved and its command is printed above for manual setup.\n`);
|
|
99
129
|
}
|
|
100
130
|
const skillTargets = await installSkill();
|
|
101
|
-
|
|
131
|
+
const companion = await companionUpgrade();
|
|
132
|
+
process.stdout.write(`\nConfigured: ${configured.join(", ") || "none automatically"}\nSkill installed in:\n${skillTargets.map((value) => ` ${value}`).join("\n")}\nCompanion: ${companion.version} (${companion.upgraded ? "upgraded automatically" : "already current"})\n\nOpen ${EDITOR_URL} in your normal local browser and click Connect AI. Do not use a sandboxed agent browser.\n`);
|
|
102
133
|
process.stdout.write(`First connection check (no browser automation): npx -y ${specifier} call list_editors\n`);
|
|
103
|
-
if (clients.includes("hermes")) process.stdout.write("
|
|
134
|
+
if (clients.includes("hermes")) process.stdout.write("The companion and browser reconnect automatically. Hermes only needs /reload-mcp when adding entirely new native tool names to an already-running session; the CLI fallback remains available immediately.\n");
|
|
104
135
|
if (clients.includes("claude")) process.stdout.write("Claude may require a new session for native MCP registration; use the CLI fallback immediately instead of stopping.\n");
|
|
105
136
|
if (clients.includes("opencode")) process.stdout.write("OpenCode currently uses its JSON config; merge the snippet printed above into opencode.json.\n");
|
|
106
137
|
return { clients, configured, skillTargets };
|