carouselbot 0.3.2 → 0.3.4

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
@@ -28,13 +28,29 @@ The same validated tool surface is available through the package CLI when a runn
28
28
  npx -y carouselbot@latest call get_design_guidance
29
29
  npx -y carouselbot@latest call list_editors
30
30
  npx -y carouselbot@latest call begin_edit_session --json '{"editorId":"EDITOR_ID","purpose":"Build my deck"}'
31
- npx -y carouselbot@latest call create_project --json '{"editSessionId":"SESSION_ID","name":"My presentation","folderPath":"/campaigns"}'
31
+ npx -y carouselbot@latest call create_project --json '{"editSessionId":"SESSION_ID","name":"My presentation","aspectRatio":"3:4","folderPath":"/campaigns"}'
32
32
  ```
33
33
 
34
34
  Every MCP tool name and JSON argument shape works with `call`. The CLI-only `list_tools` helper lists names compactly or returns selected schemas. `render_slide` writes image output to a temporary `previewPath` instead of dumping base64 into the terminal.
35
35
 
36
36
  Folders use exact canonical slash paths such as `/campaigns` and are derived from project membership. Pass `folderPath` to `create_project` to create a project in a folder. Use `move_project` with another slash path to move it between folders, or with `folderPath: null` to move it back to the dashboard root. Embedded slashes are part of the virtual path and do not create a nested UI hierarchy. Because folders are implicit, moving the last project out removes the empty folder card automatically. `inspect_editor` returns each project's current folder path.
37
37
 
38
+ ## Canvas formats and backgrounds
39
+
40
+ Choose `aspectRatio` when creating a project to set the default for new slides. The editor offers `9:16`, `2:3`, `3:4`, `4:5`, `1:1`, `4:3`, and `16:9`; MCP callers may also use another positive `W:H` ratio that produces a canvas between 180 and 3840 pixels high at the fixed 1080-pixel export width. Pass `aspectRatio` to `add_slide` when one slide should differ from the project default, or to `update_slide` to change only that slide. Existing text and image layers keep their proportions and relative centers when a slide ratio changes.
41
+
42
+ An image-backed `add_slide` automatically adopts the source image's exact reduced ratio when `backgroundPath` is supplied without `aspectRatio`. The image therefore scales to the 1080-pixel canvas width without adding workspace padding to the output; exports contain only the image-shaped slide canvas and its layers. Pass `aspectRatio` alongside `backgroundPath` only for an intentional crop-to-format slide. Native browser uploads use the same source-ratio behavior.
43
+
44
+ For example, a `3:4` project can contain an inherited `3:4` opener, a square slide, and a custom ultra-wide slide:
45
+
46
+ ```bash
47
+ npx -y carouselbot@latest call add_slide --json '{"editSessionId":"SESSION_ID","projectId":"PROJECT_ID","backgroundColor":"#F4EFE6"}'
48
+ npx -y carouselbot@latest call add_slide --json '{"editSessionId":"SESSION_ID","projectId":"PROJECT_ID","aspectRatio":"1:1","backgroundColor":"#111111"}'
49
+ npx -y carouselbot@latest call add_slide --json '{"editSessionId":"SESSION_ID","projectId":"PROJECT_ID","aspectRatio":"18:5","backgroundColor":"#FFFFFF"}'
50
+ ```
51
+
52
+ Create a flat slide directly with `add_slide({ backgroundColor: "#F4EFE6" })`, or replace a slide background with `update_slide`. A color is stored as project data and rendered natively, so no white or colored placeholder file is needed. `backgroundColor` and `backgroundPath` are mutually exclusive.
53
+
38
54
  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.
39
55
 
40
56
  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.
@@ -53,7 +69,7 @@ import_font({ editSessionId, projectId, localFontId })
53
69
  add_text({ editSessionId, projectId, slideId, text, fontId })
54
70
  ```
55
71
 
56
- `list_project_fonts` reports faces already embedded in a project. `add_text` and `update_text` accept the returned `fontId`, plus optional `fontWeight`, `fontStyle`, and variable-axis settings. The companion returns opaque local font IDs and never exposes font paths. A selected face is transferred over the authenticated loopback connection, persisted only in the browser's local IndexedDB project, and loaded before fitting or rendering. If the exact bytes are missing or invalid, rendering reports `FONT_UNAVAILABLE` instead of silently using a fallback.
72
+ `list_project_fonts` reports faces already embedded in a project. `add_text` and `update_text` accept the returned `fontId`, plus optional `fontWeight` and `fontStyle`. The `wght` variable-font axis has editor/export parity; other axes are preserved for compatibility but should not be newly applied until the canvas renderer supports them exactly. The companion returns opaque local font IDs and never exposes font paths. A selected face is transferred over the authenticated loopback connection, persisted only in the browser's local IndexedDB project, and loaded before fitting or rendering. If the exact bytes are missing or invalid, rendering reports `FONT_UNAVAILABLE` instead of silently using a fallback. Ordinary typography remains an editable text layer; agents must not substitute a text-only image to imitate a font.
57
73
 
