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 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. Rerun
10
- the command when you intentionally want to update.
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 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carouselbot",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Local-first MCP companion for the hosted CarouselBot editor",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- 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
 
@@ -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. 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
 
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}`;
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
- DAEMON_STATE_PATH, PACKAGE_NAME, PACKAGE_VERSION, PROTOCOL_VERSION, STATE_DIRECTORY,
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 new Error(`Unknown internal action: ${body.action}`);
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, { ok: true, service: PACKAGE_NAME, version: PACKAGE_VERSION, protocolVersion: PROTOCOL_VERSION, editors: activeEditors().length, agents: activeClients().length }, cors || {});
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, { ok: true, pid: process.pid, version: PACKAGE_VERSION, protocolVersion: PROTOCOL_VERSION });
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, { error: error.message }, headers);
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({ pid: process.pid, port: BRIDGE_PORT, secret: daemonSecret, version: PACKAGE_VERSION, protocolVersion: PROTOCOL_VERSION }), { mode: 0o600 });
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
 
@@ -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. Never restart a healthy companion for a transient editor disconnect; restart only for an explicit protocol mismatch or failed daemon health check. 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.`,
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
- async function installSkill() {
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(homedir(), ".agents", "skills", "carouselbot"),
50
- join(homedir(), ".claude", "skills", "carouselbot"),
51
- join(homedir(), ".hermes", "skills", "carouselbot"),
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
- for (const remove of removeCommands(client)) spawnSync(remove[0], remove.slice(1), { stdio: "ignore" });
96
- const result = spawnSync(command[0], command.slice(1), { stdio: "inherit" });
97
- if (result.status === 0) configured.push(client);
98
- else process.stderr.write(`Could not configure ${client}; its command is printed above for manual setup.\n`);
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
- process.stdout.write(`\nConfigured: ${configured.join(", ") || "none automatically"}\nSkill installed in:\n${skillTargets.map((value) => ` ${value}`).join("\n")}\n\nOpen ${EDITOR_URL} in your normal local browser and click Connect AI. Do not use a sandboxed agent browser.\n`);
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("Hermes can also refresh native tools in place with /reload-mcp and /reload-skills.\n");
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 };