slides-studio-mcp 0.1.0-beta.7 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,15 +1,15 @@
1
1
  # Slide Studio MCP
2
2
 
3
- Local-first MCP companion for the hosted [Slide Studio test editor](https://slides-mcp-poc-0821.pages.dev). It exposes complete 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 [Slide Studio editor](https://slides-editor.pages.dev). It exposes complete project, 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
- npx slides-studio-mcp@beta setup
6
+ npx slides-studio-mcp@latest setup
7
7
  ```
8
8
 
9
9
  If an agent is doing the setup itself, it should select its current client and run non-interactively:
10
10
 
11
11
  ```bash
12
- npx slides-studio-mcp@beta setup --client=codex --yes
12
+ npx slides-studio-mcp@latest setup --client=codex --yes
13
13
  ```
14
14
 
15
15
  Replace `codex` with `claude`, `hermes`, `opencode`, or `openclaw` for the agent doing the setup. OpenCode prints the version-appropriate JSON to merge into its config. Other detected clients are configured through their official CLI.
@@ -19,16 +19,18 @@ Replace `codex` with `claude`, `hermes`, `opencode`, or `openclaw` for the agent
19
19
  Some clients do not add a newly configured MCP server to the tool catalog of the already-running session. The same validated tool surface is available through the package CLI, so the current agent can continue immediately:
20
20
 
21
21
  ```bash
22
- npx -y slides-studio-mcp@beta call get_design_guidance
23
- npx -y slides-studio-mcp@beta call list_editors
24
- npx -y slides-studio-mcp@beta call begin_edit_session --json '{"editorId":"EDITOR_ID","purpose":"Build my deck"}'
25
- npx -y slides-studio-mcp@beta call create_project --json '{"editSessionId":"SESSION_ID","name":"My presentation"}'
22
+ npx -y slides-studio-mcp@latest call get_design_guidance
23
+ npx -y slides-studio-mcp@latest call list_editors
24
+ npx -y slides-studio-mcp@latest call begin_edit_session --json '{"editorId":"EDITOR_ID","purpose":"Build my deck"}'
25
+ npx -y slides-studio-mcp@latest call create_project --json '{"editSessionId":"SESSION_ID","name":"My presentation"}'
26
26
  ```
27
27
 
28
28
  Every MCP tool name and JSON argument shape works with `call`. The CLI-only `list_tools` helper lists all names compactly, or returns schemas for the requested names. The CLI automatically applies the guidance gate for each invocation and prints compact JSON. `render_slide` writes image output to a temporary `previewPath` instead of dumping base64 into the terminal.
29
29
 
30
30
  Always use `list_editors` to check the browser connection. Do not open Slide Studio or click **Connect AI** through a sandboxed, remote, or agent-controlled browser: that is a different browser session and may not have access to the user's local companion. If no editor is listed, ask the user to open the editor in their normal browser on the same computer and connect there, then retry `list_editors`.
31
31
 
32
+ Browser reconnection is automatic. For a transient `EDITOR_DISCONNECTED`, `EDITOR_RELOADED`, dropped browser request, or newly empty editor list, wait briefly and retry `list_editors`; do not restart a healthy daemon. Use `restart` only for an explicit companion protocol mismatch or when `doctor` reports that the daemon itself is unhealthy. Restarting invalidates current browser session tokens and should not be used as generic connection recovery.
33
+
32
34
  Hermes can refresh native tools without restarting by running `/reload-mcp`, then `/reload-skills`. Claude may still need a new session to register a newly added plain stdio server, but its current session can use the CLI fallback instead of stopping.
33
35
 
34
36
  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.
@@ -36,15 +38,15 @@ The companion binds only to `127.0.0.1`. There is no hosted relay: projects rema
36
38
  Any MCP client can launch it with:
37
39
 
38
40
  ```bash
39
- npx -y slides-studio-mcp@beta serve
41
+ npx -y slides-studio-mcp@latest serve
40
42
  ```
41
43
 
42
44
  Useful commands:
43
45
 
44
46
  ```bash
45
- npx slides-studio-mcp@beta setup --dry-run
46
- npx slides-studio-mcp@beta doctor
47
- npx slides-studio-mcp@beta restart
47
+ npx slides-studio-mcp@latest setup --dry-run
48
+ npx slides-studio-mcp@latest doctor
49
+ npx slides-studio-mcp@latest restart
48
50
  ```
49
51
 
50
52
  `restart` gracefully replaces an outdated local companion. Reload the real browser editor afterward; current MCP clients automatically recover their daemon registration.
@@ -57,4 +59,4 @@ For parallel editing, the parent agent must reserve a different connected editor
57
59
 
58
60
  Browser writes use revision-checked IndexedDB transactions and cross-tab synchronization. A stale tab cannot replace a newer project snapshot; it reloads the canonical copy and returns `STALE_PROJECT` so the agent can inspect and retry.
59
61
 
60
- Open the test editor in the user's normal local browser, click **Connect AI**, and call `get_design_guidance` before editing. The server provides `render_slide` so agents can inspect actual pixels without permanently storing previews.
62
+ Open the editor in the user's normal local browser, click **Connect AI**, and call `get_design_guidance` before editing. The server provides `render_slide` so agents can inspect actual pixels without permanently storing previews.
@@ -5,12 +5,15 @@ Read this before creating or editing slides. Use it as a compact quality bar, th
5
5
  ## Defaults that usually look good
6
6
 
7
7
  - Build for a 9:16 phone canvas and keep one clear idea per slide.
8
- - Prefer `boxed` text with `backgroundShape: "lines"` for highlighted copy. The per-line treatment is the product's strongest default.
9
- - Avoid `backgroundShape: "full"` unless a deliberate large label or card is required. A large rectangular text box usually looks heavy.
8
+ - Prefer `boxed` text with `backgroundShape: "lines"` for highlighted copy. Treat per-line boxes as the default; use `full` only for a deliberate card or label.
9
+ - `add_text` and `update_text` automatically preserve width and fit height around all wrapped lines with safety padding. Do not render just to discover clipping or call `fit_text_boxes` after ordinary copy edits.
10
+ - Use `fit_text_boxes` with `mode: "both"` only when you intentionally want width to shrink as well. If automatic fitting rejects copy that cannot fit on one slide, shorten it or split it across slides.
11
+ - After creating or changing any full-box text, call `fit_text_boxes`. A full box must hug its rendered content instead of leaving a large empty rectangle.
10
12
  - Use plain or outlined text for supporting copy. Use no more than two text treatments on one slide.
11
- - Start headlines around 64–88 px and supporting text around 42–60 px. Adjust after rendering.
13
+ - Choose size by role rather than one universal value: titles `92–124`, subtitles `68–84`, body copy `54–68`, captions `44–52`. These are ranges, not fixed presets; render and adjust within them.
14
+ - Do not shrink dense copy below the body range to make it fit. Shorten it or split it across slides. Aim for roughly 3–7 body lines on a slide.
12
15
  - Keep important content inside roughly `x: 0.06..0.86` and `y: 0.08..0.78` when the TikTok overlay matters. The right and bottom edges are occupied by interface controls and captions.
13
- - Give text boxes generous width and height. Leave at least 0.04 of canvas width beyond the visible longest line and enough height for every line plus its background. Never let glyphs or rounded backgrounds touch a box edge.
16
+ - Give text boxes generous width while composing. Per-line backgrounds include protected edge padding; if content or size changes, render again and use `fit_text_boxes` when the box itself should hug the content.
14
17
  - Use short lines. Two to four lines for a headline is usually stronger than one dense paragraph.
15
18
  - Keep strong contrast between copy and the image. Use black boxed backgrounds with white text or white boxed backgrounds with near-black text.
16
19
  - Preserve an obvious focal image. Do not cover faces or the main subject unless the composition intentionally calls for it.
@@ -21,7 +24,7 @@ Read this before creating or editing slides. Use it as a compact quality bar, th
21
24
  ## Working method
22
25
 
23
26
  1. Inspect the editor and use the returned project, slide, asset, and layer IDs.
24
- 2. Create or update one slide at a time. The editor automatically switches to the most recently changed slide.
27
+ 2. Create or update one slide at a time. If that project is already visible, the editor follows its most recently changed slide. Work on another project never takes over the user's current view.
25
28
  3. Use `apply_operations` when several related edits can be expressed compactly; the browser still shows each operation live.
26
29
  4. Call `render_slide` after a meaningful composition change and look at the returned image.
27
30
  5. Correct clipping, collisions, weak contrast, unsafe placement, inconsistent spacing, and visual imbalance before continuing.
@@ -31,8 +34,9 @@ Read this before creating or editing slides. Use it as a compact quality bar, th
31
34
 
32
35
  - Increase width before shrinking type when a line almost fits.
33
36
  - Increase height when multiline text or per-line backgrounds approach the top or bottom edge.
37
+ - Use `fit_text_boxes` after changing full-box copy or font size. Use `mode: "height"` when the chosen width must remain fixed.
34
38
  - Keep `x + width` and `y + height` within the canvas unless an off-canvas effect is intentional.
35
- - With boxed text, keep `backgroundShape: "lines"` and do not size the box tightly around the letters; rounded pills need breathing room.
39
+ - With boxed text, keep `backgroundShape: "lines"`; automatic height fitting includes minimum breathing room so rounded pills stay inside the text container.
36
40
  - If the result is uncertain, render it. Numeric state is not a visual review.
37
41
 
38
42
  ## Agent behavior
@@ -42,3 +46,4 @@ Read this before creating or editing slides. Use it as a compact quality bar, th
42
46
  - Keep tool responses and progress messages concise.
43
47
  - Prefer IDs returned by tools over guessed names or array positions.
44
48
  - Do not claim a slide looks good until you have inspected a rendered image.
49
+ - Do not call `open_project` merely to edit or render another project. It intentionally changes the user's browser view; use it only when the user asks to see that project.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slides-studio-mcp",
3
- "version": "0.1.0-beta.7",
3
+ "version": "0.1.0",
4
4
  "description": "Local-first MCP companion for the hosted Slide Studio editor",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,7 +9,7 @@
9
9
  "url": "git+https://github.com/alexgusevski/tiktokslideeditor.git",
10
10
  "directory": "packages/mcp"
11
11
  },
12
- "homepage": "https://slides-mcp-poc-0821.pages.dev",
12
+ "homepage": "https://slides-editor.pages.dev",
13
13
  "bugs": "https://github.com/alexgusevski/tiktokslideeditor/issues",
14
14
  "bin": {
15
15
  "slides-studio-mcp": "src/cli.mjs"
@@ -14,15 +14,17 @@ Before using any browser or making edits, call `list_editors`. A registered edit
14
14
  - If no editor is listed, ask the user to open the editor in their normal browser on the same computer, click **Connect AI**, and accept the browser permission. Then retry `list_editors`.
15
15
  - A loaded MCP server and a connected browser editor are different states. Determine browser connectivity only from `list_editors`, not from a sandbox browser or the agent's tool catalog.
16
16
 
17
- If the MCP reports an outdated companion protocol, run `npx -y slides-studio-mcp@beta restart` once, ask the user to reload their real editor tab, and retry `list_editors`.
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
+
19
+ Run `npx -y slides-studio-mcp@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`.
18
20
 
19
21
  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:
20
22
 
21
23
  ```bash
22
- npx -y slides-studio-mcp@beta call get_design_guidance
23
- npx -y slides-studio-mcp@beta call list_editors
24
- npx -y slides-studio-mcp@beta call list_tools --json '{"names":["add_slide","add_text"]}'
25
- npx -y slides-studio-mcp@beta call create_project --json '{"name":"My presentation"}'
24
+ npx -y slides-studio-mcp@latest call get_design_guidance
25
+ npx -y slides-studio-mcp@latest call list_editors
26
+ npx -y slides-studio-mcp@latest call list_tools --json '{"names":["add_slide","add_text"]}'
27
+ npx -y slides-studio-mcp@latest call create_project --json '{"name":"My presentation"}'
26
28
  ```
27
29
 
28
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`.
@@ -44,6 +46,8 @@ With only one agent and one editor, the server can create an implicit session fo
44
46
 
45
47
  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.
46
48
 
49
+ 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, so do not waste calls on render-fit-render loops. 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
+
47
51
  After each meaningful composition or after a short batch, call `render_slide` and inspect the returned image. Fix clipping, spacing, contrast, unsafe TikTok-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.
48
52
 
49
- Each assigned browser tab automatically opens the latest slide changed through its session. Other tabs synchronize project cards and project state through local browser storage. Use `show_notification` only for short, useful status messages.
53
+ Edits follow the latest changed slide only when their project is already visible. Creating or editing another project must not take over the user's current browser view. `open_project` and `set_view` intentionally navigate, so call them only when the user asks to see that project. Other tabs synchronize project cards and project state through local browser storage. Use `show_notification` only for short, useful status messages.
@@ -0,0 +1,48 @@
1
+ const KNOWN_AGENTS = [
2
+ { label: "Hermes", matches: ["hermes"] },
3
+ { label: "Claude", matches: ["claude", "anthropic"] },
4
+ { label: "Codex", matches: ["codex", "openai"] },
5
+ { label: "OpenCode", matches: ["opencode"] },
6
+ { label: "OpenClaw", matches: ["openclaw"] },
7
+ ];
8
+
9
+ const GENERIC_CLIENT_NAMES = new Set([
10
+ "ai agent",
11
+ "agent",
12
+ "client",
13
+ "mcp",
14
+ "mcp agent",
15
+ "mcp client",
16
+ "slide studio cli fallback",
17
+ ]);
18
+
19
+ export function canonicalAgentName(value) {
20
+ const name = String(value || "").trim();
21
+ const normalized = name.toLowerCase();
22
+ return KNOWN_AGENTS.find(({ matches }) => matches.some((match) => normalized.includes(match)))?.label || name || null;
23
+ }
24
+
25
+ export function isGenericAgentName(value) {
26
+ return GENERIC_CLIENT_NAMES.has(String(value || "").trim().toLowerCase());
27
+ }
28
+
29
+ export function detectHostAgent(explicitName = null, environment = process.env, runtime = process) {
30
+ const configured = canonicalAgentName(explicitName || environment.SLIDE_STUDIO_AGENT);
31
+ if (configured) return configured;
32
+ const environmentKeys = Object.keys(environment).filter((key) => environment[key]).join(" ");
33
+ const evidence = [
34
+ runtime.execPath,
35
+ ...(runtime.argv || []),
36
+ environment.PATH,
37
+ environment.TERM_PROGRAM,
38
+ environment.npm_execpath,
39
+ environmentKeys,
40
+ ].filter(Boolean).join(" ").toLowerCase();
41
+ return KNOWN_AGENTS.find(({ matches }) => matches.some((match) => evidence.includes(match)))?.label || "MCP agent";
42
+ }
43
+
44
+ export function preferHostAgent(reportedName, hostName) {
45
+ const reported = canonicalAgentName(reportedName);
46
+ const host = canonicalAgentName(hostName) || "MCP agent";
47
+ return !reported || isGenericAgentName(reported) ? host : reported;
48
+ }
package/src/cli.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { companionDoctor, companionRestart } from "./companion.mjs";
3
- import { PACKAGE_NAME, PACKAGE_VERSION, TEST_EDITOR_URL } from "./config.mjs";
3
+ import { EDITOR_URL, PACKAGE_NAME, PACKAGE_VERSION } from "./config.mjs";
4
4
  import { runSetup } from "./setup.mjs";
5
5
  import { serveMcp } from "./stdio-server.mjs";
6
6
 
@@ -8,7 +8,8 @@ const [command = "serve", ...arguments_] = process.argv.slice(2);
8
8
 
9
9
  async function main() {
10
10
  if (command === "serve") {
11
- await serveMcp();
11
+ const agentName = arguments_.find((value) => value.startsWith("--agent="))?.slice("--agent=".length) || null;
12
+ await serveMcp({ agentName });
12
13
  return;
13
14
  }
14
15
  if (command === "setup") {
@@ -22,7 +23,7 @@ async function main() {
22
23
  }
23
24
  if (command === "doctor") {
24
25
  const health = await companionDoctor();
25
- process.stdout.write(`${JSON.stringify({ package: PACKAGE_NAME, packageVersion: PACKAGE_VERSION, editor: TEST_EDITOR_URL, daemon: health }, null, 2)}\n`);
26
+ process.stdout.write(`${JSON.stringify({ package: PACKAGE_NAME, packageVersion: PACKAGE_VERSION, editor: EDITOR_URL, daemon: health }, null, 2)}\n`);
26
27
  return;
27
28
  }
28
29
  if (command === "restart") {
@@ -35,7 +36,7 @@ async function main() {
35
36
  return;
36
37
  }
37
38
  if (command === "help" || command === "--help" || command === "-h") {
38
- process.stdout.write(`Slide Studio MCP ${PACKAGE_VERSION}\n\nUsage:\n slides-studio-mcp serve\n slides-studio-mcp setup [--client=claude,codex,hermes,opencode,openclaw] [--yes] [--dry-run]\n slides-studio-mcp call <tool> [--json '{"key":"value"}'] [--stdin]\n slides-studio-mcp doctor\n slides-studio-mcp restart\n slides-studio-mcp version\n`);
39
+ process.stdout.write(`Slide Studio MCP ${PACKAGE_VERSION}\n\nUsage:\n slides-studio-mcp serve [--agent=claude|codex|hermes|opencode|openclaw]\n slides-studio-mcp setup [--client=claude,codex,hermes,opencode,openclaw] [--yes] [--dry-run]\n slides-studio-mcp call <tool> [--json '{"key":"value"}'] [--stdin]\n slides-studio-mcp doctor\n slides-studio-mcp restart\n slides-studio-mcp version\n`);
39
40
  return;
40
41
  }
41
42
  throw new Error(`Unknown command: ${command}`);
package/src/companion.mjs CHANGED
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { BRIDGE_URL, DAEMON_STATE_PATH, PACKAGE_VERSION, PROTOCOL_VERSION } from "./config.mjs";
6
+ import { preferHostAgent } from "./agent-identity.mjs";
6
7
 
7
8
  const DAEMON_ENTRY = fileURLToPath(new URL("daemon.mjs", import.meta.url));
8
9
 
@@ -33,7 +34,7 @@ async function healthyState() {
33
34
  try {
34
35
  const result = await daemonRequest(state, "/internal/health");
35
36
  if (result.protocolVersion !== PROTOCOL_VERSION) {
36
- const error = new Error(`Local companion ${result.version || "unknown"} uses protocol ${result.protocolVersion}; ${PACKAGE_VERSION} requires protocol ${PROTOCOL_VERSION}. Run \`npx -y slides-studio-mcp@beta restart\`, then reload the editor.`);
37
+ const error = new Error(`Local companion ${result.version || "unknown"} uses protocol ${result.protocolVersion}; ${PACKAGE_VERSION} requires protocol ${PROTOCOL_VERSION}. Run \`npx -y slides-studio-mcp@latest restart\`, then reload the editor.`);
37
38
  error.code = "EPROTOCOL";
38
39
  throw error;
39
40
  }
@@ -91,7 +92,7 @@ export async function createCompanion(initialName = "MCP agent", initialVersion
91
92
  clientId,
92
93
  get daemon() { return { pid: state.pid, url: BRIDGE_URL, version: state.version, packageVersion: PACKAGE_VERSION }; },
93
94
  async identify(name, version) {
94
- clientName = name || clientName;
95
+ clientName = preferHostAgent(name, clientName);
95
96
  clientVersion = version || clientVersion;
96
97
  await register();
97
98
  },
package/src/config.mjs CHANGED
@@ -11,9 +11,9 @@ export const PROTOCOL_VERSION = 3;
11
11
  export const BRIDGE_HOST = "127.0.0.1";
12
12
  export const BRIDGE_PORT = Number(process.env.SLIDE_STUDIO_BRIDGE_PORT) || 43117;
13
13
  export const BRIDGE_URL = `http://${BRIDGE_HOST}:${BRIDGE_PORT}`;
14
- export const TEST_EDITOR_URL = "https://slides-mcp-poc-0821.pages.dev";
14
+ export const EDITOR_URL = "https://slides-editor.pages.dev";
15
15
  export const ALLOWED_ORIGINS = new Set((process.env.SLIDE_STUDIO_ALLOWED_ORIGINS || [
16
- TEST_EDITOR_URL,
16
+ EDITOR_URL,
17
17
  "http://127.0.0.1:4173",
18
18
  "http://localhost:4173",
19
19
  ].join(",")).split(",").map((value) => value.trim()).filter(Boolean));
package/src/daemon.mjs CHANGED
@@ -10,14 +10,15 @@ import {
10
10
 
11
11
  const MAX_JSON_BYTES = 40 * 1024 * 1024;
12
12
  const MAX_MEDIA_BYTES = 25 * 1024 * 1024;
13
- const EDITOR_TTL_MS = Number(process.env.SLIDE_STUDIO_EDITOR_TTL_MS) || 15_000;
13
+ const EDITOR_TTL_MS = Number(process.env.SLIDE_STUDIO_EDITOR_TTL_MS) || 60_000;
14
14
  const CLIENT_TTL_MS = 45_000;
15
15
  const MEDIA_TTL_MS = 5 * 60_000;
16
16
  const COMMAND_TIMEOUT_MS = 90_000;
17
17
  const EDIT_SESSION_TTL_MS = Number(process.env.SLIDE_STUDIO_EDIT_SESSION_TTL_MS) || 5 * 60_000;
18
18
  const MAX_AUDIT_EVENTS = 500;
19
19
  const MAX_AUDIT_BYTES = 2 * 1024 * 1024;
20
- const EVENT_POLL_TIMEOUT_MS = Number(process.env.SLIDE_STUDIO_EVENT_POLL_TIMEOUT_MS) || 5 * 60_000;
20
+ const EVENT_POLL_TIMEOUT_MS = Number(process.env.SLIDE_STUDIO_EVENT_POLL_TIMEOUT_MS) || 500;
21
+ const MAX_EVENT_POLL_TIMEOUT_MS = 5_000;
21
22
  const daemonSecret = randomBytes(32).toString("base64url");
22
23
  const editors = new Map();
23
24
  const clients = new Map();
@@ -34,11 +35,17 @@ function log(message) {
34
35
  process.stderr.write(`[slide-studio-daemon] ${message}\n`);
35
36
  }
36
37
 
38
+ function editorHasInflightCommand(editorId) {
39
+ for (const pending of inflight.values()) if (pending.editorId === editorId) return true;
40
+ return false;
41
+ }
42
+
37
43
  function activeEditors() {
38
44
  const cutoff = Date.now() - EDITOR_TTL_MS;
39
45
  return [...editors.values()].filter((editor) => (
40
46
  editor.lastSeen >= cutoff
41
47
  || Boolean(editor.poll && !editor.poll.destroyed && !editor.poll.writableEnded)
48
+ || editorHasInflightCommand(editor.id)
42
49
  ));
43
50
  }
44
51
 
@@ -525,6 +532,12 @@ const server = createServer(async (request, response) => {
525
532
  focusedEditorId = editor.id;
526
533
  return sendJson(response, 200, { ok: true, editorId: editor.id }, cors);
527
534
  }
535
+ if (url.pathname === "/heartbeat" && request.method === "POST") {
536
+ const body = await readJson(request);
537
+ const editor = requireEditor(request, response, body.editorId, cors);
538
+ if (!editor) return;
539
+ return sendJson(response, 200, { ok: true, editorId: editor.id }, cors);
540
+ }
528
541
  if (url.pathname === "/disconnect" && request.method === "POST") {
529
542
  const body = await readJson(request);
530
543
  const editor = requireEditor(request, response, body.editorId, cors);
@@ -535,6 +548,10 @@ const server = createServer(async (request, response) => {
535
548
  if (url.pathname === "/events" && request.method === "GET") {
536
549
  const editor = requireEditor(request, response, url.searchParams.get("editorId"), cors);
537
550
  if (!editor) return;
551
+ const requestedWait = Number(url.searchParams.get("wait"));
552
+ const waitMs = url.searchParams.has("wait") && Number.isFinite(requestedWait)
553
+ ? Math.min(MAX_EVENT_POLL_TIMEOUT_MS, Math.max(0, requestedWait))
554
+ : EVENT_POLL_TIMEOUT_MS;
538
555
  editor.cors = cors;
539
556
  if (editor.poll) endEditorPoll(editor);
540
557
  editor.poll = response;
@@ -546,10 +563,12 @@ const server = createServer(async (request, response) => {
546
563
  editor.lastSeen = Date.now();
547
564
  });
548
565
  deliverNext(editor);
549
- if (editor.poll) editor.pollTimer = setTimeout(() => {
566
+ if (editor.poll && waitMs === 0) {
567
+ endEditorPoll(editor);
568
+ } else if (editor.poll) editor.pollTimer = setTimeout(() => {
550
569
  if (editor.poll !== response) return;
551
570
  endEditorPoll(editor);
552
- }, EVENT_POLL_TIMEOUT_MS);
571
+ }, waitMs);
553
572
  editor.pollTimer?.unref();
554
573
  return;
555
574
  }
@@ -650,7 +669,11 @@ setInterval(() => {
650
669
  clientsChanged = true;
651
670
  }
652
671
  for (const [id, editor] of editors) {
653
- if (editor.lastSeen >= now - EDITOR_TTL_MS || (editor.poll && !editor.poll.destroyed && !editor.poll.writableEnded)) continue;
672
+ if (
673
+ editor.lastSeen >= now - EDITOR_TTL_MS
674
+ || (editor.poll && !editor.poll.destroyed && !editor.poll.writableEnded)
675
+ || editorHasInflightCommand(id)
676
+ ) continue;
654
677
  disconnectEditor(id, "Browser editor connection expired.");
655
678
  }
656
679
  if (clientsChanged) broadcastAgents();
@@ -2,7 +2,7 @@ import { mkdir, readFile, stat } from "node:fs/promises";
2
2
  import { dirname, isAbsolute, join, resolve } from "node:path";
3
3
  import { McpServer } from "@modelcontextprotocol/server";
4
4
  import * as z from "zod/v4";
5
- import { GUIDANCE_PATH, PACKAGE_NAME, PACKAGE_VERSION, TEST_EDITOR_URL } from "./config.mjs";
5
+ import { EDITOR_URL, GUIDANCE_PATH, PACKAGE_NAME, PACKAGE_VERSION } from "./config.mjs";
6
6
 
7
7
  const id = z.string().min(1).max(160);
8
8
  const optionalId = id.optional();
@@ -15,6 +15,7 @@ const targetProject = { editSessionId, projectId: optionalId, expectedRevision }
15
15
  const targetSlide = { editSessionId, projectId: optionalId, slideId: optionalId, expectedRevision };
16
16
  const textFields = {
17
17
  text: z.string().max(4000).optional(), x: unit.optional(), y: unit.optional(), width: positiveUnit.optional(), height: positiveUnit.optional(),
18
+ role: z.enum(["title", "subtitle", "body", "caption"]).optional().describe("Semantic size role. Recommended ranges: title 92-124, subtitle 68-84, body 54-68, caption 44-52."),
18
19
  size: z.number().min(20).max(180).optional(), style: z.enum(["plain", "outline", "boxed"]).optional(),
19
20
  outlineWidth: z.number().min(0).max(40).optional(), color: color.optional(), background: z.enum(["white", "black"]).optional(),
20
21
  backgroundShape: z.enum(["lines", "full"]).optional(), align: z.enum(["left", "center", "right"]).optional(),
@@ -34,7 +35,7 @@ function textResult(value, summary = value) {
34
35
  }
35
36
 
36
37
  function compactMutation(value) {
37
- const keys = ["id", "editSessionId", "editorId", "projectId", "slideId", "revision", "leaseExpiresAt", "purpose", "released", "opened", "createdSlideId", "createdTextId", "createdImageId", "createdLayers", "assetId", "deletedAssetId", "deletedProjectId", "deletedSlideId", "deletedLayerIds", "updatedTextIds", "updatedImageIds", "applied", "path", "bytes"];
38
+ const keys = ["id", "editSessionId", "editorId", "projectId", "slideId", "revision", "leaseExpiresAt", "purpose", "released", "opened", "createdSlideId", "createdTextId", "fittedTextBox", "createdImageId", "createdLayers", "assetId", "deletedAssetId", "deletedProjectId", "deletedSlideId", "deletedLayerIds", "updatedTextIds", "fittedTextBoxes", "updatedImageIds", "applied", "path", "bytes"];
38
39
  return Object.fromEntries(keys.flatMap((key) => value?.[key] == null ? [] : [[key, value[key]]]));
39
40
  }
40
41
 
@@ -55,7 +56,7 @@ function operationLabel(toolName) {
55
56
  create_project: "Creating a project…", update_project: "Updating the project…", delete_project: "Deleting a project…",
56
57
  open_project: "Opening a project…", add_slide: "Adding a slide…", update_slide: "Updating a slide…",
57
58
  duplicate_slide: "Duplicating a slide…", reorder_slides: "Reordering slides…", delete_slide: "Deleting a slide…",
58
- add_text: "Adding text…", update_text: "Updating text…", import_asset: "Importing a local image…",
59
+ add_text: "Adding text…", update_text: "Updating text…", fit_text_boxes: "Fitting text boxes…", import_asset: "Importing a local image…",
59
60
  update_asset: "Updating an image asset…", delete_asset: "Deleting an image asset…", add_image: "Placing an image…",
60
61
  update_image: "Updating an image…", delete_layers: "Deleting layers…", duplicate_layers: "Duplicating layers…",
61
62
  reorder_layers: "Reordering layers…", undo: "Undoing the last edit…", redo: "Redoing the last edit…",
@@ -85,7 +86,7 @@ async function prepareOperation(companion, toolName, args) {
85
86
  const type = ({
86
87
  create_project: "project.create", open_project: "project.open", update_project: "project.update", delete_project: "project.delete",
87
88
  add_slide: "slide.add", update_slide: "slide.update", duplicate_slide: "slide.duplicate", reorder_slides: "slide.reorder", delete_slide: "slide.delete",
88
- add_text: "text.add", update_text: "text.update", import_asset: "asset.import", update_asset: "asset.update", delete_asset: "asset.delete",
89
+ add_text: "text.add", update_text: "text.update", fit_text_boxes: "text.fit", import_asset: "asset.import", update_asset: "asset.update", delete_asset: "asset.delete",
89
90
  add_image: "image.add", update_image: "image.update", delete_layers: "layer.delete", duplicate_layers: "layer.duplicate", reorder_layers: "layer.reorder",
90
91
  undo: "history.undo", redo: "history.redo", set_view: "view.update", render_slide: "slide.render", inspect_editor: "editor.inspect",
91
92
  })[toolName];
@@ -98,7 +99,7 @@ export async function createSlideStudioMcpServer(companion) {
98
99
  let guidanceRead = false;
99
100
  let identifiedAs = null;
100
101
  const server = new McpServer({ name: PACKAGE_NAME, version: PACKAGE_VERSION }, {
101
- instructions: `First call list_editors and use the registered local browser tab. Never open or connect Slide Studio through a sandboxed agent browser. If no editor is listed, ask the user to open ${TEST_EDITOR_URL} in their normal browser and click Connect AI. 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.`,
102
+ instructions: `First call list_editors and use the registered local browser tab. Never open or connect Slide Studio 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.`,
102
103
  capabilities: { tools: {}, resources: {} },
103
104
  });
104
105
 
@@ -148,19 +149,20 @@ export async function createSlideStudioMcpServer(companion) {
148
149
  register("inspect_editor", "Inspect projects, slides, assets, and every text/image layer without returning image bytes.", z.object({ ...targetSlide, includeAllProjects: z.boolean().default(true) }).strict(), (args) => browserOperation(companion, "inspect_editor", args), { readOnlyHint: true });
149
150
  register("show_notification", "Show a short visual notification in a connected editor for status or marketing demos.", z.object({ editSessionId, message: z.string().min(1).max(240), tone: z.enum(["agent", "success", "info", "error"]).default("agent") }).strict(), ({ editSessionId, ...args }) => companion.call("notify", { ...args, editSessionId }), { destructiveHint: false, idempotentHint: false });
150
151
 
151
- register("create_project", "Create an empty project. If the dashboard is visible, its card appears live; adding the first slide opens the editor.", z.object({ editSessionId, name: z.string().min(1).max(160) }).strict(), (args) => browserOperation(companion, "create_project", args), { destructiveHint: false });
152
- register("open_project", "Open a project and optionally a specific slide without changing content.", z.object({ editSessionId, projectId: id, slideId: optionalId }).strict(), (args) => browserOperation(companion, "open_project", args), { destructiveHint: false, idempotentHint: true });
152
+ register("create_project", "Create an empty project without changing the user's current browser view. Its dashboard card appears live when the dashboard is open.", z.object({ editSessionId, name: z.string().min(1).max(160) }).strict(), (args) => browserOperation(companion, "create_project", args), { destructiveHint: false });
153
+ register("open_project", "Explicitly navigate the browser to a project and optionally a specific slide without changing content. Use only when the user asks to show it.", z.object({ editSessionId, projectId: id, slideId: optionalId }).strict(), (args) => browserOperation(companion, "open_project", args), { destructiveHint: false, idempotentHint: true });
153
154
  register("update_project", "Rename a project.", z.object({ ...targetProject, name: z.string().min(1).max(160) }).strict(), (args) => browserOperation(companion, "update_project", args), { destructiveHint: true });
154
155
  register("delete_project", "Delete a project from browser storage.", z.object({ ...targetProject, projectId: id }).strict(), (args) => browserOperation(companion, "delete_project", args), { destructiveHint: true });
155
156
 
156
- register("add_slide", "Add and open a slide using a solid color or local background image path.", z.object({ ...targetProject, name: z.string().max(160).optional(), index: z.number().int().min(0).optional(), backgroundColor: color.optional(), backgroundPath: z.string().min(1).optional() }).strict(), (args) => browserOperation(companion, "add_slide", args), { destructiveHint: false });
157
- register("update_slide", "Rename a slide, replace its background, or change background pan/zoom. The browser opens this slide.", z.object({ ...targetSlide, name: z.string().max(160).optional(), backgroundColor: color.optional(), backgroundPath: z.string().min(1).optional(), imageScale: z.number().min(1).max(3).optional(), imageX: unit.optional(), imageY: unit.optional() }).strict(), (args) => browserOperation(companion, "update_slide", args), { destructiveHint: true });
158
- register("duplicate_slide", "Duplicate a slide with all layers and open the copy.", z.object({ ...targetSlide, name: z.string().max(160).optional() }).strict(), (args) => browserOperation(companion, "duplicate_slide", args), { destructiveHint: false });
157
+ register("add_slide", "Add a slide using a solid color or local background image path. The browser follows it only when that project is already visible.", z.object({ ...targetProject, name: z.string().max(160).optional(), index: z.number().int().min(0).optional(), backgroundColor: color.optional(), backgroundPath: z.string().min(1).optional() }).strict(), (args) => browserOperation(companion, "add_slide", args), { destructiveHint: false });
158
+ register("update_slide", "Rename a slide, replace its background, or change background pan/zoom. The browser follows it only when that project is already visible.", z.object({ ...targetSlide, name: z.string().max(160).optional(), backgroundColor: color.optional(), backgroundPath: z.string().min(1).optional(), imageScale: z.number().min(1).max(3).optional(), imageX: unit.optional(), imageY: unit.optional() }).strict(), (args) => browserOperation(companion, "update_slide", args), { destructiveHint: true });
159
+ register("duplicate_slide", "Duplicate a slide with all layers. The browser follows the copy only when that project is already visible.", z.object({ ...targetSlide, name: z.string().max(160).optional() }).strict(), (args) => browserOperation(companion, "duplicate_slide", args), { destructiveHint: false });
159
160
  register("reorder_slides", "Set the complete slide order using every slide ID exactly once.", z.object({ ...targetProject, slideIds: z.array(id).min(1) }).strict(), (args) => browserOperation(companion, "reorder_slides", args), { destructiveHint: true });
160
161
  register("delete_slide", "Delete one slide.", z.object({ ...targetSlide, slideId: id }).strict(), (args) => browserOperation(companion, "delete_slide", args), { destructiveHint: true });
161
162
 
162
- register("add_text", "Add a text layer. Coordinates and dimensions are normalized to the 9:16 canvas; attractive defaults use generous bounds and per-line boxes.", z.object({ ...targetSlide, ...textFields, text: z.string().min(1).max(4000) }).strict(), (args) => browserOperation(companion, "add_text", args), { destructiveHint: false });
163
- register("update_text", "Update one or more text layers, including content, geometry, color, style, alignment, rotation, and stacking.", z.object({ ...targetSlide, updates: z.array(z.object({ id, ...textFields }).strict()).min(1).max(100) }).strict(), (args) => browserOperation(companion, "update_text", args), { destructiveHint: true });
163
+ register("add_text", "Add a text layer. Choose a semantic role and a size within its readable range. Width is preserved while height is fitted automatically with safe padding; boxed text defaults to the preferred per-line background.", z.object({ ...targetSlide, ...textFields, text: z.string().min(1).max(4000) }).strict(), (args) => browserOperation(companion, "add_text", args), { destructiveHint: false });
164
+ register("update_text", "Update one or more text layers. Every updated layer automatically keeps its width and refits its height with safe padding, so a render-fit-render loop is unnecessary.", z.object({ ...targetSlide, updates: z.array(z.object({ id, ...textFields }).strict()).min(1).max(100) }).strict(), (args) => browserOperation(companion, "update_text", args), { destructiveHint: true });
165
+ register("fit_text_boxes", "Explicitly resize text boxes to their rendered content. add_text and update_text already fit height automatically; use mode=both only when you also want to shrink width.", z.object({ ...targetSlide, textIds: z.array(id).min(1).max(100), mode: z.enum(["height", "both"]).default("both") }).strict(), (args) => browserOperation(companion, "fit_text_boxes", args), { destructiveHint: true });
164
166
 
165
167
  register("import_asset", "Import a local image file into the active project's reusable asset library. Image bytes stay local.", z.object({ ...targetSlide, path: z.string().min(1), name: z.string().max(160).optional() }).strict(), (args) => browserOperation(companion, "import_asset", args), { destructiveHint: false });
166
168
  register("update_asset", "Rename a reusable image asset.", z.object({ ...targetProject, assetId: id, name: z.string().min(1).max(160) }).strict(), (args) => browserOperation(companion, "update_asset", args), { destructiveHint: true });
package/src/setup.mjs CHANGED
@@ -3,7 +3,7 @@ import { cp, mkdir } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { createInterface } from "node:readline/promises";
6
- import { PACKAGE_NAME, PACKAGE_ROOT, PACKAGE_VERSION, TEST_EDITOR_URL } from "./config.mjs";
6
+ import { EDITOR_URL, PACKAGE_NAME, PACKAGE_ROOT, PACKAGE_VERSION } from "./config.mjs";
7
7
 
8
8
  const supported = ["claude", "codex", "hermes", "opencode", "openclaw"];
9
9
 
@@ -17,10 +17,10 @@ function commandVersion(command) {
17
17
  }
18
18
 
19
19
  function shellCommand(client, specifier) {
20
- if (client === "claude") return ["claude", "mcp", "add", "--scope", "user", "--transport", "stdio", "slide-studio", "--", "npx", "-y", specifier, "serve"];
21
- if (client === "codex") return ["codex", "mcp", "add", "slide-studio", "--", "npx", "-y", specifier, "serve"];
22
- if (client === "hermes") return ["hermes", "mcp", "add", "slide-studio", "--command", "npx", "--args", "-y", specifier, "serve"];
23
- if (client === "openclaw") return ["openclaw", "mcp", "add", "slide-studio", "--command", "npx", "--arg", "-y", "--arg", specifier, "--arg", "serve"];
20
+ if (client === "claude") return ["claude", "mcp", "add", "--scope", "user", "--transport", "stdio", "slide-studio", "--", "npx", "-y", specifier, "serve", "--agent=claude"];
21
+ if (client === "codex") return ["codex", "mcp", "add", "slide-studio", "--", "npx", "-y", specifier, "serve", "--agent=codex"];
22
+ if (client === "hermes") return ["hermes", "mcp", "add", "slide-studio", "--command", "npx", "--args", "-y", specifier, "serve", "--agent=hermes"];
23
+ if (client === "openclaw") return ["openclaw", "mcp", "add", "slide-studio", "--command", "npx", "--arg", "-y", "--arg", specifier, "--arg", "serve", "--arg", "--agent=openclaw"];
24
24
  return null;
25
25
  }
26
26
 
@@ -35,7 +35,7 @@ function quote(value) {
35
35
  }
36
36
 
37
37
  function openCodeSnippet(specifier, version = commandVersion("opencode")) {
38
- const server = { type: "local", command: ["npx", "-y", specifier, "serve"] };
38
+ const server = { type: "local", command: ["npx", "-y", specifier, "serve", "--agent=opencode"] };
39
39
  return JSON.stringify(Number(version?.split(".")[0]) >= 2
40
40
  ? { mcp: { servers: { "slide-studio": server } } }
41
41
  : { mcp: { "slide-studio": { ...server, enabled: true } } }, null, 2);
@@ -60,12 +60,13 @@ export async function runSetup(arguments_) {
60
60
  const clientArgument = arguments_.find((value) => value.startsWith("--client="))?.slice("--client=".length);
61
61
  const requested = clientArgument ? clientArgument.split(",").map((value) => value.trim().toLowerCase()) : supported.filter(commandExists);
62
62
  const clients = [...new Set(requested)].filter((client) => supported.includes(client));
63
- const specifier = `${PACKAGE_NAME}@${PACKAGE_VERSION}`;
63
+ const releaseTag = PACKAGE_VERSION.includes("-beta.") ? "beta" : "latest";
64
+ const specifier = `${PACKAGE_NAME}@${releaseTag}`;
64
65
  const dryRun = flags.has("--dry-run");
65
66
  const assumeYes = flags.has("--yes") || flags.has("-y");
66
67
  if (!clients.length) throw new Error("No supported agent CLI was detected. Use --client=claude,codex,hermes,opencode,openclaw or copy the generic stdio config below.");
67
68
 
68
- process.stdout.write(`Slide Studio MCP ${PACKAGE_VERSION}\nDetected: ${clients.join(", ")}\nEditor: ${TEST_EDITOR_URL}\n\n`);
69
+ process.stdout.write(`Slide Studio MCP ${PACKAGE_VERSION}\nDetected: ${clients.join(", ")}\nEditor: ${EDITOR_URL}\n\n`);
69
70
  for (const client of clients) {
70
71
  const command = shellCommand(client, specifier);
71
72
  if (command) process.stdout.write(`${client}: ${command.map(quote).join(" ")}\n`);
@@ -97,7 +98,7 @@ export async function runSetup(arguments_) {
97
98
  else process.stderr.write(`Could not configure ${client}; its command is printed above for manual setup.\n`);
98
99
  }
99
100
  const skillTargets = await installSkill();
100
- process.stdout.write(`\nConfigured: ${configured.join(", ") || "none automatically"}\nSkill installed in:\n${skillTargets.map((value) => ` ${value}`).join("\n")}\n\nOpen ${TEST_EDITOR_URL} in your normal local browser and click Connect AI. Do not use a sandboxed agent browser.\n`);
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`);
101
102
  process.stdout.write(`First connection check (no browser automation): npx -y ${specifier} call list_editors\n`);
102
103
  if (clients.includes("hermes")) process.stdout.write("Hermes can also refresh native tools in place with /reload-mcp and /reload-skills.\n");
103
104
  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");
@@ -1,9 +1,10 @@
1
1
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
2
+ import { detectHostAgent } from "./agent-identity.mjs";
2
3
  import { createCompanion } from "./companion.mjs";
3
4
  import { createSlideStudioMcpServer } from "./mcp-server.mjs";
4
5
 
5
- export async function serveMcp() {
6
- const companion = await createCompanion();
6
+ export async function serveMcp({ agentName = null } = {}) {
7
+ const companion = await createCompanion(detectHostAgent(agentName));
7
8
  const handle = serveStdio(() => createSlideStudioMcpServer(companion), {
8
9
  legacy: "serve",
9
10
  onerror: (error) => process.stderr.write(`[slides-studio-mcp] ${error.message}\n`),