58
74
  Any MCP client can launch it with:
59
75
 
@@ -77,8 +93,10 @@ The new companion accepts both `https://carousel.bot` and the legacy `https://sl
77
93
 
78
94
  ## Parallel agents and browser tabs
79
95
 
80
- Call `begin_edit_session` before editing and pass its `editSessionId` to all operations. A session atomically reserves one browser tab and one project, follows that tab instead of global focus, and expires after inactivity. Always call `end_edit_session` when finished.
96
+ Call `begin_edit_session` before editing and pass its `editSessionId` to all operations. A session tracks one browser tab and one project, follows that target instead of global focus, and expires after inactivity. It does not lock either resource. Always call `end_edit_session` when finished.
81
97
 
82
- For parallel editing, reserve a different connected editor for each worker. The daemon rejects conflicting claims with `EDITOR_BUSY` or `PROJECT_BUSY` and records a sanitized local audit through `list_recent_operations`; it never records slide text, prompts, file paths, or image bytes.
98
+ Any number of agents can share one connected browser tab, including editing the same project. Commands execute sequentially in that tab while notifications and project updates remain live. Use explicit project and slide IDs or a project-bound session to keep targets stable. The daemon records a sanitized local audit through `list_recent_operations`; it never records slide text, prompts, file paths, or image bytes.
83
99
 
84
100
  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.
101
+
102
+ Paths support exactly two folder levels: `/Client/Account` appears as Home → Client → Account. Use this when the user asks to organize multiple accounts for a client; do not reorganize projects without being asked. Create or move projects with `folderPath: "/Client/Account"`; the parent and subfolder appear automatically. `/Client/Account/Campaign` is rejected. `inspect_editor` includes parent folders and `parentPath`; folder project counts include descendants. Renaming a parent in the UI preserves its subfolders.
@@ -4,7 +4,9 @@ Read this before creating or editing slides. Use it as a compact quality bar, th
4
4
 
5
5
  ## Defaults that usually look good
6
6
 
7
- - Build for a 9:16 phone canvas and keep one clear idea per slide.
7
+ - Choose a project aspect ratio as the default for new slides. `9:16` is the default; presets also include `2:3`, `3:4`, `4:5`, `1:1`, `4:3`, and `16:9`. Pass `aspectRatio` to `add_slide` or `update_slide` when an individual slide should differ, and use another validated positive `W:H` ratio when the reference demands it. Deliberate format changes can add rhythm and uniqueness; accidental inconsistency cannot. Keep one clear idea per slide.
8
+ - When `add_slide` uses `backgroundPath` without an explicit `aspectRatio`, the slide adopts the image's exact reduced ratio. Use this for source-faithful image slides: the image scales to the fixed 1080-pixel canvas width, workspace space above or below stays outside the slide, and export contains only the visible slide canvas. Pass `aspectRatio` only when deliberate crop-to-format behavior is wanted.
9
+ - For a flat background, call `add_slide` with `backgroundColor` (or `update_slide` with `backgroundColor`). Do not create, upload, or import a bitmap merely to get a solid color.
8
10
  - 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
11
  - `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
12
  - 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.
@@ -21,6 +23,8 @@ Read this before creating or editing slides. Use it as a compact quality bar, th
21
23
  - Use rotation sparingly. Small intentional angles can add energy; arbitrary angles make carousels feel inconsistent.
22
24
  - Reuse a small palette and consistent type scale across the project.
23
25
  - For an installed Mac font, select an exact face with `list_local_fonts`, import its opaque ID with `import_font`, and apply only the returned project `fontId`. Reuse project faces through `list_project_fonts`; never guess family strings.
26
+ - Use `fontWeight` (or the matching `wght` axis) for variable weight. Do not newly apply `wdth`, `opsz`, `slnt`, or custom axes until exported-canvas parity is available.
27
+ - Keep ordinary typography as editable text layers, including typography that uses an imported font. Never generate or import a text-only PNG/SVG and place it with `add_image` just to imitate a font. If the requested face cannot be imported, keep the copy editable in an available face and report the substitution instead of baking the words into pixels.
24
28
 
25
29
  ## Working method
26
30
 
@@ -47,4 +51,5 @@ Read this before creating or editing slides. Use it as a compact quality bar, th
47
51
  - Keep tool responses and progress messages concise.
48
52
  - Prefer IDs returned by tools over guessed names or array positions.
49
53
  - Do not claim a slide looks good until you have inspected a rendered image.
54
+ - Use `add_text` or `update_text` for all ordinary words. Reserve image assets for photographs, illustrations, logos, screenshots, and deliberate artwork that cannot be represented by editable CarouselBot layers.
50
55
  - 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": "carouselbot",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Local-first MCP companion for the hosted CarouselBot editor",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,18 +33,18 @@ Every tool accepts the same JSON arguments as MCP. Use `list_tools` without argu
33
33
 
34
34
  Before the first mutation in a task, call `get_design_guidance`. The server intentionally rejects mutations until this guidance has been read.
35
35
 
36
- Before editing, reserve a target with `begin_edit_session`. Pass its `editSessionId` to every mutating tool, relevant reads, notifications, and `apply_operations`. Release it with `end_edit_session` as soon as the work finishes or fails. Reservations expire after inactivity, but explicit release is the normal cleanup path.
36
+ Before editing, track a target with `begin_edit_session`. Pass its `editSessionId` to every mutating tool, relevant reads, notifications, and `apply_operations`. Release it with `end_edit_session` as soon as the work finishes or fails. Sessions expire after inactivity, but explicit release is the normal cleanup path.
37
37
 
38
38
  For parallel work, the parent agent owns orchestration:
39
39
 
40
40
  1. Call `list_editors` and `list_edit_sessions` before spawning editing workers.
41
- 2. Reserve one distinct editor per editing worker with `begin_edit_session`, and assign one project per session. Never launch more editing workers than available unassigned editors. Research-only workers do not need a session.
42
- 3. Give each worker its exact `editorId`, `editSessionId`, and `projectId` (once known). A worker must never select or use another worker's editor.
41
+ 2. Create one session per editing worker with `begin_edit_session`, and assign one project per session. Any number of workers can share the same editor and project; sessions do not lock them. Research-only workers do not need a session.
42
+ 3. Give each worker its exact `editorId`, `editSessionId`, and `projectId` (once known). Keep project and slide targets explicit; commands in a shared editor execute sequentially.
43
43
  4. If a worker creates a project, the daemon binds that new project to its session automatically. Report the returned `projectId` to the parent.
44
- 5. Treat `EDITOR_BUSY`, `PROJECT_BUSY`, and `SESSION_PROJECT_MISMATCH` as coordination signals. Do not retry against a different target silently. Re-plan, wait, or use another unassigned editor.
44
+ 5. Treat `SESSION_PROJECT_MISMATCH` as a targeting error. Begin a session for the intended project instead of silently changing targets.
45
45
  6. End every worker session in cleanup, including after errors. Use `list_recent_operations` to investigate routing, conflicts, or failed edits without exposing prompts, text, paths, or image bytes.
46
46
 
47
- With only one agent and one editor, the server can create an implicit session for compatibility. Explicit sessions are still preferred because they make routing deterministic. Never rely on which tab is focused once multiple editors exist.
47
+ The server can create an implicit session for each agent for compatibility, including when agents share an editor. Explicit sessions are still preferred because they make routing deterministic. Never rely on which tab is focused once multiple editors exist.
48
48
 
49
49
  Inspect the assigned editor before editing and keep the returned IDs and revision. Work on the assigned project and slide. Pass `expectedRevision` for sensitive mutations. If `STALE_PROJECT` is returned, the browser has reloaded the canonical IndexedDB copy; inspect again and retry with current IDs. Prefer `apply_operations` for compact related changes while preserving logical order.
50
50
 
@@ -57,7 +57,15 @@ When the user asks for a font installed on their Mac, use the deterministic two-
57
57
 
58
58
  If listing returns `FONT_PERMISSION_REQUIRED`, ask the user to open the real CarouselBot tab and choose **Allow local fonts** from the text font control, then retry. Do not bypass this with browser automation. Font paths and bytes are intentionally unavailable to agents. If rendering reports `FONT_UNAVAILABLE`, preserve the editable text, report the missing face, and ask the user to replace or re-import it rather than accepting fallback pixels.
59
59
 
60
- Dashboard folders are implicit and use exact canonical slash paths such as `/campaigns`. Set `folderPath` when calling `create_project` to create the project inside a folder. Call `move_project` with another slash path to move it between folders, or with `folderPath: null` to move it back to the dashboard root. Embedded slashes remain part of one virtual path rather than creating a nested UI hierarchy. Moving the final project out of a folder removes that empty folder automatically. Use `inspect_editor` to read each project's current `folderPath`.
60
+ Use `fontWeight` or `fontVariationSettings.wght` when varying an imported face. Preserve existing non-weight variable settings, but do not newly apply `wdth`, `opsz`, `slnt`, or custom axes until CarouselBot advertises exact exported-canvas parity for them.
61
+
62
+ All ordinary words must remain CarouselBot text layers, including words styled with a local font. Never generate or import a text-only PNG/SVG and place it as an image merely to imitate a font. If an exact face cannot be listed, imported, or rendered, keep the copy editable in an available face and tell the user what was substituted. Use image layers only for photographs, illustrations, logos, screenshots, and deliberate artwork that CarouselBot cannot represent as editable layers.
63
+
64
+ Choose a project's default canvas when creating it with the optional `aspectRatio`: `9:16` (default), `2:3`, `3:4`, `4:5`, `1:1`, `4:3`, or `16:9`. MCP callers may also use another validated positive `W:H` ratio when the reference requires it. Pass `aspectRatio` to `add_slide` when a new slide should differ from the project default, or to `update_slide` to change one existing slide while preserving its layers' proportions and relative centers. Mixed ratios should be a deliberate creative choice, not accidental inconsistency. For a flat slide, pass `backgroundColor` directly to `add_slide` or `update_slide`; never create or import a white or colored image just to fill the background. `backgroundColor` and `backgroundPath` are alternatives and must not be supplied together.
65
+
66
+ When `add_slide` uses `backgroundPath` without `aspectRatio`, CarouselBot derives the exact reduced ratio from the source image. Use that default for source-faithful image slides: the image scales to the 1080-pixel canvas width, any workspace space above or below is outside the slide, and export contains only the image-shaped slide canvas plus its editable layers. Supply `aspectRatio` with `backgroundPath` only when the user intentionally wants crop-to-format behavior. Native browser uploads follow the same source-ratio default, and the user can select another ratio afterward.
67
+
68
+ Dashboard folders are implicit and use exact canonical slash paths such as `/campaigns`. Set `folderPath` when calling `create_project` to create the project inside a folder. Call `move_project` with another slash path to move it between folders, or with `folderPath: null` to move it back to the dashboard root. Paths support exactly two folder levels: `/Client/Account` appears as Home → Client → Account. Use this when the user asks to organize multiple accounts for a client; do not reorganize projects without being asked. Create or move projects with `folderPath: "/Client/Account"`; the parent and subfolder appear automatically. `/Client/Account/Campaign` is rejected. `inspect_editor` includes parent folders and `parentPath`; folder project counts include descendants. Renaming a parent in the UI preserves its subfolders. Moving the final project out of a folder removes that empty folder automatically. Use `inspect_editor` to read each project's current `folderPath`.
61
69
 
62
70
  Use readable role-based type ranges: title `92–124`, subtitle `68–84`, body `54–68`, caption `44–52`. Do not solve dense copy by dropping below the body range; shorten it or split it across slides. `add_text` and `update_text` automatically preserve width and fit height around every wrapped line with safe padding. For highlighted text, prefer `style: "boxed"` with `backgroundShape: "lines"`. Use `backgroundShape: "full"` only for a deliberate card. Call `fit_text_boxes` with `mode: "both"` only when you intentionally want the width to shrink too.
63
71
 
package/src/daemon.mjs CHANGED
@@ -147,10 +147,6 @@ function sessionForEditor(editorId) {
147
147
  return activeEditSessions().find((session) => session.editorId === editorId) || null;
148
148
  }
149
149
 
150
- function sessionForProject(projectId) {
151
- return projectId ? activeEditSessions().find((session) => session.projectId === projectId) || null : null;
152
- }
153
-
154
150
  function requireEditSession(sessionId) {
155
151
  const session = editSessions.get(sessionId);
156
152
  if (!session || session.lastSeen < Date.now() - EDIT_SESSION_TTL_MS) {
@@ -167,11 +163,6 @@ function requireEditSession(sessionId) {
167
163
 
168
164
  function claimProject(session, projectId) {
169
165
  if (!projectId) return;
170
- const conflict = sessionForProject(projectId);
171
- if (conflict && conflict.id !== session.id) {
172
- recordAudit({ action: "edit_session.conflict", status: "blocked", session, projectId, message: `Project held by ${conflict.owner.name}` });
173
- throw codedError("PROJECT_BUSY", `Project ${projectId} is being edited by ${conflict.owner.name} (${conflict.purpose}). Use a different project or wait for edit session ${conflict.id} to end.`, { session: publicSession(conflict) });
174
- }
175
166
  if (session.projectId && session.projectId !== projectId) {
176
167
  if (session.implicit) {
177
168
  session.projectId = projectId;
@@ -187,16 +178,7 @@ function beginEditSession(client, { editorId, projectId, purpose }) {
187
178
  if (!connected.length) throw codedError("NO_EDITOR", "No CarouselBot editor is connected. Open the editor in the user's normal browser and click Connect AI.");
188
179
  let editor = editorId ? connected.find((item) => item.id === editorId) : null;
189
180
  if (editorId && !editor) throw codedError("EDITOR_DISCONNECTED", `Editor is not connected: ${editorId}`);
190
- if (!editor) {
191
- const selected = client.selectedEditorId && connected.find((item) => item.id === client.selectedEditorId);
192
- const available = connected.filter((item) => !sessionForEditor(item.id));
193
- editor = selected && !sessionForEditor(selected.id) ? selected : available.length === 1 ? available[0] : null;
194
- if (!editor) throw codedError("EDITOR_SELECTION_REQUIRED", "Multiple browser tabs are available. Call list_editors, choose an unassigned editor, then begin_edit_session with editorId.");
195
- }
196
- const editorConflict = sessionForEditor(editor.id);
197
- if (editorConflict) throw codedError("EDITOR_BUSY", `Editor ${editor.id} is assigned to ${editorConflict.owner.name} (${editorConflict.purpose}) until ${new Date(editorConflict.lastSeen + EDIT_SESSION_TTL_MS).toISOString()}.`, { session: publicSession(editorConflict) });
198
- const projectConflict = sessionForProject(projectId);
199
- if (projectConflict) throw codedError("PROJECT_BUSY", `Project ${projectId} is being edited by ${projectConflict.owner.name} (${projectConflict.purpose}).`, { session: publicSession(projectConflict) });
181
+ if (!editor) editor = selectEditor(client.id);
200
182
  const now = Date.now();
201
183
  const session = {
202
184
  id: randomUUID(), editorId: editor.id, projectId: projectId || null,
@@ -285,8 +267,11 @@ function requireEditor(request, response, editorId, cors) {
285
267
  }
286
268
 
287
269
  function queueEditorEvent(editor, event) {
270
+ // Keep only the latest roster snapshot, but never drop commands or notifications.
271
+ if (event.kind === "system" && ["agents.changed", "edit-sessions.changed"].includes(event.type)) {
272
+ editor.queue = editor.queue.filter((queued) => queued.kind !== "system" || queued.type !== event.type);
273
+ }
288
274
  editor.queue.push(event);
289
- if (editor.queue.length > 100) editor.queue.splice(0, editor.queue.length - 100);
290
275
  deliverNext(editor);
291
276
  }
292
277
 
@@ -377,11 +362,9 @@ function resolveBrowserTarget(clientId, { editSessionId, mutating, projectId })
377
362
  return { client, editor: editors.get(session.editorId), session };
378
363
  }
379
364
  const editor = selectEditor(clientId);
380
- const conflict = sessionForEditor(editor.id);
381
- if (conflict) throw codedError("EDITOR_BUSY", `Editor ${editor.id} is assigned to ${conflict.owner.name} (${conflict.purpose}). Begin an edit session on another editor.`, { session: publicSession(conflict) });
382
365
  const now = Date.now();
383
366
  session = {
384
- id: randomUUID(), editorId: editor.id, projectId: null, purpose: "Implicit single-agent edit",
367
+ id: randomUUID(), editorId: editor.id, projectId: null, purpose: "Implicit edit",
385
368
  owner: publicClient(client), creatorClientId: client.id, lastClientId: client.id,
386
369
  implicit: true, createdAt: now, lastSeen: now,
387
370
  };
@@ -394,6 +377,10 @@ function resolveBrowserTarget(clientId, { editSessionId, mutating, projectId })
394
377
  }
395
378
 
396
379
  function callBrowser(clientId, toolName, operation, label, { editSessionId = null, mutating = false } = {}) {
380
+ const boundSession = editSessionId ? requireEditSession(editSessionId) : null;
381
+ if (!operation?.projectId && boundSession?.projectId && operation?.type !== "project.create") {
382
+ operation = { ...operation, projectId: boundSession.projectId };
383
+ }
397
384
  const projectId = operation?.projectId || null;
398
385
  const { client, editor, session } = resolveBrowserTarget(clientId, { editSessionId, mutating, projectId });
399
386
  if (mutating && session && !session.implicit && !session.projectId && toolName !== "create_project") {
@@ -404,6 +391,7 @@ function callBrowser(clientId, toolName, operation, label, { editSessionId = nul
404
391
  return new Promise((resolve, reject) => {
405
392
  const timer = setTimeout(() => {
406
393
  inflight.delete(requestId);
394
+ editor.queue = editor.queue.filter((event) => event.requestId !== requestId);
407
395
  if (operation?.fontMediaId) fontMedia.delete(operation.fontMediaId);
408
396
  recordAudit({ action: "tool.result", client, session, editorId: editor.id, projectId, toolName, status: "error", message: "Browser timeout" });
409
397
  reject(codedError("BROWSER_TIMEOUT", "The browser did not answer within 90 seconds."));
@@ -518,7 +506,7 @@ async function handleInternalCall(body) {
518
506
  selectedEditorId,
519
507
  editors: connected.map((editor) => {
520
508
  const assigned = sessionForEditor(editor.id);
521
- return { id: editor.id, selected: editor.id === selectedEditorId, focused: editor.id === focusedEditorId, pageUrl: editor.pageUrl, state: editor.state, editSession: assigned ? publicSession(assigned) : null };
509
+ return { id: editor.id, selected: editor.id === selectedEditorId, focused: editor.id === focusedEditorId, pageUrl: editor.pageUrl, state: editor.state, editSession: assigned ? publicSession(assigned) : null, editSessions: activeEditSessions().filter((session) => session.editorId === editor.id).map(publicSession) };
522
510
  }),
523
511
  editSessions: activeEditSessions().map(publicSession),
524
512
  };
@@ -8,8 +8,23 @@ const id = z.string().min(1).max(160);
8
8
  const optionalId = id.optional();
9
9
  const folderPath = z.string().min(2).max(160)
10
10
  .regex(/^\/(?!\/)\S(?:[\s\S]*\S)?$/, "Use a canonical folder path with one leading slash and no surrounding whitespace, for example /my-folder.")
11
- .refine((value) => value !== "/." && value !== "/..", "Folder paths cannot use the reserved names /. or /..");
11
+ .refine((value) => value.slice(1).split("/").length <= 2 && value.slice(1).split("/").every((part) => part && part === part.trim() && part !== "." && part !== ".."), "Use at most two folder levels: /Client/Account; no empty, dot, or double-dot names.");
12
12
  const color = z.string().regex(/^#?[0-9a-f]{3}(?:[0-9a-f]{3})?$/i, "Use a 3- or 6-digit hex color.");
13
+ const ASPECT_RATIO_INPUT_MAX_LENGTH = 80;
14
+ const aspectRatio = z.string().max(ASPECT_RATIO_INPUT_MAX_LENGTH)
15
+ .regex(/^\d{1,39}:\d{1,39}$/, "Use a positive width:height ratio such as 4:5 or 16:9.")
16
+ .refine((value) => {
17
+ try {
18
+ if (value.length > ASPECT_RATIO_INPUT_MAX_LENGTH) return false;
19
+ const [width, height] = value.split(":").map((part) => BigInt(part));
20
+ if (width <= 0n || height <= 0n) return false;
21
+ const canvasHeight = Number((1080n * height + width / 2n) / width);
22
+ return canvasHeight >= 180 && canvasHeight <= 3840;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }, "The ratio must produce a canvas height between 180 and 3840 pixels at 1080 pixels wide.")
27
+ .describe("Canvas ratio. Presets: 9:16, 2:3, 3:4, 4:5, 1:1, 4:3, 16:9. Other positive W:H ratios are reduced to canonical form and must yield a 180–3840 px canvas height at 1080 px wide. A project ratio is the default for new slides; an add_slide or update_slide ratio applies to that slide only.");
13
28
  const unit = z.number().min(-0.5).max(1.5);
14
29
  const positiveUnit = z.number().min(0.01).max(2.4);
15
30
  const expectedRevision = z.number().int().min(0).optional().describe("Optional optimistic-concurrency guard from inspect_editor.");
@@ -21,12 +36,15 @@ const textFields = {
21
36
  text: z.string().max(4000).optional(), x: unit.optional(), y: unit.optional(), width: positiveUnit.optional(), height: positiveUnit.optional(),
22
37
  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."),
23
38
  size: z.number().min(20).max(180).optional(), style: z.enum(["plain", "outline", "boxed"]).optional(),
24
- outlineWidth: z.number().min(0).max(40).optional(), color: color.optional(), background: z.enum(["white", "black"]).optional(),
39
+ outlineWidth: z.number().min(0).max(40).optional()
40
+ .describe("Relative outline thickness. The default 12 renders at 14.4% of the font size; all values scale proportionally with the font."),
41
+ color: color.optional(), background: z.enum(["white", "black"]).optional(),
25
42
  backgroundShape: z.enum(["lines", "full"]).optional(), align: z.enum(["left", "center", "right"]).optional(),
26
43
  fontId,
27
44
  fontWeight: z.number().int().min(1).max(1000).optional(),
28
45
  fontStyle: z.enum(["normal", "italic"]).optional(),
29
- fontVariationSettings: z.record(z.string().regex(/^[A-Za-z0-9]{4}$/), z.number()).optional(),
46
+ fontVariationSettings: z.record(z.string().regex(/^[A-Za-z0-9]{4}$/), z.number()).optional()
47
+ .describe("Variable-font axis settings. Only wght currently has guaranteed DOM, fitting, and exported-canvas parity; preserve but do not newly apply other axes."),
30
48
  rotation: z.number().min(-720).max(720).optional(), z: z.number().optional(),
31
49
  };
32
50
  const imageFields = {
@@ -36,6 +54,13 @@ const imageFields = {
36
54
  cropW: z.number().min(0.05).max(1).optional(), cropH: z.number().min(0.05).max(1).optional(),
37
55
  };
38
56
 
57
+ function backgroundSourceSchema(shape) {
58
+ return z.object(shape).strict().refine(
59
+ ({ backgroundColor, backgroundPath }) => !(backgroundColor && backgroundPath),
60
+ { message: "backgroundColor and backgroundPath are mutually exclusive; choose one background source." },
61
+ );
62
+ }
63
+
39
64
  const definitions = new Map();
40
65
 
41
66
  function textResult(value, summary = value) {
@@ -43,7 +68,7 @@ function textResult(value, summary = value) {
43
68
  }
44
69
 
45
70
  function compactMutation(value) {
46
- const keys = ["id", "editSessionId", "editorId", "projectId", "folderPath", "slideId", "revision", "leaseExpiresAt", "purpose", "released", "opened", "createdSlideId", "createdTextId", "fittedTextBox", "createdImageId", "createdLayers", "assetId", "fontId", "localFontId", "existing", "repaired", "deletedAssetId", "deletedProjectId", "deletedSlideId", "deletedLayerIds", "updatedTextIds", "fittedTextBoxes", "updatedImageIds", "applied", "path", "bytes"];
71
+ const keys = ["id", "editSessionId", "editorId", "projectId", "aspectRatio", "canvasWidth", "canvasHeight", "folderPath", "slideId", "revision", "leaseExpiresAt", "purpose", "released", "opened", "createdSlideId", "createdTextId", "fittedTextBox", "createdImageId", "createdLayers", "assetId", "fontId", "localFontId", "existing", "repaired", "deletedAssetId", "deletedProjectId", "deletedSlideId", "deletedLayerIds", "updatedTextIds", "fittedTextBoxes", "updatedImageIds", "applied", "path", "bytes"];
47
72
  return Object.fromEntries(keys.flatMap((key) => {
48
73
  if (key === "folderPath" && Object.hasOwn(value || {}, key)) return [[key, value[key] ?? null]];
49
74
  return value?.[key] == null ? [] : [[key, value[key]]];
@@ -115,7 +140,7 @@ export async function createCarouselBotMcpServer(companion) {
115
140
  let guidanceRead = false;
116
141
  let identifiedAs = null;
117
142
  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. 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.`,
143
+ 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 workers can share the same browser tab and project; sessions track targets without exclusive locks. Use render_slide to inspect actual pixels.`,
119
144
  capabilities: { tools: {}, resources: {} },
120
145
  });
121
146
 
@@ -162,23 +187,23 @@ export async function createCarouselBotMcpServer(companion) {
162
187
 
163
188
  register("list_editors", "Check the user's real local browser connection and show which registered CarouselBot tab this session targets. Call this instead of opening a sandboxed browser.", z.object({}).strict(), () => companion.call("list_editors"), { readOnlyHint: true });
164
189
  register("select_editor", "Select a connected browser tab for this MCP session.", z.object({ editorId: id }).strict(), ({ editorId }) => companion.call("select_editor", { editorId }), { destructiveHint: false, idempotentHint: true });
165
- register("begin_edit_session", "Atomically reserve one browser tab and optionally one project for an editing agent. Use one session per parallel editing worker and pass editSessionId to every edit.", z.object({ editorId: optionalId, projectId: optionalId, purpose: z.string().min(1).max(160).optional() }).strict(), (args) => companion.call("begin_edit_session", args), { destructiveHint: false });
166
- register("end_edit_session", "Release a browser-tab/project reservation as soon as an editing task finishes or fails.", z.object({ editSessionId: id }).strict(), (args) => companion.call("end_edit_session", args), { destructiveHint: false, idempotentHint: true });
167
- register("list_edit_sessions", "List active edit reservations, their owners, projects, and lease expirations.", z.object({}).strict(), () => companion.call("list_edit_sessions"), { readOnlyHint: true });
190
+ register("begin_edit_session", "Track one browser tab and optionally one project for an editing agent without locking either. Any number of workers may share a tab or project. Pass editSessionId to every edit.", z.object({ editorId: optionalId, projectId: optionalId, purpose: z.string().min(1).max(160).optional() }).strict(), (args) => companion.call("begin_edit_session", args), { destructiveHint: false });
191
+ register("end_edit_session", "End an editing session as soon as an editing task finishes or fails.", z.object({ editSessionId: id }).strict(), (args) => companion.call("end_edit_session", args), { destructiveHint: false, idempotentHint: true });
192
+ register("list_edit_sessions", "List active editing sessions, their owners, projects, and lease expirations.", z.object({}).strict(), () => companion.call("list_edit_sessions"), { readOnlyHint: true });
168
193
  register("list_recent_operations", "Read the local sanitized operation audit. Text, prompts, paths, and image bytes are never logged.", z.object({ limit: z.number().int().min(1).max(200).default(50), projectId: optionalId, status: z.enum(["started", "ok", "error", "blocked"]).optional() }).strict(), (args) => companion.call("list_recent_operations", args), { readOnlyHint: true });
169
194
  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 });
170
195
  register("list_local_fonts", "Search fonts installed on this computer. Returns opaque local font IDs and never filesystem paths. The user must enable local fonts in CarouselBot first.", z.object({ editSessionId, query: z.string().max(200).optional(), limit: z.number().int().min(1).max(200).default(80), cursor: z.string().max(2048).optional(), sort: z.enum(["recent_then_alphabetical", "alphabetical"]).default("recent_then_alphabetical") }).strict(), (args) => companion.call("list_local_fonts", args), { readOnlyHint: true });
171
196
  register("list_project_fonts", "List fonts already imported into one project, including whether each face is currently available.", z.object({ ...targetProject, projectId: id }).strict(), (args) => browserOperation(companion, "list_project_fonts", args), { readOnlyHint: true });
172
197
  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 });
173
198
 
174
- register("create_project", "Create an empty project without changing the user's current browser view. Pass a canonical folderPath such as /my-folder to create it inside that folder; omit it or use null for the dashboard root.", z.object({ editSessionId, name: z.string().min(1).max(160), folderPath: folderPath.nullable().optional() }).strict(), (args) => browserOperation(companion, "create_project", args), { destructiveHint: false });
199
+ register("create_project", "Create an empty project without changing the user's current browser view. Choose an optional aspect ratio: use a documented preset or a positive integer W:H value; legacy/default projects use 9:16. Pass a canonical folderPath such as /Client or /Client/Account to create it inside that folder (maximum two levels); omit it or use null for the dashboard root.", z.object({ editSessionId, name: z.string().min(1).max(160), aspectRatio: aspectRatio.optional(), folderPath: folderPath.nullable().optional() }).strict(), (args) => browserOperation(companion, "create_project", args), { destructiveHint: false });
175
200
  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 });
176
201
  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 });
177
- register("move_project", "Move a project into a folder by canonical slash path, move it between folders, or move it back to the dashboard root with folderPath=null. Folder cards are derived from project membership, so empty folders disappear.", z.object({ ...targetProject, projectId: id, folderPath: folderPath.nullable() }).strict(), (args) => browserOperation(companion, "move_project", args), { destructiveHint: true });
202
+ register("move_project", "Move a project into a folder by canonical slash path (/Client or /Client/Account, maximum two levels), move it between folders, or move it back to the dashboard root with folderPath=null. Folder cards are derived from project membership, so empty folders disappear.", z.object({ ...targetProject, projectId: id, folderPath: folderPath.nullable() }).strict(), (args) => browserOperation(companion, "move_project", args), { destructiveHint: true });
178
203
  register("delete_project", "Delete a project from browser storage.", z.object({ ...targetProject, projectId: id }).strict(), (args) => browserOperation(companion, "delete_project", args), { destructiveHint: true });
179
204
 
180
- 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 });
181
- 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 });
205
+ register("add_slide", "Add a slide using a solid backgroundColor or local backgroundPath. A backgroundPath with no aspectRatio adopts the source image's exact reduced ratio, scales it to the 1080-pixel canvas width, and exports only that canvas. A solid slide with no aspectRatio uses the project's default; omit both background sources to use #EEEDE7. Pass aspectRatio to override either default. The browser follows it only when that project is already visible.", backgroundSourceSchema({ ...targetProject, name: z.string().max(160).optional(), index: z.number().int().min(0).optional(), aspectRatio: aspectRatio.optional(), backgroundColor: color.optional(), backgroundPath: z.string().min(1).optional() }), (args) => browserOperation(companion, "add_slide", args), { destructiveHint: false });
206
+ register("update_slide", "Rename a slide, change only that slide's optional aspectRatio, replace its background with either a solid backgroundColor or local backgroundPath, or change background pan/zoom. Ratio changes preserve layer proportions and centers. The two background sources are mutually exclusive. The browser follows it only when that project is already visible.", backgroundSourceSchema({ ...targetSlide, name: z.string().max(160).optional(), aspectRatio: aspectRatio.optional(), backgroundColor: color.optional(), backgroundPath: z.string().min(1).optional(), imageScale: z.number().min(1).max(3).optional(), imageX: unit.optional(), imageY: unit.optional() }), (args) => browserOperation(companion, "update_slide", args), { destructiveHint: true });
182
207
  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 });
183
208
  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 });
184
209
  register("delete_slide", "Delete one slide.", z.object({ ...targetSlide, slideId: id }).strict(), (args) => browserOperation(companion, "delete_slide", args), { destructiveHint: true });