From 5d67493924289448a5d6f219f4da3d2dbc37d864 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Fri, 2 Oct 2026 11:00:05 +0000 Subject: [PATCH 1/6] feat: expose live Shift sessions to local agents --- .agents/skills/shift/SKILL.md | 53 +++++ .agents/skills/shift/scripts/client.mjs | 200 ++++++++++++++++++ .claude/skills/shift/SKILL.md | 53 +++++ .claude/skills/shift/scripts/client.mjs | 200 ++++++++++++++++++ .codex/skills/shift/SKILL.md | 53 +++++ .codex/skills/shift/scripts/client.mjs | 200 ++++++++++++++++++ ROADMAP.md | 2 +- apps/desktop/build.ts | 22 +- apps/desktop/e2e/live-agent.spec.ts | 57 +++++ apps/desktop/forge.config.ts | 5 + apps/desktop/package.json | 2 + apps/desktop/playwright.config.ts | 1 + apps/desktop/src/main/agent/AgentClient.ts | 38 ++++ apps/desktop/src/main/app/App.ts | 100 ++++++++- apps/desktop/src/main/docs/DOCS.md | 15 +- .../src/main/sandbox/SandboxRuntimeProcess.ts | 160 ++++++++++++++ apps/desktop/src/main/windows/Window.ts | 3 + .../desktop/src/main/windows/WindowManager.ts | 4 + apps/desktop/src/preload/docs/DOCS.md | 8 +- apps/desktop/src/preload/preload.ts | 7 + .../src/renderer/src/agent/AgentBridge.ts | 97 +++++++++ .../src/components/editor/ObjectsPanel.tsx | 2 +- .../object-tree/flattenVisibleObjectRows.ts | 2 +- .../MultiSourcePositionEdits.test.ts | 2 +- .../src/renderer/src/types/objectTree.ts | 3 +- .../src/workspace/FontSessionProvider.tsx | 14 ++ apps/desktop/src/shared/agent/protocol.ts | 8 + apps/desktop/src/shared/host/ShiftHost.ts | 4 + apps/desktop/src/shared/ipc/contract.ts | 1 + apps/desktop/src/shared/sandbox/protocol.ts | 25 +++ apps/desktop/src/utility/sandbox.ts | 25 +++ docs/architecture/index.md | 4 +- knip.json | 1 + .../src/lib/clipboard/ClipboardSelection.ts | 3 +- packages/editor/src/lib/editor/Editor.ts | 9 +- packages/editor/src/lib/editor/Hover.ts | 2 +- packages/editor/src/lib/editor/Selection.ts | 3 +- packages/editor/src/lib/editor/docs/DOCS.md | 4 +- .../rendering/overlays/DebugOverlays.ts | 2 +- .../lib/editor/rendering/overlays/Segments.ts | 3 +- .../rendering/overlays/handles/HandleItems.ts | 3 +- .../editor/src/lib/model/ContourBuffer.ts | 10 +- packages/editor/src/lib/model/Font.ts | 2 +- packages/editor/src/lib/model/FontStore.ts | 3 +- packages/editor/src/lib/model/Glyph.ts | 2 +- .../src/lib/nodes/GlyphNodeDefinition.ts | 3 +- .../editor/src/lib/objects/SegmentObject.ts | 4 +- .../editor/src/lib/tools/pen/PenStroke.ts | 4 +- .../editor/src/lib/tools/pen/PenTargets.ts | 4 +- .../src/lib/tools/select/behaviors/Marquee.ts | 2 +- .../lib/tools/select/behaviors/Selection.ts | 2 +- packages/editor/src/lib/tools/select/types.ts | 4 +- packages/editor/src/types.ts | 2 +- packages/editor/src/types/glyph.ts | 2 +- packages/editor/src/types/indicator.ts | 5 +- packages/editor/src/types/object.ts | 18 +- packages/editor/src/types/records.ts | 4 +- packages/glyph-state/docs/DOCS.md | 2 +- packages/glyph-state/src/GlyphGeometry.ts | 3 +- packages/glyph-state/src/Segment.ts | 12 +- packages/glyph-state/src/index.ts | 2 - packages/mcp/docs/DOCS.md | 94 ++++++++ packages/mcp/package.json | 42 ++++ packages/mcp/src/code.test.ts | 73 +++++++ packages/mcp/src/code.ts | 148 +++++++++++++ packages/mcp/src/declarations.ts | 3 + packages/mcp/src/index.ts | 4 + packages/mcp/src/raw.d.ts | 4 + packages/mcp/src/runtime.ts | 1 + packages/mcp/src/server.test.ts | 114 ++++++++++ packages/mcp/src/server.ts | 198 +++++++++++++++++ packages/mcp/src/types.ts | 6 + packages/mcp/tsconfig.json | 9 + packages/mcp/vitest.config.ts | 7 + packages/runtime/docs/DOCS.md | 79 +++++++ packages/runtime/generated/code-api.d.ts | 151 +++++++++++++ packages/runtime/package.json | 33 +++ .../runtime/scripts/generate-code-api.mjs | 47 ++++ packages/runtime/src/capabilities.ts | 70 ++++++ packages/runtime/src/index.ts | 10 + packages/runtime/tsconfig.json | 8 + packages/runtime/tsdown.config.ts | 18 ++ packages/sdk/README.md | 4 + packages/sdk/api/index.api.md | 114 +++++++++- packages/sdk/package.json | 1 + packages/sdk/src/index.ts | 21 +- packages/sdk/tsdown.dts.config.ts | 1 + packages/types/docs/DOCS.md | 1 + packages/types/src/ids.ts | 22 ++ packages/types/src/index.ts | 4 + pnpm-lock.yaml | 134 ++++++++++++ vitest.config.ts | 1 + 92 files changed, 2822 insertions(+), 90 deletions(-) create mode 100644 .agents/skills/shift/SKILL.md create mode 100644 .agents/skills/shift/scripts/client.mjs create mode 100644 .claude/skills/shift/SKILL.md create mode 100644 .claude/skills/shift/scripts/client.mjs create mode 100644 .codex/skills/shift/SKILL.md create mode 100644 .codex/skills/shift/scripts/client.mjs create mode 100644 apps/desktop/e2e/live-agent.spec.ts create mode 100644 apps/desktop/src/main/agent/AgentClient.ts create mode 100644 apps/desktop/src/main/sandbox/SandboxRuntimeProcess.ts create mode 100644 apps/desktop/src/renderer/src/agent/AgentBridge.ts create mode 100644 apps/desktop/src/shared/agent/protocol.ts create mode 100644 apps/desktop/src/shared/sandbox/protocol.ts create mode 100644 apps/desktop/src/utility/sandbox.ts create mode 100644 packages/mcp/docs/DOCS.md create mode 100644 packages/mcp/package.json create mode 100644 packages/mcp/src/code.test.ts create mode 100644 packages/mcp/src/code.ts create mode 100644 packages/mcp/src/declarations.ts create mode 100644 packages/mcp/src/index.ts create mode 100644 packages/mcp/src/raw.d.ts create mode 100644 packages/mcp/src/runtime.ts create mode 100644 packages/mcp/src/server.test.ts create mode 100644 packages/mcp/src/server.ts create mode 100644 packages/mcp/src/types.ts create mode 100644 packages/mcp/tsconfig.json create mode 100644 packages/mcp/vitest.config.ts create mode 100644 packages/runtime/docs/DOCS.md create mode 100644 packages/runtime/generated/code-api.d.ts create mode 100644 packages/runtime/package.json create mode 100644 packages/runtime/scripts/generate-code-api.mjs create mode 100644 packages/runtime/src/capabilities.ts create mode 100644 packages/runtime/src/index.ts create mode 100644 packages/runtime/tsconfig.json create mode 100644 packages/runtime/tsdown.config.ts diff --git a/.agents/skills/shift/SKILL.md b/.agents/skills/shift/SKILL.md new file mode 100644 index 000000000..8ad4950ac --- /dev/null +++ b/.agents/skills/shift/SKILL.md @@ -0,0 +1,53 @@ +--- +name: shift +description: Inspect and work with the font and editor currently open in the live Shift desktop application through its local MCP code-mode API. Use for live Shift sessions, current glyph/source/selection/tool state, font-development investigation, and agent workflows that need the running app rather than a closed file. +--- + +# Live Shift + +Use the live MCP connection when the user refers to the font, glyph, source, selection, tool, or view currently open in Shift. Use `shift-cli` instead for closed documents, batch processing, or CI. + +## Connect + +Run scripts relative to this skill directory: + +```sh +node scripts/client.mjs connections +``` + +The client discovers run descriptors for Shift, Shift Nightly, and development builds. Set `SHIFT_MCP_DESCRIPTOR=/absolute/path/to/mcp.json` when Shift uses a custom user-data directory. If multiple applications are running, pass `--descriptor ` explicitly; never guess. + +## Discover the API + +```sh +node scripts/client.mjs describe --descriptor +``` + +The first slice exposes `shift.sessions.list()` and `shift.editor.inspect({ windowId })` inside `shift.execute`. + +## Execute code + +Pass an async zero-argument function on stdin to avoid shell escaping: + +```sh +node scripts/client.mjs execute --descriptor <<'EOF' +async () => { + const sessions = await shift.sessions.list(); + const session = sessions.find(({ sessionId }) => sessionId === "..."); + if (!session) throw new Error("Target Shift session is not open"); + return shift.editor.inspect({ windowId: session.windowId }); +} +EOF +``` + +Always target the explicit `windowId` returned by `sessions.list()`. Do not assume focus is stable. Treat `editor.inspect()` as a point-in-time renderer observation: it reports the current glyph occurrence, active/editing sources, external location, selection, tool, gesture flags, and workspace apply status. It does not return authored glyph geometry or commit edits in this first slice. + +Keep returned values focused. Filter and map inside code mode instead of returning complete intermediate responses. Generated code has no filesystem, network, environment, Node.js, or Electron access. + +## Safety and interpretation + +- The connection secret is transport material, not a user login. Never print or include it in conversation output. +- A preview session is read-only; do not imply that it can be edited. +- `dragging: true` or `applyStatus !== "idle"` means the observation may include transient or unsettled editor state. Say so when it affects the answer. +- A missing or closed window is an error. Re-list sessions instead of silently switching targets. +- Do not use this interface as a replacement for regression tests. Reproduce and prove fixes through the repository's normal unit or Electron E2E boundary. diff --git a/.agents/skills/shift/scripts/client.mjs b/.agents/skills/shift/scripts/client.mjs new file mode 100644 index 000000000..b1fd1bf8a --- /dev/null +++ b/.agents/skills/shift/scripts/client.mjs @@ -0,0 +1,200 @@ +#!/usr/bin/env node + +import { readFile, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; + +const APP_NAMES = ["Shift Dev", "Shift Nightly Dev", "Shift", "Shift Nightly"]; +let requestId = 0; + +async function main() { + const command = process.argv[2]; + const { descriptorPath, operands } = parseArguments(process.argv.slice(3)); + + switch (command) { + case "connections": { + const connections = await discoverConnections(); + console.log( + JSON.stringify( + connections.map(({ descriptorPath: discoveredPath, connection }) => ({ + descriptorPath: discoveredPath, + url: connection.url, + })), + null, + 2, + ), + ); + return; + } + case "describe": { + const connection = await selectConnection(descriptorPath); + await initialize(connection); + const result = await callTool(connection, "shift.describe", {}); + printToolText(result); + return; + } + case "execute": { + const connection = await selectConnection(descriptorPath); + const code = operands.length > 0 ? operands.join(" ") : await readStdin(); + if (!code.trim()) throw new Error("execute requires code as an argument or on stdin"); + + await initialize(connection); + const result = await callTool(connection, "shift.execute", { code }); + printToolText(result); + return; + } + default: + throw new Error( + "usage: client.mjs connections | describe [--descriptor path] | execute [--descriptor path] [code]", + ); + } +} + +function parseArguments(args) { + let descriptorPath = process.env.SHIFT_MCP_DESCRIPTOR; + const operands = []; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument !== "--descriptor") { + operands.push(argument); + continue; + } + + descriptorPath = args[index + 1]; + if (!descriptorPath) throw new Error("--descriptor requires a path"); + index += 1; + } + + return { descriptorPath, operands }; +} + +async function selectConnection(requestedPath) { + if (requestedPath) return readConnection(path.resolve(requestedPath)); + + const connections = await discoverConnections(); + if (connections.length === 0) throw new Error("no running Shift MCP connection found"); + if (connections.length > 1) { + const paths = connections.map(({ descriptorPath }) => descriptorPath).join("\n"); + throw new Error(`multiple Shift MCP connections found; pass --descriptor:\n${paths}`); + } + + return connections[0].connection; +} + +async function discoverConnections() { + const root = applicationDataRoot(); + const configuredPath = process.env.SHIFT_MCP_DESCRIPTOR; + const discoveredPaths = APP_NAMES.map((name) => path.join(root, name, "mcp.json")); + const candidates = [ + ...(configuredPath ? [path.resolve(configuredPath)] : []), + ...discoveredPaths, + ].filter((candidate, index, paths) => paths.indexOf(candidate) === index); + const connections = []; + + for (const descriptorPath of candidates) { + try { + const [connection, descriptorStat] = await Promise.all([ + readConnection(descriptorPath), + stat(descriptorPath), + ]); + connections.push({ descriptorPath, connection, modified: descriptorStat.mtimeMs }); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + } + + return connections.sort((left, right) => right.modified - left.modified); +} + +function applicationDataRoot() { + switch (process.platform) { + case "darwin": + return path.join(os.homedir(), "Library", "Application Support"); + case "win32": { + const appData = process.env.APPDATA; + if (!appData) throw new Error("APPDATA is not set"); + return appData; + } + default: + return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"); + } +} + +async function readConnection(descriptorPath) { + const connection = JSON.parse(await readFile(descriptorPath, "utf8")); + if (typeof connection?.url !== "string" || typeof connection?.token !== "string") { + throw new Error(`invalid Shift MCP descriptor: ${descriptorPath}`); + } + + return connection; +} + +async function initialize(connection) { + await request(connection, "initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "shift-agent-skill", version: "0.1.0" }, + }); +} + +async function callTool(connection, name, args) { + const response = await request(connection, "tools/call", { name, arguments: args }); + if (response.isError) throw new Error(toolText(response)); + return response; +} + +async function request(connection, method, params) { + requestId += 1; + const response = await fetch(connection.url, { + method: "POST", + headers: { + authorization: `Bearer ${connection.token}`, + accept: "application/json, text/event-stream", + "content-type": "application/json", + "mcp-protocol-version": "2025-06-18", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: requestId, method, params }), + }); + + const text = await response.text(); + if (!response.ok) throw new Error(`Shift MCP request failed (${response.status}): ${text}`); + + const message = parseMessage(text); + if (message.error) throw new Error(message.error.message ?? JSON.stringify(message.error)); + return message.result; +} + +function parseMessage(body) { + if (!body.startsWith("event:")) return JSON.parse(body); + + const dataLines = body + .split("\n") + .filter((line) => line.startsWith("data: ")) + .map((line) => line.slice("data: ".length)); + if (dataLines.length === 0) throw new Error("Shift MCP returned an empty event stream"); + return JSON.parse(dataLines[dataLines.length - 1]); +} + +function toolText(result) { + return (result.content ?? []) + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n"); +} + +function printToolText(result) { + console.log(toolText(result)); +} + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/.claude/skills/shift/SKILL.md b/.claude/skills/shift/SKILL.md new file mode 100644 index 000000000..8ad4950ac --- /dev/null +++ b/.claude/skills/shift/SKILL.md @@ -0,0 +1,53 @@ +--- +name: shift +description: Inspect and work with the font and editor currently open in the live Shift desktop application through its local MCP code-mode API. Use for live Shift sessions, current glyph/source/selection/tool state, font-development investigation, and agent workflows that need the running app rather than a closed file. +--- + +# Live Shift + +Use the live MCP connection when the user refers to the font, glyph, source, selection, tool, or view currently open in Shift. Use `shift-cli` instead for closed documents, batch processing, or CI. + +## Connect + +Run scripts relative to this skill directory: + +```sh +node scripts/client.mjs connections +``` + +The client discovers run descriptors for Shift, Shift Nightly, and development builds. Set `SHIFT_MCP_DESCRIPTOR=/absolute/path/to/mcp.json` when Shift uses a custom user-data directory. If multiple applications are running, pass `--descriptor ` explicitly; never guess. + +## Discover the API + +```sh +node scripts/client.mjs describe --descriptor +``` + +The first slice exposes `shift.sessions.list()` and `shift.editor.inspect({ windowId })` inside `shift.execute`. + +## Execute code + +Pass an async zero-argument function on stdin to avoid shell escaping: + +```sh +node scripts/client.mjs execute --descriptor <<'EOF' +async () => { + const sessions = await shift.sessions.list(); + const session = sessions.find(({ sessionId }) => sessionId === "..."); + if (!session) throw new Error("Target Shift session is not open"); + return shift.editor.inspect({ windowId: session.windowId }); +} +EOF +``` + +Always target the explicit `windowId` returned by `sessions.list()`. Do not assume focus is stable. Treat `editor.inspect()` as a point-in-time renderer observation: it reports the current glyph occurrence, active/editing sources, external location, selection, tool, gesture flags, and workspace apply status. It does not return authored glyph geometry or commit edits in this first slice. + +Keep returned values focused. Filter and map inside code mode instead of returning complete intermediate responses. Generated code has no filesystem, network, environment, Node.js, or Electron access. + +## Safety and interpretation + +- The connection secret is transport material, not a user login. Never print or include it in conversation output. +- A preview session is read-only; do not imply that it can be edited. +- `dragging: true` or `applyStatus !== "idle"` means the observation may include transient or unsettled editor state. Say so when it affects the answer. +- A missing or closed window is an error. Re-list sessions instead of silently switching targets. +- Do not use this interface as a replacement for regression tests. Reproduce and prove fixes through the repository's normal unit or Electron E2E boundary. diff --git a/.claude/skills/shift/scripts/client.mjs b/.claude/skills/shift/scripts/client.mjs new file mode 100644 index 000000000..b1fd1bf8a --- /dev/null +++ b/.claude/skills/shift/scripts/client.mjs @@ -0,0 +1,200 @@ +#!/usr/bin/env node + +import { readFile, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; + +const APP_NAMES = ["Shift Dev", "Shift Nightly Dev", "Shift", "Shift Nightly"]; +let requestId = 0; + +async function main() { + const command = process.argv[2]; + const { descriptorPath, operands } = parseArguments(process.argv.slice(3)); + + switch (command) { + case "connections": { + const connections = await discoverConnections(); + console.log( + JSON.stringify( + connections.map(({ descriptorPath: discoveredPath, connection }) => ({ + descriptorPath: discoveredPath, + url: connection.url, + })), + null, + 2, + ), + ); + return; + } + case "describe": { + const connection = await selectConnection(descriptorPath); + await initialize(connection); + const result = await callTool(connection, "shift.describe", {}); + printToolText(result); + return; + } + case "execute": { + const connection = await selectConnection(descriptorPath); + const code = operands.length > 0 ? operands.join(" ") : await readStdin(); + if (!code.trim()) throw new Error("execute requires code as an argument or on stdin"); + + await initialize(connection); + const result = await callTool(connection, "shift.execute", { code }); + printToolText(result); + return; + } + default: + throw new Error( + "usage: client.mjs connections | describe [--descriptor path] | execute [--descriptor path] [code]", + ); + } +} + +function parseArguments(args) { + let descriptorPath = process.env.SHIFT_MCP_DESCRIPTOR; + const operands = []; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument !== "--descriptor") { + operands.push(argument); + continue; + } + + descriptorPath = args[index + 1]; + if (!descriptorPath) throw new Error("--descriptor requires a path"); + index += 1; + } + + return { descriptorPath, operands }; +} + +async function selectConnection(requestedPath) { + if (requestedPath) return readConnection(path.resolve(requestedPath)); + + const connections = await discoverConnections(); + if (connections.length === 0) throw new Error("no running Shift MCP connection found"); + if (connections.length > 1) { + const paths = connections.map(({ descriptorPath }) => descriptorPath).join("\n"); + throw new Error(`multiple Shift MCP connections found; pass --descriptor:\n${paths}`); + } + + return connections[0].connection; +} + +async function discoverConnections() { + const root = applicationDataRoot(); + const configuredPath = process.env.SHIFT_MCP_DESCRIPTOR; + const discoveredPaths = APP_NAMES.map((name) => path.join(root, name, "mcp.json")); + const candidates = [ + ...(configuredPath ? [path.resolve(configuredPath)] : []), + ...discoveredPaths, + ].filter((candidate, index, paths) => paths.indexOf(candidate) === index); + const connections = []; + + for (const descriptorPath of candidates) { + try { + const [connection, descriptorStat] = await Promise.all([ + readConnection(descriptorPath), + stat(descriptorPath), + ]); + connections.push({ descriptorPath, connection, modified: descriptorStat.mtimeMs }); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + } + + return connections.sort((left, right) => right.modified - left.modified); +} + +function applicationDataRoot() { + switch (process.platform) { + case "darwin": + return path.join(os.homedir(), "Library", "Application Support"); + case "win32": { + const appData = process.env.APPDATA; + if (!appData) throw new Error("APPDATA is not set"); + return appData; + } + default: + return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"); + } +} + +async function readConnection(descriptorPath) { + const connection = JSON.parse(await readFile(descriptorPath, "utf8")); + if (typeof connection?.url !== "string" || typeof connection?.token !== "string") { + throw new Error(`invalid Shift MCP descriptor: ${descriptorPath}`); + } + + return connection; +} + +async function initialize(connection) { + await request(connection, "initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "shift-agent-skill", version: "0.1.0" }, + }); +} + +async function callTool(connection, name, args) { + const response = await request(connection, "tools/call", { name, arguments: args }); + if (response.isError) throw new Error(toolText(response)); + return response; +} + +async function request(connection, method, params) { + requestId += 1; + const response = await fetch(connection.url, { + method: "POST", + headers: { + authorization: `Bearer ${connection.token}`, + accept: "application/json, text/event-stream", + "content-type": "application/json", + "mcp-protocol-version": "2025-06-18", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: requestId, method, params }), + }); + + const text = await response.text(); + if (!response.ok) throw new Error(`Shift MCP request failed (${response.status}): ${text}`); + + const message = parseMessage(text); + if (message.error) throw new Error(message.error.message ?? JSON.stringify(message.error)); + return message.result; +} + +function parseMessage(body) { + if (!body.startsWith("event:")) return JSON.parse(body); + + const dataLines = body + .split("\n") + .filter((line) => line.startsWith("data: ")) + .map((line) => line.slice("data: ".length)); + if (dataLines.length === 0) throw new Error("Shift MCP returned an empty event stream"); + return JSON.parse(dataLines[dataLines.length - 1]); +} + +function toolText(result) { + return (result.content ?? []) + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n"); +} + +function printToolText(result) { + console.log(toolText(result)); +} + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/.codex/skills/shift/SKILL.md b/.codex/skills/shift/SKILL.md new file mode 100644 index 000000000..8ad4950ac --- /dev/null +++ b/.codex/skills/shift/SKILL.md @@ -0,0 +1,53 @@ +--- +name: shift +description: Inspect and work with the font and editor currently open in the live Shift desktop application through its local MCP code-mode API. Use for live Shift sessions, current glyph/source/selection/tool state, font-development investigation, and agent workflows that need the running app rather than a closed file. +--- + +# Live Shift + +Use the live MCP connection when the user refers to the font, glyph, source, selection, tool, or view currently open in Shift. Use `shift-cli` instead for closed documents, batch processing, or CI. + +## Connect + +Run scripts relative to this skill directory: + +```sh +node scripts/client.mjs connections +``` + +The client discovers run descriptors for Shift, Shift Nightly, and development builds. Set `SHIFT_MCP_DESCRIPTOR=/absolute/path/to/mcp.json` when Shift uses a custom user-data directory. If multiple applications are running, pass `--descriptor ` explicitly; never guess. + +## Discover the API + +```sh +node scripts/client.mjs describe --descriptor +``` + +The first slice exposes `shift.sessions.list()` and `shift.editor.inspect({ windowId })` inside `shift.execute`. + +## Execute code + +Pass an async zero-argument function on stdin to avoid shell escaping: + +```sh +node scripts/client.mjs execute --descriptor <<'EOF' +async () => { + const sessions = await shift.sessions.list(); + const session = sessions.find(({ sessionId }) => sessionId === "..."); + if (!session) throw new Error("Target Shift session is not open"); + return shift.editor.inspect({ windowId: session.windowId }); +} +EOF +``` + +Always target the explicit `windowId` returned by `sessions.list()`. Do not assume focus is stable. Treat `editor.inspect()` as a point-in-time renderer observation: it reports the current glyph occurrence, active/editing sources, external location, selection, tool, gesture flags, and workspace apply status. It does not return authored glyph geometry or commit edits in this first slice. + +Keep returned values focused. Filter and map inside code mode instead of returning complete intermediate responses. Generated code has no filesystem, network, environment, Node.js, or Electron access. + +## Safety and interpretation + +- The connection secret is transport material, not a user login. Never print or include it in conversation output. +- A preview session is read-only; do not imply that it can be edited. +- `dragging: true` or `applyStatus !== "idle"` means the observation may include transient or unsettled editor state. Say so when it affects the answer. +- A missing or closed window is an error. Re-list sessions instead of silently switching targets. +- Do not use this interface as a replacement for regression tests. Reproduce and prove fixes through the repository's normal unit or Electron E2E boundary. diff --git a/.codex/skills/shift/scripts/client.mjs b/.codex/skills/shift/scripts/client.mjs new file mode 100644 index 000000000..b1fd1bf8a --- /dev/null +++ b/.codex/skills/shift/scripts/client.mjs @@ -0,0 +1,200 @@ +#!/usr/bin/env node + +import { readFile, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; + +const APP_NAMES = ["Shift Dev", "Shift Nightly Dev", "Shift", "Shift Nightly"]; +let requestId = 0; + +async function main() { + const command = process.argv[2]; + const { descriptorPath, operands } = parseArguments(process.argv.slice(3)); + + switch (command) { + case "connections": { + const connections = await discoverConnections(); + console.log( + JSON.stringify( + connections.map(({ descriptorPath: discoveredPath, connection }) => ({ + descriptorPath: discoveredPath, + url: connection.url, + })), + null, + 2, + ), + ); + return; + } + case "describe": { + const connection = await selectConnection(descriptorPath); + await initialize(connection); + const result = await callTool(connection, "shift.describe", {}); + printToolText(result); + return; + } + case "execute": { + const connection = await selectConnection(descriptorPath); + const code = operands.length > 0 ? operands.join(" ") : await readStdin(); + if (!code.trim()) throw new Error("execute requires code as an argument or on stdin"); + + await initialize(connection); + const result = await callTool(connection, "shift.execute", { code }); + printToolText(result); + return; + } + default: + throw new Error( + "usage: client.mjs connections | describe [--descriptor path] | execute [--descriptor path] [code]", + ); + } +} + +function parseArguments(args) { + let descriptorPath = process.env.SHIFT_MCP_DESCRIPTOR; + const operands = []; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument !== "--descriptor") { + operands.push(argument); + continue; + } + + descriptorPath = args[index + 1]; + if (!descriptorPath) throw new Error("--descriptor requires a path"); + index += 1; + } + + return { descriptorPath, operands }; +} + +async function selectConnection(requestedPath) { + if (requestedPath) return readConnection(path.resolve(requestedPath)); + + const connections = await discoverConnections(); + if (connections.length === 0) throw new Error("no running Shift MCP connection found"); + if (connections.length > 1) { + const paths = connections.map(({ descriptorPath }) => descriptorPath).join("\n"); + throw new Error(`multiple Shift MCP connections found; pass --descriptor:\n${paths}`); + } + + return connections[0].connection; +} + +async function discoverConnections() { + const root = applicationDataRoot(); + const configuredPath = process.env.SHIFT_MCP_DESCRIPTOR; + const discoveredPaths = APP_NAMES.map((name) => path.join(root, name, "mcp.json")); + const candidates = [ + ...(configuredPath ? [path.resolve(configuredPath)] : []), + ...discoveredPaths, + ].filter((candidate, index, paths) => paths.indexOf(candidate) === index); + const connections = []; + + for (const descriptorPath of candidates) { + try { + const [connection, descriptorStat] = await Promise.all([ + readConnection(descriptorPath), + stat(descriptorPath), + ]); + connections.push({ descriptorPath, connection, modified: descriptorStat.mtimeMs }); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + } + + return connections.sort((left, right) => right.modified - left.modified); +} + +function applicationDataRoot() { + switch (process.platform) { + case "darwin": + return path.join(os.homedir(), "Library", "Application Support"); + case "win32": { + const appData = process.env.APPDATA; + if (!appData) throw new Error("APPDATA is not set"); + return appData; + } + default: + return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"); + } +} + +async function readConnection(descriptorPath) { + const connection = JSON.parse(await readFile(descriptorPath, "utf8")); + if (typeof connection?.url !== "string" || typeof connection?.token !== "string") { + throw new Error(`invalid Shift MCP descriptor: ${descriptorPath}`); + } + + return connection; +} + +async function initialize(connection) { + await request(connection, "initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "shift-agent-skill", version: "0.1.0" }, + }); +} + +async function callTool(connection, name, args) { + const response = await request(connection, "tools/call", { name, arguments: args }); + if (response.isError) throw new Error(toolText(response)); + return response; +} + +async function request(connection, method, params) { + requestId += 1; + const response = await fetch(connection.url, { + method: "POST", + headers: { + authorization: `Bearer ${connection.token}`, + accept: "application/json, text/event-stream", + "content-type": "application/json", + "mcp-protocol-version": "2025-06-18", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: requestId, method, params }), + }); + + const text = await response.text(); + if (!response.ok) throw new Error(`Shift MCP request failed (${response.status}): ${text}`); + + const message = parseMessage(text); + if (message.error) throw new Error(message.error.message ?? JSON.stringify(message.error)); + return message.result; +} + +function parseMessage(body) { + if (!body.startsWith("event:")) return JSON.parse(body); + + const dataLines = body + .split("\n") + .filter((line) => line.startsWith("data: ")) + .map((line) => line.slice("data: ".length)); + if (dataLines.length === 0) throw new Error("Shift MCP returned an empty event stream"); + return JSON.parse(dataLines[dataLines.length - 1]); +} + +function toolText(result) { + return (result.content ?? []) + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n"); +} + +function printToolText(result) { + console.log(toolText(result)); +} + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/ROADMAP.md b/ROADMAP.md index eba6a1f63..4bc8fc40b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -919,7 +919,7 @@ interface ShiftScriptContext { **MCP Server** -- [ ] Expose Shift as MCP server +- [x] Expose Shift as MCP server - [ ] Tools: getGlyph, movePoint, addContour, exportPreview - [ ] Claude can read/write font data diff --git a/apps/desktop/build.ts b/apps/desktop/build.ts index 071dd7838..1d48ccd4a 100644 --- a/apps/desktop/build.ts +++ b/apps/desktop/build.ts @@ -32,6 +32,23 @@ async function buildMain(): Promise { }); } +async function buildSandbox(): Promise { + await build({ + configFile: path.join(appRoot, "vite.main.config.ts"), + build: { + lib: { + entry: path.join(appRoot, "src/utility/sandbox.ts"), + formats: ["cjs"], + fileName: () => "sandbox.js", + }, + outDir: path.join(appRoot, ".vite/build"), + emptyOutDir: false, + minify: !isE2E, + rollupOptions: { external: nodeExternals }, + }, + }); +} + async function buildWorkspace(): Promise { await build({ configFile: path.join(appRoot, "vite.main.config.ts"), @@ -82,12 +99,13 @@ async function buildRenderer(): Promise { } async function main(): Promise { - console.log(`Building Electron app${isE2E ? " for E2E tests" : ""}...`); + process.stdout.write(`Building Electron app${isE2E ? " for E2E tests" : ""}...\n`); await buildMain(); + await buildSandbox(); await buildWorkspace(); await buildPreload(); await buildRenderer(); - console.log("Electron build complete."); + process.stdout.write("Electron build complete.\n"); } main().catch((error) => { diff --git a/apps/desktop/e2e/live-agent.spec.ts b/apps/desktop/e2e/live-agent.spec.ts new file mode 100644 index 000000000..28e4504fc --- /dev/null +++ b/apps/desktop/e2e/live-agent.spec.ts @@ -0,0 +1,57 @@ +import { execFile } from "node:child_process"; +import path from "node:path"; +import { promisify } from "node:util"; +import { workspaceTest as test, expect } from "./fixtures/electronApp"; + +const execFileAsync = promisify(execFile); +const MCP_CLIENT = path.resolve(__dirname, "../../../.agents/skills/shift/scripts/client.mjs"); + +async function runShiftCode(testRoot: string, code: string): Promise { + const descriptor = path.join(testRoot, "user-data", "mcp.json"); + const { stdout } = await execFileAsync(process.execPath, [ + MCP_CLIENT, + "execute", + "--descriptor", + descriptor, + code, + ]); + return JSON.parse(stdout); +} + +test("inspects the explicitly targeted live editor", async ({ editor, testRoot }) => { + await editor.openGlyphByName("A"); + const point = await editor.selectVisiblePoint(); + + const observation = await runShiftCode( + testRoot, + `async () => { + const sessions = await shift.sessions.list(); + const target = sessions.find((session) => session.editorConnected); + if (!target) throw new Error("Expected connected editor"); + + let missingWindowError = null; + try { + await shift.editor.inspect({ windowId: 2147483647 }); + } catch (error) { + missingWindowError = error.message; + } + + return { + sessions, + editor: await shift.editor.inspect({ windowId: target.windowId }), + missingWindowError, + }; + }`, + ); + + expect(observation).toMatchObject({ + sessions: [{ mode: "workspace", editorConnected: true }], + editor: { + glyph: { name: "A" }, + selectionIds: [point.id], + tool: { id: "select" }, + applyStatus: "idle", + }, + missingWindowError: "Shift window 2147483647 is not open", + }); +}); diff --git a/apps/desktop/forge.config.ts b/apps/desktop/forge.config.ts index 94b88d342..a4b465368 100644 --- a/apps/desktop/forge.config.ts +++ b/apps/desktop/forge.config.ts @@ -15,6 +15,11 @@ const config: ForgeConfig = { config: "vite.preload.config.ts", target: "preload", }, + { + entry: "src/utility/sandbox.ts", + config: "vite.main.config.ts", + target: "main", + }, { entry: "src/utility/workspace.ts", config: "vite.main.config.ts", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5ccc3f367..1c167b81b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -62,6 +62,8 @@ "@shift/geo": "workspace:*", "@shift/glyph-info": "workspace:*", "@shift/glyph-state": "workspace:*", + "@shift/mcp": "workspace:*", + "@shift/runtime": "workspace:*", "@shift/types": "workspace:*", "@shift/ui": "workspace:*", "@shift/validation": "workspace:*", diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts index 590e2a8a6..f4823d3b5 100644 --- a/apps/desktop/playwright.config.ts +++ b/apps/desktop/playwright.config.ts @@ -17,6 +17,7 @@ export const PLATFORM_SPECS = [ "document-crash.spec.ts", "document-lifecycle.spec.ts", "document-recovery.spec.ts", + "live-agent.spec.ts", "platform-integration.spec.ts", "recent-files.spec.ts", "variable-font-recovery.spec.ts", diff --git a/apps/desktop/src/main/agent/AgentClient.ts b/apps/desktop/src/main/agent/AgentClient.ts new file mode 100644 index 000000000..17e30b49e --- /dev/null +++ b/apps/desktop/src/main/agent/AgentClient.ts @@ -0,0 +1,38 @@ +import type { EditorView } from "@shift/runtime"; +import type { MessagePortMain } from "electron"; +import type { AgentCallMap, AgentEventMap } from "../../shared/agent/protocol"; +import { Channel, electronPortTransport } from "../../shared/workspace/channel"; +import { createShiftLogger, type ShiftLogger } from "../logging"; + +/** Calls the agent inspection lane served by one renderer window. */ +export class AgentClient { + readonly #log: ShiftLogger; + #channel: Channel | null = null; + + constructor(log: ShiftLogger = createShiftLogger("agent.client")) { + this.#log = log; + } + + get connected(): boolean { + return this.#channel !== null && !this.#channel.closed; + } + + /** Replaces the inspection lane after a renderer connects or reloads. */ + connect(port: MessagePortMain): void { + this.#channel?.dispose(); + this.#channel = new Channel(electronPortTransport(port)); + this.#log.info("agent renderer connected"); + } + + /** Returns a point-in-time view of the editor owned by this renderer. */ + inspectEditor(): Promise { + if (!this.#channel) return Promise.reject(new Error("agent renderer is not connected")); + return this.#channel.call("editor.inspect", undefined); + } + + /** Disconnects the renderer and rejects pending inspection calls. */ + dispose(): void { + this.#channel?.dispose(); + this.#channel = null; + } +} diff --git a/apps/desktop/src/main/app/App.ts b/apps/desktop/src/main/app/App.ts index f717e49b3..da59adc2a 100644 --- a/apps/desktop/src/main/app/App.ts +++ b/apps/desktop/src/main/app/App.ts @@ -30,8 +30,11 @@ import { shiftProductName } from "../release"; import { AppUpdater } from "../update/AppUpdater"; import { isConvertiblePreviewPath } from "../../shared/workspace/previewConversion"; import { OPEN_FONT_EXTENSIONS } from "../../shared/openFontExtensions"; -import { RecentDocuments } from "../recents/RecentDocuments"; +import { ShiftMcpServer } from "@shift/mcp"; +import type { EditorInspection, ShiftSession } from "@shift/runtime"; import type { RecentDocumentVisit } from "../../shared/recents"; +import { RecentDocuments } from "../recents/RecentDocuments"; +import { SandboxRuntimeProcess } from "../sandbox/SandboxRuntimeProcess"; const SLUG_ATLAS_PROFILING_ENABLED = process.env.SHIFT_PROFILE_SLUG_ATLAS !== undefined && @@ -60,6 +63,8 @@ export class App { readonly #updater: AppUpdater; #commands = new CommandRegistry(); + #mcp: ShiftMcpServer | null = null; + #sandbox: SandboxRuntimeProcess | null = null; #windows = new WindowManager(); #workspaces: WorkspaceManager; #documentsRoot: string | null = null; @@ -213,6 +218,7 @@ export class App { path.join(app.getPath("userData"), "recent-documents.json"), ); this.#recents.onChanged(() => this.#publishRecents()); + await this.#startMcp(); const restoredSessions = await this.#workspaces.restoreRecoveries(); for (const session of restoredSessions) { @@ -255,6 +261,7 @@ export class App { }); app.on("will-quit", () => { this.#log.info("will quit: disposing app services"); + void this.#stopMcp(); for (const session of this.#workspaces.list()) { this.#workspaces.unregister(session.workspaceId); } @@ -290,6 +297,7 @@ export class App { this.#log.info("working window closed"); const session = this.#workspaces.getForBrowserWindow(window.window); this.#workspaces.detachWindow(window); + window.agent.dispose(); if (session?.windows.size === 0) { this.#workspaces.unregister(session.workspaceId); } @@ -430,6 +438,12 @@ export class App { } #registerIpcHandlers(): void { + ipc.handle(ipcMain, "agent.connect", (event) => { + const window = this.#requireWindowForWebContents(event.sender); + const { port1, port2 } = new MessageChannelMain(); + window.agent.connect(port1); + event.sender.postMessage("agent.port", null, [port2]); + }); ipc.handle(ipcMain, "commands.run", async (event, id) => { const window = this.#requireWindowForWebContents(event.sender); try { @@ -831,6 +845,90 @@ export class App { launcher.close(); } + async #startMcp(): Promise { + const sandbox = new SandboxRuntimeProcess({ + sessions: { + list: () => Promise.resolve(this.#agentSessions()), + }, + editor: { + inspect: ({ windowId }) => this.#inspectEditor(windowId), + }, + }); + const mcp = new ShiftMcpServer({ + execute: (code) => sandbox.execute(code), + descriptorPath: path.join(app.getPath("userData"), "mcp.json"), + logger: createShiftLogger("app.mcp"), + }); + + try { + await sandbox.start(); + await mcp.start(); + this.#sandbox = sandbox; + this.#mcp = mcp; + } catch (error) { + sandbox.stop(); + try { + await mcp.stop(); + } catch (stopError) { + this.#log.error("failed to clean up MCP server startup", stopError); + } + this.#log.error("failed to start MCP server", error); + } + } + + async #stopMcp(): Promise { + const mcp = this.#mcp; + const sandbox = this.#sandbox; + this.#mcp = null; + this.#sandbox = null; + + const stoppingMcp = mcp?.stop(); + sandbox?.stop(); + if (!stoppingMcp) return; + + try { + await stoppingMcp; + } catch (error) { + this.#log.error("failed to stop MCP server", error); + } + } + + #agentSessions(): ShiftSession[] { + const focusedWindowId = BrowserWindow.getFocusedWindow()?.id ?? null; + const sessions: ShiftSession[] = []; + + for (const window of this.#windows.allWindows()) { + const session = this.#workspaces.getForBrowserWindow(window.window); + if (!session) continue; + + sessions.push({ + windowId: window.window.id, + sessionId: session.sessionId, + mode: session.mode, + focused: window.window.id === focusedWindowId, + editorConnected: window.agent.connected, + }); + } + + return sessions; + } + + async #inspectEditor(windowId: number): Promise { + const window = this.#windows.windowForId(windowId); + if (!window) throw new Error(`Shift window ${windowId} is not open`); + + const session = this.#workspaces.getForBrowserWindow(window.window); + if (!session) throw new Error(`Shift window ${windowId} has no font session`); + + const view = await window.agent.inspectEditor(); + return { + ...view, + windowId, + sessionId: session.sessionId, + mode: session.mode, + }; + } + #fontSessionForSender(sender: WebContents, operation: string): FontSessionHost { const window = this.#requireWindowForWebContents(sender); const session = this.#workspaces.getForBrowserWindow(window.window); diff --git a/apps/desktop/src/main/docs/DOCS.md b/apps/desktop/src/main/docs/DOCS.md index d364d6ad7..ecab74b62 100644 --- a/apps/desktop/src/main/docs/DOCS.md +++ b/apps/desktop/src/main/docs/DOCS.md @@ -1,6 +1,6 @@ # Main - + Electron main process: app startup, windows, menus, document dialogs, and workspace session ownership. @@ -23,6 +23,8 @@ Electron main process: app startup, windows, menus, document dialogs, and worksp - **Architecture Invariant:** Disposable Slug pages live under the app-wide `derived-cache/slug-atlases` root beside `working-documents`, never inside authored `.shift` content. Utility processes share the one-GiB byte-budgeted LRU; each process validates an artifact index once and then verifies and decompresses its fixed pages independently. Staging paths use readable `run-{pid}-{id}/page-{index}-{id}.zst` names, and every retry owns a distinct file until publication. The LRU scans after an artifact is opened or published, never after every page stream. Stale, corrupt, and evicted entries rebuild. - **Architecture Invariant:** Recovery discovery prunes only storage that cannot contain authored work: empty workspace directories, document bindings with no working store or recovery overlay, and SQLite sidecars whose primary file is absent. Working stores and recovery overlays are recoverable and never expire by age. A stale binding whose exact recovery overlay is absent is detached from a surviving working store so unsaved-workspace discovery can recover that store. Malformed or unknown artifacts are retained and reported rather than deleted. - **Architecture Invariant:** IPC channels are type-safe. `ipcMain.handle` calls use the typed wrapper from `shared/ipc/main`, and channel names and payload types live in `shared/ipc/contract.ts` and `shared/workspace/protocol.ts`. +- **Architecture Invariant:** Main owns one run-scoped local MCP server. It binds only to loopback, publishes a random connection secret under the distribution-specific user-data root, resolves every request through an explicit window/session identity, and routes renderer observations over a per-window typed request lane. +- **Architecture Invariant:** Generated agent and plugin code never executes in Electron main or a renderer. `SandboxRuntimeProcess` supervises a dedicated utility process, serves only typed capabilities back into main, and terminates the process when a hard execution deadline expires. ## Codemap @@ -35,6 +37,8 @@ src/main/ AboutWindow.ts -- singleton product information window feedback/ FeedbackWindow.ts -- singleton modeless feedback composer window + agent/ + AgentClient.ts -- main client for one renderer's live inspection lane app/ App.ts -- app service graph, IPC handlers, command context AppLifecycle.ts -- close/quit confirmation flow @@ -52,6 +56,8 @@ src/main/ types.ts -- close reasons and dirty-document choices menu/ ApplicationMenu.ts -- Electron application menu + sandbox/ + SandboxRuntimeProcess.ts -- isolated code runtime lifecycle and capability lane update/ AppUpdater.ts -- update orchestration, scheduling, consent, and restart safety UpdateWindow.ts -- download progress and install prompt window @@ -82,13 +88,16 @@ src/main/ - `UpdateWindow` -- renderer of download progress and ready-to-install choices. - `FeedbackWindow` -- singleton modeless composer that routes user-authored feedback to email, GitHub, or Discord without collecting files or diagnostics. - `UpdateStatus` -- updater lifecycle: idle, checking, available, downloading, ready, or restarting. +- `ShiftMcpServer` -- local code-mode MCP adapter over explicitly targeted live app capabilities. +- `SandboxRuntimeProcess` -- utility-process supervisor for bounded generated-code execution and typed capability callbacks. +- `AgentClient` -- per-window main-process client for renderer-owned editor observations. - `WorkspaceDocumentState` -- utility-owned lifecycle state mirrored into main and renderer. ## How it works ### Startup -`main.ts` constructs `App` and calls `start()`. `App.start()` applies the compiled `SHIFT_DISTRIBUTION` identity before its first log entry or path-dependent service action, so logging, settings, caches, and recovery all resolve beneath the correct app-data root. Startup recovery discovery calls `DocumentStorage.pruneOrphanedStorage()` before enumerating recoverable work; this cleanup is structural rather than age-based and never removes a recognized working store or recovery overlay. The production/E2E build and development-only Forge runner write the `main_window` renderer to `.vite/renderer/main_window`; production resolves that same directory through `MAIN_WINDOW_VITE_NAME`. `App` registers commands and IPC handlers, starts `AppLifecycle`, sets the user-data-backed `working-documents` root, creates the launcher window, and installs the application menu. Development uses `Shift Dev` or `Shift Nightly Dev`; an explicit standard `--user-data-dir` switch takes precedence so E2E runs can own isolated browser and working-document state. +`main.ts` constructs `App` and calls `start()`. `App.start()` applies the compiled `SHIFT_DISTRIBUTION` identity before its first log entry or path-dependent service action, so logging, settings, caches, and recovery all resolve beneath the correct app-data root. Startup recovery discovery calls `DocumentStorage.pruneOrphanedStorage()` before enumerating recoverable work; this cleanup is structural rather than age-based and never removes a recognized working store or recovery overlay. The production/E2E build and development-only Forge runner write the `main_window` renderer to `.vite/renderer/main_window`; production resolves that same directory through `MAIN_WINDOW_VITE_NAME`. `App` registers commands and IPC handlers, starts `AppLifecycle`, starts the sandbox utility process before publishing the MCP descriptor, sets the user-data-backed `working-documents` root, creates the launcher window, and installs the application menu. Development uses `Shift Dev` or `Shift Nightly Dev`; an explicit standard `--user-data-dir` switch takes precedence so E2E runs can own isolated browser and working-document state. The runtime icon follows the same compiled identity: `AppIcon` selects `nightly-macos.png` when `shiftDistribution` is `"nightly"` and `icon-macos.png` otherwise, so Release and Nightly are visually distinct in the development macOS Dock. Packaged installer icons remain owned by electron-builder configuration. The renderer's shared `app-icon.png` supplies the custom About and Update screens. Both distributions use the shared `shift-document` artwork for `.shift` files; association priority, not document appearance, distinguishes their ownership. Packaged macOS builds also declare TTF, OTF, Glyphs, Glyphspackage, UFO, and Designspace sources as alternate viewer types so Finder recommends Shift under **Open With** without making it the default source-font application. @@ -144,7 +153,7 @@ Message lanes reject in-flight calls when their remote port closes. An unexpecte ### IPC -Renderer IPC in `App` is limited to shell capabilities: command execution, clipboard, update-window progress/actions, optional document-lane port transfer, immutable session mode, readiness, and shared session sync-lane port transfer. Font data stays on that sync lane between renderer and utility. +Renderer IPC in `App` is limited to shell capabilities: command execution, clipboard, update-window progress/actions, optional document-lane and agent-lane port transfer, immutable session mode, readiness, and shared session sync-lane port transfer. Font data stays on the sync lane between renderer and utility. The agent lane returns renderer-owned view facts only; main adds the explicit window and font-session identities before returning an MCP result. ## Workflow recipes diff --git a/apps/desktop/src/main/sandbox/SandboxRuntimeProcess.ts b/apps/desktop/src/main/sandbox/SandboxRuntimeProcess.ts new file mode 100644 index 000000000..a57bcaaa4 --- /dev/null +++ b/apps/desktop/src/main/sandbox/SandboxRuntimeProcess.ts @@ -0,0 +1,160 @@ +import { utilityProcess, type UtilityProcess } from "electron"; +import path from "node:path"; +import type { ShiftCapabilities } from "@shift/runtime"; +import type { + SandboxCallMap, + SandboxEventMap, + SandboxHostCallMap, + SandboxHostEventMap, +} from "../../shared/sandbox/protocol"; +import { Channel, serveChannel, utilityProcessTransport } from "../../shared/workspace/channel"; +import { createShiftLogger, type ShiftLogger } from "../logging"; + +const HARD_EXECUTION_TIMEOUT_MS = 5_000; + +/** Owns the isolated process used for agent and future plugin code execution. */ +export class SandboxRuntimeProcess { + readonly #capabilities: ShiftCapabilities; + readonly #log: ShiftLogger; + #process: UtilityProcess | null = null; + #channel: Channel | null = null; + #ready: Promise | null = null; + + /** + * Creates an unstarted sandbox process controller. + * + * @param capabilities - typed operations the isolated runtime may call back into. + * @param log - destination for process lifecycle and failure diagnostics. + */ + constructor( + capabilities: ShiftCapabilities, + log: ShiftLogger = createShiftLogger("sandbox.process"), + ) { + this.#capabilities = capabilities; + this.#log = log; + } + + /** + * Starts the isolated runtime and resolves after its request lane is ready. + * + * @throws {Error} when the utility process exits before announcing readiness. + */ + async start(): Promise { + if (this.#ready) return this.#ready; + + const entryPoint = path.join(__dirname, "sandbox.js"); + const proc = utilityProcess.fork(entryPoint, [], { + serviceName: "Shift Sandbox", + stdio: "pipe", + }); + const transport = utilityProcessTransport(proc); + const channel = new Channel(transport); + serveChannel(transport, { + "shift.sessions.list": () => this.#capabilities.sessions.list(), + "shift.editor.inspect": (input) => this.#capabilities.editor.inspect(input), + }); + + this.#process = proc; + this.#channel = channel; + this.#ready = this.#trackReady(proc, channel); + this.#wire(proc, channel); + await this.#ready; + } + + /** Stops the isolated runtime and rejects its in-flight execution requests. */ + stop(): void { + const proc = this.#process; + this.#process = null; + this.#ready = null; + + this.#channel?.dispose(); + this.#channel = null; + proc?.kill(); + } + + /** + * Executes code in a fresh bounded QuickJS realm inside the isolated process. + * + * @param code - async zero-argument function source accepted by Shift code mode. + * @remarks + * Each call receives a fresh QuickJS realm. A hard deadline terminates the utility process; + * the next call starts a replacement process before executing. + * + * @returns the JSON-compatible value returned by the function. + * @throws {Error} when the process exits, execution fails, or the hard process deadline expires. + */ + async execute(code: string): Promise { + await this.start(); + const channel = this.#channel; + if (!channel) throw new Error("sandbox runtime is not running"); + + let didTimeout = false; + let timeout: NodeJS.Timeout | undefined; + const expired = new Promise((_, reject) => { + timeout = setTimeout(() => { + didTimeout = true; + reject(new Error("sandbox execution exceeded its hard deadline")); + }, HARD_EXECUTION_TIMEOUT_MS); + }); + + try { + return await Promise.race([channel.call("sandbox.execute", { code }), expired]); + } catch (error) { + if (didTimeout) { + this.#log.warn("execution timed out; restarting sandbox process"); + this.stop(); + } + + throw error; + } finally { + clearTimeout(timeout); + } + } + + #trackReady( + proc: UtilityProcess, + channel: Channel, + ): Promise { + const ready = new Promise((resolve, reject) => { + const onExit = (code: number) => { + unlisten(); + reject(new Error(`sandbox process exited with code ${code} before ready`)); + }; + const unlisten = channel.listen("sandbox.ready", () => { + proc.off("exit", onExit); + unlisten(); + this.#log.info("ready"); + resolve(); + }); + proc.once("exit", onExit); + }); + + ready.catch(() => {}); + return ready; + } + + #wire(proc: UtilityProcess, channel: Channel): void { + proc.on("spawn", () => this.#log.info("spawned", proc.pid)); + proc.on("exit", (code) => { + this.#log.info("exited", code); + channel.dispose(); + + if (this.#process !== proc) return; + + this.#process = null; + this.#channel = null; + this.#ready = null; + }); + proc.on("error", (type, location, report) => { + this.#log.error("error", type, location, report); + }); + proc.stdout?.on("data", (chunk) => { + const text = String(chunk).trimEnd(); + if (text) this.#log.info(text); + }); + proc.stderr?.on("data", (chunk) => { + const text = String(chunk).trimEnd(); + if (text) this.#log.error(text); + }); + } +} diff --git a/apps/desktop/src/main/windows/Window.ts b/apps/desktop/src/main/windows/Window.ts index 34701cd88..60a25f4dd 100644 --- a/apps/desktop/src/main/windows/Window.ts +++ b/apps/desktop/src/main/windows/Window.ts @@ -1,6 +1,7 @@ import { BrowserWindow, type BrowserWindowConstructorOptions } from "electron"; import * as ipc from "../../shared/ipc/main"; import type { RendererCommandId } from "../../shared/commands"; +import { AgentClient } from "../agent/AgentClient"; export interface WindowOptions { title?: string; @@ -50,6 +51,8 @@ const BROWSER_WINDOW_DEFAULT_OPTIONS: BrowserWindowConstructorOptions = { }; export class Window { + readonly agent = new AgentClient(); + #window: BrowserWindow; #maximiseOnPresent: boolean; diff --git a/apps/desktop/src/main/windows/WindowManager.ts b/apps/desktop/src/main/windows/WindowManager.ts index 4345732f4..80e786a93 100644 --- a/apps/desktop/src/main/windows/WindowManager.ts +++ b/apps/desktop/src/main/windows/WindowManager.ts @@ -29,6 +29,10 @@ export class WindowManager { return this.#windows.get(window.id) ?? null; } + windowForId(windowId: number): Window | null { + return this.#windows.get(windowId) ?? null; + } + allWindows(): readonly Window[] { return [...this.#windows.values()]; } diff --git a/apps/desktop/src/preload/docs/DOCS.md b/apps/desktop/src/preload/docs/DOCS.md index 8b9864bd7..2b488885d 100644 --- a/apps/desktop/src/preload/docs/DOCS.md +++ b/apps/desktop/src/preload/docs/DOCS.md @@ -1,14 +1,14 @@ # Preload - + -Electron preload script that exposes the typed Shift host API and relays session ports to the renderer. +Electron preload script that exposes the typed Shift host API and relays session, document, and agent ports to the renderer. ## Architecture Invariants - **Architecture Invariant:** Preload exposes only `window.shiftHost`; the native bridge lives in the utility process behind the typed font-session lane. **WHY:** renderer isolation must not expose native font methods directly. - **Architecture Invariant:** Every `shiftHost` method delegates through typed IPC helpers and contains only context-bridge-compatible functions and values. -- **Architecture Invariant:** Session and document `MessagePort`s are relayed with `window.postMessage` because ports cannot cross Electron's context bridge. Renderer listeners authenticate the same window and expected message type before accepting a port. +- **Architecture Invariant:** Session, document, and agent `MessagePort`s are relayed with `window.postMessage` because ports cannot cross Electron's context bridge. Renderer listeners authenticate the same window and expected message type before accepting a port. ## Codemap @@ -45,7 +45,7 @@ The preload runs once before the renderer loads: 1. In the main-side handler, create a `MessageChannelMain` and transfer one half with `event.sender.postMessage(".port", null, [port])` — ports cannot travel through `invoke` responses. 2. In `preload.ts`, relay it into the page: `ipcRenderer.on(".port", ...)` forwarding via `window.postMessage({ type: ".port" }, "*", event.ports)`, mirroring the existing `session.port` and `document.port` relays. -3. In the renderer listener, accept the port only after checking `event.source === window` and the expected message type. +3. In the renderer listener, accept the port only after checking `event.source === window` and the expected message type. The live agent lane follows the same transfer contract as the session and document lanes. 4. Verify: `pnpm --filter @shift/desktop typecheck`, then run the app and confirm the lane connects. ## Gotchas diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 2fb111f27..ebc2e038e 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -8,6 +8,9 @@ import { invoke, listen } from "../shared/ipc/renderer"; const shiftHost: ShiftHost = { platform: process.platform, + agent: { + connect: invoke(ipcRenderer, "agent.connect"), + }, commands: { run: invoke(ipcRenderer, "commands.run"), onRunRendererCommand: listen(ipcRenderer, "commands.runRenderer"), @@ -57,6 +60,10 @@ const shiftHost: ShiftHost = { contextBridge.exposeInMainWorld("shiftHost", shiftHost); // MessagePorts cannot cross the context bridge; relay them into the page. +ipcRenderer.on("agent.port", (event: IpcRendererEvent) => { + window.postMessage({ type: "agent.port" }, "*", event.ports); +}); + ipcRenderer.on("session.port", (event: IpcRendererEvent) => { window.postMessage({ type: "session.port" }, "*", event.ports); }); diff --git a/apps/desktop/src/renderer/src/agent/AgentBridge.ts b/apps/desktop/src/renderer/src/agent/AgentBridge.ts new file mode 100644 index 000000000..010d38003 --- /dev/null +++ b/apps/desktop/src/renderer/src/agent/AgentBridge.ts @@ -0,0 +1,97 @@ +import type { EditorView } from "@shift/runtime"; +import type { ShiftHost } from "@shared/host/ShiftHost"; +import type { AgentCallMap, AgentEventMap } from "@shared/agent/protocol"; +import { domPortTransport, serveChannel, type ChannelServer } from "@shared/workspace/channel"; +import type { FontSession } from "@/types/fontSession"; + +/** Serves bounded editor observations for one live renderer window. */ +export class AgentBridge { + readonly #host: ShiftHost; + readonly #session: FontSession; + #requests: ChannelServer | null = null; + #disposed = false; + + constructor(host: ShiftHost, session: FontSession) { + this.#host = host; + this.#session = session; + } + + /** Connects the renderer lane requested by Electron main. */ + async connect(): Promise { + const port = nextAgentPort(); + + try { + await this.#host.agent.connect(); + const received = await port.received; + if (this.#disposed) { + received.close(); + return; + } + + this.#requests?.dispose(); + this.#requests = serveChannel(domPortTransport(received), { + "editor.inspect": () => this.#inspectEditor(), + }); + } catch (error) { + port.cancel(); + throw error; + } + } + + /** Disconnects the request lane and prevents a pending connection from publishing. */ + dispose(): void { + this.#disposed = true; + this.#requests?.dispose(); + this.#requests = null; + } + + #inspectEditor(): EditorView { + const editor = this.#session.editor; + const node = editor.scene.nodesOfKind("glyph")[0] ?? null; + const record = node ? this.#session.font.recordForId(node.glyphId) : null; + const glyph = + node && record + ? { + glyphId: node.glyphId, + name: record.name, + nodeId: node.id, + sourceId: node.sourceId, + } + : null; + const tool = editor.tool; + + return { + route: window.location.hash.slice(1), + glyph, + activeSourceId: editor.activeSourceId, + editingSourceIds: [...editor.editingSourceIds], + externalLocation: [...editor.externalLocation].map(([axisId, value]) => ({ axisId, value })), + selectionIds: [...editor.selection.ids], + tool: tool ? { id: tool.id, state: tool.state.type } : null, + dragging: editor.isDragging, + editing: editor.isEditing, + applyStatus: this.#session.workspace?.applyStatusCell.peek() ?? null, + }; + } +} + +function nextAgentPort(): { received: Promise; cancel: () => void } { + let cancel = () => {}; + const received = new Promise((resolve) => { + const listener = (event: MessageEvent) => { + if (event.source !== window) return; + if ((event.data as { type?: string } | null)?.type !== "agent.port") return; + + const port = event.ports[0]; + if (!port) return; + + window.removeEventListener("message", listener); + resolve(port); + }; + + cancel = () => window.removeEventListener("message", listener); + window.addEventListener("message", listener); + }); + + return { received, cancel }; +} diff --git a/apps/desktop/src/renderer/src/components/editor/ObjectsPanel.tsx b/apps/desktop/src/renderer/src/components/editor/ObjectsPanel.tsx index 1f7b6fdb4..a20bd0d45 100644 --- a/apps/desktop/src/renderer/src/components/editor/ObjectsPanel.tsx +++ b/apps/desktop/src/renderer/src/components/editor/ObjectsPanel.tsx @@ -1,6 +1,6 @@ import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { computed, track, useSignalState } from "@shift/editor/signals"; -import type { SelectableId } from "@shift/editor/types"; +import type { SelectableId } from "@shift/types"; import { useEditor } from "@/workspace/WorkspaceContext"; import type { ObjectTreeSectionId } from "@/types/objectTree"; import { useListSelection } from "@/hooks/useListSelection"; diff --git a/apps/desktop/src/renderer/src/components/editor/object-tree/flattenVisibleObjectRows.ts b/apps/desktop/src/renderer/src/components/editor/object-tree/flattenVisibleObjectRows.ts index 79c16b411..3d934752d 100644 --- a/apps/desktop/src/renderer/src/components/editor/object-tree/flattenVisibleObjectRows.ts +++ b/apps/desktop/src/renderer/src/components/editor/object-tree/flattenVisibleObjectRows.ts @@ -1,4 +1,4 @@ -import type { SelectableId } from "@shift/editor/types"; +import type { SelectableId } from "@shift/types"; import type { ObjectTreeItem, VisibleObjectRow } from "@/types/objectTree"; export function flattenVisibleObjectRows( diff --git a/apps/desktop/src/renderer/src/lib/model/positions/MultiSourcePositionEdits.test.ts b/apps/desktop/src/renderer/src/lib/model/positions/MultiSourcePositionEdits.test.ts index fabff90c2..b5930ef43 100644 --- a/apps/desktop/src/renderer/src/lib/model/positions/MultiSourcePositionEdits.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/positions/MultiSourcePositionEdits.test.ts @@ -4,7 +4,7 @@ import type { AnchorId, AxisId, PointId, SourceId } from "@shift/types"; import { externalAxisLocationFromRecord } from "@shift/editor/variation"; import type { GlyphLayer } from "@shift/editor/model"; import { TestEditor } from "@/testing/TestEditor"; -import type { SelectableId } from "@shift/editor/types"; +import type { SelectableId } from "@shift/types"; import type { PositionSelection } from "@shift/editor/types"; import { PositionEdits } from "@shift/editor/model"; diff --git a/apps/desktop/src/renderer/src/types/objectTree.ts b/apps/desktop/src/renderer/src/types/objectTree.ts index 911348034..a6a945ac8 100644 --- a/apps/desktop/src/renderer/src/types/objectTree.ts +++ b/apps/desktop/src/renderer/src/types/objectTree.ts @@ -1,4 +1,5 @@ -import type { ListSelectionMode, SelectableId } from "@shift/editor/types"; +import type { ListSelectionMode } from "@shift/editor/types"; +import type { SelectableId } from "@shift/types"; export type ObjectTreeIcon = | "anchor" diff --git a/apps/desktop/src/renderer/src/workspace/FontSessionProvider.tsx b/apps/desktop/src/renderer/src/workspace/FontSessionProvider.tsx index 05f5aea4e..344b61997 100644 --- a/apps/desktop/src/renderer/src/workspace/FontSessionProvider.tsx +++ b/apps/desktop/src/renderer/src/workspace/FontSessionProvider.tsx @@ -5,6 +5,8 @@ import { DocumentErrorScreen } from "@/app/DocumentErrorScreen"; import { reportRendererError } from "@/app/errorReporting"; import { FontSessionContext, WorkspaceContext } from "./WorkspaceContext"; import { getFontSession } from "./runtime"; +import { getShiftHost } from "@/host/shiftHost"; +import { AgentBridge } from "@/agent/AgentBridge"; export function FontSessionProvider({ children }: { children: ReactNode }) { const [session, setSession] = useState(null); @@ -38,7 +40,19 @@ export function FontSessionProvider({ children }: { children: ReactNode }) { const workspace = session.workspace; if (workspace) window.shift = workspace; + const agent = new AgentBridge(getShiftHost(), session); + async function connectAgent(): Promise { + try { + await agent.connect(); + } catch (error) { + console.error("agent bridge failed to connect", error); + reportRendererError("AgentBridge", error); + } + } + void connectAgent(); + return () => { + agent.dispose(); delete window.shift; delete window.shiftSession; }; diff --git a/apps/desktop/src/shared/agent/protocol.ts b/apps/desktop/src/shared/agent/protocol.ts new file mode 100644 index 000000000..9aa2ac12d --- /dev/null +++ b/apps/desktop/src/shared/agent/protocol.ts @@ -0,0 +1,8 @@ +import type { EditorView } from "@shift/runtime"; + +/** Main-to-renderer calls for live agent inspection of one explicit window. */ +export type AgentCallMap = { + "editor.inspect": { request: void; response: EditorView }; +}; + +export type AgentEventMap = Record; diff --git a/apps/desktop/src/shared/host/ShiftHost.ts b/apps/desktop/src/shared/host/ShiftHost.ts index 57001bbe9..f3d888ef4 100644 --- a/apps/desktop/src/shared/host/ShiftHost.ts +++ b/apps/desktop/src/shared/host/ShiftHost.ts @@ -15,6 +15,10 @@ import type { RecentDocument } from "../recents"; export interface ShiftHost { /** Operating system that owns the current application window. */ platform: NodeJS.Platform; + /** Connects this renderer to main-owned live agent requests. */ + agent: { + connect: () => Promise; + }; /** Runs app commands owned by the main process. */ commands: { /** diff --git a/apps/desktop/src/shared/ipc/contract.ts b/apps/desktop/src/shared/ipc/contract.ts index ca1c233c1..7adbe8490 100644 --- a/apps/desktop/src/shared/ipc/contract.ts +++ b/apps/desktop/src/shared/ipc/contract.ts @@ -39,6 +39,7 @@ export type RendererErrorReport = { * channels here only when preload needs a new main-process capability. */ export type RendererToMain = { + "agent.connect": () => void; "commands.run": (id: CommandId) => void; "clipboard.readText": () => string; "clipboard.writeText": (text: string) => void; diff --git a/apps/desktop/src/shared/sandbox/protocol.ts b/apps/desktop/src/shared/sandbox/protocol.ts new file mode 100644 index 000000000..a3308241c --- /dev/null +++ b/apps/desktop/src/shared/sandbox/protocol.ts @@ -0,0 +1,25 @@ +import type { EditorInspection, ShiftSession } from "@shift/runtime"; + +export type SandboxCallMap = { + "sandbox.execute": { + request: { code: string }; + response: unknown; + }; +}; + +export type SandboxEventMap = { + "sandbox.ready": null; +}; + +export type SandboxHostCallMap = { + "shift.sessions.list": { + request: undefined; + response: ShiftSession[]; + }; + "shift.editor.inspect": { + request: { windowId: number }; + response: EditorInspection; + }; +}; + +export type SandboxHostEventMap = Record; diff --git a/apps/desktop/src/utility/sandbox.ts b/apps/desktop/src/utility/sandbox.ts new file mode 100644 index 000000000..24921837b --- /dev/null +++ b/apps/desktop/src/utility/sandbox.ts @@ -0,0 +1,25 @@ +import { executeShiftCode } from "@shift/mcp/runtime"; +import type { ShiftCapabilities } from "@shift/runtime"; +import { Channel, parentPortTransport, serveChannel } from "../shared/workspace/channel"; +import type { + SandboxCallMap, + SandboxEventMap, + SandboxHostCallMap, + SandboxHostEventMap, +} from "../shared/sandbox/protocol"; + +const transport = parentPortTransport(); +const host = new Channel(transport); +const capabilities: ShiftCapabilities = { + sessions: { + list: () => host.call("shift.sessions.list", undefined), + }, + editor: { + inspect: (input) => host.call("shift.editor.inspect", input), + }, +}; +const runtime = serveChannel(transport, { + "sandbox.execute": ({ code }) => executeShiftCode(capabilities, code), +}); + +runtime.emit("sandbox.ready", null); diff --git a/docs/architecture/index.md b/docs/architecture/index.md index 7c64bc625..7ced48a60 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -15,7 +15,7 @@ Central routing table for Shift's distributed documentation. Before creating new | Path pattern | Canonical doc | Purpose | | --------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| `crates/fontsrc/**` | [`crates/fontsrc/docs/DOCS.md`](../../crates/fontsrc/docs/DOCS.md) | Shift-independent authored font source reading and writing | +| `crates/fontsrc/**` | [`crates/fontsrc/docs/DOCS.md`](../../crates/fontsrc/docs/DOCS.md) | Shift-independent authored font source reading and writing | | `crates/shift-backends/**` | [`crates/shift-backends/docs/DOCS.md`](../../crates/shift-backends/docs/DOCS.md) | Font format backends for reading/writing various font formats | | `crates/shift-font/**` | [`crates/shift-font/docs/DOCS.md`](../../crates/shift-font/docs/DOCS.md) | First-class Rust font object model and editing behavior | | `crates/shift-slug/**` | [`crates/shift-slug/docs/DOCS.md`](../../crates/shift-slug/docs/DOCS.md) | GPU-independent Slug curves, retained compilation, and packing | @@ -54,6 +54,8 @@ Central routing table for Shift's distributed documentation. Before creating new | `packages/types/**` | [`packages/types/docs/DOCS.md`](../../packages/types/docs/DOCS.md) | Branded IDs, generated bridge DTO facade, and shared domain types | | `packages/geo/**` | [`packages/geo/docs/DOCS.md`](../../packages/geo/docs/DOCS.md) | Geometry utilities (Vec2, Curve, Polygon, Mat) | | `packages/glyph-state/**` | [`packages/glyph-state/docs/DOCS.md`](../../packages/glyph-state/docs/DOCS.md) | Glyph-domain geometry (contour traversal, segment parsing, bounds) | +| `packages/mcp/**` | [`packages/mcp/docs/DOCS.md`](../../packages/mcp/docs/DOCS.md) | Local code-mode access to the live desktop application | +| `packages/runtime/**` | [`packages/runtime/docs/DOCS.md`](../../packages/runtime/docs/DOCS.md) | Shared protocol and plugin capability contracts | | `packages/ui/**` | [`packages/ui/docs/DOCS.md`](../../packages/ui/docs/DOCS.md) | UI component library wrapping Base UI primitives | | `packages/validation/**` | [`packages/validation/docs/DOCS.md`](../../packages/validation/docs/DOCS.md) | Point sequence validation and persistence schemas | | `packages/rules/**` | [`packages/rules/docs/DOCS.md`](../../packages/rules/docs/DOCS.md) | Point editing rules engine for geometric constraints | diff --git a/knip.json b/knip.json index 4124fad2b..5b986ceaf 100644 --- a/knip.json +++ b/knip.json @@ -19,6 +19,7 @@ "entry": [ "src/main/main.ts", "src/preload/preload.ts", + "src/utility/sandbox.ts", "src/utility/workspace.ts", "src/renderer/renderer.ts", "electron-builder.config.ts", diff --git a/packages/editor/src/lib/clipboard/ClipboardSelection.ts b/packages/editor/src/lib/clipboard/ClipboardSelection.ts index 0eabd47bf..dcfbee549 100644 --- a/packages/editor/src/lib/clipboard/ClipboardSelection.ts +++ b/packages/editor/src/lib/clipboard/ClipboardSelection.ts @@ -1,8 +1,7 @@ -import { isPointId, type PointId } from "@shift/types"; +import { isPointId, type PointId, type SelectableId } from "@shift/types"; import { Validate } from "@shift/validation"; import type { Contour, Point } from "@shift/glyph-state"; import type { ContourContent, PointContent, ShiftContent } from "./types"; -import type { SelectableId } from "../../types/object"; export interface ClipboardContourSource { readonly contours: readonly Contour[]; diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 53713a9f2..99ad26078 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -1,5 +1,4 @@ import type { CursorType, ToolRegistryItem } from "../../types/editor"; -import type { FontSessionMode } from "@shift/types"; import { isAnchorId, isContourId, @@ -16,10 +15,14 @@ import { type GlyphName, type GlyphRecord, type Unicode, + type FontSessionMode, type LayerId, type LayerMatch, + type SegmentId, + type SelectableId, + type ShiftId, } from "@shift/types"; -import { isSegmentId, type SegmentId } from "@shift/glyph-state"; +import { isSegmentId } from "@shift/glyph-state"; import type { ExternalAxisLocation } from "../../types/variation"; import type { SourceSelectionMode } from "../../types/sourceSelection"; import type { Coordinates, NodePoint, ScenePoint } from "../../types/coordinates"; @@ -83,7 +86,7 @@ import type { PointerTarget } from "../../types/target"; import type { ComponentTransformSelection } from "../../types/componentTransform"; import type { ComponentTargets } from "../../types/componentTargets"; import type { PositionSelection } from "../../types/positionEdit"; -import type { SelectableId, ShiftId, ShiftObject } from "../../types/object"; +import type { ShiftObject } from "../../types/object"; import type { ShiftEditorRecord } from "../../types/records"; import type { GlyphNode, NodeKind } from "../../types/node"; import { diff --git a/packages/editor/src/lib/editor/Hover.ts b/packages/editor/src/lib/editor/Hover.ts index dc37bee38..07204df33 100644 --- a/packages/editor/src/lib/editor/Hover.ts +++ b/packages/editor/src/lib/editor/Hover.ts @@ -1,5 +1,5 @@ import { computed, signal, type Signal, type WritableSignal } from "../signals/index"; -import type { SelectableId } from "../../types/object"; +import type { SelectableId } from "@shift/types"; export type HoverEntry = SelectableId; export type HoverableId = SelectableId; diff --git a/packages/editor/src/lib/editor/Selection.ts b/packages/editor/src/lib/editor/Selection.ts index b7699a979..40243ab58 100644 --- a/packages/editor/src/lib/editor/Selection.ts +++ b/packages/editor/src/lib/editor/Selection.ts @@ -1,7 +1,8 @@ import { computed, type Signal } from "../signals/signal"; import type { ShiftStore } from "../store/ShiftStore"; import { uniqueInOrder } from "../utils/utils"; -import { currentSelectionId, type SelectableId } from "../../types/object"; +import type { SelectableId } from "@shift/types"; +import { currentSelectionId } from "../../types/object"; import type { ShiftEditorRecord } from "../../types/records"; export interface SelectionState { diff --git a/packages/editor/src/lib/editor/docs/DOCS.md b/packages/editor/src/lib/editor/docs/DOCS.md index 7fb700ce4..464dbfaf4 100644 --- a/packages/editor/src/lib/editor/docs/DOCS.md +++ b/packages/editor/src/lib/editor/docs/DOCS.md @@ -92,7 +92,7 @@ editor/ - **`Canvas`** -- Thin wrapper around `CanvasRenderingContext2D` with `pxToUpm()` conversion and themed drawing primitives. Carries `CameraTransform` and `EditorRenderTheme`. - **`CameraTransform`** -- Value object: `{ zoom, panX, panY, centre, upmScale, logicalHeight, layoutHeight, padding, descender }`. Snapshot of viewport state passed to rendering code. - **`Selection`** -- Ordered branded-ID selection state. It exposes `stateCell` and unwrapped ID getters; `Editor.selectionBoundsCell` resolves current live objects and their bounds. -- **`SelectableId`** -- Branded identity accepted by selection regardless of the object's concrete kind. +- **`SelectableId`** -- Identity union imported from `@shift/types` and accepted by selection regardless of the object's concrete kind. - **`Coordinates`** -- Pair of `{ screen, scene }` for a single pointer position. Node-local coordinates are derived after hit testing identifies the node being acted on. - **`PositionSelection`** -- One active reference `GlyphLayer` and normalized point/anchor targets plus the corresponding targets on every completely matched editing layer. It contains edit ownership only, not scene placement or pointer coordinates. - **`editingSourceIdsCell`** -- Session-only source selection for multi-source editing. The active source remains the reference; additional selected sources render as comparison outlines. @@ -198,7 +198,7 @@ Glyph geometry exposes domain hit queries for points, anchors, and segments. Too ### Add a new selectable entity kind 1. Define or import its branded identity and guard. -2. Add the identity to `ShiftId` and `SelectableId` in the object type boundary. +2. Add the identity to `ShiftId` and `SelectableId` in `@shift/types`. 3. Resolve it in `Editor.object()` and provide live object bounds. 4. Add editor tests for lookup, selection bounds, and invalidation after edits. diff --git a/packages/editor/src/lib/editor/rendering/overlays/DebugOverlays.ts b/packages/editor/src/lib/editor/rendering/overlays/DebugOverlays.ts index 38a62a116..6141467c2 100644 --- a/packages/editor/src/lib/editor/rendering/overlays/DebugOverlays.ts +++ b/packages/editor/src/lib/editor/rendering/overlays/DebugOverlays.ts @@ -1,6 +1,6 @@ import type { Canvas } from "../Canvas"; import type { GlyphRenderModel } from "../../../model/Glyph"; -import type { SegmentId } from "../../../../types/indicator"; +import type { SegmentId } from "@shift/types"; export class DebugOverlays { draw( diff --git a/packages/editor/src/lib/editor/rendering/overlays/Segments.ts b/packages/editor/src/lib/editor/rendering/overlays/Segments.ts index 6b6cf36ec..a820acc1a 100644 --- a/packages/editor/src/lib/editor/rendering/overlays/Segments.ts +++ b/packages/editor/src/lib/editor/rendering/overlays/Segments.ts @@ -1,5 +1,6 @@ import type { Canvas } from "../Canvas"; -import type { Segment, SegmentId } from "@shift/glyph-state"; +import type { Segment } from "@shift/glyph-state"; +import type { SegmentId } from "@shift/types"; import type { GlyphRenderModel } from "../../../model/Glyph"; export class Segments { diff --git a/packages/editor/src/lib/editor/rendering/overlays/handles/HandleItems.ts b/packages/editor/src/lib/editor/rendering/overlays/handles/HandleItems.ts index baee65fb5..30637238d 100644 --- a/packages/editor/src/lib/editor/rendering/overlays/handles/HandleItems.ts +++ b/packages/editor/src/lib/editor/rendering/overlays/handles/HandleItems.ts @@ -1,6 +1,5 @@ -import type { PointId, ContourId } from "@shift/types"; import { Bounds, type Bounds as BoundsType } from "@shift/geo"; -import type { SelectableId } from "../../../../../types/object"; +import type { ContourId, PointId, SelectableId } from "@shift/types"; import type { HandleState } from "../../../../../types/graphics"; import type { Hover } from "../../../Hover"; import type { Selection } from "../../../Selection"; diff --git a/packages/editor/src/lib/model/ContourBuffer.ts b/packages/editor/src/lib/model/ContourBuffer.ts index e6722884f..420a28904 100644 --- a/packages/editor/src/lib/model/ContourBuffer.ts +++ b/packages/editor/src/lib/model/ContourBuffer.ts @@ -1,12 +1,6 @@ import type { Bounds as BoundsType } from "@shift/geo"; -import type { ContourData, PointId, PointSeed } from "@shift/types"; -import { - Contour, - type GlyphPosition, - Point, - type Segment, - type SegmentId, -} from "@shift/glyph-state"; +import type { ContourData, PointId, PointSeed, SegmentId } from "@shift/types"; +import { Contour, type GlyphPosition, Point, type Segment } from "@shift/glyph-state"; import { batch, computed, diff --git a/packages/editor/src/lib/model/Font.ts b/packages/editor/src/lib/model/Font.ts index 8d4fe3215..df6032949 100644 --- a/packages/editor/src/lib/model/Font.ts +++ b/packages/editor/src/lib/model/Font.ts @@ -24,6 +24,7 @@ import type { LayerMatch, Location, PointId, + SegmentId, NamedInstance, NamedInstanceDefinition, NamedInstanceId, @@ -36,7 +37,6 @@ import { mintNamedInstanceId, mintSourceId, } from "@shift/types"; -import type { SegmentId } from "@shift/glyph-state"; import { batch, computed, diff --git a/packages/editor/src/lib/model/FontStore.ts b/packages/editor/src/lib/model/FontStore.ts index 4ee52fae0..0063ad69e 100644 --- a/packages/editor/src/lib/model/FontStore.ts +++ b/packages/editor/src/lib/model/FontStore.ts @@ -15,11 +15,12 @@ import type { LayerId, PointData, PointId, + SegmentId, SourceId, WorkspaceGlyphLayerSnapshot, WorkspaceSnapshot, } from "@shift/types"; -import { segmentIdFor, type SegmentId } from "@shift/glyph-state"; +import { segmentIdFor } from "@shift/glyph-state"; import { Validate } from "@shift/validation"; import { batch, diff --git a/packages/editor/src/lib/model/Glyph.ts b/packages/editor/src/lib/model/Glyph.ts index 45ad3fe14..d7992ec99 100644 --- a/packages/editor/src/lib/model/Glyph.ts +++ b/packages/editor/src/lib/model/Glyph.ts @@ -18,6 +18,7 @@ import type { LayerId, PointId, PointSeed, + SegmentId, Source, SourceId, Unicode, @@ -70,7 +71,6 @@ import { type GeometrySegmentHit, type GlyphHit, Segment, - type SegmentId, type GlyphPosition as GlyphLayerPosition, type GlyphPositions as GlyphLayerPositions, type GlyphPositionTarget as GlyphLayerPositionTarget, diff --git a/packages/editor/src/lib/nodes/GlyphNodeDefinition.ts b/packages/editor/src/lib/nodes/GlyphNodeDefinition.ts index 4c7106993..84ed518ad 100644 --- a/packages/editor/src/lib/nodes/GlyphNodeDefinition.ts +++ b/packages/editor/src/lib/nodes/GlyphNodeDefinition.ts @@ -1,6 +1,5 @@ import { Bounds, type Rect2D } from "@shift/geo"; -import type { SegmentId } from "@shift/glyph-state"; -import type { ComponentId, NodeId, PointId } from "@shift/types"; +import type { ComponentId, NodeId, PointId, SegmentId } from "@shift/types"; import type { NodePoint } from "../../types/coordinates"; import { SCREEN_HIT_RADIUS } from "../editor/rendering/constants"; import { OutlineRenderer } from "../editor/rendering/Outline"; diff --git a/packages/editor/src/lib/objects/SegmentObject.ts b/packages/editor/src/lib/objects/SegmentObject.ts index 363c5f60c..c827f62b5 100644 --- a/packages/editor/src/lib/objects/SegmentObject.ts +++ b/packages/editor/src/lib/objects/SegmentObject.ts @@ -1,6 +1,6 @@ import { Bounds, Vec2, type Rect2D } from "@shift/geo"; -import type { GlyphGeometry, SegmentId } from "@shift/glyph-state"; -import type { ContourId, PointId } from "@shift/types"; +import type { GlyphGeometry } from "@shift/glyph-state"; +import type { ContourId, PointId, SegmentId } from "@shift/types"; import { track } from "../signals/index"; import type { GlyphLayer } from "../model/Glyph"; import type { ShiftObjectOf } from "../../types/object"; diff --git a/packages/editor/src/lib/tools/pen/PenStroke.ts b/packages/editor/src/lib/tools/pen/PenStroke.ts index 88203f4ad..5dda31a64 100644 --- a/packages/editor/src/lib/tools/pen/PenStroke.ts +++ b/packages/editor/src/lib/tools/pen/PenStroke.ts @@ -1,6 +1,6 @@ import type { Point2D } from "@shift/geo"; -import type { ContourId, PointId } from "@shift/types"; -import { Point, type Contour, type SegmentId } from "@shift/glyph-state"; +import type { ContourId, PointId, SegmentId } from "@shift/types"; +import { Point, type Contour } from "@shift/glyph-state"; import type { GlyphLayer } from "../../model/Glyph"; import type { GlyphLayerEdit } from "../../model/GlyphLayerEdit"; import type { MoveEdit } from "../../model/positions/index"; diff --git a/packages/editor/src/lib/tools/pen/PenTargets.ts b/packages/editor/src/lib/tools/pen/PenTargets.ts index ca0865a92..04d2fdd1e 100644 --- a/packages/editor/src/lib/tools/pen/PenTargets.ts +++ b/packages/editor/src/lib/tools/pen/PenTargets.ts @@ -1,6 +1,6 @@ import type { Point2D } from "@shift/geo"; -import { Point, type SegmentId } from "@shift/glyph-state"; -import type { ContourId, PointId } from "@shift/types"; +import { Point } from "@shift/glyph-state"; +import type { ContourId, PointId, SegmentId } from "@shift/types"; import type { GlyphGeometry } from "../../model/Glyph"; export type PenTarget = diff --git a/packages/editor/src/lib/tools/select/behaviors/Marquee.ts b/packages/editor/src/lib/tools/select/behaviors/Marquee.ts index b84ca2ca0..9db94b416 100644 --- a/packages/editor/src/lib/tools/select/behaviors/Marquee.ts +++ b/packages/editor/src/lib/tools/select/behaviors/Marquee.ts @@ -1,5 +1,5 @@ import { Curve, Rect, Vec2, type Rect2D } from "@shift/geo"; -import type { SelectableId } from "../../../../types/object"; +import type { SelectableId } from "@shift/types"; import type { ToolContext } from "../../core/Behavior"; import type { DragEndEvent, DragEvent, DragStartEvent } from "../../core/GestureDetector"; import type { SelectBehavior, SelectState } from "../types"; diff --git a/packages/editor/src/lib/tools/select/behaviors/Selection.ts b/packages/editor/src/lib/tools/select/behaviors/Selection.ts index d6775f238..1b60a122f 100644 --- a/packages/editor/src/lib/tools/select/behaviors/Selection.ts +++ b/packages/editor/src/lib/tools/select/behaviors/Selection.ts @@ -1,7 +1,7 @@ import type { ToolContext } from "../../core/Behavior"; import type { ClickEvent } from "../../core/GestureDetector"; import type { SelectBehavior, SelectState } from "../types"; -import type { SelectableId } from "../../../../types/object"; +import type { SelectableId } from "@shift/types"; export class Selection implements SelectBehavior { onClick(state: SelectState, ctx: ToolContext, event: ClickEvent): boolean { diff --git a/packages/editor/src/lib/tools/select/types.ts b/packages/editor/src/lib/tools/select/types.ts index 0701f44fe..cbf71c84a 100644 --- a/packages/editor/src/lib/tools/select/types.ts +++ b/packages/editor/src/lib/tools/select/types.ts @@ -1,12 +1,10 @@ import type { Point2D, Rect2D } from "@shift/geo"; -import type { AnchorId, PointId } from "@shift/types"; +import type { AnchorId, PointId, SegmentId, SelectableId } from "@shift/types"; import type { BoundingRectEdge } from "./cursor"; import type { CornerHandle } from "./BoundingBox"; import type { Behavior } from "../core/Behavior"; import type { Select } from "./Select"; -import type { SegmentId } from "../../../types/indicator"; import type { PositionGuide } from "../../../types/positionEdit"; -import type { SelectableId } from "../../../types/object"; export interface DragTarget { pointIds: PointId[]; diff --git a/packages/editor/src/types.ts b/packages/editor/src/types.ts index 79e29ffab..dd3e9834d 100644 --- a/packages/editor/src/types.ts +++ b/packages/editor/src/types.ts @@ -27,7 +27,7 @@ export type { CanvasRef } from "./types/graphics"; export type { CubicHandle } from "./types/handle"; export type { GlyphNode } from "./types/node"; export { NUDGES_VALUES, nudgeMagnitude, type NudgeMagnitude } from "./types/nudge"; -export { currentSelectionId, objectIsKindOf, type SelectableId } from "./types/object"; +export { currentSelectionId, objectIsKindOf } from "./types/object"; export type { ListSelectionMode } from "./types/listSelection"; export type { PositionGuide, PositionSelection } from "./types/positionEdit"; export type { ShiftEditorRecord } from "./types/records"; diff --git a/packages/editor/src/types/glyph.ts b/packages/editor/src/types/glyph.ts index 765af119a..1a8b8f214 100644 --- a/packages/editor/src/types/glyph.ts +++ b/packages/editor/src/types/glyph.ts @@ -11,10 +11,10 @@ import type { GlyphSnapshot, LayerId, PointId, + SegmentId, Source, SourceId, } from "@shift/types"; -import type { SegmentId } from "@shift/glyph-state"; import type { Glyph, GlyphLayer } from "../lib/model/Glyph"; import type { Signal } from "../lib/signals/signal"; import type { DesignAxisLocation } from "./variation"; diff --git a/packages/editor/src/types/indicator.ts b/packages/editor/src/types/indicator.ts index 1dff939bc..43b78cca6 100644 --- a/packages/editor/src/types/indicator.ts +++ b/packages/editor/src/types/indicator.ts @@ -1,8 +1,5 @@ import type { Point2D } from "@shift/geo"; -import type { PointId } from "@shift/types"; -import type { SegmentId } from "@shift/glyph-state"; - -export type { SegmentId }; +import type { PointId, SegmentId } from "@shift/types"; /** * Describes the closest point on a segment to the cursor. diff --git a/packages/editor/src/types/object.ts b/packages/editor/src/types/object.ts index 146b63e08..3dc854566 100644 --- a/packages/editor/src/types/object.ts +++ b/packages/editor/src/types/object.ts @@ -1,6 +1,14 @@ import type { Rect2D } from "@shift/geo"; -import type { GlyphGeometry, SegmentId } from "@shift/glyph-state"; -import type { AnchorId, ComponentId, ContourId, NodeId, PointId } from "@shift/types"; +import type { GlyphGeometry } from "@shift/glyph-state"; +import type { + AnchorId, + ComponentId, + ContourId, + NodeId, + PointId, + SegmentId, + ShiftId, +} from "@shift/types"; import type { GlyphLayer } from "../lib/model/Glyph"; import type { ComponentGlyph } from "../lib/model/ComponentGlyph"; import type { GlyphNode, ShiftNode } from "./node"; @@ -11,12 +19,6 @@ export type SelectionId = string & { readonly [SelectionIdBrand]: typeof Selecti export const currentSelectionId = "selection:current" as SelectionId; -/** Identifies an editor-addressable scene node or glyph object. */ -export type ShiftId = NodeId | PointId | AnchorId | ContourId | SegmentId | ComponentId; - -/** Identifies objects that can be selected by the editor. */ -export type SelectableId = ShiftId; - /** * Defines the shared contract for a resolved editor object. * diff --git a/packages/editor/src/types/records.ts b/packages/editor/src/types/records.ts index 12729159c..4a2bfcef2 100644 --- a/packages/editor/src/types/records.ts +++ b/packages/editor/src/types/records.ts @@ -1,7 +1,7 @@ -import type { NodeId, RunId } from "@shift/types"; +import type { NodeId, RunId, SelectableId, ShiftId } from "@shift/types"; import type { EditingId } from "./editing"; import type { GlyphNode, TextRunNode } from "./node"; -import type { SelectableId, SelectionId, ShiftId } from "./object"; +import type { SelectionId } from "./object"; import type { TextRunRecord } from "./text"; export type ShiftRecordId = ShiftId | SelectionId | EditingId | RunId; diff --git a/packages/glyph-state/docs/DOCS.md b/packages/glyph-state/docs/DOCS.md index 53b1be051..b427fea31 100644 --- a/packages/glyph-state/docs/DOCS.md +++ b/packages/glyph-state/docs/DOCS.md @@ -97,7 +97,7 @@ Renderer code should keep using cached `GlyphGeometry` instances from the model - The `componentTransformKind` passed to the `GlyphGeometry` constructor must match how the value buffer was packed: `"decomposed"` reads 9 values per component, `"affine"` reads 6. There is no runtime check -- a mismatch silently misreads every component transform. The default is `"decomposed"`. - `withPositionUpdates` copies the entire value buffer per call. Batch a frame's updates into one call; unknown point/anchor ids in the update list are skipped without error. - `allPoints` returns a fresh array copy on every access. Read it once and reuse the result inside loops. -- `SegmentId` is derived from the endpoint point ids (`segment::`), so it is stable across re-parses of unchanged geometry -- but any operation that replaces an endpoint produces a different id. Use `parseSegmentId` to recover the endpoints from an id. +- `SegmentId` is imported from `@shift/types` and derived here from endpoint point ids (`segment::`), so it is stable across re-parses of unchanged geometry -- but any operation that replaces an endpoint produces a different id. Use `parseSegmentId` to recover the endpoints from an id. ## Verification diff --git a/packages/glyph-state/src/GlyphGeometry.ts b/packages/glyph-state/src/GlyphGeometry.ts index aa68c4885..3c0ed4e0d 100644 --- a/packages/glyph-state/src/GlyphGeometry.ts +++ b/packages/glyph-state/src/GlyphGeometry.ts @@ -13,7 +13,8 @@ import type { AnchorHit } from "./Anchor"; import { Component } from "./Component"; import { Contour } from "./Contour"; import { IdIndex } from "./IdIndex"; -import { Segment, type SegmentId } from "./Segment"; +import type { SegmentId } from "@shift/types"; +import { Segment } from "./Segment"; import type { SegmentHit } from "./Segment"; import { Point } from "./Point"; import type { PointHit } from "./Point"; diff --git a/packages/glyph-state/src/Segment.ts b/packages/glyph-state/src/Segment.ts index 87e163d05..2d3f4f3ca 100644 --- a/packages/glyph-state/src/Segment.ts +++ b/packages/glyph-state/src/Segment.ts @@ -1,5 +1,5 @@ import { Curve, Vec2, type Bounds, type CurveType, type Point2D } from "@shift/geo"; -import type { PointId } from "@shift/types"; +import { asSegmentId, type PointId, type SegmentId } from "@shift/types"; import { Point } from "./Point"; import type { ContourGeometry, @@ -17,18 +17,8 @@ export type { CubicSegmentPoints, } from "./types/contour"; -declare const SegmentIdBrand: unique symbol; - -export type SegmentId = string & { - readonly [SegmentIdBrand]: typeof SegmentIdBrand; -}; - const SEGMENT_ID_PREFIX = "segment:"; -export function asSegmentId(id: string): SegmentId { - return id as SegmentId; -} - /** Returns the derived segment id for a segment's endpoint point ids. */ export function segmentIdFor(startPointId: PointId, endPointId: PointId): SegmentId { return asSegmentId(`${SEGMENT_ID_PREFIX}${startPointId}:${endPointId}`); diff --git a/packages/glyph-state/src/index.ts b/packages/glyph-state/src/index.ts index 8bcbe10a5..e6724e889 100644 --- a/packages/glyph-state/src/index.ts +++ b/packages/glyph-state/src/index.ts @@ -24,11 +24,9 @@ export { } from "./GlyphGeometry"; export { Segment, - asSegmentId, isSegmentId, parseSegmentId, segmentIdFor, - type SegmentId, type SegmentHit, type SegmentType, type SegmentPoints, diff --git a/packages/mcp/docs/DOCS.md b/packages/mcp/docs/DOCS.md new file mode 100644 index 000000000..1c32e7c1c --- /dev/null +++ b/packages/mcp/docs/DOCS.md @@ -0,0 +1,94 @@ +# MCP + + + +Local code-mode access to the live Shift desktop application. + +## Architecture Invariants + +- **Architecture Invariant:** `@shift/mcp` is an adapter over `ShiftCapabilities` from `@shift/runtime`. It does not own font, document, editor, window, persistence state, or the reusable plugin contract. +- **Architecture Invariant:** The server binds to `127.0.0.1` on a random port, validates localhost Host and Origin headers, and requires a random secret generated for one application run. It never listens on a public interface. +- **Architecture Invariant:** Agent-written code runs in a fresh QuickJS runtime with bounded time, memory, source size, and result size. Desktop hosts that runtime in a dedicated utility process so generated code cannot block or crash Electron main. It has no Node.js, filesystem, environment, Electron, or network globals. +- **Architecture Invariant:** Every editor request names a window explicitly. Focus changes never retarget an in-flight or subsequent call. +- **Architecture Invariant:** MCP is not Shift's canonical font API. Shared document and editor capabilities remain usable by future plugin and protocol hosts without MCP. + +## Codemap + +```text +src/ + declarations.ts -- loads @shift/runtime's generated declaration for shift.describe + types.ts -- MCP connection contract + code.ts -- bounded QuickJS execution over ShiftCapabilities + runtime.ts -- isolated-runtime-only package surface + server.ts -- MCP tools, loopback HTTP, run secret, connection descriptor + index.ts -- main-process-safe public package surface +``` + +## Key Types + +- `ShiftCapabilities` -- host-neutral live operations owned by `@shift/runtime` and supplied by the desktop application. +- `ShiftSession` -- explicit window and font-session identity, mode, focus, and editor connection status. +- `EditorInspection` -- point-in-time renderer observation for one explicitly targeted session. +- `ShiftMcpServer` -- loopback MCP lifecycle, authentication, connection descriptor, and tool registration. +- `ShiftMcpConnection` -- run-scoped local URL and bearer token written to the private descriptor. + +## How it works + +### `shift.describe` + +Returns the TypeScript declarations available inside code mode. + +### `shift.execute` + +Accepts an async zero-argument JavaScript function and returns its JSON result. The first slice exposes: + +```ts +async () => { + const sessions = await shift.sessions.list(); + return shift.editor.inspect({ windowId: sessions[0].windowId }); +}; +``` + +`shift.sessions.list()` returns one entry per open font window. `shift.editor.inspect()` returns renderer-owned facts: route, current glyph occurrence, active and editing sources, external location, selection, tool, gesture flags, and authored edit status. + +## Desktop ownership + +Electron main starts one `ShiftMcpServer` and one `SandboxRuntimeProcess` after `app.whenReady()`. It writes `mcp.json` under the distribution-specific user-data directory with mode `0600`. The descriptor contains the loopback URL and run secret; it is local connection material, not a user login. MCP delegates execution to the sandbox utility process, and main serves only the typed capability requests that return from that process. A hard host deadline terminates the sandbox if its internal QuickJS deadline cannot settle. + +Each renderer serves an agent request lane over a transferred `MessagePort`. Main pairs renderer observations with the explicit window and font-session identities before returning them. Launcher windows are excluded from session discovery. + +## Workflow recipes + +### Add a capability + +1. Put reusable semantic computation in its existing domain owner or shared Rust capability layer. +2. Add the public snapshot or operation to `@shift/runtime` and regenerate its code API. +3. Extend the desktop host implementation and sandbox protocol. +4. Route document truth to utility ownership and view truth to renderer ownership. +5. Add package contract coverage and a real desktop integration test. +6. Update the `shift` agent skill with the intended workflow. + +Do not expose internal `Editor`, `FontStore`, `WorkspaceHost`, NAPI, SQLite rows, or arbitrary IPC through this package. + +## Gotchas + +- The connection descriptor is removed during graceful shutdown, but an application crash can leave a stale descriptor. A client must treat connection failure as authoritative. +- Focus is descriptive only. Always pass a `windowId` from the same `sessions.list()` result used to choose a target. +- A renderer can exist before its agent lane connects. Check `editorConnected` or retry session discovery rather than substituting another window. +- Code-mode results must be JSON-serializable and remain under the configured output bound. + +## Verification + +```sh +pnpm --filter @shift/mcp test +pnpm --filter @shift/mcp typecheck +pnpm --filter @shift/mcp lint:check +pnpm typecheck +``` + +## Related + +- [`packages/runtime/docs/DOCS.md`](../../runtime/docs/DOCS.md) -- canonical protocol and plugin capability contracts. +- [`apps/desktop/src/main/docs/DOCS.md`](../../../apps/desktop/src/main/docs/DOCS.md) -- Electron lifecycle, window/session identity, and renderer lanes. +- [`apps/desktop/src/preload/docs/DOCS.md`](../../../apps/desktop/src/preload/docs/DOCS.md) -- authenticated `MessagePort` transfer into the renderer. +- [`docs/architecture/index.md`](../../../docs/architecture/index.md) -- repository documentation routing and API boundaries. diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 000000000..77e7af9a9 --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,42 @@ +{ + "name": "@shift/mcp", + "version": "0.0.1", + "license": "MIT OR Apache-2.0", + "private": true, + "description": "Live MCP access to the Shift font editor", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./runtime": { + "types": "./src/runtime.ts", + "import": "./src/runtime.ts" + } + }, + "scripts": { + "typecheck": "tsgo --noEmit", + "lint": "oxlint --fix src/", + "lint:check": "oxlint --deny-warnings src/", + "test": "vitest run --config vitest.config.ts", + "test:watch": "vitest --config vitest.config.ts" + }, + "dependencies": { + "@jitl/quickjs-singlefile-cjs-release-sync": "0.32.0", + "@modelcontextprotocol/node": "2.1.0", + "@modelcontextprotocol/server": "2.2.0", + "@shift/runtime": "workspace:*", + "quickjs-emscripten-core": "0.32.0", + "zod": "^4.1.12" + }, + "devDependencies": { + "@shift/types": "workspace:*", + "@types/node": "^25.3.0", + "oxlint": "^1.85.0", + "typescript": "^5.5.4", + "vitest": "^4.1.10" + } +} diff --git a/packages/mcp/src/code.test.ts b/packages/mcp/src/code.test.ts new file mode 100644 index 000000000..bf28ece5b --- /dev/null +++ b/packages/mcp/src/code.test.ts @@ -0,0 +1,73 @@ +import { asGlyphId, asNodeId, asPointId, asSourceId } from "@shift/types"; +import type { ShiftCapabilities } from "@shift/runtime"; +import { describe, expect, it } from "vitest"; +import { executeShiftCode } from "./code"; + +const capabilities: ShiftCapabilities = { + sessions: { + async list() { + return [ + { + windowId: 7, + sessionId: "session-a", + mode: "workspace", + focused: true, + editorConnected: true, + }, + ]; + }, + }, + editor: { + async inspect({ windowId }) { + return { + windowId, + sessionId: "session-a", + mode: "workspace", + route: "/editor/glyph-a", + glyph: { + glyphId: asGlyphId("glyph-a"), + name: "A", + nodeId: asNodeId("node-a"), + sourceId: asSourceId("source-a"), + }, + activeSourceId: asSourceId("source-a"), + editingSourceIds: [asSourceId("source-a")], + externalLocation: [], + selectionIds: [asPointId("point-a")], + tool: { id: "select", state: "idle" }, + dragging: false, + editing: true, + applyStatus: "idle", + }; + }, + }, +}; + +describe("Shift code mode exposes bounded live capabilities", () => { + it("composes session discovery and editor inspection", async () => { + const result = await executeShiftCode( + capabilities, + "async () => { const [session] = await shift.sessions.list(); return shift.editor.inspect({ windowId: session.windowId }); }", + ); + + expect(result).toMatchObject({ windowId: 7, glyph: { name: "A" }, selectionIds: ["point-a"] }); + }); + + it("cannot access Node process globals", async () => { + await expect(executeShiftCode(capabilities, "async () => typeof process")).resolves.toBe( + "undefined", + ); + }); + + it("requires a JSON-compatible result", async () => { + await expect(executeShiftCode(capabilities, "async () => undefined")).rejects.toThrow( + "shift.execute must return a JSON value", + ); + }); + + it("stops code that never settles", async () => { + await expect( + executeShiftCode(capabilities, "async () => await new Promise(() => {})"), + ).rejects.toThrow("shift.execute timed out"); + }); +}); diff --git a/packages/mcp/src/code.ts b/packages/mcp/src/code.ts new file mode 100644 index 000000000..3cca888ea --- /dev/null +++ b/packages/mcp/src/code.ts @@ -0,0 +1,148 @@ +import RELEASE_SYNC from "@jitl/quickjs-singlefile-cjs-release-sync"; +import { + newQuickJSWASMModuleFromVariant, + shouldInterruptAfterDeadline, + type QuickJSContext, + type QuickJSWASMModule, +} from "quickjs-emscripten-core"; +import type { ShiftCapabilities } from "@shift/runtime"; + +const EXECUTION_TIMEOUT_MS = 3_000; +const MEMORY_LIMIT_BYTES = 16 * 1024 * 1024; +const MAX_CODE_BYTES = 16 * 1024; +const MAX_RESULT_BYTES = 64 * 1024; +let quickJsPromise: Promise | null = null; + +/** Executes agent-written JavaScript against only the supplied Shift capabilities. */ +export async function executeShiftCode( + capabilities: ShiftCapabilities, + code: string, +): Promise { + if (Buffer.byteLength(code, "utf8") > MAX_CODE_BYTES) { + throw new Error(`shift.execute code exceeds ${MAX_CODE_BYTES} bytes`); + } + + const QuickJS = await loadQuickJS(); + const deadline = Date.now() + EXECUTION_TIMEOUT_MS; + const runtime = QuickJS.newRuntime(); + runtime.setMemoryLimit(MEMORY_LIMIT_BYTES); + runtime.setMaxStackSize(512 * 1024); + runtime.setInterruptHandler(shouldInterruptAfterDeadline(deadline)); + + const vm = runtime.newContext(); + installAsyncJsonFunction(vm, "__shiftListSessions", deadline, () => capabilities.sessions.list()); + installAsyncJsonFunction(vm, "__shiftInspectEditor", deadline, (windowId) => { + if (typeof windowId !== "number" || !Number.isInteger(windowId)) { + throw new Error("editor.inspect requires an integer windowId"); + } + + return capabilities.editor.inspect({ windowId }); + }); + + const bootstrap = ` + "use strict"; + const shift = Object.freeze({ + sessions: Object.freeze({ + list: async () => JSON.parse(await __shiftListSessions()), + }), + editor: Object.freeze({ + inspect: async ({ windowId }) => JSON.parse(await __shiftInspectEditor(windowId)), + }), + }); + (async () => { + const entry = (${code}); + if (typeof entry !== "function") throw new Error("shift.execute code must evaluate to a function"); + const result = await entry(); + const json = JSON.stringify(result); + if (json === undefined) throw new Error("shift.execute must return a JSON value"); + return json; + })(); + `; + + try { + const evaluation = vm.evalCode(bootstrap, "shift-agent.js"); + const promiseHandle = vm.unwrapResult(evaluation); + const settledPromise = vm.resolvePromise(promiseHandle); + vm.runtime.executePendingJobs(); + + let settled: Awaited; + try { + settled = await withDeadline(settledPromise, deadline); + } finally { + promiseHandle.dispose(); + } + + const resultHandle = vm.unwrapResult(settled); + const json = vm.getString(resultHandle); + resultHandle.dispose(); + + if (Buffer.byteLength(json, "utf8") > MAX_RESULT_BYTES) { + throw new Error(`shift.execute result exceeds ${MAX_RESULT_BYTES} bytes`); + } + + return JSON.parse(json) as unknown; + } finally { + vm.dispose(); + runtime.dispose(); + } +} + +function loadQuickJS(): Promise { + quickJsPromise ??= newQuickJSWASMModuleFromVariant(RELEASE_SYNC); + return quickJsPromise; +} + +function installAsyncJsonFunction( + vm: QuickJSContext, + name: string, + deadline: number, + call: (...args: unknown[]) => unknown | Promise, +): void { + const functionHandle = vm.newFunction(name, (...argumentHandles) => { + const args = argumentHandles.map((handle) => vm.dump(handle)); + const promise = vm.newPromise(); + + try { + const result = call(...args); + withDeadline(Promise.resolve(result), deadline).then( + (value) => { + const resultHandle = vm.newString(JSON.stringify(value)); + promise.resolve(resultHandle); + resultHandle.dispose(); + }, + (error) => { + const errorHandle = vm.newError(errorMessage(error)); + promise.reject(errorHandle); + errorHandle.dispose(); + }, + ); + } catch (error) { + const errorHandle = vm.newError(errorMessage(error)); + promise.reject(errorHandle); + errorHandle.dispose(); + } + + void promise.settled.then(() => vm.runtime.executePendingJobs()); + return promise.handle; + }); + + functionHandle.consume((handle) => vm.setProp(vm.global, name, handle)); +} + +async function withDeadline(promise: Promise, deadline: number): Promise { + const timeoutMs = Math.max(0, deadline - Date.now()); + let timeout: NodeJS.Timeout | undefined; + const expired = new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error("shift.execute timed out")), timeoutMs); + }); + + try { + return await Promise.race([promise, expired]); + } finally { + clearTimeout(timeout); + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/mcp/src/declarations.ts b/packages/mcp/src/declarations.ts new file mode 100644 index 000000000..573e59c40 --- /dev/null +++ b/packages/mcp/src/declarations.ts @@ -0,0 +1,3 @@ +import shiftCodeTypes from "@shift/runtime/code-api?raw"; + +export const SHIFT_CODE_TYPES = shiftCodeTypes; diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 000000000..810237a70 --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,4 @@ +export { SHIFT_CODE_TYPES } from "./declarations"; +export { ShiftMcpServer } from "./server"; +export type { ShiftMcpLogger, ShiftMcpServerOptions } from "./server"; +export type { ShiftMcpConnection } from "./types"; diff --git a/packages/mcp/src/raw.d.ts b/packages/mcp/src/raw.d.ts new file mode 100644 index 000000000..8e6dc861f --- /dev/null +++ b/packages/mcp/src/raw.d.ts @@ -0,0 +1,4 @@ +declare module "*?raw" { + const source: string; + export default source; +} diff --git a/packages/mcp/src/runtime.ts b/packages/mcp/src/runtime.ts new file mode 100644 index 000000000..93f1414b1 --- /dev/null +++ b/packages/mcp/src/runtime.ts @@ -0,0 +1 @@ +export { executeShiftCode } from "./code"; diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts new file mode 100644 index 000000000..8caa187ed --- /dev/null +++ b/packages/mcp/src/server.test.ts @@ -0,0 +1,114 @@ +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { executeShiftCode } from "./code"; +import { ShiftMcpServer } from "./server"; +import type { ShiftCapabilities } from "@shift/runtime"; +import type { ShiftMcpConnection } from "./types"; + +const capabilities: ShiftCapabilities = { + sessions: { + async list() { + return []; + }, + }, + editor: { + async inspect() { + throw new Error("No open Shift window"); + }, + }, +}; + +const execute = (code: string) => executeShiftCode(capabilities, code); +const startedServers: ShiftMcpServer[] = []; +const temporaryDirectories: string[] = []; + +async function mcpRequest( + connection: ShiftMcpConnection, + method: string, + params: unknown, +): Promise { + const response = await fetch(connection.url, { + method: "POST", + headers: { + authorization: `Bearer ${connection.token}`, + accept: "application/json, text/event-stream", + "content-type": "application/json", + "mcp-protocol-version": "2025-06-18", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + expect(response.status).toBe(200); + const body = await response.text(); + return body.startsWith("event:") + ? JSON.parse(body.split("\ndata: ")[1]!.trim()) + : JSON.parse(body); +} + +afterEach(async () => { + await Promise.all(startedServers.splice(0).map((server) => server.stop())); + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +describe("Shift MCP local connection", () => { + it("publishes a private run-scoped descriptor and rejects missing secrets", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "shift-mcp-")); + temporaryDirectories.push(directory); + const descriptorPath = path.join(directory, "connection.json"); + const server = new ShiftMcpServer({ execute, descriptorPath }); + startedServers.push(server); + + const connection = await server.start(); + const descriptor = JSON.parse(await readFile(descriptorPath, "utf8")); + const missingSecret = await fetch(connection.url, { method: "POST" }); + const foreignOrigin = await fetch(connection.url, { + method: "POST", + headers: { + authorization: `Bearer ${connection.token}`, + origin: "https://example.com", + }, + }); + + expect(descriptor).toEqual(connection); + expect((await stat(descriptorPath)).mode & 0o777).toBe(0o600); + expect(missingSecret.status).toBe(401); + expect(foreignOrigin.status).toBe(403); + + await server.stop(); + await expect(stat(descriptorPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("accepts an MCP initialization with the run-scoped secret", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "shift-mcp-")); + temporaryDirectories.push(directory); + const server = new ShiftMcpServer({ + execute, + descriptorPath: path.join(directory, "connection.json"), + }); + startedServers.push(server); + const connection = await server.start(); + + const initialized = await mcpRequest(connection, "initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "shift-test", version: "1.0.0" }, + }); + const described = await mcpRequest(connection, "tools/call", { + name: "shift.describe", + arguments: {}, + }); + const executed = await mcpRequest(connection, "tools/call", { + name: "shift.execute", + arguments: { code: "async () => await shift.sessions.list()" }, + }); + + expect(initialized).toMatchObject({ result: { serverInfo: { name: "shift" } } }); + expect(described).toMatchObject({ + result: { content: [{ text: expect.stringContaining("declare global") }] }, + }); + expect(executed).toMatchObject({ result: { content: [{ text: "[]" }] } }); + }); +}); diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts new file mode 100644 index 000000000..2d20f0197 --- /dev/null +++ b/packages/mcp/src/server.ts @@ -0,0 +1,198 @@ +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { rmSync } from "node:fs"; +import { mkdir, rename, writeFile } from "node:fs/promises"; +import { createServer, type Server } from "node:http"; +import path from "node:path"; +import { + localhostHostValidation, + localhostOriginValidation, + toNodeHandler, +} from "@modelcontextprotocol/node"; +import { createMcpHandler, McpServer } from "@modelcontextprotocol/server"; +import * as z from "zod/v4"; +import { SHIFT_CODE_TYPES } from "./declarations"; +import type { ShiftMcpConnection } from "./types"; + +const LOOPBACK_HOST = "127.0.0.1"; +const MCP_PATH = "/mcp"; + +export interface ShiftMcpLogger { + info(message: string, details?: unknown): void; + warn(message: string, details?: unknown): void; + error(message: string, details?: unknown): void; +} + +export interface ShiftMcpServerOptions { + execute(code: string): Promise; + descriptorPath: string; + logger?: ShiftMcpLogger; +} + +/** Serves code-mode access to one running Shift application over loopback HTTP. */ +export class ShiftMcpServer { + readonly #execute: (code: string) => Promise; + readonly #descriptorPath: string; + readonly #logger: ShiftMcpLogger | undefined; + #httpServer: Server | null = null; + #closeHandler: (() => Promise) | null = null; + #connection: ShiftMcpConnection | null = null; + + /** + * Creates an unstarted server bound to a host-owned isolated executor. + * + * @param options - execution callback, private descriptor path, and optional diagnostics sink. + */ + constructor(options: ShiftMcpServerOptions) { + this.#execute = options.execute; + this.#descriptorPath = options.descriptorPath; + this.#logger = options.logger; + } + + /** Starts a random loopback port and publishes same-user connection details. */ + async start(): Promise { + if (this.#connection) return this.#connection; + + const token = randomBytes(32).toString("base64url"); + const handler = createMcpHandler(() => this.#createProtocolServer()); + const nodeHandler = toNodeHandler(handler, { + maxRequestBodySize: 128 * 1024, + onerror: (error) => this.#logger?.error("MCP request failed", error), + }); + const validateHost = localhostHostValidation(); + const validateOrigin = localhostOriginValidation(); + const httpServer = createServer((request, response) => { + if (!validateHost(request, response) || !validateOrigin(request, response)) return; + + const requestPath = new URL(request.url ?? "/", `http://${LOOPBACK_HOST}`).pathname; + if (requestPath !== MCP_PATH) { + response.writeHead(404).end(); + return; + } + + if (!hasBearerToken(request.headers.authorization, token)) { + response.writeHead(401, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "invalid Shift MCP connection secret" })); + return; + } + + void nodeHandler(request, response); + }); + + try { + const port = await listen(httpServer); + const connection = { + url: `http://${LOOPBACK_HOST}:${port}${MCP_PATH}`, + token, + descriptorPath: this.#descriptorPath, + } satisfies ShiftMcpConnection; + + await publishConnection(connection); + this.#httpServer = httpServer; + this.#closeHandler = handler.close; + this.#connection = connection; + this.#logger?.info("MCP server started", { url: connection.url }); + return connection; + } catch (error) { + httpServer.close(); + await handler.close(); + throw error; + } + } + + /** Stops accepting requests and removes the run-scoped connection descriptor. */ + async stop(): Promise { + const httpServer = this.#httpServer; + const closeHandler = this.#closeHandler; + const connection = this.#connection; + this.#httpServer = null; + this.#closeHandler = null; + this.#connection = null; + + const closingServer = httpServer ? closeServer(httpServer) : Promise.resolve(); + if (connection) rmSync(connection.descriptorPath, { force: true }); + await closingServer; + if (closeHandler) await closeHandler(); + if (connection) this.#logger?.info("MCP server stopped"); + } + + #createProtocolServer(): McpServer { + const server = new McpServer({ name: "shift", version: "0.1.0" }); + server.registerTool( + "shift.describe", + { + description: "Describe the typed live Shift API available to shift.execute.", + inputSchema: z.object({}), + }, + async () => ({ + content: [{ type: "text", text: SHIFT_CODE_TYPES }], + }), + ); + server.registerTool( + "shift.execute", + { + description: `Execute an async JavaScript function against the live Shift API. Return a JSON value.\n\n${SHIFT_CODE_TYPES}`, + inputSchema: z.object({ + code: z + .string() + .min(1) + .max(16_384) + .describe( + "An async zero-argument function, for example async () => await shift.sessions.list()", + ), + }), + }, + async ({ code }) => { + const result = await this.#execute(code); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + }, + ); + return server; + } +} + +function hasBearerToken(header: string | undefined, token: string): boolean { + if (!header?.startsWith("Bearer ")) return false; + + const supplied = Buffer.from(header.slice("Bearer ".length)); + const expected = Buffer.from(token); + if (supplied.length !== expected.length) return false; + + return timingSafeEqual(supplied, expected); +} + +async function listen(server: Server): Promise { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, LOOPBACK_HOST, () => { + server.off("error", reject); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Shift MCP server has no TCP port"); + return address.port; +} + +async function closeServer(server: Server): Promise { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); +} + +async function publishConnection(connection: ShiftMcpConnection): Promise { + const directory = path.dirname(connection.descriptorPath); + const temporaryPath = `${connection.descriptorPath}.${process.pid}.tmp`; + await mkdir(directory, { recursive: true, mode: 0o700 }); + await writeFile(temporaryPath, `${JSON.stringify(connection)}\n`, { mode: 0o600 }); + await rename(temporaryPath, connection.descriptorPath); +} diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts new file mode 100644 index 000000000..4c69f2f4e --- /dev/null +++ b/packages/mcp/src/types.ts @@ -0,0 +1,6 @@ +/** Connection details written for same-user local clients while Shift is running. */ +export interface ShiftMcpConnection { + url: string; + token: string; + descriptorPath: string; +} diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 000000000..f53b6d51f --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig/library.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/mcp/vitest.config.ts b/packages/mcp/vitest.config.ts new file mode 100644 index 000000000..ae847ff6d --- /dev/null +++ b/packages/mcp/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/runtime/docs/DOCS.md b/packages/runtime/docs/DOCS.md new file mode 100644 index 000000000..874356b51 --- /dev/null +++ b/packages/runtime/docs/DOCS.md @@ -0,0 +1,79 @@ +# @shift/runtime + + + +Host-neutral capability contracts shared by Shift protocol adapters and future plugin hosts. + +## Architecture Invariants + +- **Architecture Invariant:** `@shift/runtime` defines capability contracts only. It owns no Electron, MCP, renderer, utility-process, persistence, or sandbox implementation. +- **Architecture Invariant:** Existing domain identities and session modes come from `@shift/types`. Runtime contracts never redeclare `AxisId`, `GlyphId`, `NodeId`, `SourceId`, or `FontSessionMode` as parallel primitives. +- **Architecture Invariant:** `ShiftCapabilities` is the reusable platform surface. MCP, plugins, browser hosts, and tests adapt that contract without exposing internal `Editor`, `FontStore`, NAPI, IPC, or database objects. +- **Architecture Invariant:** `generated/code-api.d.ts` is generated from `src/capabilities.ts` with canonical `@shift/types` dependencies bundled. It is never edited manually. + +## Codemap + +```text +packages/runtime/ + src/ + capabilities.ts -- canonical capability and observation contracts + index.ts -- public type exports + scripts/ + generate-code-api.mjs -- deterministic self-contained declaration generator + generated/ + code-api.d.ts -- bundled declaration consumed by code-mode adapters +``` + +## Key Types + +- `ShiftCapabilities` -- nested live operations exposed by a host. +- `ShiftSession` -- explicitly addressable live window/session identity and mode. +- `EditorInspection` -- point-in-time editor observation paired with its explicit target. +- `EditorView` -- renderer-owned portion of an editor observation. +- `EditorGlyph` -- active glyph occurrence using canonical domain identifiers. +- `ShiftSessionMode` -- alias of the canonical `FontSessionMode`, including memory hosts. + +## How it works + +A host implements `ShiftCapabilities` by routing each operation to the subsystem that owns the truth. The desktop host lists window/session identity in Electron main and requests editor observations from the targeted renderer. Other hosts may provide memory sessions while preserving the same capability shape. + +The code-API generator bundles only declarations reachable from `capabilities.ts`, including the canonical branded identifiers from `@shift/types`, then adds the code-mode global `shift`. `@shift/mcp` imports that generated file as text for `shift.describe`; it does not maintain another declaration. + +## Workflow recipes + +### Add a capability + +1. Reuse domain types from `@shift/types` or the package that canonically owns them. +2. Add the host-neutral operation and result to `src/capabilities.ts`. +3. Run `pnpm --filter @shift/runtime code-api:generate`. +4. Adapt the operation in each host and protocol boundary. +5. Add contract and host integration coverage. + +### Verify generated declarations + +```sh +pnpm --filter @shift/runtime code-api:check +``` + +`@shift/runtime` typechecking runs this check automatically. + +## Gotchas + +- The generated code API declares a global `shift`; the canonical runtime package does not pollute application globals. +- Branded identifiers serialize as strings but remain distinct types for TypeScript hosts. +- A capability interface describes authority, not ownership. Implementations must still route font truth, editor state, and persistence to their canonical subsystems. + +## Verification + +```sh +pnpm --filter @shift/runtime code-api:check +pnpm --filter @shift/runtime typecheck +pnpm --filter @shift/runtime lint:check +pnpm typecheck +``` + +## Related + +- [`packages/types/docs/DOCS.md`](../../types/docs/DOCS.md) -- canonical domain identities and snapshots. +- [`packages/mcp/docs/DOCS.md`](../../mcp/docs/DOCS.md) -- local MCP adapter and code-mode executor. +- [`apps/desktop/src/main/docs/DOCS.md`](../../../apps/desktop/src/main/docs/DOCS.md) -- desktop capability routing and sandbox process ownership. diff --git a/packages/runtime/generated/code-api.d.ts b/packages/runtime/generated/code-api.d.ts new file mode 100644 index 000000000..39085847a --- /dev/null +++ b/packages/runtime/generated/code-api.d.ts @@ -0,0 +1,151 @@ +//#region ../types/src/ids.d.ts +/** + * Branded ID types for type-safe identification of font entities. + * + * These types ensure compile-time safety when working with IDs across the + * TS/Rust boundary. Most ids are prefixed strings (`point_`). The + * renderer MINTS ids for entities it creates (client-minted ids: verbs return + * identity synchronously; Rust validates and honors them); all other ids come + * from Rust. + */ +declare const PointIdBrand: unique symbol; +declare const ContourIdBrand: unique symbol; +declare const AnchorIdBrand: unique symbol; +declare const AxisIdBrand: unique symbol; +declare const ComponentIdBrand: unique symbol; +declare const GlyphIdBrand: unique symbol; +declare const NodeIdBrand: unique symbol; +declare const SegmentIdBrand: unique symbol; +declare const SourceIdBrand: unique symbol; +/** + * A point identifier from Rust. + * Branded string type - can't be confused with ContourId or plain strings. + */ +type PointId = string & { + readonly [PointIdBrand]: typeof PointIdBrand; +}; +/** + * A contour identifier from Rust. + * Branded string type - can't be confused with PointId or plain strings. + */ +type ContourId = string & { + readonly [ContourIdBrand]: typeof ContourIdBrand; +}; +/** + * An anchor identifier from Rust. + * Branded string type - can't be confused with PointId/ContourId or plain strings. + */ +type AnchorId = string & { + readonly [AnchorIdBrand]: typeof AnchorIdBrand; +}; +/** + * An axis identifier from Rust. + * Branded string type - can't be confused with OpenType axis tags. + */ +type AxisId = string & { + readonly [AxisIdBrand]: typeof AxisIdBrand; +}; +/** + * A component identifier from Rust. + * Branded string type - can't be confused with other IDs or plain strings. + */ +type ComponentId = string & { + readonly [ComponentIdBrand]: typeof ComponentIdBrand; +}; +/** + * A glyph identifier from Rust. + * Branded string type - can't be confused with names or other IDs. + */ +type GlyphId = string & { + readonly [GlyphIdBrand]: typeof GlyphIdBrand; +}; +/** + * A scene node identifier minted by the renderer. + * + * Node ids identify placed editor nodes. They are placement identity only; + * commands that mutate authored glyph geometry must resolve the glyph layer + * separately from document glyph identity and designspace location. + */ +type NodeId = string & { + readonly [NodeIdBrand]: typeof NodeIdBrand; +}; +/** Stable identity of one segment derived from its endpoint identities. */ +type SegmentId = string & { + readonly [SegmentIdBrand]: typeof SegmentIdBrand; +}; +/** + * A source identifier from Rust. + * Branded string type - can't be confused with other IDs or plain strings. + */ +type SourceId = string & { + readonly [SourceIdBrand]: typeof SourceIdBrand; +}; +/** Identifies an editor-addressable scene node or glyph object. */ +type ShiftId = NodeId | PointId | AnchorId | ContourId | SegmentId | ComponentId; +/** Identifies objects that can be selected by the editor. */ +type SelectableId = ShiftId; +//#endregion +//#region ../types/src/workspace.d.ts +/** Immutable product mode for one live font session. */ +type FontSessionMode = "preview" | "memory" | "workspace"; +//#endregion +//#region src/capabilities.d.ts +export type ShiftSessionMode = FontSessionMode; +/** One open Shift window that can be addressed through runtime capabilities. */ +export interface ShiftSession { + windowId: number; + sessionId: string; + mode: ShiftSessionMode; + focused: boolean; + editorConnected: boolean; +} +/** One external/user-space axis coordinate displayed by the editor. */ +export interface AxisCoordinate { + axisId: AxisId; + value: number; +} +/** Glyph occurrence currently placed in the editor, when a glyph route is open. */ +export interface EditorGlyph { + glyphId: GlyphId; + name: string; + nodeId: NodeId; + sourceId: SourceId; +} +/** Active editor tool and its published state discriminator. */ +export interface EditorTool { + id: string; + state: string; +} +/** Renderer-owned facts for one live Shift window. */ +export interface EditorView { + route: string; + glyph: EditorGlyph | null; + activeSourceId: SourceId | null; + editingSourceIds: SourceId[]; + externalLocation: AxisCoordinate[]; + selectionIds: SelectableId[]; + tool: EditorTool | null; + dragging: boolean; + editing: boolean; + applyStatus: "idle" | "queued" | "applying" | null; +} +/** Editor facts paired with the explicit window and font-session target. */ +export interface EditorInspection extends EditorView { + windowId: number; + sessionId: string; + mode: ShiftSessionMode; +} +/** Live application capabilities shared by protocol and plugin hosts. */ +export interface ShiftCapabilities { + sessions: { + list(): Promise; + }; + editor: { + inspect(input: { windowId: number }): Promise; + }; +} +//#endregion + +declare global { + const shift: ShiftCapabilities; +} diff --git a/packages/runtime/package.json b/packages/runtime/package.json new file mode 100644 index 000000000..6a89e473c --- /dev/null +++ b/packages/runtime/package.json @@ -0,0 +1,33 @@ +{ + "name": "@shift/runtime", + "version": "0.0.1", + "license": "MIT OR Apache-2.0", + "private": true, + "description": "Typed capabilities shared by Shift protocol and plugin hosts", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./code-api": "./generated/code-api.d.ts" + }, + "scripts": { + "code-api:generate": "node scripts/generate-code-api.mjs", + "code-api:check": "node scripts/generate-code-api.mjs --check", + "typecheck": "pnpm code-api:check && tsgo --noEmit", + "lint": "oxlint --fix src/", + "lint:check": "oxlint --deny-warnings src/" + }, + "dependencies": { + "@shift/types": "workspace:*" + }, + "devDependencies": { + "oxfmt": "^0.49.0", + "oxlint": "^1.85.0", + "tsdown": "0.23.0", + "typescript": "^5.5.4" + } +} diff --git a/packages/runtime/scripts/generate-code-api.mjs b/packages/runtime/scripts/generate-code-api.mjs new file mode 100644 index 000000000..b24abba37 --- /dev/null +++ b/packages/runtime/scripts/generate-code-api.mjs @@ -0,0 +1,47 @@ +import { spawnSync } from "node:child_process"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const temporaryRoot = path.join(packageRoot, ".code-api"); +const bundledPath = path.join(temporaryRoot, "capabilities.d.ts"); +const formattedPath = path.join(temporaryRoot, "code-api.d.ts"); +const outputPath = path.join(packageRoot, "generated", "code-api.d.ts"); + +try { + run("pnpm", ["exec", "tsdown", "--config", "tsdown.config.ts"]); + const declarations = await readFile(bundledPath, "utf8"); + const withoutSourceMap = declarations.replace(/^\/\/# sourceMappingURL=.*$/m, "").trim(); + const generated = `${withoutSourceMap}\n\ndeclare global {\n const shift: ShiftCapabilities;\n}\n`; + + await writeFile(formattedPath, generated); + run("pnpm", ["exec", "oxfmt", ".code-api/code-api.d.ts"]); + const formatted = await readFile(formattedPath, "utf8"); + + if (process.argv.includes("--check")) { + const existing = await readFile(outputPath, "utf8").catch(() => ""); + if (existing !== formatted) { + console.error( + "Generated runtime code API is stale. Run pnpm --filter @shift/runtime code-api:generate.", + ); + process.exitCode = 1; + } + } else { + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, formatted); + } +} finally { + await rm(temporaryRoot, { recursive: true, force: true }); +} + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: packageRoot, + env: process.env, + stdio: "inherit", + }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed with status ${result.status}`); + } +} diff --git a/packages/runtime/src/capabilities.ts b/packages/runtime/src/capabilities.ts new file mode 100644 index 000000000..378ab4b5c --- /dev/null +++ b/packages/runtime/src/capabilities.ts @@ -0,0 +1,70 @@ +import type { + AxisId, + FontSessionMode, + GlyphId, + NodeId, + SelectableId, + SourceId, +} from "@shift/types"; + +export type ShiftSessionMode = FontSessionMode; + +/** One open Shift window that can be addressed through runtime capabilities. */ +export interface ShiftSession { + windowId: number; + sessionId: string; + mode: ShiftSessionMode; + focused: boolean; + editorConnected: boolean; +} + +/** One external/user-space axis coordinate displayed by the editor. */ +export interface AxisCoordinate { + axisId: AxisId; + value: number; +} + +/** Glyph occurrence currently placed in the editor, when a glyph route is open. */ +export interface EditorGlyph { + glyphId: GlyphId; + name: string; + nodeId: NodeId; + sourceId: SourceId; +} + +/** Active editor tool and its published state discriminator. */ +export interface EditorTool { + id: string; + state: string; +} + +/** Renderer-owned facts for one live Shift window. */ +export interface EditorView { + route: string; + glyph: EditorGlyph | null; + activeSourceId: SourceId | null; + editingSourceIds: SourceId[]; + externalLocation: AxisCoordinate[]; + selectionIds: SelectableId[]; + tool: EditorTool | null; + dragging: boolean; + editing: boolean; + applyStatus: "idle" | "queued" | "applying" | null; +} + +/** Editor facts paired with the explicit window and font-session target. */ +export interface EditorInspection extends EditorView { + windowId: number; + sessionId: string; + mode: ShiftSessionMode; +} + +/** Live application capabilities shared by protocol and plugin hosts. */ +export interface ShiftCapabilities { + sessions: { + list(): Promise; + }; + editor: { + inspect(input: { windowId: number }): Promise; + }; +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts new file mode 100644 index 000000000..7ae690c7c --- /dev/null +++ b/packages/runtime/src/index.ts @@ -0,0 +1,10 @@ +export type { + AxisCoordinate, + EditorGlyph, + EditorInspection, + EditorTool, + EditorView, + ShiftCapabilities, + ShiftSession, + ShiftSessionMode, +} from "./capabilities"; diff --git a/packages/runtime/tsconfig.json b/packages/runtime/tsconfig.json new file mode 100644 index 000000000..98bcaccb3 --- /dev/null +++ b/packages/runtime/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig/library.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src"] +} diff --git a/packages/runtime/tsdown.config.ts b/packages/runtime/tsdown.config.ts new file mode 100644 index 000000000..6a661face --- /dev/null +++ b/packages/runtime/tsdown.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: { capabilities: "src/capabilities.ts" }, + outDir: ".code-api", + format: "esm", + platform: "neutral", + dts: { + eager: true, + generator: "tsc", + tsconfig: "tsconfig.json", + }, + sourcemap: false, + clean: true, + deps: { + alwaysBundle: ["@shift/types"], + }, +}); diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 9f634c8a0..2ea21fdbf 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -70,6 +70,10 @@ export function FontEditor({ source }: { source: MemoryFontSource }) { The source owns its loaded font data. If it has a `dispose` operation—for example, a future worker-backed WASM source—the host disposes it separately from the editor session. +## Host capabilities + +`ShiftCapabilities` and its observation types describe the host-neutral live application API shared by protocol adapters and future plugin hosts. Browser integrations may implement the same contract with `"memory"` sessions; Electron and MCP are not part of the SDK contract. + ## Custom chrome Use individual primitives when the standard desktop-like shell is not appropriate: diff --git a/packages/sdk/api/index.api.md b/packages/sdk/api/index.api.md index d48451553..8b77dbc60 100644 --- a/packages/sdk/api/index.api.md +++ b/packages/sdk/api/index.api.md @@ -4,6 +4,16 @@ ```ts +// @public +export interface AxisCoordinate { + // Warning: (ae-forgotten-export) The symbol "AxisId" needs to be exported by the entry point index.d.ts + // + // (undocumented) + axisId: AxisId; + // (undocumented) + value: number; +} + // Warning: (ae-forgotten-export) The symbol "ComputedOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ComputedSignal" needs to be exported by the entry point index.d.ts // @@ -45,7 +55,6 @@ export class Editor { get camera(): Camera; canDecomposeSelection(): boolean; collapseEditingSources(): boolean; - // Warning: (ae-forgotten-export) The symbol "SelectableId" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ComponentTransformSelection" needs to be exported by the entry point index.d.ts componentTransformSelection(ids: readonly SelectableId[]): ComponentTransformSelection | null; // Warning: (ae-forgotten-export) The symbol "ShiftContent" needs to be exported by the entry point index.d.ts @@ -186,7 +195,6 @@ export class Editor { // (undocumented) nodeDefinition(kind: NodeKind): NodeDefinition; notifyPreviewMutationAttempt(): void; - // Warning: (ae-forgotten-export) The symbol "ShiftId" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ShiftObject" needs to be exported by the entry point index.d.ts object(id: ShiftId): ShiftObject | null; objects(ids: readonly ShiftId[]): readonly ShiftObject[]; @@ -315,6 +323,62 @@ export class Editor { zoomToSelection(): void; } +// @public +export interface EditorGlyph { + // (undocumented) + glyphId: GlyphId; + // (undocumented) + name: string; + // Warning: (ae-forgotten-export) The symbol "NodeId" needs to be exported by the entry point index.d.ts + // + // (undocumented) + nodeId: NodeId; + // (undocumented) + sourceId: SourceId; +} + +// @public +export interface EditorInspection extends EditorView { + // (undocumented) + mode: ShiftSessionMode; + // (undocumented) + sessionId: string; + // (undocumented) + windowId: number; +} + +// @public +export interface EditorTool { + // (undocumented) + id: string; + // (undocumented) + state: string; +} + +// @public +export interface EditorView { + // (undocumented) + activeSourceId: SourceId | null; + // (undocumented) + applyStatus: "idle" | "queued" | "applying" | null; + // (undocumented) + dragging: boolean; + // (undocumented) + editing: boolean; + // (undocumented) + editingSourceIds: SourceId[]; + // (undocumented) + externalLocation: AxisCoordinate[]; + // (undocumented) + glyph: EditorGlyph | null; + // (undocumented) + route: string; + // (undocumented) + selectionIds: SelectableId[]; + // (undocumented) + tool: EditorTool | null; +} + // Warning: (ae-forgotten-export) The symbol "EffectOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "Effect" needs to be exported by the entry point index.d.ts // @@ -341,7 +405,6 @@ export class Font { contourIdForPoint(pointId: PointId): ContourId | null; contourIdForSegment(segmentId: SegmentId): ContourId | null; // Warning: (ae-forgotten-export) The symbol "AxisDefinition" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "AxisId" needs to be exported by the entry point index.d.ts createAxis(axis: AxisDefinition): AxisId; createGlyph(name: GlyphName): GlyphRecord; createGlyphForUnicode(unicode: Unicode): GlyphRecord; @@ -392,7 +455,6 @@ export class Font { layerIdForAnchor(anchorId: AnchorId): LayerId | null; layerIdForContour(contourId: ContourId): LayerId | null; layerIdForPoint(pointId: PointId): LayerId | null; - // Warning: (ae-forgotten-export) The symbol "SegmentId" needs to be exported by the entry point index.d.ts layerIdForSegment(segmentId: SegmentId): LayerId | null; // Warning: (ae-forgotten-export) The symbol "GlyphLayerState" needs to be exported by the entry point index.d.ts layerState(layerId: LayerId): GlyphLayerState | null; @@ -642,6 +704,48 @@ export interface MemoryFontSource extends GlyphReader { // @public export type MemoryToolName = "select" | "hand"; +// @public +export type SegmentId = string & { + readonly [SegmentIdBrand]: typeof SegmentIdBrand; +}; + +// @public +export type SelectableId = ShiftId; + +// @public +export interface ShiftCapabilities { + // (undocumented) + editor: { + inspect(input: { + windowId: number; + }): Promise; + }; + // (undocumented) + sessions: { + list(): Promise; + }; +} + +// @public +export type ShiftId = NodeId | PointId | AnchorId | ContourId | SegmentId | ComponentId; + +// @public +export interface ShiftSession { + // (undocumented) + editorConnected: boolean; + // (undocumented) + focused: boolean; + // (undocumented) + mode: ShiftSessionMode; + // (undocumented) + sessionId: string; + // (undocumented) + windowId: number; +} + +// @public (undocumented) +export type ShiftSessionMode = FontSessionMode; + // @public export interface Signal { // Warning: (ae-forgotten-export) The symbol "SignalDebugSnapshot" needs to be exported by the entry point index.d.ts @@ -666,7 +770,7 @@ export function useSignalState(signal: Signal, options?: UseSignalOptions) // Warnings were encountered during analysis: // -// dist/Editor-BO84Q4oC.d.ts:3725:5 - (ae-forgotten-export) The symbol "Segment" needs to be exported by the entry point index.d.ts +// dist/Editor-Ch6PpQ3W.d.ts:3726:5 - (ae-forgotten-export) The symbol "Segment" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 186ad46e7..ffa439dc0 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -46,6 +46,7 @@ "@microsoft/api-extractor": "^7.59.2", "@playwright/test": "^1.59.1", "@shift/editor": "workspace:*", + "@shift/runtime": "workspace:*", "@shift/types": "workspace:*", "@shift/ui": "workspace:*", "@tailwindcss/vite": "^4.1.11", diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index d4e0ca8ea..7ed3acd50 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -14,4 +14,23 @@ export { computed, effect, useSignalState } from "@shift/editor/signals"; export type { Signal } from "@shift/editor/signals"; export { externalAxisLocationFromRecord } from "@shift/editor/variation"; export type { DesignAxisLocation, ExternalAxisLocation } from "@shift/editor/variation"; -export type { FontSnapshot, GlyphId, GlyphPreview, GlyphRecord, GlyphSnapshot } from "@shift/types"; +export type { + AxisCoordinate, + EditorGlyph, + EditorInspection, + EditorTool, + EditorView, + ShiftCapabilities, + ShiftSession, + ShiftSessionMode, +} from "@shift/runtime"; +export type { + FontSnapshot, + GlyphId, + GlyphPreview, + GlyphRecord, + GlyphSnapshot, + SegmentId, + SelectableId, + ShiftId, +} from "@shift/types"; diff --git a/packages/sdk/tsdown.dts.config.ts b/packages/sdk/tsdown.dts.config.ts index 70f9935f9..156a530b4 100644 --- a/packages/sdk/tsdown.dts.config.ts +++ b/packages/sdk/tsdown.dts.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ "@shift/geo", "@shift/glyph-state", "@shift/rules", + "@shift/runtime", "@shift/types", "@shift/validation", "regl", diff --git a/packages/types/docs/DOCS.md b/packages/types/docs/DOCS.md index aed98f274..c4d3410a9 100644 --- a/packages/types/docs/DOCS.md +++ b/packages/types/docs/DOCS.md @@ -46,6 +46,7 @@ Import from `@shift/types`. - `LayerReplaced` -- one replaced glyph layer in an applied change. - `AddComponentIntent` / `SetComponentTransformsIntent` / `RemoveComponentsIntent` / `DecomposeComponentsIntent` -- generated component-authoring DTOs using branded layer, component, and glyph identities plus ordered decomposed-transform values. - `PointType` -- bridge point type union: `"onCurve" | "offCurve" | "qCurve"`. Quadratic endpoints remain distinct across transport even though anchor predicates accept both on-curve variants. +- `SegmentId` / `ShiftId` / `SelectableId` -- canonical identities for derived segments and editor-addressable objects. Geometry packages construct and interpret segment identities, but all identity brands and unions are owned here. ## How it works diff --git a/packages/types/src/ids.ts b/packages/types/src/ids.ts index b50ff1226..372545ee4 100644 --- a/packages/types/src/ids.ts +++ b/packages/types/src/ids.ts @@ -23,6 +23,7 @@ declare const MetricIdBrand: unique symbol; declare const NamedInstanceIdBrand: unique symbol; declare const NodeIdBrand: unique symbol; declare const RunIdBrand: unique symbol; +declare const SegmentIdBrand: unique symbol; declare const SourceIdBrand: unique symbol; /** @@ -116,6 +117,11 @@ export type NodeId = string & { readonly [NodeIdBrand]: typeof NodeIdBrand }; */ export type RunId = string & { readonly [RunIdBrand]: typeof RunIdBrand }; +/** Stable identity of one segment derived from its endpoint identities. */ +export type SegmentId = string & { + readonly [SegmentIdBrand]: typeof SegmentIdBrand; +}; + /** * A source identifier from Rust. * Branded string type - can't be confused with other IDs or plain strings. @@ -124,6 +130,12 @@ export type SourceId = string & { readonly [SourceIdBrand]: typeof SourceIdBrand; }; +/** Identifies an editor-addressable scene node or glyph object. */ +export type ShiftId = NodeId | PointId | AnchorId | ContourId | SegmentId | ComponentId; + +/** Identifies objects that can be selected by the editor. */ +export type SelectableId = ShiftId; + /** * Convert a string ID from Rust to a typed PointId. * Use this when receiving IDs from Rust snapshots. @@ -224,6 +236,16 @@ export function asRunId(id: string): RunId { return id as RunId; } +/** + * Converts a derived segment identity string to a typed SegmentId. + * + * @param id - identity produced from one segment's endpoint identities. + * @returns the same string with its segment identity brand. + */ +export function asSegmentId(id: string): SegmentId { + return id as SegmentId; +} + /** * Convert a string ID from Rust to a typed SourceId. * Use this when receiving IDs from Rust snapshots. diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 5ba7ad99b..94977990e 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -18,6 +18,9 @@ export type { NamedInstanceId, NodeId, RunId, + SegmentId, + SelectableId, + ShiftId, SourceId, } from "./ids"; export { @@ -35,6 +38,7 @@ export { asNamedInstanceId, asNodeId, asRunId, + asSegmentId, asSourceId, isPointId, isContourId, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b08a14566..a5b853b7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,12 @@ importers: '@shift/glyph-state': specifier: workspace:* version: link:../../packages/glyph-state + '@shift/mcp': + specifier: workspace:* + version: link:../../packages/mcp + '@shift/runtime': + specifier: workspace:* + version: link:../../packages/runtime '@shift/types': specifier: workspace:* version: link:../../packages/types @@ -301,6 +307,43 @@ importers: specifier: ^4.1.10 version: 4.1.10(@types/node@25.3.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.49.0)(tsx@4.21.0)) + packages/mcp: + dependencies: + '@jitl/quickjs-singlefile-cjs-release-sync': + specifier: 0.32.0 + version: 0.32.0 + '@modelcontextprotocol/node': + specifier: 2.1.0 + version: 2.1.0(@modelcontextprotocol/server@2.2.0)(hono@4.13.12) + '@modelcontextprotocol/server': + specifier: 2.2.0 + version: 2.2.0 + '@shift/runtime': + specifier: workspace:* + version: link:../runtime + quickjs-emscripten-core: + specifier: 0.32.0 + version: 0.32.0 + zod: + specifier: ^4.1.12 + version: 4.3.6 + devDependencies: + '@shift/types': + specifier: workspace:* + version: link:../types + '@types/node': + specifier: ^25.3.0 + version: 25.3.0 + oxlint: + specifier: ^1.85.0 + version: 1.85.0 + typescript: + specifier: ^5.5.4 + version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@25.3.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.49.0)(tsx@4.21.0)) + packages/rules: dependencies: '@shift/geo': @@ -317,6 +360,25 @@ importers: specifier: ^4.1.10 version: 4.1.10(@types/node@25.3.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.49.0)(tsx@4.21.0)) + packages/runtime: + dependencies: + '@shift/types': + specifier: workspace:* + version: link:../types + devDependencies: + oxfmt: + specifier: ^0.49.0 + version: 0.49.0 + oxlint: + specifier: ^1.85.0 + version: 1.85.0 + tsdown: + specifier: 0.23.0 + version: 0.23.0(@typescript/native-preview@7.0.0-dev.20260707.2)(oxc-resolver@11.17.1)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.5.4 + version: 5.9.3 + packages/sdk: dependencies: '@base-ui-components/react': @@ -332,6 +394,9 @@ importers: '@shift/editor': specifier: workspace:* version: link:../editor + '@shift/runtime': + specifier: workspace:* + version: link:../runtime '@shift/types': specifier: workspace:* version: link:../types @@ -1061,6 +1126,12 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@hono/node-server@1.19.17': + resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@inquirer/ansi@2.0.4': resolution: {integrity: sha512-DpcZrQObd7S0R/U3bFdkcT5ebRwbTTC4D3tCc1vsJizmgPLxNJBo+AAFmrZwe8zk30P2QzgzGWZ3Q9uJwWuhIg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} @@ -1199,6 +1270,12 @@ packages: resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} + '@jitl/quickjs-ffi-types@0.32.0': + resolution: {integrity: sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==} + + '@jitl/quickjs-singlefile-cjs-release-sync@0.32.0': + resolution: {integrity: sha512-NjUUcw26PoeJHND6nmflAH8nIvAJvxJ2qkSPi95wfiBqPim80GtcdWommroiWb8hh1/7fVettEwodAsGt2Mrsg==} + '@jridgewell/gen-mapping@0.3.12': resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} @@ -1241,6 +1318,24 @@ packages: '@microsoft/tsdoc@0.17.0': resolution: {integrity: sha512-p68VexhnH7ojf3U27RdryUDqnKJgKaWUbCL96vJR7N0mqDuhKG5OIECk3sCPPdynxjk+yoVhRkScqtv5KQjzsw==} + '@modelcontextprotocol/core@2.2.0': + resolution: {integrity: sha512-iLhmprRmWI8EcosOA3wVvww22z02NkgqhV4fBH6f/odQBsi7xnJc0HGmi21yWO+/iKw2yzqVE127Zhna5/+JXw==} + engines: {node: '>=20'} + + '@modelcontextprotocol/node@2.1.0': + resolution: {integrity: sha512-6xg3iWVcOfiL8Y7rI6xUqXD0lI2AY5q3djsa/cOi1IJUSwx06t0gvhSwU/6mIIohrFlt40/ANUG56DQ0HLhILQ==} + engines: {node: '>=20'} + peerDependencies: + '@modelcontextprotocol/server': ^2.1.0 + hono: ^4.11.4 + peerDependenciesMeta: + hono: + optional: true + + '@modelcontextprotocol/server@2.2.0': + resolution: {integrity: sha512-qFnltjus6Gk8Lx6J1w2RXmr3YPN22MNT6qD6zjAkaiWIlFIFxWClz0rXHNuJF/KSUyn5YGhkSvXkWDbQHVLFEQ==} + engines: {node: '>=20'} + '@napi-rs/cli@3.6.2': resolution: {integrity: sha512-jy5rABUh9tbE/vPRzw9kGzGuqZiVslyDQUV8LkvjzqVX/oJMN7g0U1uhtr9L3W1H+iRM/urXHXUf+CE4n8FvLA==} engines: {node: '>= 16'} @@ -3789,6 +3884,10 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hono@4.13.12: + resolution: {integrity: sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==} + engines: {node: '>=16.9.0'} + hookable@6.1.2: resolution: {integrity: sha512-+abwxtiEA52GCVIsQqut3S/uKTbUwYIp4Pe/vv+6py5XiXBCqMZHg6pA6Y5qhgLSEys0/cYuPbO0z1QFj5ZCmg==} @@ -4440,6 +4539,9 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + quickjs-emscripten-core@0.32.0: + resolution: {integrity: sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==} + react-dom@19.2.8: resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: @@ -5824,6 +5926,10 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@hono/node-server@1.19.17(hono@4.13.12)': + dependencies: + hono: 4.13.12 + '@inquirer/ansi@2.0.4': {} '@inquirer/checkbox@5.1.2(@types/node@25.3.0)': @@ -5947,6 +6053,12 @@ snapshots: dependencies: minipass: 7.1.2 + '@jitl/quickjs-ffi-types@0.32.0': {} + + '@jitl/quickjs-singlefile-cjs-release-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + '@jridgewell/gen-mapping@0.3.12': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -6020,6 +6132,22 @@ snapshots: '@microsoft/tsdoc@0.17.0': {} + '@modelcontextprotocol/core@2.2.0': + dependencies: + zod: 4.3.6 + + '@modelcontextprotocol/node@2.1.0(@modelcontextprotocol/server@2.2.0)(hono@4.13.12)': + dependencies: + '@hono/node-server': 1.19.17(hono@4.13.12) + '@modelcontextprotocol/server': 2.2.0 + optionalDependencies: + hono: 4.13.12 + + '@modelcontextprotocol/server@2.2.0': + dependencies: + '@modelcontextprotocol/core': 2.2.0 + zod: 4.3.6 + '@napi-rs/cli@3.6.2(@emnapi/runtime@1.8.1)(@types/node@25.3.0)(supports-color@8.1.1)': dependencies: '@inquirer/prompts': 8.3.2(@types/node@25.3.0) @@ -8154,6 +8282,8 @@ snapshots: dependencies: function-bind: 1.1.2 + hono@4.13.12: {} + hookable@6.1.2: {} hosted-git-info@4.1.0: @@ -8834,6 +8964,10 @@ snapshots: quick-lru@5.1.1: {} + quickjs-emscripten-core@0.32.0: + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + react-dom@19.2.8(react@19.2.8): dependencies: react: 19.2.8 diff --git a/vitest.config.ts b/vitest.config.ts index cb1679d91..3e1e59e77 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ projects: [ "apps/desktop/vitest.config.ts", "packages/geo/vitest.config.ts", + "packages/mcp/vitest.config.ts", "packages/validation/vitest.config.ts", "packages/ui/vitest.config.ts", "packages/glyph-info/vitest.config.ts", From 80235145b91fac3c626046a0383fe1acfd60d4dd Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Fri, 2 Oct 2026 11:05:06 +0000 Subject: [PATCH 2/6] refactor(types): use canonical selectable identity --- .../src/components/editor/object-tree/VirtualObjectRows.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/src/components/editor/object-tree/VirtualObjectRows.tsx b/apps/desktop/src/renderer/src/components/editor/object-tree/VirtualObjectRows.tsx index eaf592a19..5da1bb73d 100644 --- a/apps/desktop/src/renderer/src/components/editor/object-tree/VirtualObjectRows.tsx +++ b/apps/desktop/src/renderer/src/components/editor/object-tree/VirtualObjectRows.tsx @@ -1,6 +1,6 @@ import { cn } from "@shift/ui"; import { useLayoutEffect, useState } from "react"; -import type { SelectableId } from "@shift/editor/types"; +import type { SelectableId } from "@shift/types"; import type { VirtualObjectRowsProps } from "@/types/objectTree"; import { ObjectRow } from "./ObjectRow"; From af4f7e65fbfd2b9a57e4e20a89937ec81fc3ddfd Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sat, 3 Oct 2026 16:06:04 +0300 Subject: [PATCH 3/6] feat(mcp): add live font and glyph inspection Expose paged glyph directories and source-scoped authored layers through shared runtime capabilities. Read workspace snapshots so agents can inspect unopened glyphs without treating interpolated previews as authored data. --- .agents/skills/shift/SKILL.md | 37 ++- .claude/skills/shift/SKILL.md | 37 ++- .codex/skills/shift/SKILL.md | 37 ++- apps/desktop/e2e/live-agent.spec.ts | 79 ++++- apps/desktop/src/main/agent/AgentClient.ts | 34 ++- apps/desktop/src/main/app/App.ts | 22 ++ apps/desktop/src/main/docs/DOCS.md | 2 +- .../src/main/sandbox/SandboxRuntimeProcess.ts | 4 + .../src/renderer/src/agent/AgentBridge.ts | 174 ++++++++++- apps/desktop/src/shared/agent/protocol.ts | 20 +- apps/desktop/src/shared/sandbox/protocol.ts | 24 +- apps/desktop/src/utility/sandbox.ts | 10 + packages/mcp/docs/DOCS.md | 16 +- packages/mcp/src/code.test.ts | 74 +++++ packages/mcp/src/code.ts | 24 +- packages/mcp/src/server.test.ts | 18 ++ packages/runtime/docs/DOCS.md | 7 +- packages/runtime/generated/code-api.d.ts | 269 ++++++++++++++++++ packages/runtime/package.json | 4 +- packages/runtime/src/capabilities.ts | 77 +++++ packages/runtime/src/index.ts | 7 + packages/runtime/src/inputs.ts | 22 ++ packages/runtime/tsdown.config.ts | 2 +- pnpm-lock.yaml | 6 + 24 files changed, 985 insertions(+), 21 deletions(-) create mode 100644 packages/runtime/src/inputs.ts diff --git a/.agents/skills/shift/SKILL.md b/.agents/skills/shift/SKILL.md index 8ad4950ac..937d8717d 100644 --- a/.agents/skills/shift/SKILL.md +++ b/.agents/skills/shift/SKILL.md @@ -23,7 +23,7 @@ The client discovers run descriptors for Shift, Shift Nightly, and development b node scripts/client.mjs describe --descriptor ``` -The first slice exposes `shift.sessions.list()` and `shift.editor.inspect({ windowId })` inside `shift.execute`. +The typed API exposes `shift.sessions.list()`, `shift.editor.inspect({ windowId })`, `shift.font.get({ windowId })`, `shift.glyphs.list({ windowId, limit?, cursor?, sourceId? })`, `shift.glyphs.get({ windowId, glyphId })` or `shift.glyphs.get({ windowId, name })`, and `shift.layers.get({ windowId, glyphId, sourceId })` inside `shift.execute`. ## Execute code @@ -40,10 +40,43 @@ async () => { EOF ``` -Always target the explicit `windowId` returned by `sessions.list()`. Do not assume focus is stable. Treat `editor.inspect()` as a point-in-time renderer observation: it reports the current glyph occurrence, active/editing sources, external location, selection, tool, gesture flags, and workspace apply status. It does not return authored glyph geometry or commit edits in this first slice. +Always target the explicit `windowId` returned by `sessions.list()`. Do not assume focus is stable. `editor.inspect()` reports a point-in-time renderer observation; `font.get()` returns Home-safe metadata, metrics, axes, global master sources, named instances, and glyph count. Individual glyphs may advertise additional authored `sourceIds` for non-master support layers; those IDs are also valid for layer reads. `glyphs.get()` resolves one glyph by exact name or stable ID, without scanning the directory; provide exactly one of `name` or `glyphId`. `glyphs.list()` returns directory entries with an opaque `nextCursor` (pass it back as `cursor` until null). Supply a specific `sourceId` to include authored `structure` for each glyph in a bounded page; `null` means no layer in that source. `layers.get()` returns positions and structure for one authored glyph/source layer, or `null` if the layer is absent. Preview sessions expose font and glyph directory facts but have no authored layers; these operations are read-only. + +For example, count authored anchors in one explicitly chosen source, paging without returning every glyph: + +```js +async () => { + const session = (await shift.sessions.list()).find((session) => session.sessionId === "..."); + if (!session) throw new Error("Target Shift session is not open"); + const font = await shift.font.get({ windowId: session.windowId }); + const source = font.sources.find((source) => source.name === "Regular"); + if (!source) throw new Error("Target source is not open"); + let cursor; + let anchors = 0; + do { + const page = await shift.glyphs.list({ windowId: session.windowId, sourceId: source.id, cursor, limit: 20 }); + for (const glyph of page.items) anchors += glyph.structure?.anchors.length ?? 0; + cursor = page.nextCursor ?? undefined; + } while (cursor); + return { sourceId: source.id, anchors }; +} +``` + +A live directory may change between pages; a cursor is not a frozen snapshot. Large scans may exceed the sandbox deadline: return a partial result and `nextCursor` to continue in another call rather than assuming the entire font fits one execution. Keep returned values focused. Filter and map inside code mode instead of returning complete intermediate responses. Generated code has no filesystem, network, environment, Node.js, or Electron access. +## Saved fonts without Shift + +Use the Rust `shift-cli` binary, not the live MCP, for a file that is not open in the app. From the Shift checkout, install or update it after pulling changes with `cargo install --path crates/shift-cli --bin shift-cli --locked --force` (inside the repository's Nix dev shell). For a one-off query without installation, use `cargo run -p shift-cli --` in place of `shift-cli`. + +```sh +shift-cli inspect --json /absolute/path/Family.shift +shift-cli glyph inspect /absolute/path/Family.ufo A --json +``` + +`inspect --json` exposes `.shift` glyph directories and per-layer counts; `glyph inspect --json` accepts `.shift`, UFO, Designspace, Glyphs, TTF, and OTF and reports glyph structure, source-layer presence, and resolved geometry. Its `--view` flag changes human-readable output, **not** the JSON report. Use `fontTools` for low-level UFO/OpenType format questions where useful, but do not describe its data as Shift's authored source model. Neither CLI JSON command currently returns every authored layer's point and anchor coordinates: ask for a CLI read extension if a saved-file question requires those rather than substituting interpolated/resolved geometry. Do not claim the disk file includes unsaved app edits. + ## Safety and interpretation - The connection secret is transport material, not a user login. Never print or include it in conversation output. diff --git a/.claude/skills/shift/SKILL.md b/.claude/skills/shift/SKILL.md index 8ad4950ac..937d8717d 100644 --- a/.claude/skills/shift/SKILL.md +++ b/.claude/skills/shift/SKILL.md @@ -23,7 +23,7 @@ The client discovers run descriptors for Shift, Shift Nightly, and development b node scripts/client.mjs describe --descriptor ``` -The first slice exposes `shift.sessions.list()` and `shift.editor.inspect({ windowId })` inside `shift.execute`. +The typed API exposes `shift.sessions.list()`, `shift.editor.inspect({ windowId })`, `shift.font.get({ windowId })`, `shift.glyphs.list({ windowId, limit?, cursor?, sourceId? })`, `shift.glyphs.get({ windowId, glyphId })` or `shift.glyphs.get({ windowId, name })`, and `shift.layers.get({ windowId, glyphId, sourceId })` inside `shift.execute`. ## Execute code @@ -40,10 +40,43 @@ async () => { EOF ``` -Always target the explicit `windowId` returned by `sessions.list()`. Do not assume focus is stable. Treat `editor.inspect()` as a point-in-time renderer observation: it reports the current glyph occurrence, active/editing sources, external location, selection, tool, gesture flags, and workspace apply status. It does not return authored glyph geometry or commit edits in this first slice. +Always target the explicit `windowId` returned by `sessions.list()`. Do not assume focus is stable. `editor.inspect()` reports a point-in-time renderer observation; `font.get()` returns Home-safe metadata, metrics, axes, global master sources, named instances, and glyph count. Individual glyphs may advertise additional authored `sourceIds` for non-master support layers; those IDs are also valid for layer reads. `glyphs.get()` resolves one glyph by exact name or stable ID, without scanning the directory; provide exactly one of `name` or `glyphId`. `glyphs.list()` returns directory entries with an opaque `nextCursor` (pass it back as `cursor` until null). Supply a specific `sourceId` to include authored `structure` for each glyph in a bounded page; `null` means no layer in that source. `layers.get()` returns positions and structure for one authored glyph/source layer, or `null` if the layer is absent. Preview sessions expose font and glyph directory facts but have no authored layers; these operations are read-only. + +For example, count authored anchors in one explicitly chosen source, paging without returning every glyph: + +```js +async () => { + const session = (await shift.sessions.list()).find((session) => session.sessionId === "..."); + if (!session) throw new Error("Target Shift session is not open"); + const font = await shift.font.get({ windowId: session.windowId }); + const source = font.sources.find((source) => source.name === "Regular"); + if (!source) throw new Error("Target source is not open"); + let cursor; + let anchors = 0; + do { + const page = await shift.glyphs.list({ windowId: session.windowId, sourceId: source.id, cursor, limit: 20 }); + for (const glyph of page.items) anchors += glyph.structure?.anchors.length ?? 0; + cursor = page.nextCursor ?? undefined; + } while (cursor); + return { sourceId: source.id, anchors }; +} +``` + +A live directory may change between pages; a cursor is not a frozen snapshot. Large scans may exceed the sandbox deadline: return a partial result and `nextCursor` to continue in another call rather than assuming the entire font fits one execution. Keep returned values focused. Filter and map inside code mode instead of returning complete intermediate responses. Generated code has no filesystem, network, environment, Node.js, or Electron access. +## Saved fonts without Shift + +Use the Rust `shift-cli` binary, not the live MCP, for a file that is not open in the app. From the Shift checkout, install or update it after pulling changes with `cargo install --path crates/shift-cli --bin shift-cli --locked --force` (inside the repository's Nix dev shell). For a one-off query without installation, use `cargo run -p shift-cli --` in place of `shift-cli`. + +```sh +shift-cli inspect --json /absolute/path/Family.shift +shift-cli glyph inspect /absolute/path/Family.ufo A --json +``` + +`inspect --json` exposes `.shift` glyph directories and per-layer counts; `glyph inspect --json` accepts `.shift`, UFO, Designspace, Glyphs, TTF, and OTF and reports glyph structure, source-layer presence, and resolved geometry. Its `--view` flag changes human-readable output, **not** the JSON report. Use `fontTools` for low-level UFO/OpenType format questions where useful, but do not describe its data as Shift's authored source model. Neither CLI JSON command currently returns every authored layer's point and anchor coordinates: ask for a CLI read extension if a saved-file question requires those rather than substituting interpolated/resolved geometry. Do not claim the disk file includes unsaved app edits. + ## Safety and interpretation - The connection secret is transport material, not a user login. Never print or include it in conversation output. diff --git a/.codex/skills/shift/SKILL.md b/.codex/skills/shift/SKILL.md index 8ad4950ac..937d8717d 100644 --- a/.codex/skills/shift/SKILL.md +++ b/.codex/skills/shift/SKILL.md @@ -23,7 +23,7 @@ The client discovers run descriptors for Shift, Shift Nightly, and development b node scripts/client.mjs describe --descriptor ``` -The first slice exposes `shift.sessions.list()` and `shift.editor.inspect({ windowId })` inside `shift.execute`. +The typed API exposes `shift.sessions.list()`, `shift.editor.inspect({ windowId })`, `shift.font.get({ windowId })`, `shift.glyphs.list({ windowId, limit?, cursor?, sourceId? })`, `shift.glyphs.get({ windowId, glyphId })` or `shift.glyphs.get({ windowId, name })`, and `shift.layers.get({ windowId, glyphId, sourceId })` inside `shift.execute`. ## Execute code @@ -40,10 +40,43 @@ async () => { EOF ``` -Always target the explicit `windowId` returned by `sessions.list()`. Do not assume focus is stable. Treat `editor.inspect()` as a point-in-time renderer observation: it reports the current glyph occurrence, active/editing sources, external location, selection, tool, gesture flags, and workspace apply status. It does not return authored glyph geometry or commit edits in this first slice. +Always target the explicit `windowId` returned by `sessions.list()`. Do not assume focus is stable. `editor.inspect()` reports a point-in-time renderer observation; `font.get()` returns Home-safe metadata, metrics, axes, global master sources, named instances, and glyph count. Individual glyphs may advertise additional authored `sourceIds` for non-master support layers; those IDs are also valid for layer reads. `glyphs.get()` resolves one glyph by exact name or stable ID, without scanning the directory; provide exactly one of `name` or `glyphId`. `glyphs.list()` returns directory entries with an opaque `nextCursor` (pass it back as `cursor` until null). Supply a specific `sourceId` to include authored `structure` for each glyph in a bounded page; `null` means no layer in that source. `layers.get()` returns positions and structure for one authored glyph/source layer, or `null` if the layer is absent. Preview sessions expose font and glyph directory facts but have no authored layers; these operations are read-only. + +For example, count authored anchors in one explicitly chosen source, paging without returning every glyph: + +```js +async () => { + const session = (await shift.sessions.list()).find((session) => session.sessionId === "..."); + if (!session) throw new Error("Target Shift session is not open"); + const font = await shift.font.get({ windowId: session.windowId }); + const source = font.sources.find((source) => source.name === "Regular"); + if (!source) throw new Error("Target source is not open"); + let cursor; + let anchors = 0; + do { + const page = await shift.glyphs.list({ windowId: session.windowId, sourceId: source.id, cursor, limit: 20 }); + for (const glyph of page.items) anchors += glyph.structure?.anchors.length ?? 0; + cursor = page.nextCursor ?? undefined; + } while (cursor); + return { sourceId: source.id, anchors }; +} +``` + +A live directory may change between pages; a cursor is not a frozen snapshot. Large scans may exceed the sandbox deadline: return a partial result and `nextCursor` to continue in another call rather than assuming the entire font fits one execution. Keep returned values focused. Filter and map inside code mode instead of returning complete intermediate responses. Generated code has no filesystem, network, environment, Node.js, or Electron access. +## Saved fonts without Shift + +Use the Rust `shift-cli` binary, not the live MCP, for a file that is not open in the app. From the Shift checkout, install or update it after pulling changes with `cargo install --path crates/shift-cli --bin shift-cli --locked --force` (inside the repository's Nix dev shell). For a one-off query without installation, use `cargo run -p shift-cli --` in place of `shift-cli`. + +```sh +shift-cli inspect --json /absolute/path/Family.shift +shift-cli glyph inspect /absolute/path/Family.ufo A --json +``` + +`inspect --json` exposes `.shift` glyph directories and per-layer counts; `glyph inspect --json` accepts `.shift`, UFO, Designspace, Glyphs, TTF, and OTF and reports glyph structure, source-layer presence, and resolved geometry. Its `--view` flag changes human-readable output, **not** the JSON report. Use `fontTools` for low-level UFO/OpenType format questions where useful, but do not describe its data as Shift's authored source model. Neither CLI JSON command currently returns every authored layer's point and anchor coordinates: ask for a CLI read extension if a saved-file question requires those rather than substituting interpolated/resolved geometry. Do not claim the disk file includes unsaved app edits. + ## Safety and interpretation - The connection secret is transport material, not a user login. Never print or include it in conversation output. diff --git a/apps/desktop/e2e/live-agent.spec.ts b/apps/desktop/e2e/live-agent.spec.ts index 28e4504fc..4ee67d0dc 100644 --- a/apps/desktop/e2e/live-agent.spec.ts +++ b/apps/desktop/e2e/live-agent.spec.ts @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; import path from "node:path"; import { promisify } from "node:util"; -import { workspaceTest as test, expect } from "./fixtures/electronApp"; +import { workspaceTest as test, expect, UFO_FONT_PATH } from "./fixtures/electronApp"; const execFileAsync = promisify(execFile); const MCP_CLIENT = path.resolve(__dirname, "../../../.agents/skills/shift/scripts/client.mjs"); @@ -18,6 +18,83 @@ async function runShiftCode(testRoot: string, code: string): Promise { return JSON.parse(stdout); } +test.describe("authored font reads from Home", () => { + test.use({ startupFontPath: UFO_FONT_PATH }); + + test("paginates glyphs and reads named source anchors", async ({ testRoot }) => { + const result = await runShiftCode( + testRoot, + `async () => { + const session = (await shift.sessions.list()).find(({ editorConnected }) => editorConnected); + if (!session) throw new Error("Expected connected font"); + const windowId = session.windowId; + const font = await shift.font.get({ windowId }); + const first = await shift.glyphs.list({ windowId, limit: 1 }); + const second = await shift.glyphs.list({ windowId, limit: 1, cursor: first.nextCursor }); + const glyph = await shift.glyphs.get({ windowId, glyphId: first.items[0].id }); + const directory = await shift.glyphs.list({ windowId, limit: 100 }); + const e = directory.items.find(({ name }) => name === "E"); + if (!e) throw new Error("Fixture glyph E is missing"); + const eByName = await shift.glyphs.get({ windowId, name: "E" }); + const sourceId = font.sources[0].id; + const layer = await shift.layers.get({ windowId, glyphId: e.id, sourceId }); + const sourcePage = await shift.glyphs.list({ windowId, limit: 1, sourceId }); + const a = directory.items.find(({ name }) => name === "A"); + const supportId = a?.sourceIds.find((id) => id !== sourceId); + if (!a || !supportId) throw new Error("Missing support-layer fixture"); + const sparse = directory.items.find(({ sourceIds }) => !sourceIds.includes(supportId)); + if (!sparse) throw new Error("Missing sparse-layer fixture"); + const support = await shift.layers.get({ windowId, glyphId: a.id, sourceId: supportId }); + const absent = await shift.layers.get({ windowId, glyphId: sparse.id, sourceId: supportId }); + const supportPage = await shift.glyphs.list({ windowId, limit: 1, sourceId: supportId }); + return { + supportLayerId: support?.layerId, + absent, + supportStructure: supportPage.items[0].structure, + familyName: font.metadata.familyName, + unitsPerEm: font.metrics.unitsPerEm, + glyphCount: font.glyphCount, + mode: font.mode, + first: first.items[0], + nextCursor: first.nextCursor, + second: second.items[0], + glyph, + anchors: layer?.anchors.map(({ name }) => name), + eId: e.id, + eByNameId: eByName.id, + structure: sourcePage.items[0].structure, + }; + }`, + ); + + expect(result).toMatchObject({ + familyName: "MutatorMathTest", + unitsPerEm: 1000, + glyphCount: 48, + mode: "workspace", + anchors: ["top"], + absent: null, + supportLayerId: expect.any(String), + }); + const page = result as { + first: { id: string }; + nextCursor: string; + second: { id: string }; + glyph: { id: string }; + structure: unknown; + supportStructure: unknown; + eId: string; + eByNameId: string; + }; + expect(page.nextCursor).toEqual(expect.any(String)); + expect(page.second.id).not.toBe(page.first.id); + expect(page.glyph.id).toBe(page.first.id); + expect(page.eByNameId).toBe(page.eId); + expect(page.structure).not.toBeNull(); + expect(page.supportStructure).not.toBeNull(); + }); +}); + test("inspects the explicitly targeted live editor", async ({ editor, testRoot }) => { await editor.openGlyphByName("A"); const point = await editor.selectVisiblePoint(); diff --git a/apps/desktop/src/main/agent/AgentClient.ts b/apps/desktop/src/main/agent/AgentClient.ts index 17e30b49e..0d1d8c7d2 100644 --- a/apps/desktop/src/main/agent/AgentClient.ts +++ b/apps/desktop/src/main/agent/AgentClient.ts @@ -1,4 +1,12 @@ -import type { EditorView } from "@shift/runtime"; +import type { + EditorView, + FontOverview, + GlyphPage, + GlyphSelector, + GlyphSummary, + LayerView, +} from "@shift/runtime"; +import type { GlyphId, SourceId } from "@shift/types"; import type { MessagePortMain } from "electron"; import type { AgentCallMap, AgentEventMap } from "../../shared/agent/protocol"; import { Channel, electronPortTransport } from "../../shared/workspace/channel"; @@ -30,6 +38,30 @@ export class AgentClient { return this.#channel.call("editor.inspect", undefined); } + getFont(): Promise { + return this.#call("font.get", undefined); + } + + listGlyphs(input: { limit?: number; cursor?: string; sourceId?: SourceId }): Promise { + return this.#call("glyphs.list", input); + } + + getGlyph(selector: GlyphSelector): Promise { + return this.#call("glyphs.get", selector); + } + + getLayer(glyphId: GlyphId, sourceId: SourceId): Promise { + return this.#call("layers.get", { glyphId, sourceId }); + } + + #call( + operation: K, + input: AgentCallMap[K]["request"], + ): Promise { + if (!this.#channel) return Promise.reject(new Error("agent renderer is not connected")); + return this.#channel.call(operation, input); + } + /** Disconnects the renderer and rejects pending inspection calls. */ dispose(): void { this.#channel?.dispose(); diff --git a/apps/desktop/src/main/app/App.ts b/apps/desktop/src/main/app/App.ts index da59adc2a..a71136c32 100644 --- a/apps/desktop/src/main/app/App.ts +++ b/apps/desktop/src/main/app/App.ts @@ -853,6 +853,19 @@ export class App { editor: { inspect: ({ windowId }) => this.#inspectEditor(windowId), }, + font: { + get: ({ windowId }) => this.#windowForAgentRequest(windowId).agent.getFont(), + }, + glyphs: { + list: ({ windowId, limit, cursor, sourceId }) => + this.#windowForAgentRequest(windowId).agent.listGlyphs({ limit, cursor, sourceId }), + get: ({ windowId, ...selector }) => + this.#windowForAgentRequest(windowId).agent.getGlyph(selector), + }, + layers: { + get: ({ windowId, glyphId, sourceId }) => + this.#windowForAgentRequest(windowId).agent.getLayer(glyphId, sourceId), + }, }); const mcp = new ShiftMcpServer({ execute: (code) => sandbox.execute(code), @@ -929,6 +942,15 @@ export class App { }; } + #windowForAgentRequest(windowId: number): Window { + const window = this.#windows.windowForId(windowId); + if (!window) throw new Error(`Shift window ${windowId} is not open`); + if (!this.#workspaces.getForBrowserWindow(window.window)) { + throw new Error(`Shift window ${windowId} has no font session`); + } + return window; + } + #fontSessionForSender(sender: WebContents, operation: string): FontSessionHost { const window = this.#requireWindowForWebContents(sender); const session = this.#workspaces.getForBrowserWindow(window.window); diff --git a/apps/desktop/src/main/docs/DOCS.md b/apps/desktop/src/main/docs/DOCS.md index ecab74b62..86dc17188 100644 --- a/apps/desktop/src/main/docs/DOCS.md +++ b/apps/desktop/src/main/docs/DOCS.md @@ -153,7 +153,7 @@ Message lanes reject in-flight calls when their remote port closes. An unexpecte ### IPC -Renderer IPC in `App` is limited to shell capabilities: command execution, clipboard, update-window progress/actions, optional document-lane and agent-lane port transfer, immutable session mode, readiness, and shared session sync-lane port transfer. Font data stays on the sync lane between renderer and utility. The agent lane returns renderer-owned view facts only; main adds the explicit window and font-session identities before returning an MCP result. +Renderer IPC in `App` is limited to shell capabilities: command execution, clipboard, update-window progress/actions, optional document-lane and agent-lane port transfer, immutable session mode, readiness, and shared session sync-lane port transfer. Font data stays on the sync lane between renderer and utility. The agent lane returns renderer-owned view facts and read-only font projections. Source-specific authored reads obtain workspace snapshots through the existing serialized sync lane rather than loading every glyph into the editor. Main resolves the explicit window and session before routing each MCP call; editor observations also carry their window and session identities. ## Workflow recipes diff --git a/apps/desktop/src/main/sandbox/SandboxRuntimeProcess.ts b/apps/desktop/src/main/sandbox/SandboxRuntimeProcess.ts index a57bcaaa4..49313f150 100644 --- a/apps/desktop/src/main/sandbox/SandboxRuntimeProcess.ts +++ b/apps/desktop/src/main/sandbox/SandboxRuntimeProcess.ts @@ -52,6 +52,10 @@ export class SandboxRuntimeProcess { serveChannel(transport, { "shift.sessions.list": () => this.#capabilities.sessions.list(), "shift.editor.inspect": (input) => this.#capabilities.editor.inspect(input), + "shift.font.get": (input) => this.#capabilities.font.get(input), + "shift.glyphs.list": (input) => this.#capabilities.glyphs.list(input), + "shift.glyphs.get": (input) => this.#capabilities.glyphs.get(input), + "shift.layers.get": (input) => this.#capabilities.layers.get(input), }); this.#process = proc; diff --git a/apps/desktop/src/renderer/src/agent/AgentBridge.ts b/apps/desktop/src/renderer/src/agent/AgentBridge.ts index 010d38003..6c5eecdac 100644 --- a/apps/desktop/src/renderer/src/agent/AgentBridge.ts +++ b/apps/desktop/src/renderer/src/agent/AgentBridge.ts @@ -1,4 +1,13 @@ -import type { EditorView } from "@shift/runtime"; +import type { + EditorView, + FontOverview, + GlyphPage, + GlyphSelector, + GlyphSummary, + LayerView, +} from "@shift/runtime"; +import type { GlyphId, GlyphState, SourceId } from "@shift/types"; +import { GlyphGeometry } from "@shift/glyph-state"; import type { ShiftHost } from "@shared/host/ShiftHost"; import type { AgentCallMap, AgentEventMap } from "@shared/agent/protocol"; import { domPortTransport, serveChannel, type ChannelServer } from "@shared/workspace/channel"; @@ -31,6 +40,10 @@ export class AgentBridge { this.#requests?.dispose(); this.#requests = serveChannel(domPortTransport(received), { "editor.inspect": () => this.#inspectEditor(), + "font.get": () => this.#getFont(), + "glyphs.list": (input) => this.#listGlyphs(input), + "glyphs.get": (selector) => this.#getGlyph(selector), + "layers.get": ({ glyphId, sourceId }) => this.#getLayer(glyphId, sourceId), }); } catch (error) { port.cancel(); @@ -45,6 +58,152 @@ export class AgentBridge { this.#requests = null; } + #getFont(): FontOverview { + const font = this.#session.font; + return { + mode: this.#session.mode, + metadata: font.metadata, + metrics: font.metrics, + glyphCount: font.glyphEntries().length, + axes: font.getAxes(), + sources: font.sources, + namedInstances: font.namedInstances, + }; + } + + async #listGlyphs({ + limit = 25, + cursor, + sourceId, + }: { + limit?: number; + cursor?: string; + sourceId?: SourceId; + }): Promise { + if (sourceId) this.#requireAuthoredSource(sourceId); + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + throw new Error("glyphs.list limit must be between 1 and 100"); + } + + const font = this.#session.font; + const entries = font.glyphEntries(); + let start = 0; + if (cursor) { + let previousId: string; + try { + previousId = atob(cursor); + } catch { + throw new Error("Invalid glyph cursor"); + } + + const previousIndex = entries.findIndex(({ id }) => id === previousId); + if (previousIndex < 0) throw new Error("Glyph cursor is no longer in this font"); + start = previousIndex + 1; + } + + const page = entries.slice(start, start + limit); + const items = page.map((entry) => glyphSummary(this.#session, entry.id)); + if (sourceId) { + const requested = items.filter((item) => item.sourceIds.includes(sourceId)); + if (this.#session.workspace) { + const snapshots = await this.#session.workspace.editCoordinator.readGlyphSnapshots( + requested.map(({ id }) => ({ glyphId: id })), + ); + const byId = new Map(snapshots.map((snapshot) => [snapshot.glyphId, snapshot])); + for (const item of items) { + if (!item.sourceIds.includes(sourceId)) { + item.structure = null; + continue; + } + const structure = byId.get(item.id)?.layers.find((layer) => layer.sourceId === sourceId) + ?.state.structure; + if (!structure) throw new Error(`Authored layer for glyph ${item.id} was not returned`); + item.structure = structure; + } + } else { + const glyphs = await font.loadGlyphs(requested.map(({ id }) => id)); + const byId = new Map(glyphs.map((glyph) => [glyph.id, glyph])); + for (const item of items) { + item.structure = byId.get(item.id)?.layerForSource(sourceId)?.state.structure ?? null; + } + } + } + + const last = page.at(-1); + return { + items, + nextCursor: start + page.length < entries.length && last ? btoa(last.id) : null, + }; + } + + #getGlyph(selector: GlyphSelector): GlyphSummary { + const font = this.#session.font; + const entry = + selector.name !== undefined + ? font.entryForName(selector.name) + : font.entryForId(selector.glyphId); + if (!entry) throw new Error(`Glyph ${selector.name ?? selector.glyphId} is not in this font`); + return glyphSummary(this.#session, entry.id); + } + + async #getLayer(glyphId: GlyphId, sourceId: SourceId): Promise { + this.#requireAuthoredSource(sourceId); + const font = this.#session.font; + const entry = font.entryForId(glyphId); + if (!entry) throw new Error(`Glyph ${glyphId} is not in this font`); + if (!font.recordForId(glyphId)?.layers.some((layer) => layer.sourceId === sourceId)) { + return null; + } + + let state: GlyphState; + if (this.#session.workspace) { + const snapshots = await this.#session.workspace.editCoordinator.readGlyphSnapshots([ + { glyphId }, + ]); + const layer = snapshots[0]?.layers.find((layer) => layer.sourceId === sourceId); + if (!layer) throw new Error(`Authored layer for glyph ${glyphId} was not returned`); + state = layer.state; + } else { + const glyph = await font.loadGlyph(glyphId); + const layer = glyph.layerForSource(sourceId); + if (!layer) throw new Error(`Authored layer for glyph ${glyphId} was not returned`); + state = layer.state; + } + const geometry = GlyphGeometry.fromState(state); + + return { + glyphId, + sourceId, + layerId: state.layerId, + structure: state.structure, + xAdvance: geometry.xAdvance, + bounds: geometry.bounds, + anchors: geometry.anchors.map(({ id, name, x, y }) => ({ id, name: name ?? null, x, y })), + points: geometry.allPoints.map(({ id, x, y, pointType, smooth }) => ({ + id, + x, + y, + pointType, + smooth, + })), + }; + } + + #requireAuthoredSource(sourceId: SourceId): void { + if (this.#session.mode === "preview") { + throw new Error("Authored layers are unavailable in preview sessions"); + } + const font = this.#session.font; + if ( + !font.sources.some(({ id }) => id === sourceId) && + !font + .glyphEntries() + .some(({ id }) => font.recordForId(id)?.layers.some((layer) => layer.sourceId === sourceId)) + ) { + throw new Error(`Source ${sourceId} is not in this font`); + } + } + #inspectEditor(): EditorView { const editor = this.#session.editor; const node = editor.scene.nodesOfKind("glyph")[0] ?? null; @@ -75,6 +234,19 @@ export class AgentBridge { } } +function glyphSummary(session: FontSession, glyphId: GlyphId): GlyphSummary { + const entry = session.font.entryForId(glyphId); + if (!entry) throw new Error(`Glyph ${glyphId} is not in this font`); + + return { + id: entry.id, + name: entry.name, + unicodes: [...entry.unicodes], + componentBaseGlyphIds: session.font.recordForId(glyphId)?.componentBaseGlyphIds ?? [], + sourceIds: session.font.recordForId(glyphId)?.layers.map(({ sourceId }) => sourceId) ?? [], + }; +} + function nextAgentPort(): { received: Promise; cancel: () => void } { let cancel = () => {}; const received = new Promise((resolve) => { diff --git a/apps/desktop/src/shared/agent/protocol.ts b/apps/desktop/src/shared/agent/protocol.ts index 9aa2ac12d..6e2a3df47 100644 --- a/apps/desktop/src/shared/agent/protocol.ts +++ b/apps/desktop/src/shared/agent/protocol.ts @@ -1,8 +1,26 @@ -import type { EditorView } from "@shift/runtime"; +import type { + EditorView, + FontOverview, + GlyphPage, + GlyphSelector, + GlyphSummary, + LayerView, +} from "@shift/runtime"; +import type { GlyphId, SourceId } from "@shift/types"; /** Main-to-renderer calls for live agent inspection of one explicit window. */ export type AgentCallMap = { "editor.inspect": { request: void; response: EditorView }; + "font.get": { request: void; response: FontOverview }; + "glyphs.list": { + request: { limit?: number; cursor?: string; sourceId?: SourceId }; + response: GlyphPage; + }; + "glyphs.get": { request: GlyphSelector; response: GlyphSummary }; + "layers.get": { + request: { glyphId: GlyphId; sourceId: SourceId }; + response: LayerView | null; + }; }; export type AgentEventMap = Record; diff --git a/apps/desktop/src/shared/sandbox/protocol.ts b/apps/desktop/src/shared/sandbox/protocol.ts index a3308241c..7567dc0ee 100644 --- a/apps/desktop/src/shared/sandbox/protocol.ts +++ b/apps/desktop/src/shared/sandbox/protocol.ts @@ -1,4 +1,13 @@ -import type { EditorInspection, ShiftSession } from "@shift/runtime"; +import type { + EditorInspection, + FontOverview, + GlyphPage, + GlyphSelector, + GlyphSummary, + LayerView, + ShiftSession, +} from "@shift/runtime"; +import type { GlyphId, SourceId } from "@shift/types"; export type SandboxCallMap = { "sandbox.execute": { @@ -20,6 +29,19 @@ export type SandboxHostCallMap = { request: { windowId: number }; response: EditorInspection; }; + "shift.font.get": { request: { windowId: number }; response: FontOverview }; + "shift.glyphs.list": { + request: { windowId: number; limit?: number; cursor?: string; sourceId?: SourceId }; + response: GlyphPage; + }; + "shift.glyphs.get": { + request: { windowId: number } & GlyphSelector; + response: GlyphSummary; + }; + "shift.layers.get": { + request: { windowId: number; glyphId: GlyphId; sourceId: SourceId }; + response: LayerView | null; + }; }; export type SandboxHostEventMap = Record; diff --git a/apps/desktop/src/utility/sandbox.ts b/apps/desktop/src/utility/sandbox.ts index 24921837b..7f5a8ee1c 100644 --- a/apps/desktop/src/utility/sandbox.ts +++ b/apps/desktop/src/utility/sandbox.ts @@ -17,6 +17,16 @@ const capabilities: ShiftCapabilities = { editor: { inspect: (input) => host.call("shift.editor.inspect", input), }, + font: { + get: (input) => host.call("shift.font.get", input), + }, + glyphs: { + list: (input) => host.call("shift.glyphs.list", input), + get: (input) => host.call("shift.glyphs.get", input), + }, + layers: { + get: (input) => host.call("shift.layers.get", input), + }, }; const runtime = serveChannel(transport, { "sandbox.execute": ({ code }) => executeShiftCode(capabilities, code), diff --git a/packages/mcp/docs/DOCS.md b/packages/mcp/docs/DOCS.md index 1c32e7c1c..d1d18ec38 100644 --- a/packages/mcp/docs/DOCS.md +++ b/packages/mcp/docs/DOCS.md @@ -40,16 +40,23 @@ Returns the TypeScript declarations available inside code mode. ### `shift.execute` -Accepts an async zero-argument JavaScript function and returns its JSON result. The first slice exposes: +Accepts an async zero-argument JavaScript function and returns its JSON result. For a targeted live session: ```ts async () => { - const sessions = await shift.sessions.list(); - return shift.editor.inspect({ windowId: sessions[0].windowId }); + const session = (await shift.sessions.list()).find(({ sessionId }) => sessionId === "..."); + if (!session) throw new Error("Target session closed"); + const font = await shift.font.get({ windowId: session.windowId }); + const page = await shift.glyphs.list({ windowId: session.windowId, limit: 20 }); + return { + family: font.metadata.familyName, + count: font.glyphCount, + names: page.items.map((g) => g.name), + }; }; ``` -`shift.sessions.list()` returns one entry per open font window. `shift.editor.inspect()` returns renderer-owned facts: route, current glyph occurrence, active and editing sources, external location, selection, tool, gesture flags, and authored edit status. +`shift.sessions.list()` returns one entry per open font window. `shift.editor.inspect()` returns renderer-owned UI facts. `shift.font.get()` returns metadata, metrics, axes, sources, named instances, and glyph count even on Home. `shift.glyphs.list()` returns bounded directory pages with `nextCursor`; passing an explicit `sourceId` includes each glyph's authored structure for code-mode aggregation. `shift.glyphs.get()` returns one directory entry by exact `name` or stable `glyphId` (not both), and `shift.layers.get()` returns authored positions and structure for one glyph/source pair. Untrusted inputs are parsed using `@shift/runtime`'s shared Zod schemas; the code-mode sandbox remains bounded. ## Desktop ownership @@ -76,6 +83,7 @@ Do not expose internal `Editor`, `FontStore`, `WorkspaceHost`, NAPI, SQLite rows - Focus is descriptive only. Always pass a `windowId` from the same `sessions.list()` result used to choose a target. - A renderer can exist before its agent lane connects. Check `editorConnected` or retry session discovery rather than substituting another window. - Code-mode results must be JSON-serializable and remain under the configured output bound. +- Preview fonts have no authored layers; source-scoped structure reads fail explicitly. Directory cursors do not freeze a changing font; scope counts to an explicit source and restart if the directory changes. ## Verification diff --git a/packages/mcp/src/code.test.ts b/packages/mcp/src/code.test.ts index bf28ece5b..8f1db86b6 100644 --- a/packages/mcp/src/code.test.ts +++ b/packages/mcp/src/code.test.ts @@ -41,6 +41,50 @@ const capabilities: ShiftCapabilities = { }; }, }, + font: { + async get() { + return { + mode: "workspace", + metadata: { familyName: "Example" }, + metrics: { unitsPerEm: 1000 }, + glyphCount: 1, + axes: [], + sources: [], + namedInstances: [], + }; + }, + }, + glyphs: { + async list() { + return { + items: [ + { + id: asGlyphId("glyph-a"), + name: "A", + unicodes: [65], + componentBaseGlyphIds: [], + sourceIds: [], + }, + ], + nextCursor: null, + }; + }, + async get({ glyphId, name }) { + if (name !== undefined && name !== "A") throw new Error(`Glyph ${name} is not in this font`); + return { + id: glyphId ?? asGlyphId("glyph-a"), + name: "A", + unicodes: [65], + componentBaseGlyphIds: [], + sourceIds: [], + }; + }, + }, + layers: { + async get() { + return null; + }, + }, }; describe("Shift code mode exposes bounded live capabilities", () => { @@ -53,6 +97,36 @@ describe("Shift code mode exposes bounded live capabilities", () => { expect(result).toMatchObject({ windowId: 7, glyph: { name: "A" }, selectionIds: ["point-a"] }); }); + it("composes font and glyph reads through the typed capabilities", async () => { + const result = await executeShiftCode( + capabilities, + "async () => { const font = await shift.font.get({ windowId: 7 }); const page = await shift.glyphs.list({ windowId: 7, limit: 1 }); const glyph = await shift.glyphs.get({ windowId: 7, glyphId: page.items[0].id }); const layer = await shift.layers.get({ windowId: 7, glyphId: glyph.id, sourceId: 'source-a' }); return { family: font.metadata.familyName, glyph: glyph.name, nextCursor: page.nextCursor, layer }; }", + ); + + expect(result).toEqual({ family: "Example", glyph: "A", nextCursor: null, layer: null }); + }); + + it("gets a glyph by exact name and rejects ambiguous selectors", async () => { + const result = await executeShiftCode( + capabilities, + "async () => shift.glyphs.get({ windowId: 7, name: 'A' })", + ); + expect(result).toMatchObject({ id: "glyph-a", name: "A" }); + + await expect( + executeShiftCode( + capabilities, + "async () => shift.glyphs.get({ windowId: 7, name: 'A', glyphId: 'glyph-a' })", + ), + ).rejects.toThrow(); + }); + + it("rejects an invalid glyph page before it reaches the host", async () => { + await expect( + executeShiftCode(capabilities, "async () => shift.glyphs.list({ windowId: 7, limit: 0 })"), + ).rejects.toThrow(); + }); + it("cannot access Node process globals", async () => { await expect(executeShiftCode(capabilities, "async () => typeof process")).resolves.toBe( "undefined", diff --git a/packages/mcp/src/code.ts b/packages/mcp/src/code.ts index 3cca888ea..8d9dbda6e 100644 --- a/packages/mcp/src/code.ts +++ b/packages/mcp/src/code.ts @@ -5,7 +5,7 @@ import { type QuickJSContext, type QuickJSWASMModule, } from "quickjs-emscripten-core"; -import type { ShiftCapabilities } from "@shift/runtime"; +import { shiftInputSchemas, type ShiftCapabilities } from "@shift/runtime"; const EXECUTION_TIMEOUT_MS = 3_000; const MEMORY_LIMIT_BYTES = 16 * 1024 * 1024; @@ -38,6 +38,18 @@ export async function executeShiftCode( return capabilities.editor.inspect({ windowId }); }); + installAsyncJsonFunction(vm, "__shiftGetFont", deadline, (input) => + capabilities.font.get(shiftInputSchemas["font.get"].parse(input)), + ); + installAsyncJsonFunction(vm, "__shiftListGlyphs", deadline, (input) => + capabilities.glyphs.list(shiftInputSchemas["glyphs.list"].parse(input)), + ); + installAsyncJsonFunction(vm, "__shiftGetGlyph", deadline, (input) => + capabilities.glyphs.get(shiftInputSchemas["glyphs.get"].parse(input)), + ); + installAsyncJsonFunction(vm, "__shiftGetLayer", deadline, (input) => + capabilities.layers.get(shiftInputSchemas["layers.get"].parse(input)), + ); const bootstrap = ` "use strict"; @@ -48,6 +60,16 @@ export async function executeShiftCode( editor: Object.freeze({ inspect: async ({ windowId }) => JSON.parse(await __shiftInspectEditor(windowId)), }), + font: Object.freeze({ + get: async (input) => JSON.parse(await __shiftGetFont(input)), + }), + glyphs: Object.freeze({ + list: async (input) => JSON.parse(await __shiftListGlyphs(input)), + get: async (input) => JSON.parse(await __shiftGetGlyph(input)), + }), + layers: Object.freeze({ + get: async (input) => JSON.parse(await __shiftGetLayer(input)), + }), }); (async () => { const entry = (${code}); diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts index 8caa187ed..41fc53591 100644 --- a/packages/mcp/src/server.test.ts +++ b/packages/mcp/src/server.test.ts @@ -18,6 +18,24 @@ const capabilities: ShiftCapabilities = { throw new Error("No open Shift window"); }, }, + font: { + async get() { + throw new Error("No open Shift window"); + }, + }, + glyphs: { + async list() { + throw new Error("No open Shift window"); + }, + async get() { + throw new Error("No open Shift window"); + }, + }, + layers: { + async get() { + throw new Error("No open Shift window"); + }, + }, }; const execute = (code: string) => executeShiftCode(capabilities, code); diff --git a/packages/runtime/docs/DOCS.md b/packages/runtime/docs/DOCS.md index 874356b51..95128aa6e 100644 --- a/packages/runtime/docs/DOCS.md +++ b/packages/runtime/docs/DOCS.md @@ -17,7 +17,8 @@ Host-neutral capability contracts shared by Shift protocol adapters and future p packages/runtime/ src/ capabilities.ts -- canonical capability and observation contracts - index.ts -- public type exports + inputs.ts -- Zod validation of untrusted code-mode arguments + index.ts -- public capability types and input schemas scripts/ generate-code-api.mjs -- deterministic self-contained declaration generator generated/ @@ -29,13 +30,15 @@ packages/runtime/ - `ShiftCapabilities` -- nested live operations exposed by a host. - `ShiftSession` -- explicitly addressable live window/session identity and mode. - `EditorInspection` -- point-in-time editor observation paired with its explicit target. +- `FontOverview`, `GlyphPage`, `GlyphSummary`, and `LayerView` -- read-only font, paged directory, and source-specific authored geometry views. +- `shiftInputSchemas` -- reusable runtime validation of untrusted capability inputs before IPC. - `EditorView` -- renderer-owned portion of an editor observation. - `EditorGlyph` -- active glyph occurrence using canonical domain identifiers. - `ShiftSessionMode` -- alias of the canonical `FontSessionMode`, including memory hosts. ## How it works -A host implements `ShiftCapabilities` by routing each operation to the subsystem that owns the truth. The desktop host lists window/session identity in Electron main and requests editor observations from the targeted renderer. Other hosts may provide memory sessions while preserving the same capability shape. +A host implements `ShiftCapabilities` by routing each operation to the subsystem that owns the truth. The desktop host lists window/session identity in Electron main and requests editor and font observations from the targeted renderer. Source-scoped glyph listings and layer reads use workspace glyph snapshots, not renderer editor-model loading. `FontOverview.sources` lists global masters; `GlyphSummary.sourceIds` also advertises glyph-specific support layers. `glyphs.get` accepts exactly one of a stable `glyphId` or an exact glyph `name` and uses the font directory's name index. Every advertised layer is readable by `sourceId`, even if it is not a global master. Previews offer font and glyph directory facts, but no authored layer views. A missing layer in a known source returns `null`; unknown targets and preview-authored requests fail explicitly. Directory pages carry an opaque cursor, not a frozen revision; consumers should restart a scan if the font changes between pages. Other hosts may provide memory sessions while preserving the same capability shape. The code-API generator bundles only declarations reachable from `capabilities.ts`, including the canonical branded identifiers from `@shift/types`, then adds the code-mode global `shift`. `@shift/mcp` imports that generated file as text for `shift.describe`; it does not maintain another declaration. diff --git a/packages/runtime/generated/code-api.d.ts b/packages/runtime/generated/code-api.d.ts index 39085847a..bb32dfcd3 100644 --- a/packages/runtime/generated/code-api.d.ts +++ b/packages/runtime/generated/code-api.d.ts @@ -1,3 +1,71 @@ +//#region ../geo/src/types.d.ts +type Point2D = { + x: number; + y: number; +}; +type Rect2D = { + x: number; + y: number; + width: number; + height: number; + left: number; + top: number; + right: number; + bottom: number; +}; +//#endregion +//#region ../geo/src/Bounds.d.ts +/** + * Axis-aligned bounding box defined by its minimum and maximum corners. + * + * In screen/canvas space, `min` is the top-left corner and `max` is the + * bottom-right. In UPM space (y-up), `min.y` is the bottom and `max.y` + * is the top. + */ +interface Bounds { + readonly min: { + readonly x: number; + readonly y: number; + }; + readonly max: { + readonly x: number; + readonly y: number; + }; +} +declare const Bounds: { + /** Create a bounds from explicit min and max corners. */ + readonly create: (min: Point2D, max: Point2D) => Bounds; + /** Create a zero-area bounds located at a single point. */ + readonly fromPoint: (p: Point2D) => Bounds; + /** + * Compute the tightest bounds enclosing all given points. + * @returns `null` when the array is empty. + */ + readonly fromPoints: (points: readonly Point2D[]) => Bounds | null; + /** Create bounds from an origin and dimensions (x, y, width, height). */ + readonly fromXYWH: (x: number, y: number, w: number, h: number) => Bounds; + /** Return the smallest bounds that contains both `a` and `b`. */ + readonly union: (a: Bounds, b: Bounds) => Bounds; + /** + * Merge an array of nullable bounds into one. Skips `null` entries. + * @returns `null` when every entry is `null`. + */ + readonly unionAll: (bounds: readonly (Bounds | null)[]) => Bounds | null; + /** Expand bounds just enough to include the given point. */ + readonly includePoint: (b: Bounds, p: Point2D) => Bounds; + readonly width: (b: Bounds) => number; + readonly height: (b: Bounds) => number; + readonly center: (b: Bounds) => Point2D; + /** Test whether a point lies inside or on the edge of the bounds. */ + readonly containsPoint: (b: Bounds, p: Point2D) => boolean; + /** Test whether two bounds overlap (inclusive of touching edges). */ + readonly overlaps: (a: Bounds, b: Bounds) => boolean; + /** Grow the bounds outward by `padding` on every side. Use a negative value to shrink. */ + readonly expand: (b: Bounds, padding: number) => Bounds; + /** Convert to a {@link Rect2D} with x/y/width/height and edge accessors. */ + readonly toRect: (b: Bounds) => Rect2D; +}; +//#endregion //#region ../types/src/ids.d.ts /** * Branded ID types for type-safe identification of font entities. @@ -12,8 +80,12 @@ declare const PointIdBrand: unique symbol; declare const ContourIdBrand: unique symbol; declare const AnchorIdBrand: unique symbol; declare const AxisIdBrand: unique symbol; +declare const AxisLabelIdBrand: unique symbol; declare const ComponentIdBrand: unique symbol; declare const GlyphIdBrand: unique symbol; +declare const LayerIdBrand: unique symbol; +declare const MetricIdBrand: unique symbol; +declare const NamedInstanceIdBrand: unique symbol; declare const NodeIdBrand: unique symbol; declare const SegmentIdBrand: unique symbol; declare const SourceIdBrand: unique symbol; @@ -45,6 +117,10 @@ type AnchorId = string & { type AxisId = string & { readonly [AxisIdBrand]: typeof AxisIdBrand; }; +/** A stable identifier for one user-space axis label. */ +type AxisLabelId = string & { + readonly [AxisLabelIdBrand]: typeof AxisLabelIdBrand; +}; /** * A component identifier from Rust. * Branded string type - can't be confused with other IDs or plain strings. @@ -59,6 +135,21 @@ type ComponentId = string & { type GlyphId = string & { readonly [GlyphIdBrand]: typeof GlyphIdBrand; }; +/** + * A layer identifier from Rust. + * Branded string type - can't be confused with other IDs or plain strings. + */ +type LayerId = string & { + readonly [LayerIdBrand]: typeof LayerIdBrand; +}; +/** Stable identity of one font-owned metric definition. */ +type MetricId = string & { + readonly [MetricIdBrand]: typeof MetricIdBrand; +}; +/** A stable identifier for one authored product preset. */ +type NamedInstanceId = string & { + readonly [NamedInstanceIdBrand]: typeof NamedInstanceIdBrand; +}; /** * A scene node identifier minted by the renderer. * @@ -85,6 +176,103 @@ type ShiftId = NodeId | PointId | AnchorId | ContourId | SegmentId | ComponentId /** Identifies objects that can be selected by the editor. */ type SelectableId = ShiftId; //#endregion +//#region ../types/src/bridge/generated.d.ts +type GlyphName = string; +interface AnchorData { + id: AnchorId; + name?: string; +} +interface Axis { + id: AxisId; + tag: string; + name: string; + role: AxisRole; + axisType: AxisType; + minimum?: number; + default: number; + maximum?: number; + values?: Array; + labels: Array; + hidden: boolean; +} +interface AxisLabel { + id: AxisLabelId; + name: string; + value: number; + minimum?: number; + maximum?: number; + linkedValue?: number; + elidable: boolean; +} +type AxisRole = "external" | "internal"; +type AxisType = "continuous" | "discrete"; +interface ComponentData { + id: ComponentId; + baseGlyphId: GlyphId; + baseGlyphName: GlyphName; +} +interface ContourData { + id: ContourId; + points: Array; + closed: boolean; +} +interface FontMetadata { + familyName?: string; + styleName?: string; + versionMajor?: number; + versionMinor?: number; + copyright?: string; + trademark?: string; + designer?: string; + designerUrl?: string; + manufacturer?: string; + manufacturerUrl?: string; + license?: string; + licenseUrl?: string; + description?: string; + note?: string; +} +interface FontMetrics { + unitsPerEm: number; +} +interface GlyphStructure { + contours: Array; + anchors: Array; + components: Array; +} +interface Location { + values: Record; +} +/** NAPI projection of one explicit named product preset. */ +interface NamedInstance { + id: NamedInstanceId; + name: string; + location: Location; + postscriptName?: string; +} +interface PointData { + id: PointId; + pointType: PointType; + smooth: boolean; +} +type PointType = "onCurve" | "offCurve" | "qCurve"; +interface Source { + id: SourceId; + name: string; + location: Location; + filename?: string; + metricValues: Array; + italicAngle?: number; + lineGap?: number; + underlinePosition?: number; + underlineThickness?: number; +} +interface SourceMetricValue { + metricId: MetricId; + position: number; + overshoot: number; +} +//#endregion //#region ../types/src/workspace.d.ts /** Immutable product mode for one live font session. */ type FontSessionMode = "preview" | "memory" | "workspace"; @@ -135,6 +323,64 @@ export interface EditorInspection extends EditorView { sessionId: string; mode: ShiftSessionMode; } +/** Current font facts from one explicitly targeted live session, including Home. */ +export interface FontOverview { + mode: ShiftSessionMode; + metadata: FontMetadata; + metrics: FontMetrics; + glyphCount: number; + axes: Axis[]; + /** Global font masters; glyph-specific supplementary sources are advertised on glyphs. */ + sources: Source[]; + namedInstances: NamedInstance[]; +} +/** Directory identity; optional structure is for one requested authored source. */ +export interface GlyphSummary { + id: GlyphId; + name: string; + unicodes: number[]; + componentBaseGlyphIds: GlyphId[]; + /** Authored layer sources, including glyph-specific non-master sources. */ + sourceIds: SourceId[]; + structure?: GlyphStructure | null; +} +/** Select one glyph by stable ID or exact name, never by both. */ +export type GlyphSelector = + | { + glyphId: GlyphId; + name?: never; + } + | { + name: GlyphName; + glyphId?: never; + }; +/** One bounded page of glyphs in font directory order. */ +export interface GlyphPage { + items: GlyphSummary[]; + nextCursor: string | null; +} +/** Authored geometry of one glyph in one source, never an interpolated preview. */ +export interface LayerView { + glyphId: GlyphId; + sourceId: SourceId; + layerId: LayerId; + structure: GlyphStructure; + xAdvance: number; + bounds: Bounds | null; + anchors: { + id: AnchorId; + name: string | null; + x: number; + y: number; + }[]; + points: { + id: PointId; + x: number; + y: number; + pointType: PointType; + smooth: boolean; + }[]; +} /** Live application capabilities shared by protocol and plugin hosts. */ export interface ShiftCapabilities { sessions: { @@ -143,6 +389,29 @@ export interface ShiftCapabilities { editor: { inspect(input: { windowId: number }): Promise; }; + font: { + get(input: { windowId: number }): Promise; + }; + glyphs: { + list(input: { + windowId: number; + limit?: number; + cursor?: string; + sourceId?: SourceId; + }): Promise; + get( + input: { + windowId: number; + } & GlyphSelector, + ): Promise; + }; + layers: { + get(input: { + windowId: number; + glyphId: GlyphId; + sourceId: SourceId; + }): Promise; + }; } //#endregion diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 6a89e473c..8f6a65fc5 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -22,7 +22,9 @@ "lint:check": "oxlint --deny-warnings src/" }, "dependencies": { - "@shift/types": "workspace:*" + "@shift/geo": "workspace:*", + "@shift/types": "workspace:*", + "zod": "^4.1.12" }, "devDependencies": { "oxfmt": "^0.49.0", diff --git a/packages/runtime/src/capabilities.ts b/packages/runtime/src/capabilities.ts index 378ab4b5c..5135270f1 100644 --- a/packages/runtime/src/capabilities.ts +++ b/packages/runtime/src/capabilities.ts @@ -1,9 +1,21 @@ +import type { Bounds } from "@shift/geo"; import type { + AnchorId, + Axis, AxisId, + FontMetadata, + FontMetrics, FontSessionMode, GlyphId, + GlyphName, + GlyphStructure, + LayerId, + NamedInstance, NodeId, + PointId, + PointType, SelectableId, + Source, SourceId, } from "@shift/types"; @@ -59,6 +71,52 @@ export interface EditorInspection extends EditorView { mode: ShiftSessionMode; } +/** Current font facts from one explicitly targeted live session, including Home. */ +export interface FontOverview { + mode: ShiftSessionMode; + metadata: FontMetadata; + metrics: FontMetrics; + glyphCount: number; + axes: Axis[]; + /** Global font masters; glyph-specific supplementary sources are advertised on glyphs. */ + sources: Source[]; + namedInstances: NamedInstance[]; +} + +/** Directory identity; optional structure is for one requested authored source. */ +export interface GlyphSummary { + id: GlyphId; + name: string; + unicodes: number[]; + componentBaseGlyphIds: GlyphId[]; + /** Authored layer sources, including glyph-specific non-master sources. */ + sourceIds: SourceId[]; + structure?: GlyphStructure | null; +} + +/** Select one glyph by stable ID or exact name, never by both. */ +export type GlyphSelector = + | { glyphId: GlyphId; name?: never } + | { name: GlyphName; glyphId?: never }; + +/** One bounded page of glyphs in font directory order. */ +export interface GlyphPage { + items: GlyphSummary[]; + nextCursor: string | null; +} + +/** Authored geometry of one glyph in one source, never an interpolated preview. */ +export interface LayerView { + glyphId: GlyphId; + sourceId: SourceId; + layerId: LayerId; + structure: GlyphStructure; + xAdvance: number; + bounds: Bounds | null; + anchors: { id: AnchorId; name: string | null; x: number; y: number }[]; + points: { id: PointId; x: number; y: number; pointType: PointType; smooth: boolean }[]; +} + /** Live application capabilities shared by protocol and plugin hosts. */ export interface ShiftCapabilities { sessions: { @@ -67,4 +125,23 @@ export interface ShiftCapabilities { editor: { inspect(input: { windowId: number }): Promise; }; + font: { + get(input: { windowId: number }): Promise; + }; + glyphs: { + list(input: { + windowId: number; + limit?: number; + cursor?: string; + sourceId?: SourceId; + }): Promise; + get(input: { windowId: number } & GlyphSelector): Promise; + }; + layers: { + get(input: { + windowId: number; + glyphId: GlyphId; + sourceId: SourceId; + }): Promise; + }; } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 7ae690c7c..4a3a8fb29 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,9 +1,16 @@ +export { shiftInputSchemas } from "./inputs"; + export type { AxisCoordinate, EditorGlyph, EditorInspection, EditorTool, EditorView, + FontOverview, + GlyphPage, + GlyphSelector, + GlyphSummary, + LayerView, ShiftCapabilities, ShiftSession, ShiftSessionMode, diff --git a/packages/runtime/src/inputs.ts b/packages/runtime/src/inputs.ts new file mode 100644 index 000000000..0bbcaf46c --- /dev/null +++ b/packages/runtime/src/inputs.ts @@ -0,0 +1,22 @@ +import { asGlyphId, asSourceId } from "@shift/types"; +import * as z from "zod/v4"; + +const windowId = z.number().int().positive(); +const glyphId = z.string().min(1).transform(asGlyphId); +const sourceId = z.string().min(1).transform(asSourceId); + +/** Validates untrusted scripting inputs before they enter a host capability. */ +export const shiftInputSchemas = { + "font.get": z.strictObject({ windowId }), + "glyphs.list": z.strictObject({ + windowId, + limit: z.number().int().min(1).max(100).optional(), + cursor: z.string().min(1).max(1024).optional(), + sourceId: sourceId.optional(), + }), + "glyphs.get": z.union([ + z.strictObject({ windowId, glyphId }), + z.strictObject({ windowId, name: z.string().min(1) }), + ]), + "layers.get": z.strictObject({ windowId, glyphId, sourceId }), +} as const; diff --git a/packages/runtime/tsdown.config.ts b/packages/runtime/tsdown.config.ts index 6a661face..340831405 100644 --- a/packages/runtime/tsdown.config.ts +++ b/packages/runtime/tsdown.config.ts @@ -13,6 +13,6 @@ export default defineConfig({ sourcemap: false, clean: true, deps: { - alwaysBundle: ["@shift/types"], + alwaysBundle: ["@shift/types", "@shift/geo"], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5b853b7d..bf7baf115 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -362,9 +362,15 @@ importers: packages/runtime: dependencies: + '@shift/geo': + specifier: workspace:* + version: link:../geo '@shift/types': specifier: workspace:* version: link:../types + zod: + specifier: ^4.1.12 + version: 4.3.6 devDependencies: oxfmt: specifier: ^0.49.0 From 9910cc65e82606299bba78dac9b7648a5737fbd5 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sat, 3 Oct 2026 18:17:48 +0300 Subject: [PATCH 4/6] feat(mcp): add reusable authored reads and a packaged client --- .agents/skills/shift/SKILL.md | 8 +- .claude/skills/shift/SKILL.md | 8 +- .claude/skills/shift/scripts/client.mjs | 200 ------------------ .codex/skills/shift/SKILL.md | 8 +- .codex/skills/shift/scripts/client.mjs | 200 ------------------ apps/desktop/e2e/live-agent.spec.ts | 8 +- .../src/renderer/src/agent/AgentBridge.ts | 70 +----- package.json | 1 + packages/editor/src/lib/model/Font.ts | 49 +++++ packages/editor/src/lib/model/docs/DOCS.md | 1 + packages/mcp-client/package.json | 23 ++ .../mcp-client/src/cli.mjs | 139 ++++-------- packages/mcp-client/src/cli.test.mjs | 40 ++++ packages/mcp-client/vitest.config.ts | 7 + packages/mcp/docs/DOCS.md | 3 +- packages/sdk/api/index.api.md | 127 ++++++++++- packages/sdk/src/index.ts | 6 + packages/types/src/bridge/index.ts | 1 + packages/types/src/index.ts | 1 + pnpm-lock.yaml | 58 +++++ 20 files changed, 385 insertions(+), 573 deletions(-) delete mode 100644 .claude/skills/shift/scripts/client.mjs delete mode 100644 .codex/skills/shift/scripts/client.mjs create mode 100644 packages/mcp-client/package.json rename .agents/skills/shift/scripts/client.mjs => packages/mcp-client/src/cli.mjs (52%) mode change 100644 => 100755 create mode 100644 packages/mcp-client/src/cli.test.mjs create mode 100644 packages/mcp-client/vitest.config.ts diff --git a/.agents/skills/shift/SKILL.md b/.agents/skills/shift/SKILL.md index 937d8717d..b3cd873f6 100644 --- a/.agents/skills/shift/SKILL.md +++ b/.agents/skills/shift/SKILL.md @@ -9,10 +9,10 @@ Use the live MCP connection when the user refers to the font, glyph, source, sel ## Connect -Run scripts relative to this skill directory: +From a Shift checkout with dependencies installed, use the packaged client: ```sh -node scripts/client.mjs connections +pnpm exec shift-mcp connections ``` The client discovers run descriptors for Shift, Shift Nightly, and development builds. Set `SHIFT_MCP_DESCRIPTOR=/absolute/path/to/mcp.json` when Shift uses a custom user-data directory. If multiple applications are running, pass `--descriptor ` explicitly; never guess. @@ -20,7 +20,7 @@ The client discovers run descriptors for Shift, Shift Nightly, and development b ## Discover the API ```sh -node scripts/client.mjs describe --descriptor +pnpm exec shift-mcp describe --descriptor ``` The typed API exposes `shift.sessions.list()`, `shift.editor.inspect({ windowId })`, `shift.font.get({ windowId })`, `shift.glyphs.list({ windowId, limit?, cursor?, sourceId? })`, `shift.glyphs.get({ windowId, glyphId })` or `shift.glyphs.get({ windowId, name })`, and `shift.layers.get({ windowId, glyphId, sourceId })` inside `shift.execute`. @@ -30,7 +30,7 @@ The typed API exposes `shift.sessions.list()`, `shift.editor.inspect({ windowId Pass an async zero-argument function on stdin to avoid shell escaping: ```sh -node scripts/client.mjs execute --descriptor <<'EOF' +pnpm exec shift-mcp execute --descriptor <<'EOF' async () => { const sessions = await shift.sessions.list(); const session = sessions.find(({ sessionId }) => sessionId === "..."); diff --git a/.claude/skills/shift/SKILL.md b/.claude/skills/shift/SKILL.md index 937d8717d..b3cd873f6 100644 --- a/.claude/skills/shift/SKILL.md +++ b/.claude/skills/shift/SKILL.md @@ -9,10 +9,10 @@ Use the live MCP connection when the user refers to the font, glyph, source, sel ## Connect -Run scripts relative to this skill directory: +From a Shift checkout with dependencies installed, use the packaged client: ```sh -node scripts/client.mjs connections +pnpm exec shift-mcp connections ``` The client discovers run descriptors for Shift, Shift Nightly, and development builds. Set `SHIFT_MCP_DESCRIPTOR=/absolute/path/to/mcp.json` when Shift uses a custom user-data directory. If multiple applications are running, pass `--descriptor ` explicitly; never guess. @@ -20,7 +20,7 @@ The client discovers run descriptors for Shift, Shift Nightly, and development b ## Discover the API ```sh -node scripts/client.mjs describe --descriptor +pnpm exec shift-mcp describe --descriptor ``` The typed API exposes `shift.sessions.list()`, `shift.editor.inspect({ windowId })`, `shift.font.get({ windowId })`, `shift.glyphs.list({ windowId, limit?, cursor?, sourceId? })`, `shift.glyphs.get({ windowId, glyphId })` or `shift.glyphs.get({ windowId, name })`, and `shift.layers.get({ windowId, glyphId, sourceId })` inside `shift.execute`. @@ -30,7 +30,7 @@ The typed API exposes `shift.sessions.list()`, `shift.editor.inspect({ windowId Pass an async zero-argument function on stdin to avoid shell escaping: ```sh -node scripts/client.mjs execute --descriptor <<'EOF' +pnpm exec shift-mcp execute --descriptor <<'EOF' async () => { const sessions = await shift.sessions.list(); const session = sessions.find(({ sessionId }) => sessionId === "..."); diff --git a/.claude/skills/shift/scripts/client.mjs b/.claude/skills/shift/scripts/client.mjs deleted file mode 100644 index b1fd1bf8a..000000000 --- a/.claude/skills/shift/scripts/client.mjs +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env node - -import { readFile, stat } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import process from "node:process"; - -const APP_NAMES = ["Shift Dev", "Shift Nightly Dev", "Shift", "Shift Nightly"]; -let requestId = 0; - -async function main() { - const command = process.argv[2]; - const { descriptorPath, operands } = parseArguments(process.argv.slice(3)); - - switch (command) { - case "connections": { - const connections = await discoverConnections(); - console.log( - JSON.stringify( - connections.map(({ descriptorPath: discoveredPath, connection }) => ({ - descriptorPath: discoveredPath, - url: connection.url, - })), - null, - 2, - ), - ); - return; - } - case "describe": { - const connection = await selectConnection(descriptorPath); - await initialize(connection); - const result = await callTool(connection, "shift.describe", {}); - printToolText(result); - return; - } - case "execute": { - const connection = await selectConnection(descriptorPath); - const code = operands.length > 0 ? operands.join(" ") : await readStdin(); - if (!code.trim()) throw new Error("execute requires code as an argument or on stdin"); - - await initialize(connection); - const result = await callTool(connection, "shift.execute", { code }); - printToolText(result); - return; - } - default: - throw new Error( - "usage: client.mjs connections | describe [--descriptor path] | execute [--descriptor path] [code]", - ); - } -} - -function parseArguments(args) { - let descriptorPath = process.env.SHIFT_MCP_DESCRIPTOR; - const operands = []; - - for (let index = 0; index < args.length; index += 1) { - const argument = args[index]; - if (argument !== "--descriptor") { - operands.push(argument); - continue; - } - - descriptorPath = args[index + 1]; - if (!descriptorPath) throw new Error("--descriptor requires a path"); - index += 1; - } - - return { descriptorPath, operands }; -} - -async function selectConnection(requestedPath) { - if (requestedPath) return readConnection(path.resolve(requestedPath)); - - const connections = await discoverConnections(); - if (connections.length === 0) throw new Error("no running Shift MCP connection found"); - if (connections.length > 1) { - const paths = connections.map(({ descriptorPath }) => descriptorPath).join("\n"); - throw new Error(`multiple Shift MCP connections found; pass --descriptor:\n${paths}`); - } - - return connections[0].connection; -} - -async function discoverConnections() { - const root = applicationDataRoot(); - const configuredPath = process.env.SHIFT_MCP_DESCRIPTOR; - const discoveredPaths = APP_NAMES.map((name) => path.join(root, name, "mcp.json")); - const candidates = [ - ...(configuredPath ? [path.resolve(configuredPath)] : []), - ...discoveredPaths, - ].filter((candidate, index, paths) => paths.indexOf(candidate) === index); - const connections = []; - - for (const descriptorPath of candidates) { - try { - const [connection, descriptorStat] = await Promise.all([ - readConnection(descriptorPath), - stat(descriptorPath), - ]); - connections.push({ descriptorPath, connection, modified: descriptorStat.mtimeMs }); - } catch (error) { - if (error?.code !== "ENOENT") throw error; - } - } - - return connections.sort((left, right) => right.modified - left.modified); -} - -function applicationDataRoot() { - switch (process.platform) { - case "darwin": - return path.join(os.homedir(), "Library", "Application Support"); - case "win32": { - const appData = process.env.APPDATA; - if (!appData) throw new Error("APPDATA is not set"); - return appData; - } - default: - return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"); - } -} - -async function readConnection(descriptorPath) { - const connection = JSON.parse(await readFile(descriptorPath, "utf8")); - if (typeof connection?.url !== "string" || typeof connection?.token !== "string") { - throw new Error(`invalid Shift MCP descriptor: ${descriptorPath}`); - } - - return connection; -} - -async function initialize(connection) { - await request(connection, "initialize", { - protocolVersion: "2025-06-18", - capabilities: {}, - clientInfo: { name: "shift-agent-skill", version: "0.1.0" }, - }); -} - -async function callTool(connection, name, args) { - const response = await request(connection, "tools/call", { name, arguments: args }); - if (response.isError) throw new Error(toolText(response)); - return response; -} - -async function request(connection, method, params) { - requestId += 1; - const response = await fetch(connection.url, { - method: "POST", - headers: { - authorization: `Bearer ${connection.token}`, - accept: "application/json, text/event-stream", - "content-type": "application/json", - "mcp-protocol-version": "2025-06-18", - }, - body: JSON.stringify({ jsonrpc: "2.0", id: requestId, method, params }), - }); - - const text = await response.text(); - if (!response.ok) throw new Error(`Shift MCP request failed (${response.status}): ${text}`); - - const message = parseMessage(text); - if (message.error) throw new Error(message.error.message ?? JSON.stringify(message.error)); - return message.result; -} - -function parseMessage(body) { - if (!body.startsWith("event:")) return JSON.parse(body); - - const dataLines = body - .split("\n") - .filter((line) => line.startsWith("data: ")) - .map((line) => line.slice("data: ".length)); - if (dataLines.length === 0) throw new Error("Shift MCP returned an empty event stream"); - return JSON.parse(dataLines[dataLines.length - 1]); -} - -function toolText(result) { - return (result.content ?? []) - .filter((item) => item.type === "text") - .map((item) => item.text) - .join("\n"); -} - -function printToolText(result) { - console.log(toolText(result)); -} - -async function readStdin() { - const chunks = []; - for await (const chunk of process.stdin) chunks.push(chunk); - return Buffer.concat(chunks).toString("utf8"); -} - -main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; -}); diff --git a/.codex/skills/shift/SKILL.md b/.codex/skills/shift/SKILL.md index 937d8717d..b3cd873f6 100644 --- a/.codex/skills/shift/SKILL.md +++ b/.codex/skills/shift/SKILL.md @@ -9,10 +9,10 @@ Use the live MCP connection when the user refers to the font, glyph, source, sel ## Connect -Run scripts relative to this skill directory: +From a Shift checkout with dependencies installed, use the packaged client: ```sh -node scripts/client.mjs connections +pnpm exec shift-mcp connections ``` The client discovers run descriptors for Shift, Shift Nightly, and development builds. Set `SHIFT_MCP_DESCRIPTOR=/absolute/path/to/mcp.json` when Shift uses a custom user-data directory. If multiple applications are running, pass `--descriptor ` explicitly; never guess. @@ -20,7 +20,7 @@ The client discovers run descriptors for Shift, Shift Nightly, and development b ## Discover the API ```sh -node scripts/client.mjs describe --descriptor +pnpm exec shift-mcp describe --descriptor ``` The typed API exposes `shift.sessions.list()`, `shift.editor.inspect({ windowId })`, `shift.font.get({ windowId })`, `shift.glyphs.list({ windowId, limit?, cursor?, sourceId? })`, `shift.glyphs.get({ windowId, glyphId })` or `shift.glyphs.get({ windowId, name })`, and `shift.layers.get({ windowId, glyphId, sourceId })` inside `shift.execute`. @@ -30,7 +30,7 @@ The typed API exposes `shift.sessions.list()`, `shift.editor.inspect({ windowId Pass an async zero-argument function on stdin to avoid shell escaping: ```sh -node scripts/client.mjs execute --descriptor <<'EOF' +pnpm exec shift-mcp execute --descriptor <<'EOF' async () => { const sessions = await shift.sessions.list(); const session = sessions.find(({ sessionId }) => sessionId === "..."); diff --git a/.codex/skills/shift/scripts/client.mjs b/.codex/skills/shift/scripts/client.mjs deleted file mode 100644 index b1fd1bf8a..000000000 --- a/.codex/skills/shift/scripts/client.mjs +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env node - -import { readFile, stat } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import process from "node:process"; - -const APP_NAMES = ["Shift Dev", "Shift Nightly Dev", "Shift", "Shift Nightly"]; -let requestId = 0; - -async function main() { - const command = process.argv[2]; - const { descriptorPath, operands } = parseArguments(process.argv.slice(3)); - - switch (command) { - case "connections": { - const connections = await discoverConnections(); - console.log( - JSON.stringify( - connections.map(({ descriptorPath: discoveredPath, connection }) => ({ - descriptorPath: discoveredPath, - url: connection.url, - })), - null, - 2, - ), - ); - return; - } - case "describe": { - const connection = await selectConnection(descriptorPath); - await initialize(connection); - const result = await callTool(connection, "shift.describe", {}); - printToolText(result); - return; - } - case "execute": { - const connection = await selectConnection(descriptorPath); - const code = operands.length > 0 ? operands.join(" ") : await readStdin(); - if (!code.trim()) throw new Error("execute requires code as an argument or on stdin"); - - await initialize(connection); - const result = await callTool(connection, "shift.execute", { code }); - printToolText(result); - return; - } - default: - throw new Error( - "usage: client.mjs connections | describe [--descriptor path] | execute [--descriptor path] [code]", - ); - } -} - -function parseArguments(args) { - let descriptorPath = process.env.SHIFT_MCP_DESCRIPTOR; - const operands = []; - - for (let index = 0; index < args.length; index += 1) { - const argument = args[index]; - if (argument !== "--descriptor") { - operands.push(argument); - continue; - } - - descriptorPath = args[index + 1]; - if (!descriptorPath) throw new Error("--descriptor requires a path"); - index += 1; - } - - return { descriptorPath, operands }; -} - -async function selectConnection(requestedPath) { - if (requestedPath) return readConnection(path.resolve(requestedPath)); - - const connections = await discoverConnections(); - if (connections.length === 0) throw new Error("no running Shift MCP connection found"); - if (connections.length > 1) { - const paths = connections.map(({ descriptorPath }) => descriptorPath).join("\n"); - throw new Error(`multiple Shift MCP connections found; pass --descriptor:\n${paths}`); - } - - return connections[0].connection; -} - -async function discoverConnections() { - const root = applicationDataRoot(); - const configuredPath = process.env.SHIFT_MCP_DESCRIPTOR; - const discoveredPaths = APP_NAMES.map((name) => path.join(root, name, "mcp.json")); - const candidates = [ - ...(configuredPath ? [path.resolve(configuredPath)] : []), - ...discoveredPaths, - ].filter((candidate, index, paths) => paths.indexOf(candidate) === index); - const connections = []; - - for (const descriptorPath of candidates) { - try { - const [connection, descriptorStat] = await Promise.all([ - readConnection(descriptorPath), - stat(descriptorPath), - ]); - connections.push({ descriptorPath, connection, modified: descriptorStat.mtimeMs }); - } catch (error) { - if (error?.code !== "ENOENT") throw error; - } - } - - return connections.sort((left, right) => right.modified - left.modified); -} - -function applicationDataRoot() { - switch (process.platform) { - case "darwin": - return path.join(os.homedir(), "Library", "Application Support"); - case "win32": { - const appData = process.env.APPDATA; - if (!appData) throw new Error("APPDATA is not set"); - return appData; - } - default: - return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"); - } -} - -async function readConnection(descriptorPath) { - const connection = JSON.parse(await readFile(descriptorPath, "utf8")); - if (typeof connection?.url !== "string" || typeof connection?.token !== "string") { - throw new Error(`invalid Shift MCP descriptor: ${descriptorPath}`); - } - - return connection; -} - -async function initialize(connection) { - await request(connection, "initialize", { - protocolVersion: "2025-06-18", - capabilities: {}, - clientInfo: { name: "shift-agent-skill", version: "0.1.0" }, - }); -} - -async function callTool(connection, name, args) { - const response = await request(connection, "tools/call", { name, arguments: args }); - if (response.isError) throw new Error(toolText(response)); - return response; -} - -async function request(connection, method, params) { - requestId += 1; - const response = await fetch(connection.url, { - method: "POST", - headers: { - authorization: `Bearer ${connection.token}`, - accept: "application/json, text/event-stream", - "content-type": "application/json", - "mcp-protocol-version": "2025-06-18", - }, - body: JSON.stringify({ jsonrpc: "2.0", id: requestId, method, params }), - }); - - const text = await response.text(); - if (!response.ok) throw new Error(`Shift MCP request failed (${response.status}): ${text}`); - - const message = parseMessage(text); - if (message.error) throw new Error(message.error.message ?? JSON.stringify(message.error)); - return message.result; -} - -function parseMessage(body) { - if (!body.startsWith("event:")) return JSON.parse(body); - - const dataLines = body - .split("\n") - .filter((line) => line.startsWith("data: ")) - .map((line) => line.slice("data: ".length)); - if (dataLines.length === 0) throw new Error("Shift MCP returned an empty event stream"); - return JSON.parse(dataLines[dataLines.length - 1]); -} - -function toolText(result) { - return (result.content ?? []) - .filter((item) => item.type === "text") - .map((item) => item.text) - .join("\n"); -} - -function printToolText(result) { - console.log(toolText(result)); -} - -async function readStdin() { - const chunks = []; - for await (const chunk of process.stdin) chunks.push(chunk); - return Buffer.concat(chunks).toString("utf8"); -} - -main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; -}); diff --git a/apps/desktop/e2e/live-agent.spec.ts b/apps/desktop/e2e/live-agent.spec.ts index 4ee67d0dc..e8fd199c2 100644 --- a/apps/desktop/e2e/live-agent.spec.ts +++ b/apps/desktop/e2e/live-agent.spec.ts @@ -4,7 +4,7 @@ import { promisify } from "node:util"; import { workspaceTest as test, expect, UFO_FONT_PATH } from "./fixtures/electronApp"; const execFileAsync = promisify(execFile); -const MCP_CLIENT = path.resolve(__dirname, "../../../.agents/skills/shift/scripts/client.mjs"); +const MCP_CLIENT = path.resolve(__dirname, "../../../packages/mcp-client/src/cli.mjs"); async function runShiftCode(testRoot: string, code: string): Promise { const descriptor = path.join(testRoot, "user-data", "mcp.json"); @@ -46,11 +46,12 @@ test.describe("authored font reads from Home", () => { if (!sparse) throw new Error("Missing sparse-layer fixture"); const support = await shift.layers.get({ windowId, glyphId: a.id, sourceId: supportId }); const absent = await shift.layers.get({ windowId, glyphId: sparse.id, sourceId: supportId }); - const supportPage = await shift.glyphs.list({ windowId, limit: 1, sourceId: supportId }); + const supportPage = await shift.glyphs.list({ windowId, limit: 100, sourceId: supportId }); return { supportLayerId: support?.layerId, absent, - supportStructure: supportPage.items[0].structure, + sparseStructure: supportPage.items.find(({ id }) => id === sparse.id)?.structure, + supportStructure: supportPage.items.find(({ id }) => id === a.id)?.structure, familyName: font.metadata.familyName, unitsPerEm: font.metrics.unitsPerEm, glyphCount: font.glyphCount, @@ -74,6 +75,7 @@ test.describe("authored font reads from Home", () => { mode: "workspace", anchors: ["top"], absent: null, + sparseStructure: null, supportLayerId: expect.any(String), }); const page = result as { diff --git a/apps/desktop/src/renderer/src/agent/AgentBridge.ts b/apps/desktop/src/renderer/src/agent/AgentBridge.ts index 6c5eecdac..10bba8e97 100644 --- a/apps/desktop/src/renderer/src/agent/AgentBridge.ts +++ b/apps/desktop/src/renderer/src/agent/AgentBridge.ts @@ -6,7 +6,7 @@ import type { GlyphSummary, LayerView, } from "@shift/runtime"; -import type { GlyphId, GlyphState, SourceId } from "@shift/types"; +import type { GlyphId, SourceId } from "@shift/types"; import { GlyphGeometry } from "@shift/glyph-state"; import type { ShiftHost } from "@shared/host/ShiftHost"; import type { AgentCallMap, AgentEventMap } from "@shared/agent/protocol"; @@ -80,7 +80,6 @@ export class AgentBridge { cursor?: string; sourceId?: SourceId; }): Promise { - if (sourceId) this.#requireAuthoredSource(sourceId); if (!Number.isInteger(limit) || limit < 1 || limit > 100) { throw new Error("glyphs.list limit must be between 1 and 100"); } @@ -104,28 +103,12 @@ export class AgentBridge { const page = entries.slice(start, start + limit); const items = page.map((entry) => glyphSummary(this.#session, entry.id)); if (sourceId) { - const requested = items.filter((item) => item.sourceIds.includes(sourceId)); - if (this.#session.workspace) { - const snapshots = await this.#session.workspace.editCoordinator.readGlyphSnapshots( - requested.map(({ id }) => ({ glyphId: id })), - ); - const byId = new Map(snapshots.map((snapshot) => [snapshot.glyphId, snapshot])); - for (const item of items) { - if (!item.sourceIds.includes(sourceId)) { - item.structure = null; - continue; - } - const structure = byId.get(item.id)?.layers.find((layer) => layer.sourceId === sourceId) - ?.state.structure; - if (!structure) throw new Error(`Authored layer for glyph ${item.id} was not returned`); - item.structure = structure; - } - } else { - const glyphs = await font.loadGlyphs(requested.map(({ id }) => id)); - const byId = new Map(glyphs.map((glyph) => [glyph.id, glyph])); - for (const item of items) { - item.structure = byId.get(item.id)?.layerForSource(sourceId)?.state.structure ?? null; - } + const layers = await font.readAuthoredLayers({ + glyphIds: page.map(({ id }) => id), + sourceId, + }); + for (const [index, item] of items.entries()) { + item.structure = layers[index]?.state.structure ?? null; } } @@ -147,28 +130,10 @@ export class AgentBridge { } async #getLayer(glyphId: GlyphId, sourceId: SourceId): Promise { - this.#requireAuthoredSource(sourceId); - const font = this.#session.font; - const entry = font.entryForId(glyphId); - if (!entry) throw new Error(`Glyph ${glyphId} is not in this font`); - if (!font.recordForId(glyphId)?.layers.some((layer) => layer.sourceId === sourceId)) { - return null; - } + const [layer] = await this.#session.font.readAuthoredLayers({ glyphIds: [glyphId], sourceId }); + if (!layer) return null; - let state: GlyphState; - if (this.#session.workspace) { - const snapshots = await this.#session.workspace.editCoordinator.readGlyphSnapshots([ - { glyphId }, - ]); - const layer = snapshots[0]?.layers.find((layer) => layer.sourceId === sourceId); - if (!layer) throw new Error(`Authored layer for glyph ${glyphId} was not returned`); - state = layer.state; - } else { - const glyph = await font.loadGlyph(glyphId); - const layer = glyph.layerForSource(sourceId); - if (!layer) throw new Error(`Authored layer for glyph ${glyphId} was not returned`); - state = layer.state; - } + const state = layer.state; const geometry = GlyphGeometry.fromState(state); return { @@ -189,21 +154,6 @@ export class AgentBridge { }; } - #requireAuthoredSource(sourceId: SourceId): void { - if (this.#session.mode === "preview") { - throw new Error("Authored layers are unavailable in preview sessions"); - } - const font = this.#session.font; - if ( - !font.sources.some(({ id }) => id === sourceId) && - !font - .glyphEntries() - .some(({ id }) => font.recordForId(id)?.layers.some((layer) => layer.sourceId === sourceId)) - ) { - throw new Error(`Source ${sourceId} is not in this font`); - } - } - #inspectEditor(): EditorView { const editor = this.#session.editor; const node = editor.scene.nodesOfKind("glyph")[0] ?? null; diff --git a/package.json b/package.json index 836267b39..375e3d1af 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "node": ">=24.0.0 <25" }, "devDependencies": { + "@shift/mcp-client": "workspace:*", "@typescript/native-preview": "7.0.0-dev.20260707.2", "js-yaml": "4.3.1", "knip": "^5.84.1", diff --git a/packages/editor/src/lib/model/Font.ts b/packages/editor/src/lib/model/Font.ts index df6032949..b9237262e 100644 --- a/packages/editor/src/lib/model/Font.ts +++ b/packages/editor/src/lib/model/Font.ts @@ -11,6 +11,7 @@ import type { GlyphEntry, GlyphHandle, GlyphId, + GlyphLayerSnapshot, GlyphPreview, GlyphRecord, GlyphSnapshotRequest, @@ -615,6 +616,54 @@ export class Font { return this.#directoryCell.peek().records; } + /** + * Reads accepted authored layers without loading glyphs into the editor. + * + * @remarks + * Workspace reads follow pending writes; gesture previews are not included. + * The returned snapshots do not publish glyph models or mutate the font. + * + * @param input - Glyph IDs to read in order and an authored source identity. + * @returns One snapshot per glyph ID, or `null` when that glyph lacks a layer in a known source. + * @throws {Error} when workspace authorship is unavailable, an identity is unknown, or an advertised layer cannot be read. + */ + async readAuthoredLayers({ + glyphIds, + sourceId, + }: { + glyphIds: readonly GlyphId[]; + sourceId: SourceId; + }): Promise { + if (!this.#editCoordinator) throw new Error("Authored layers are unavailable in this font"); + + const sourceIsKnown = + this.sources.some((source) => source.id === sourceId) || + this.glyphRecords().some((glyph) => + glyph.layers.some((layer) => layer.sourceId === sourceId), + ); + if (!sourceIsKnown) throw new Error(`Source ${sourceId} is not in this font`); + + const records = glyphIds.map((glyphId) => { + if (!this.entryForId(glyphId)) throw new Error(`Glyph ${glyphId} is not in this font`); + return this.recordForId(glyphId); + }); + const requests = glyphIds + .filter((glyphId) => + this.recordForId(glyphId)?.layers.some((layer) => layer.sourceId === sourceId), + ) + .map((glyphId) => ({ glyphId })); + const snapshots = await this.#editCoordinator.readGlyphSnapshots(requests); + const byId = new Map(snapshots.map((snapshot) => [snapshot.glyphId, snapshot])); + + return records.map((record) => { + if (!record?.layers.some((layer) => layer.sourceId === sourceId)) return null; + + const layer = byId.get(record.id)?.layers.find((layer) => layer.sourceId === sourceId); + if (!layer) throw new Error(`Authored layer for glyph ${record.id} was not returned`); + return layer; + }); + } + /** * Resolves the preferred glyph name for a Unicode scalar. * diff --git a/packages/editor/src/lib/model/docs/DOCS.md b/packages/editor/src/lib/model/docs/DOCS.md index 8f61c05fa..cc5b44f3e 100644 --- a/packages/editor/src/lib/model/docs/DOCS.md +++ b/packages/editor/src/lib/model/docs/DOCS.md @@ -8,6 +8,7 @@ Reactive TypeScript font, authored glyph-layer, and derived glyph-view surfaces. - **Architecture Invariant:** `FontSession` is the renderer's discriminated union of immutable `PreviewFontSession`, `MemoryFontSession`, and `WorkspaceFontSession` compositions. Every mode uses `FontStore → Font → Editor → Scene → Renderer` and exposes one concrete `GlyphCatalog`. Preview is read-only; memory permits local editing without persistence; workspace owns a non-null `Workspace` and durable mutation coordinator. Desktop presentation and persistence code narrow authoring to the workspace variant until local edit completion no longer requires a workspace; the memory variant has no desktop host or persistence lane. Preview sessions eagerly receive stable session `GlyphId` values but no authored `GlyphRecord` or `GlyphLayer`. - **Architecture Invariant:** `Font.loadGlyph()` is the asynchronous acquisition boundary. It returns one canonical `Glyph` only after every authored layer and transitive component dependency is available; retained calls return that same object without workspace I/O. `Editor.glyphForId()` may synchronously expose that object to runtime and plugin code after acquisition, but never initiates loading. +- **Architecture Invariant:** `Font.readAuthoredLayers({ glyphIds, sourceId })` reads accepted workspace snapshots behind pending writes without loading glyph models or exposing gesture previews. It returns ordered authored layer snapshots, `null` for a glyph without a layer in a known source, and errors for unknown identities or unavailable workspace authorship. Glyph-specific support sources count as known source identities even when absent from global masters. - **Architecture Invariant:** Complete `GlyphInfo` metadata is an optional `Font` construction input owned by the composition root. Without it, existing font mappings still resolve first and unknown codepoints use deterministic `uniXXXX` names; model modules never import the bundled resource payload at runtime. - **Architecture Invariant:** A loaded `Glyph` owns all authored `GlyphLayer` objects and its transitive component-Glyph closure. Its record, layer, and component collections update reactively without replacing the Glyph; its synchronous properties never initiate I/O. - **Architecture Invariant:** `FontStore.#glyphs` contains only completely assembled Glyphs. Failed assembly installs nothing, and workspace replacement clears the complete object graph. diff --git a/packages/mcp-client/package.json b/packages/mcp-client/package.json new file mode 100644 index 000000000..57f120f4c --- /dev/null +++ b/packages/mcp-client/package.json @@ -0,0 +1,23 @@ +{ + "name": "@shift/mcp-client", + "version": "0.0.1", + "license": "MIT OR Apache-2.0", + "private": true, + "description": "Command-line client for a running Shift MCP server", + "type": "module", + "bin": { + "shift-mcp": "./src/cli.mjs" + }, + "scripts": { + "lint:check": "oxlint --deny-warnings src/", + "test": "vitest run --config vitest.config.ts" + }, + "dependencies": { + "@modelcontextprotocol/client": "2.2.0" + }, + "devDependencies": { + "@shift/mcp": "workspace:*", + "oxlint": "^1.85.0", + "vitest": "^4.1.10" + } +} diff --git a/.agents/skills/shift/scripts/client.mjs b/packages/mcp-client/src/cli.mjs old mode 100644 new mode 100755 similarity index 52% rename from .agents/skills/shift/scripts/client.mjs rename to packages/mcp-client/src/cli.mjs index b1fd1bf8a..4a483a532 --- a/.agents/skills/shift/scripts/client.mjs +++ b/packages/mcp-client/src/cli.mjs @@ -4,50 +4,58 @@ import { readFile, stat } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import process from "node:process"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; const APP_NAMES = ["Shift Dev", "Shift Nightly Dev", "Shift", "Shift Nightly"]; -let requestId = 0; async function main() { const command = process.argv[2]; const { descriptorPath, operands } = parseArguments(process.argv.slice(3)); - switch (command) { - case "connections": { - const connections = await discoverConnections(); - console.log( - JSON.stringify( - connections.map(({ descriptorPath: discoveredPath, connection }) => ({ - descriptorPath: discoveredPath, - url: connection.url, - })), - null, - 2, - ), - ); - return; - } - case "describe": { - const connection = await selectConnection(descriptorPath); - await initialize(connection); - const result = await callTool(connection, "shift.describe", {}); - printToolText(result); - return; - } - case "execute": { - const connection = await selectConnection(descriptorPath); - const code = operands.length > 0 ? operands.join(" ") : await readStdin(); - if (!code.trim()) throw new Error("execute requires code as an argument or on stdin"); - - await initialize(connection); - const result = await callTool(connection, "shift.execute", { code }); - printToolText(result); - return; - } - default: - throw new Error( - "usage: client.mjs connections | describe [--descriptor path] | execute [--descriptor path] [code]", - ); + if (command === "connections") { + const connections = await discoverConnections(); + const output = JSON.stringify( + connections.map(({ descriptorPath: discoveredPath, connection }) => ({ + descriptorPath: discoveredPath, + url: connection.url, + })), + null, + 2, + ); + process.stdout.write(`${output}\n`); + return; + } + + if (command !== "describe" && command !== "execute") { + throw new Error( + "usage: shift-mcp connections | describe [--descriptor path] | execute [--descriptor path] [code]", + ); + } + + const connection = await selectConnection(descriptorPath); + const code = command === "execute" ? operands.join(" ") || (await readStdin()) : null; + if (command === "execute" && !code.trim()) { + throw new Error("execute requires code as an argument or on stdin"); + } + + const client = new Client({ name: "shift-mcp", version: "0.0.1" }); + const transport = new StreamableHTTPClientTransport(new URL(connection.url), { + authProvider: { token: async () => connection.token }, + }); + try { + await client.connect(transport); + const result = await client.callTool({ + name: command === "describe" ? "shift.describe" : "shift.execute", + arguments: command === "execute" ? { code } : {}, + }); + const text = result.content + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n"); + if (result.isError) throw new Error(text); + process.stdout.write(`${text}\n`); + } finally { + await client.close(); } } @@ -131,63 +139,6 @@ async function readConnection(descriptorPath) { return connection; } -async function initialize(connection) { - await request(connection, "initialize", { - protocolVersion: "2025-06-18", - capabilities: {}, - clientInfo: { name: "shift-agent-skill", version: "0.1.0" }, - }); -} - -async function callTool(connection, name, args) { - const response = await request(connection, "tools/call", { name, arguments: args }); - if (response.isError) throw new Error(toolText(response)); - return response; -} - -async function request(connection, method, params) { - requestId += 1; - const response = await fetch(connection.url, { - method: "POST", - headers: { - authorization: `Bearer ${connection.token}`, - accept: "application/json, text/event-stream", - "content-type": "application/json", - "mcp-protocol-version": "2025-06-18", - }, - body: JSON.stringify({ jsonrpc: "2.0", id: requestId, method, params }), - }); - - const text = await response.text(); - if (!response.ok) throw new Error(`Shift MCP request failed (${response.status}): ${text}`); - - const message = parseMessage(text); - if (message.error) throw new Error(message.error.message ?? JSON.stringify(message.error)); - return message.result; -} - -function parseMessage(body) { - if (!body.startsWith("event:")) return JSON.parse(body); - - const dataLines = body - .split("\n") - .filter((line) => line.startsWith("data: ")) - .map((line) => line.slice("data: ".length)); - if (dataLines.length === 0) throw new Error("Shift MCP returned an empty event stream"); - return JSON.parse(dataLines[dataLines.length - 1]); -} - -function toolText(result) { - return (result.content ?? []) - .filter((item) => item.type === "text") - .map((item) => item.text) - .join("\n"); -} - -function printToolText(result) { - console.log(toolText(result)); -} - async function readStdin() { const chunks = []; for await (const chunk of process.stdin) chunks.push(chunk); diff --git a/packages/mcp-client/src/cli.test.mjs b/packages/mcp-client/src/cli.test.mjs new file mode 100644 index 000000000..e575c9630 --- /dev/null +++ b/packages/mcp-client/src/cli.test.mjs @@ -0,0 +1,40 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { ShiftMcpServer } from "@shift/mcp"; +import { describe, expect, it } from "vitest"; + +const execFileAsync = promisify(execFile); +const cli = fileURLToPath(new URL("./cli.mjs", import.meta.url)); + +describe("Shift MCP client", () => { + it("calls the live server with a descriptor using the MCP transport", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "shift-mcp-client-")); + const descriptorPath = path.join(directory, "mcp.json"); + const server = new ShiftMcpServer({ + descriptorPath, + async execute(code) { + return { received: code }; + }, + }); + + try { + await server.start(); + const code = "async () => 42"; + const { stdout } = await execFileAsync(process.execPath, [ + cli, + "execute", + "--descriptor", + descriptorPath, + code, + ]); + expect(JSON.parse(stdout)).toEqual({ received: code }); + } finally { + await server.stop(); + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/mcp-client/vitest.config.ts b/packages/mcp-client/vitest.config.ts new file mode 100644 index 000000000..efccfd0c8 --- /dev/null +++ b/packages/mcp-client/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.mjs"], + }, +}); diff --git a/packages/mcp/docs/DOCS.md b/packages/mcp/docs/DOCS.md index d1d18ec38..c80677ae5 100644 --- a/packages/mcp/docs/DOCS.md +++ b/packages/mcp/docs/DOCS.md @@ -10,7 +10,7 @@ Local code-mode access to the live Shift desktop application. - **Architecture Invariant:** The server binds to `127.0.0.1` on a random port, validates localhost Host and Origin headers, and requires a random secret generated for one application run. It never listens on a public interface. - **Architecture Invariant:** Agent-written code runs in a fresh QuickJS runtime with bounded time, memory, source size, and result size. Desktop hosts that runtime in a dedicated utility process so generated code cannot block or crash Electron main. It has no Node.js, filesystem, environment, Electron, or network globals. - **Architecture Invariant:** Every editor request names a window explicitly. Focus changes never retarget an in-flight or subsequent call. -- **Architecture Invariant:** MCP is not Shift's canonical font API. Shared document and editor capabilities remain usable by future plugin and protocol hosts without MCP. +- **Architecture Invariant:** MCP is not Shift's canonical font API. Shared document and editor capabilities remain usable by future plugin and protocol hosts without MCP. The desktop host asks `Font.readAuthoredLayers()` for accepted authored snapshots; `@shift/mcp-client` owns connection discovery and uses the MCP client transport rather than embedding JSON-RPC handling in agent skills. ## Codemap @@ -97,6 +97,7 @@ pnpm typecheck ## Related - [`packages/runtime/docs/DOCS.md`](../../runtime/docs/DOCS.md) -- canonical protocol and plugin capability contracts. +- [`packages/mcp-client/src/cli.mjs`](../../mcp-client/src/cli.mjs) -- packaged `shift-mcp` command for run-scoped descriptor discovery and MCP calls. - [`apps/desktop/src/main/docs/DOCS.md`](../../../apps/desktop/src/main/docs/DOCS.md) -- Electron lifecycle, window/session identity, and renderer lanes. - [`apps/desktop/src/preload/docs/DOCS.md`](../../../apps/desktop/src/preload/docs/DOCS.md) -- authenticated `MessagePort` transfer into the renderer. - [`docs/architecture/index.md`](../../../docs/architecture/index.md) -- repository documentation routing and API boundaries. diff --git a/packages/sdk/api/index.api.md b/packages/sdk/api/index.api.md index 8b77dbc60..ce4718479 100644 --- a/packages/sdk/api/index.api.md +++ b/packages/sdk/api/index.api.md @@ -490,6 +490,10 @@ export class Font { nextAvailableGlyphName(name: GlyphName): GlyphName; pointIdsForSegment(segmentId: SegmentId): readonly PointId[] | null; primaryUnicodeForName(name: GlyphName): Unicode | null; + readAuthoredLayers(input: { + glyphIds: readonly GlyphId[]; + sourceId: SourceId; + }): Promise; // (undocumented) recordForId(glyphId: GlyphId): GlyphRecord | null; // (undocumented) @@ -516,6 +520,23 @@ export class Font { updateSource(source: Source): Promise; } +// @public +export interface FontOverview { + // (undocumented) + axes: Axis[]; + // (undocumented) + glyphCount: number; + // (undocumented) + metadata: FontMetadata; + // (undocumented) + metrics: FontMetrics; + // (undocumented) + mode: ShiftSessionMode; + // (undocumented) + namedInstances: NamedInstance[]; + sources: Source[]; +} + // @public (undocumented) export interface FontSnapshot { // (undocumented) @@ -620,6 +641,24 @@ export type GlyphId = string & { readonly [GlyphIdBrand]: typeof GlyphIdBrand; }; +// @public (undocumented) +export interface GlyphLayerSnapshot { + // (undocumented) + glyphId: GlyphId; + // (undocumented) + sourceId: SourceId; + // (undocumented) + state: GlyphState; +} + +// @public +export interface GlyphPage { + // (undocumented) + items: GlyphSummary[]; + // (undocumented) + nextCursor: string | null; +} + // @public export interface GlyphPreview { // (undocumented) @@ -654,12 +693,19 @@ export interface GlyphRecord { unicodes: Array; } +// @public +export type GlyphSelector = { + glyphId: GlyphId; + name?: never; +} | { + name: GlyphName; + glyphId?: never; +}; + // @public (undocumented) export interface GlyphSnapshot { // (undocumented) glyphId: GlyphId; - // Warning: (ae-forgotten-export) The symbol "GlyphLayerSnapshot" needs to be exported by the entry point index.d.ts - // // (undocumented) layers: Array; // Warning: (ae-forgotten-export) The symbol "GlyphProjection" needs to be exported by the entry point index.d.ts @@ -668,6 +714,54 @@ export interface GlyphSnapshot { projection?: GlyphProjection; } +// @public +export interface GlyphSummary { + // (undocumented) + componentBaseGlyphIds: GlyphId[]; + // (undocumented) + id: GlyphId; + // (undocumented) + name: string; + sourceIds: SourceId[]; + // Warning: (ae-forgotten-export) The symbol "GlyphStructure" needs to be exported by the entry point index.d.ts + // + // (undocumented) + structure?: GlyphStructure | null; + // (undocumented) + unicodes: number[]; +} + +// @public +export interface LayerView { + // (undocumented) + anchors: { + id: AnchorId; + name: string | null; + x: number; + y: number; + }[]; + // (undocumented) + bounds: Bounds | null; + // (undocumented) + glyphId: GlyphId; + // (undocumented) + layerId: LayerId; + // (undocumented) + points: { + id: PointId; + x: number; + y: number; + pointType: PointType; + smooth: boolean; + }[]; + // (undocumented) + sourceId: SourceId; + // (undocumented) + structure: GlyphStructure; + // (undocumented) + xAdvance: number; +} + // @public export interface MemoryFontSession { // (undocumented) @@ -721,6 +815,32 @@ export interface ShiftCapabilities { }): Promise; }; // (undocumented) + font: { + get(input: { + windowId: number; + }): Promise; + }; + // (undocumented) + glyphs: { + list(input: { + windowId: number; + limit?: number; + cursor?: string; + sourceId?: SourceId; + }): Promise; + get(input: { + windowId: number; + } & GlyphSelector): Promise; + }; + // (undocumented) + layers: { + get(input: { + windowId: number; + glyphId: GlyphId; + sourceId: SourceId; + }): Promise; + }; + // (undocumented) sessions: { list(): Promise; }; @@ -770,7 +890,8 @@ export function useSignalState(signal: Signal, options?: UseSignalOptions) // Warnings were encountered during analysis: // -// dist/Editor-Ch6PpQ3W.d.ts:3726:5 - (ae-forgotten-export) The symbol "Segment" needs to be exported by the entry point index.d.ts +// dist/Editor-CBzTw075.d.ts:3726:5 - (ae-forgotten-export) The symbol "Segment" needs to be exported by the entry point index.d.ts +// dist/index.d.ts:155:5 - (ae-forgotten-export) The symbol "PointType" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 7ed3acd50..53406dbbe 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -20,6 +20,11 @@ export type { EditorInspection, EditorTool, EditorView, + FontOverview, + GlyphPage, + GlyphSelector, + GlyphSummary, + LayerView, ShiftCapabilities, ShiftSession, ShiftSessionMode, @@ -27,6 +32,7 @@ export type { export type { FontSnapshot, GlyphId, + GlyphLayerSnapshot, GlyphPreview, GlyphRecord, GlyphSnapshot, diff --git a/packages/types/src/bridge/index.ts b/packages/types/src/bridge/index.ts index cc29e988b..7a248b1c1 100644 --- a/packages/types/src/bridge/index.ts +++ b/packages/types/src/bridge/index.ts @@ -70,6 +70,7 @@ export type { GlyphChangedEntities, GlyphComponents, GlyphEntry, + GlyphLayerSnapshot, GlyphLayerRecord, GlyphLayerShape, GlyphName, diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 94977990e..4f33d1558 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -152,6 +152,7 @@ export type { GlyphEntry, GlyphHandle, GlyphLayerRecord, + GlyphLayerSnapshot, GlyphName, GlyphInterpolation, GlyphLayerShape, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf7baf115..7a3ca868a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: .: devDependencies: + '@shift/mcp-client': + specifier: workspace:* + version: link:packages/mcp-client '@typescript/native-preview': specifier: 7.0.0-dev.20260707.2 version: 7.0.0-dev.20260707.2 @@ -344,6 +347,22 @@ importers: specifier: ^4.1.10 version: 4.1.10(@types/node@25.3.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.49.0)(tsx@4.21.0)) + packages/mcp-client: + dependencies: + '@modelcontextprotocol/client': + specifier: 2.2.0 + version: 2.2.0 + devDependencies: + '@shift/mcp': + specifier: workspace:* + version: link:../mcp + oxlint: + specifier: ^1.85.0 + version: 1.85.0 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@25.3.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.49.0)(tsx@4.21.0)) + packages/rules: dependencies: '@shift/geo': @@ -1324,6 +1343,10 @@ packages: '@microsoft/tsdoc@0.17.0': resolution: {integrity: sha512-p68VexhnH7ojf3U27RdryUDqnKJgKaWUbCL96vJR7N0mqDuhKG5OIECk3sCPPdynxjk+yoVhRkScqtv5KQjzsw==} + '@modelcontextprotocol/client@2.2.0': + resolution: {integrity: sha512-LxCou/CSYQ6dwEnjhLZY0KnEuc8V4UJ3IEQCl/uR2yHobSQIEdJKUlKLRYRF5i5FqRGHy1eK7une3aAWDHLtig==} + engines: {node: '>=20'} + '@modelcontextprotocol/core@2.2.0': resolution: {integrity: sha512-iLhmprRmWI8EcosOA3wVvww22z02NkgqhV4fBH6f/odQBsi7xnJc0HGmi21yWO+/iKw2yzqVE127Zhna5/+JXw==} engines: {node: '>=20'} @@ -3691,6 +3714,14 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -4026,6 +4057,9 @@ packages: jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4472,6 +4506,10 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkijs@3.4.0: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} @@ -6138,6 +6176,16 @@ snapshots: '@microsoft/tsdoc@0.17.0': {} + '@modelcontextprotocol/client@2.2.0': + dependencies: + '@modelcontextprotocol/core': 2.2.0 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + jose: 6.2.12 + pkce-challenge: 5.0.1 + zod: 4.3.6 + '@modelcontextprotocol/core@2.2.0': dependencies: zod: 4.3.6 @@ -8044,6 +8092,12 @@ snapshots: eventemitter3@5.0.1: {} + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + expect-type@1.3.0: {} exponential-backoff@3.1.2: {} @@ -8411,6 +8465,8 @@ snapshots: jju@1.4.0: {} + jose@6.2.12: {} + js-tokens@4.0.0: {} js-yaml@4.3.1: @@ -8895,6 +8951,8 @@ snapshots: picomatch@4.0.7: {} + pkce-challenge@5.0.1: {} + pkijs@3.4.0: dependencies: '@noble/hashes': 1.4.0 From d87aca923e2e60d58159c7da956c273f6c0fba26 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 4 Oct 2026 20:27:15 +0300 Subject: [PATCH 5/6] feat(mcp): support stable native client connections Use fixed per-build loopback endpoints and retain private credentials across launches. Replace the packaged client with native MCP transport tests and document one-time user setup. --- .agents/skills/shift/SKILL.md | 20 +- .claude/skills/shift/SKILL.md | 20 +- .codex/skills/shift/SKILL.md | 20 +- README.md | 4 + apps/desktop/e2e/live-agent.spec.ts | 40 ++-- apps/desktop/package.json | 1 + apps/desktop/src/main/app/App.ts | 19 ++ apps/desktop/src/main/docs/DOCS.md | 4 +- docs/mcp.md | 53 +++++ package.json | 1 - packages/mcp-client/package.json | 23 -- packages/mcp-client/src/cli.mjs | 151 ------------- packages/mcp-client/src/cli.test.mjs | 40 ---- packages/mcp-client/vitest.config.ts | 7 - packages/mcp/docs/DOCS.md | 16 +- packages/mcp/package.json | 3 + packages/mcp/src/server.test.ts | 80 ++++++- packages/mcp/src/server.ts | 99 ++++----- pnpm-lock.yaml | 310 +++++++++++++++++++++++++-- 19 files changed, 534 insertions(+), 377 deletions(-) create mode 100644 docs/mcp.md delete mode 100644 packages/mcp-client/package.json delete mode 100755 packages/mcp-client/src/cli.mjs delete mode 100644 packages/mcp-client/src/cli.test.mjs delete mode 100644 packages/mcp-client/vitest.config.ts diff --git a/.agents/skills/shift/SKILL.md b/.agents/skills/shift/SKILL.md index b3cd873f6..62e91070b 100644 --- a/.agents/skills/shift/SKILL.md +++ b/.agents/skills/shift/SKILL.md @@ -9,35 +9,23 @@ Use the live MCP connection when the user refers to the font, glyph, source, sel ## Connect -From a Shift checkout with dependencies installed, use the packaged client: - -```sh -pnpm exec shift-mcp connections -``` - -The client discovers run descriptors for Shift, Shift Nightly, and development builds. Set `SHIFT_MCP_DESCRIPTOR=/absolute/path/to/mcp.json` when Shift uses a custom user-data directory. If multiple applications are running, pass `--descriptor ` explicitly; never guess. +Configure your agent's native MCP client once with the running app's URL and private token; see the [one-time setup guide](../../../docs/mcp.md). Release, Nightly, Dev, and Nightly Dev have separate named connections. Never check in or disclose the token. No Shift-specific client CLI is needed. ## Discover the API -```sh -pnpm exec shift-mcp describe --descriptor -``` - -The typed API exposes `shift.sessions.list()`, `shift.editor.inspect({ windowId })`, `shift.font.get({ windowId })`, `shift.glyphs.list({ windowId, limit?, cursor?, sourceId? })`, `shift.glyphs.get({ windowId, glyphId })` or `shift.glyphs.get({ windowId, name })`, and `shift.layers.get({ windowId, glyphId, sourceId })` inside `shift.execute`. +Call the native `shift.describe` MCP tool. The typed API exposes `shift.sessions.list()`, `shift.editor.inspect({ windowId })`, `shift.font.get({ windowId })`, `shift.glyphs.list({ windowId, limit?, cursor?, sourceId? })`, `shift.glyphs.get({ windowId, glyphId })` or `shift.glyphs.get({ windowId, name })`, and `shift.layers.get({ windowId, glyphId, sourceId })` inside `shift.execute`. ## Execute code -Pass an async zero-argument function on stdin to avoid shell escaping: +Call the native `shift.execute` MCP tool, passing an async zero-argument function in its `code` argument: -```sh -pnpm exec shift-mcp execute --descriptor <<'EOF' +```js async () => { const sessions = await shift.sessions.list(); const session = sessions.find(({ sessionId }) => sessionId === "..."); if (!session) throw new Error("Target Shift session is not open"); return shift.editor.inspect({ windowId: session.windowId }); } -EOF ``` Always target the explicit `windowId` returned by `sessions.list()`. Do not assume focus is stable. `editor.inspect()` reports a point-in-time renderer observation; `font.get()` returns Home-safe metadata, metrics, axes, global master sources, named instances, and glyph count. Individual glyphs may advertise additional authored `sourceIds` for non-master support layers; those IDs are also valid for layer reads. `glyphs.get()` resolves one glyph by exact name or stable ID, without scanning the directory; provide exactly one of `name` or `glyphId`. `glyphs.list()` returns directory entries with an opaque `nextCursor` (pass it back as `cursor` until null). Supply a specific `sourceId` to include authored `structure` for each glyph in a bounded page; `null` means no layer in that source. `layers.get()` returns positions and structure for one authored glyph/source layer, or `null` if the layer is absent. Preview sessions expose font and glyph directory facts but have no authored layers; these operations are read-only. diff --git a/.claude/skills/shift/SKILL.md b/.claude/skills/shift/SKILL.md index b3cd873f6..62e91070b 100644 --- a/.claude/skills/shift/SKILL.md +++ b/.claude/skills/shift/SKILL.md @@ -9,35 +9,23 @@ Use the live MCP connection when the user refers to the font, glyph, source, sel ## Connect -From a Shift checkout with dependencies installed, use the packaged client: - -```sh -pnpm exec shift-mcp connections -``` - -The client discovers run descriptors for Shift, Shift Nightly, and development builds. Set `SHIFT_MCP_DESCRIPTOR=/absolute/path/to/mcp.json` when Shift uses a custom user-data directory. If multiple applications are running, pass `--descriptor ` explicitly; never guess. +Configure your agent's native MCP client once with the running app's URL and private token; see the [one-time setup guide](../../../docs/mcp.md). Release, Nightly, Dev, and Nightly Dev have separate named connections. Never check in or disclose the token. No Shift-specific client CLI is needed. ## Discover the API -```sh -pnpm exec shift-mcp describe --descriptor -``` - -The typed API exposes `shift.sessions.list()`, `shift.editor.inspect({ windowId })`, `shift.font.get({ windowId })`, `shift.glyphs.list({ windowId, limit?, cursor?, sourceId? })`, `shift.glyphs.get({ windowId, glyphId })` or `shift.glyphs.get({ windowId, name })`, and `shift.layers.get({ windowId, glyphId, sourceId })` inside `shift.execute`. +Call the native `shift.describe` MCP tool. The typed API exposes `shift.sessions.list()`, `shift.editor.inspect({ windowId })`, `shift.font.get({ windowId })`, `shift.glyphs.list({ windowId, limit?, cursor?, sourceId? })`, `shift.glyphs.get({ windowId, glyphId })` or `shift.glyphs.get({ windowId, name })`, and `shift.layers.get({ windowId, glyphId, sourceId })` inside `shift.execute`. ## Execute code -Pass an async zero-argument function on stdin to avoid shell escaping: +Call the native `shift.execute` MCP tool, passing an async zero-argument function in its `code` argument: -```sh -pnpm exec shift-mcp execute --descriptor <<'EOF' +```js async () => { const sessions = await shift.sessions.list(); const session = sessions.find(({ sessionId }) => sessionId === "..."); if (!session) throw new Error("Target Shift session is not open"); return shift.editor.inspect({ windowId: session.windowId }); } -EOF ``` Always target the explicit `windowId` returned by `sessions.list()`. Do not assume focus is stable. `editor.inspect()` reports a point-in-time renderer observation; `font.get()` returns Home-safe metadata, metrics, axes, global master sources, named instances, and glyph count. Individual glyphs may advertise additional authored `sourceIds` for non-master support layers; those IDs are also valid for layer reads. `glyphs.get()` resolves one glyph by exact name or stable ID, without scanning the directory; provide exactly one of `name` or `glyphId`. `glyphs.list()` returns directory entries with an opaque `nextCursor` (pass it back as `cursor` until null). Supply a specific `sourceId` to include authored `structure` for each glyph in a bounded page; `null` means no layer in that source. `layers.get()` returns positions and structure for one authored glyph/source layer, or `null` if the layer is absent. Preview sessions expose font and glyph directory facts but have no authored layers; these operations are read-only. diff --git a/.codex/skills/shift/SKILL.md b/.codex/skills/shift/SKILL.md index b3cd873f6..62e91070b 100644 --- a/.codex/skills/shift/SKILL.md +++ b/.codex/skills/shift/SKILL.md @@ -9,35 +9,23 @@ Use the live MCP connection when the user refers to the font, glyph, source, sel ## Connect -From a Shift checkout with dependencies installed, use the packaged client: - -```sh -pnpm exec shift-mcp connections -``` - -The client discovers run descriptors for Shift, Shift Nightly, and development builds. Set `SHIFT_MCP_DESCRIPTOR=/absolute/path/to/mcp.json` when Shift uses a custom user-data directory. If multiple applications are running, pass `--descriptor ` explicitly; never guess. +Configure your agent's native MCP client once with the running app's URL and private token; see the [one-time setup guide](../../../docs/mcp.md). Release, Nightly, Dev, and Nightly Dev have separate named connections. Never check in or disclose the token. No Shift-specific client CLI is needed. ## Discover the API -```sh -pnpm exec shift-mcp describe --descriptor -``` - -The typed API exposes `shift.sessions.list()`, `shift.editor.inspect({ windowId })`, `shift.font.get({ windowId })`, `shift.glyphs.list({ windowId, limit?, cursor?, sourceId? })`, `shift.glyphs.get({ windowId, glyphId })` or `shift.glyphs.get({ windowId, name })`, and `shift.layers.get({ windowId, glyphId, sourceId })` inside `shift.execute`. +Call the native `shift.describe` MCP tool. The typed API exposes `shift.sessions.list()`, `shift.editor.inspect({ windowId })`, `shift.font.get({ windowId })`, `shift.glyphs.list({ windowId, limit?, cursor?, sourceId? })`, `shift.glyphs.get({ windowId, glyphId })` or `shift.glyphs.get({ windowId, name })`, and `shift.layers.get({ windowId, glyphId, sourceId })` inside `shift.execute`. ## Execute code -Pass an async zero-argument function on stdin to avoid shell escaping: +Call the native `shift.execute` MCP tool, passing an async zero-argument function in its `code` argument: -```sh -pnpm exec shift-mcp execute --descriptor <<'EOF' +```js async () => { const sessions = await shift.sessions.list(); const session = sessions.find(({ sessionId }) => sessionId === "..."); if (!session) throw new Error("Target Shift session is not open"); return shift.editor.inspect({ windowId: session.windowId }); } -EOF ``` Always target the explicit `windowId` returned by `sessions.list()`. Do not assume focus is stable. `editor.inspect()` reports a point-in-time renderer observation; `font.get()` returns Home-safe metadata, metrics, axes, global master sources, named instances, and glyph count. Individual glyphs may advertise additional authored `sourceIds` for non-master support layers; those IDs are also valid for layer reads. `glyphs.get()` resolves one glyph by exact name or stable ID, without scanning the directory; provide exactly one of `name` or `glyphId`. `glyphs.list()` returns directory entries with an opaque `nextCursor` (pass it back as `cursor` until null). Supply a specific `sourceId` to include authored `structure` for each glyph in a bounded page; `null` means no layer in that source. `layers.get()` returns positions and structure for one authored glyph/source layer, or `null` if the layer is absent. Preview sessions expose font and glyph directory facts but have no authored layers; these operations are read-only. diff --git a/README.md b/README.md index 3f1b670c6..714166daf 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,10 @@ Shift is free and open source, and runs on macOS, Windows, and Linux. It stays r Download Shift for macOS, Windows, and Linux from [shift.graphics](https://shift.graphics) or [GitHub Releases](https://github.com/shift-editor/shift/releases). On Linux, install from the [APT or DNF repositories](docs/releases.md#linux-installation) to get updates. For the latest development build, use [Shift Nightly](https://github.com/shift-editor/shift/releases/tag/nightly). +## AI agents + +Connect Claude Code, Codex, or another MCP client to fonts open in Shift using the [one-time MCP setup guide](docs/mcp.md). The connection is read-only; use `shift-cli` for saved files that are not open in the app. + ## Status | Area | Status | diff --git a/apps/desktop/e2e/live-agent.spec.ts b/apps/desktop/e2e/live-agent.spec.ts index e8fd199c2..7b2d9815e 100644 --- a/apps/desktop/e2e/live-agent.spec.ts +++ b/apps/desktop/e2e/live-agent.spec.ts @@ -1,27 +1,39 @@ -import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; import path from "node:path"; -import { promisify } from "node:util"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; +import type { ShiftMcpConnection } from "@shift/mcp"; import { workspaceTest as test, expect, UFO_FONT_PATH } from "./fixtures/electronApp"; -const execFileAsync = promisify(execFile); -const MCP_CLIENT = path.resolve(__dirname, "../../../packages/mcp-client/src/cli.mjs"); - async function runShiftCode(testRoot: string, code: string): Promise { const descriptor = path.join(testRoot, "user-data", "mcp.json"); - const { stdout } = await execFileAsync(process.execPath, [ - MCP_CLIENT, - "execute", - "--descriptor", - descriptor, - code, - ]); - return JSON.parse(stdout); + const connection = JSON.parse(await readFile(descriptor, "utf8")) as ShiftMcpConnection; + const client = new Client({ name: "shift-e2e", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(connection.url), { + authProvider: { token: async () => connection.token }, + }); + + try { + await client.connect(transport); + const result = await client.callTool({ name: "shift.execute", arguments: { code } }); + const text = result.content + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n"); + if (result.isError) throw new Error(text); + return JSON.parse(text); + } finally { + await client.close(); + } } test.describe("authored font reads from Home", () => { test.use({ startupFontPath: UFO_FONT_PATH }); - test("paginates glyphs and reads named source anchors", async ({ testRoot }) => { + test("paginates glyphs and reads named source anchors", async ({ + page: workspacePage, + testRoot, + }) => { + await expect(workspacePage).toHaveURL(/#\/home/); const result = await runShiftCode( testRoot, `async () => { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1c167b81b..142464db7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -33,6 +33,7 @@ }, "license": "MIT OR Apache-2.0", "devDependencies": { + "@modelcontextprotocol/client": "2.2.0", "@electron-forge/cli": "8.0.0-alpha.10", "@electron-forge/plugin-vite": "8.0.0-alpha.10", "@electron-forge/shared-types": "8.0.0-alpha.10", diff --git a/apps/desktop/src/main/app/App.ts b/apps/desktop/src/main/app/App.ts index a71136c32..dbcefeca7 100644 --- a/apps/desktop/src/main/app/App.ts +++ b/apps/desktop/src/main/app/App.ts @@ -867,9 +867,28 @@ export class App { this.#windowForAgentRequest(windowId).agent.getLayer(glyphId, sourceId), }, }); + let port: number; + switch (app.getName()) { + case "Shift": + port = 17461; + break; + case "Shift Nightly": + port = 17462; + break; + case "Shift Dev": + port = 17463; + break; + case "Shift Nightly Dev": + port = 17464; + break; + default: + throw new Error(`Unknown Shift distribution: ${app.getName()}`); + } + const mcp = new ShiftMcpServer({ execute: (code) => sandbox.execute(code), descriptorPath: path.join(app.getPath("userData"), "mcp.json"), + port: process.env.NODE_ENV === "test" ? 0 : port, logger: createShiftLogger("app.mcp"), }); diff --git a/apps/desktop/src/main/docs/DOCS.md b/apps/desktop/src/main/docs/DOCS.md index 86dc17188..793631bea 100644 --- a/apps/desktop/src/main/docs/DOCS.md +++ b/apps/desktop/src/main/docs/DOCS.md @@ -1,6 +1,6 @@ # Main - + Electron main process: app startup, windows, menus, document dialogs, and workspace session ownership. @@ -23,7 +23,7 @@ Electron main process: app startup, windows, menus, document dialogs, and worksp - **Architecture Invariant:** Disposable Slug pages live under the app-wide `derived-cache/slug-atlases` root beside `working-documents`, never inside authored `.shift` content. Utility processes share the one-GiB byte-budgeted LRU; each process validates an artifact index once and then verifies and decompresses its fixed pages independently. Staging paths use readable `run-{pid}-{id}/page-{index}-{id}.zst` names, and every retry owns a distinct file until publication. The LRU scans after an artifact is opened or published, never after every page stream. Stale, corrupt, and evicted entries rebuild. - **Architecture Invariant:** Recovery discovery prunes only storage that cannot contain authored work: empty workspace directories, document bindings with no working store or recovery overlay, and SQLite sidecars whose primary file is absent. Working stores and recovery overlays are recoverable and never expire by age. A stale binding whose exact recovery overlay is absent is detached from a surviving working store so unsaved-workspace discovery can recover that store. Malformed or unknown artifacts are retained and reported rather than deleted. - **Architecture Invariant:** IPC channels are type-safe. `ipcMain.handle` calls use the typed wrapper from `shared/ipc/main`, and channel names and payload types live in `shared/ipc/contract.ts` and `shared/workspace/protocol.ts`. -- **Architecture Invariant:** Main owns one run-scoped local MCP server. It binds only to loopback, publishes a random connection secret under the distribution-specific user-data root, resolves every request through an explicit window/session identity, and routes renderer observations over a per-window typed request lane. +- **Architecture Invariant:** Main owns one local MCP server per app instance. It binds only to loopback on the distribution's fixed port (Shift `17461`, Nightly `17462`, Dev `17463`, Nightly Dev `17464`; E2E uses an explicit ephemeral port), retains a private bearer token under the distribution-specific user-data root across launches, resolves every request through an explicit window/session identity, and routes renderer observations over a per-window typed request lane. Port collisions disable MCP without preventing the app from opening. - **Architecture Invariant:** Generated agent and plugin code never executes in Electron main or a renderer. `SandboxRuntimeProcess` supervises a dedicated utility process, serves only typed capabilities back into main, and terminates the process when a hard execution deadline expires. ## Codemap diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 000000000..e3f698c73 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,53 @@ +# Connect an AI agent to Shift + +Shift exposes the fonts **currently open in the desktop app** through a local, read-only [MCP](https://modelcontextprotocol.io/) server. Claude Code, Codex, and other HTTP MCP clients can connect directly; no Shift client program or project checkout is required. This is a one-time setup per Shift build and MCP client. + +## Find your connection + +1. Start Shift. It creates a private `mcp.json` in its user-data directory. Open that file **locally** and copy its `url` and `token` fields. Never share the token with an agent, put it in a prompt, or commit it to a repository. +2. Configure your MCP client at **user scope**, not inside a project. Add the HTTP URL and the header `Authorization: Bearer `. You can add just the Shift builds you use. +3. Restart or refresh the client, then call `shift.describe` to confirm the connection. Shift must be running for calls to succeed. + +| Build | MCP name | URL | User-data directory name | +| --- | --- | --- | --- | +| Shift | `shift` | `http://127.0.0.1:17461/mcp` | `Shift` | +| Shift Nightly | `shift-nightly` | `http://127.0.0.1:17462/mcp` | `Shift Nightly` | +| Shift Dev | `shift-dev` | `http://127.0.0.1:17463/mcp` | `Shift Dev` | +| Shift Nightly Dev | `shift-nightly-dev` | `http://127.0.0.1:17464/mcp` | `Shift Nightly Dev` | + +The user-data directory is normally under `~/Library/Application Support/` on macOS, `%APPDATA%\` on Windows, and `${XDG_CONFIG_HOME:-~/.config}/` on Linux. If Shift was launched with `--user-data-dir`, its `mcp.json` is there instead. Each build has a **different token**. The file remains after Shift quits, and the token survives relaunches; its URL is only reachable while that build is running. + +### Claude Code + +Use Claude Code's [user-scoped MCP configuration](https://code.claude.com/docs/en/mcp). On macOS/Linux, the following reads the token from your clipboard without putting it in shell history (paste when `read` waits for input): + +```sh +read -rs SHIFT_MCP_TOKEN +claude mcp add --transport http --scope user shift http://127.0.0.1:17461/mcp \ + --header "Authorization: Bearer $SHIFT_MCP_TOKEN" +unset SHIFT_MCP_TOKEN +``` + +Use the matching name and URL from the table for Nightly or Dev, and copy **that build's token**. Claude Code stores the header in your private user configuration; keep that file private. Check the connection with `claude mcp get shift` or Claude Code's `/mcp` command. + +### Codex + +Add a server to your **personal** `~/.codex/config.toml` (not a repository's `.codex/config.toml`). Replace the placeholder with the token from your private Shift `mcp.json`: + +```toml +[mcp_servers.shift] +url = "http://127.0.0.1:17461/mcp" +http_headers = { Authorization = "Bearer " } +``` + +Add another `[mcp_servers.shift-nightly]` or `[mcp_servers.shift-dev]` entry with its matching URL and token if needed. Protect the token-bearing file from other users (for example, `chmod 600 ~/.codex/config.toml` on macOS/Linux), and never copy it into a project. Codex also supports `bearer_token_env_var` if you prefer to supply the token through your own secret manager instead of storing it in client configuration. + +### Other MCP clients + +Add a **Streamable HTTP** server with the name and URL from the table, and set its `Authorization` header to `Bearer `. Configure it in your personal client settings; Shift does not modify another application's configuration. Keep each build's name distinct so the client doesn't silently switch fonts when you change apps. + +## What the connection can read + +`shift.describe` describes the typed API. `shift.execute` runs bounded, read-only code against explicit live Shift window IDs. Agents can list open sessions and read font metadata, glyph directories, authored source layers, and point-in-time editor observations. Preview geometry is not authored data. An offline file that is not open in Shift should be inspected with [`shift-cli`](../crates/shift-cli/README.md), not this live server. + +If a connection fails, make sure that build is running and its URL matches the file. Shift does **not** switch to another port when the assigned one is occupied; close the conflicting process and restart Shift. An invalid or insecure `mcp.json` is not overwritten automatically. To deliberately rotate a token, quit Shift, move the private `mcp.json` out of its user-data directory, start Shift again, and update your MCP clients with the new token. Keep the old file private or delete it when you no longer need it. diff --git a/package.json b/package.json index 375e3d1af..836267b39 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,6 @@ "node": ">=24.0.0 <25" }, "devDependencies": { - "@shift/mcp-client": "workspace:*", "@typescript/native-preview": "7.0.0-dev.20260707.2", "js-yaml": "4.3.1", "knip": "^5.84.1", diff --git a/packages/mcp-client/package.json b/packages/mcp-client/package.json deleted file mode 100644 index 57f120f4c..000000000 --- a/packages/mcp-client/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@shift/mcp-client", - "version": "0.0.1", - "license": "MIT OR Apache-2.0", - "private": true, - "description": "Command-line client for a running Shift MCP server", - "type": "module", - "bin": { - "shift-mcp": "./src/cli.mjs" - }, - "scripts": { - "lint:check": "oxlint --deny-warnings src/", - "test": "vitest run --config vitest.config.ts" - }, - "dependencies": { - "@modelcontextprotocol/client": "2.2.0" - }, - "devDependencies": { - "@shift/mcp": "workspace:*", - "oxlint": "^1.85.0", - "vitest": "^4.1.10" - } -} diff --git a/packages/mcp-client/src/cli.mjs b/packages/mcp-client/src/cli.mjs deleted file mode 100755 index 4a483a532..000000000 --- a/packages/mcp-client/src/cli.mjs +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env node - -import { readFile, stat } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import process from "node:process"; -import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; - -const APP_NAMES = ["Shift Dev", "Shift Nightly Dev", "Shift", "Shift Nightly"]; - -async function main() { - const command = process.argv[2]; - const { descriptorPath, operands } = parseArguments(process.argv.slice(3)); - - if (command === "connections") { - const connections = await discoverConnections(); - const output = JSON.stringify( - connections.map(({ descriptorPath: discoveredPath, connection }) => ({ - descriptorPath: discoveredPath, - url: connection.url, - })), - null, - 2, - ); - process.stdout.write(`${output}\n`); - return; - } - - if (command !== "describe" && command !== "execute") { - throw new Error( - "usage: shift-mcp connections | describe [--descriptor path] | execute [--descriptor path] [code]", - ); - } - - const connection = await selectConnection(descriptorPath); - const code = command === "execute" ? operands.join(" ") || (await readStdin()) : null; - if (command === "execute" && !code.trim()) { - throw new Error("execute requires code as an argument or on stdin"); - } - - const client = new Client({ name: "shift-mcp", version: "0.0.1" }); - const transport = new StreamableHTTPClientTransport(new URL(connection.url), { - authProvider: { token: async () => connection.token }, - }); - try { - await client.connect(transport); - const result = await client.callTool({ - name: command === "describe" ? "shift.describe" : "shift.execute", - arguments: command === "execute" ? { code } : {}, - }); - const text = result.content - .filter((item) => item.type === "text") - .map((item) => item.text) - .join("\n"); - if (result.isError) throw new Error(text); - process.stdout.write(`${text}\n`); - } finally { - await client.close(); - } -} - -function parseArguments(args) { - let descriptorPath = process.env.SHIFT_MCP_DESCRIPTOR; - const operands = []; - - for (let index = 0; index < args.length; index += 1) { - const argument = args[index]; - if (argument !== "--descriptor") { - operands.push(argument); - continue; - } - - descriptorPath = args[index + 1]; - if (!descriptorPath) throw new Error("--descriptor requires a path"); - index += 1; - } - - return { descriptorPath, operands }; -} - -async function selectConnection(requestedPath) { - if (requestedPath) return readConnection(path.resolve(requestedPath)); - - const connections = await discoverConnections(); - if (connections.length === 0) throw new Error("no running Shift MCP connection found"); - if (connections.length > 1) { - const paths = connections.map(({ descriptorPath }) => descriptorPath).join("\n"); - throw new Error(`multiple Shift MCP connections found; pass --descriptor:\n${paths}`); - } - - return connections[0].connection; -} - -async function discoverConnections() { - const root = applicationDataRoot(); - const configuredPath = process.env.SHIFT_MCP_DESCRIPTOR; - const discoveredPaths = APP_NAMES.map((name) => path.join(root, name, "mcp.json")); - const candidates = [ - ...(configuredPath ? [path.resolve(configuredPath)] : []), - ...discoveredPaths, - ].filter((candidate, index, paths) => paths.indexOf(candidate) === index); - const connections = []; - - for (const descriptorPath of candidates) { - try { - const [connection, descriptorStat] = await Promise.all([ - readConnection(descriptorPath), - stat(descriptorPath), - ]); - connections.push({ descriptorPath, connection, modified: descriptorStat.mtimeMs }); - } catch (error) { - if (error?.code !== "ENOENT") throw error; - } - } - - return connections.sort((left, right) => right.modified - left.modified); -} - -function applicationDataRoot() { - switch (process.platform) { - case "darwin": - return path.join(os.homedir(), "Library", "Application Support"); - case "win32": { - const appData = process.env.APPDATA; - if (!appData) throw new Error("APPDATA is not set"); - return appData; - } - default: - return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"); - } -} - -async function readConnection(descriptorPath) { - const connection = JSON.parse(await readFile(descriptorPath, "utf8")); - if (typeof connection?.url !== "string" || typeof connection?.token !== "string") { - throw new Error(`invalid Shift MCP descriptor: ${descriptorPath}`); - } - - return connection; -} - -async function readStdin() { - const chunks = []; - for await (const chunk of process.stdin) chunks.push(chunk); - return Buffer.concat(chunks).toString("utf8"); -} - -main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; -}); diff --git a/packages/mcp-client/src/cli.test.mjs b/packages/mcp-client/src/cli.test.mjs deleted file mode 100644 index e575c9630..000000000 --- a/packages/mcp-client/src/cli.test.mjs +++ /dev/null @@ -1,40 +0,0 @@ -import { execFile } from "node:child_process"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; -import { ShiftMcpServer } from "@shift/mcp"; -import { describe, expect, it } from "vitest"; - -const execFileAsync = promisify(execFile); -const cli = fileURLToPath(new URL("./cli.mjs", import.meta.url)); - -describe("Shift MCP client", () => { - it("calls the live server with a descriptor using the MCP transport", async () => { - const directory = await mkdtemp(path.join(tmpdir(), "shift-mcp-client-")); - const descriptorPath = path.join(directory, "mcp.json"); - const server = new ShiftMcpServer({ - descriptorPath, - async execute(code) { - return { received: code }; - }, - }); - - try { - await server.start(); - const code = "async () => 42"; - const { stdout } = await execFileAsync(process.execPath, [ - cli, - "execute", - "--descriptor", - descriptorPath, - code, - ]); - expect(JSON.parse(stdout)).toEqual({ received: code }); - } finally { - await server.stop(); - await rm(directory, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/mcp-client/vitest.config.ts b/packages/mcp-client/vitest.config.ts deleted file mode 100644 index efccfd0c8..000000000 --- a/packages/mcp-client/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["src/**/*.test.mjs"], - }, -}); diff --git a/packages/mcp/docs/DOCS.md b/packages/mcp/docs/DOCS.md index c80677ae5..aac9a95b6 100644 --- a/packages/mcp/docs/DOCS.md +++ b/packages/mcp/docs/DOCS.md @@ -1,16 +1,16 @@ # MCP - + Local code-mode access to the live Shift desktop application. ## Architecture Invariants - **Architecture Invariant:** `@shift/mcp` is an adapter over `ShiftCapabilities` from `@shift/runtime`. It does not own font, document, editor, window, persistence state, or the reusable plugin contract. -- **Architecture Invariant:** The server binds to `127.0.0.1` on a random port, validates localhost Host and Origin headers, and requires a random secret generated for one application run. It never listens on a public interface. +- **Architecture Invariant:** The Fastify MCP adapter binds only to `127.0.0.1`, validates localhost Host and Origin headers, and requires a persistent, private bearer token. Release, Nightly, Dev, and Nightly Dev use distinct fixed ports; a collision leaves MCP unavailable rather than selecting another port. Explicit test instances use port `0`. - **Architecture Invariant:** Agent-written code runs in a fresh QuickJS runtime with bounded time, memory, source size, and result size. Desktop hosts that runtime in a dedicated utility process so generated code cannot block or crash Electron main. It has no Node.js, filesystem, environment, Electron, or network globals. - **Architecture Invariant:** Every editor request names a window explicitly. Focus changes never retarget an in-flight or subsequent call. -- **Architecture Invariant:** MCP is not Shift's canonical font API. Shared document and editor capabilities remain usable by future plugin and protocol hosts without MCP. The desktop host asks `Font.readAuthoredLayers()` for accepted authored snapshots; `@shift/mcp-client` owns connection discovery and uses the MCP client transport rather than embedding JSON-RPC handling in agent skills. +- **Architecture Invariant:** MCP is not Shift's canonical font API. Shared document and editor capabilities remain usable by future plugin and protocol hosts without MCP. The desktop host asks `Font.readAuthoredLayers()` for accepted authored snapshots; agent clients connect through native MCP support rather than a Shift-specific client CLI. ## Codemap @@ -20,7 +20,7 @@ src/ types.ts -- MCP connection contract code.ts -- bounded QuickJS execution over ShiftCapabilities runtime.ts -- isolated-runtime-only package surface - server.ts -- MCP tools, loopback HTTP, run secret, connection descriptor + server.ts -- MCP tools, Fastify loopback HTTP, persistent token, connection descriptor index.ts -- main-process-safe public package surface ``` @@ -30,7 +30,7 @@ src/ - `ShiftSession` -- explicit window and font-session identity, mode, focus, and editor connection status. - `EditorInspection` -- point-in-time renderer observation for one explicitly targeted session. - `ShiftMcpServer` -- loopback MCP lifecycle, authentication, connection descriptor, and tool registration. -- `ShiftMcpConnection` -- run-scoped local URL and bearer token written to the private descriptor. +- `ShiftMcpConnection` -- local URL and persistent bearer token written to the private descriptor. ## How it works @@ -60,7 +60,7 @@ async () => { ## Desktop ownership -Electron main starts one `ShiftMcpServer` and one `SandboxRuntimeProcess` after `app.whenReady()`. It writes `mcp.json` under the distribution-specific user-data directory with mode `0600`. The descriptor contains the loopback URL and run secret; it is local connection material, not a user login. MCP delegates execution to the sandbox utility process, and main serves only the typed capability requests that return from that process. A hard host deadline terminates the sandbox if its internal QuickJS deadline cannot settle. +Electron main starts one `ShiftMcpServer` and one `SandboxRuntimeProcess` after `app.whenReady()`. It writes `mcp.json` under the distribution-specific user-data directory with mode `0600` on POSIX. The descriptor contains the loopback URL and persistent token; an existing valid, private token is reused on restart, while an invalid or insecure descriptor prevents MCP startup. Shutdown leaves the credential in place. The token is local connection material, not a user login. MCP delegates execution to the sandbox utility process, and main serves only the typed capability requests that return from that process. A hard host deadline terminates the sandbox if its internal QuickJS deadline cannot settle. Each renderer serves an agent request lane over a transferred `MessagePort`. Main pairs renderer observations with the explicit window and font-session identities before returning them. Launcher windows are excluded from session discovery. @@ -79,7 +79,7 @@ Do not expose internal `Editor`, `FontStore`, `WorkspaceHost`, NAPI, SQLite rows ## Gotchas -- The connection descriptor is removed during graceful shutdown, but an application crash can leave a stale descriptor. A client must treat connection failure as authoritative. +- The connection descriptor survives shutdown. A client must treat connection failure as authoritative when Shift is not running. The descriptor is checked for file type and, on POSIX, private mode before the token is reused; Windows relies on user-data directory ACLs. - Focus is descriptive only. Always pass a `windowId` from the same `sessions.list()` result used to choose a target. - A renderer can exist before its agent lane connects. Check `editorConnected` or retry session discovery rather than substituting another window. - Code-mode results must be JSON-serializable and remain under the configured output bound. @@ -97,7 +97,7 @@ pnpm typecheck ## Related - [`packages/runtime/docs/DOCS.md`](../../runtime/docs/DOCS.md) -- canonical protocol and plugin capability contracts. -- [`packages/mcp-client/src/cli.mjs`](../../mcp-client/src/cli.mjs) -- packaged `shift-mcp` command for run-scoped descriptor discovery and MCP calls. +- [`docs/mcp.md`](../../../docs/mcp.md) -- one-time user-scoped native MCP setup for installed Shift builds. - [`apps/desktop/src/main/docs/DOCS.md`](../../../apps/desktop/src/main/docs/DOCS.md) -- Electron lifecycle, window/session identity, and renderer lanes. - [`apps/desktop/src/preload/docs/DOCS.md`](../../../apps/desktop/src/preload/docs/DOCS.md) -- authenticated `MessagePort` transfer into the renderer. - [`docs/architecture/index.md`](../../../docs/architecture/index.md) -- repository documentation routing and API boundaries. diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 77e7af9a9..bdd220234 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -26,13 +26,16 @@ }, "dependencies": { "@jitl/quickjs-singlefile-cjs-release-sync": "0.32.0", + "@modelcontextprotocol/fastify": "2.0.0", "@modelcontextprotocol/node": "2.1.0", "@modelcontextprotocol/server": "2.2.0", + "fastify": "^5.12.5", "@shift/runtime": "workspace:*", "quickjs-emscripten-core": "0.32.0", "zod": "^4.1.12" }, "devDependencies": { + "@modelcontextprotocol/client": "2.2.0", "@shift/types": "workspace:*", "@types/node": "^25.3.0", "oxlint": "^1.85.0", diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts index 41fc53591..fe24e99ff 100644 --- a/packages/mcp/src/server.test.ts +++ b/packages/mcp/src/server.test.ts @@ -1,4 +1,5 @@ -import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -72,11 +73,11 @@ afterEach(async () => { }); describe("Shift MCP local connection", () => { - it("publishes a private run-scoped descriptor and rejects missing secrets", async () => { + it("keeps a private credential across restarts and rejects unauthorized callers", async () => { const directory = await mkdtemp(path.join(tmpdir(), "shift-mcp-")); temporaryDirectories.push(directory); const descriptorPath = path.join(directory, "connection.json"); - const server = new ShiftMcpServer({ execute, descriptorPath }); + const server = new ShiftMcpServer({ execute, descriptorPath, port: 0 }); startedServers.push(server); const connection = await server.start(); @@ -96,15 +97,20 @@ describe("Shift MCP local connection", () => { expect(foreignOrigin.status).toBe(403); await server.stop(); - await expect(stat(descriptorPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect(JSON.parse(await readFile(descriptorPath, "utf8"))).toEqual(connection); + + const restarted = await server.start(); + expect(restarted.token).toBe(connection.token); + expect(JSON.parse(await readFile(descriptorPath, "utf8"))).toEqual(restarted); }); - it("accepts an MCP initialization with the run-scoped secret", async () => { + it("accepts an MCP initialization with the persistent secret", async () => { const directory = await mkdtemp(path.join(tmpdir(), "shift-mcp-")); temporaryDirectories.push(directory); const server = new ShiftMcpServer({ execute, descriptorPath: path.join(directory, "connection.json"), + port: 0, }); startedServers.push(server); const connection = await server.start(); @@ -129,4 +135,68 @@ describe("Shift MCP local connection", () => { }); expect(executed).toMatchObject({ result: { content: [{ text: "[]" }] } }); }); + + it("connects with a native MCP client", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "shift-mcp-")); + temporaryDirectories.push(directory); + const server = new ShiftMcpServer({ + execute, + descriptorPath: path.join(directory, "connection.json"), + port: 0, + }); + startedServers.push(server); + const connection = await server.start(); + const client = new Client({ name: "shift-test", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(connection.url), { + authProvider: { token: async () => connection.token }, + }); + + try { + await client.connect(transport); + const result = await client.callTool({ + name: "shift.execute", + arguments: { code: "async () => await shift.sessions.list()" }, + }); + expect(result.content).toMatchObject([{ type: "text", text: "[]" }]); + } finally { + await client.close(); + } + }); + + it("rejects malformed or insecure credential files without replacing them", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "shift-mcp-")); + temporaryDirectories.push(directory); + const descriptorPath = path.join(directory, "connection.json"); + const server = new ShiftMcpServer({ execute, descriptorPath, port: 0 }); + startedServers.push(server); + await writeFile(descriptorPath, "invalid JSON", { mode: 0o600 }); + await expect(server.start()).rejects.toThrow(); + expect(await readFile(descriptorPath, "utf8")).toBe("invalid JSON"); + + if (process.platform === "win32") return; + await chmod(descriptorPath, 0o644); + await expect(server.start()).rejects.toThrow(/insecure Shift MCP descriptor/); + }); + + it("does not fall back to another port when its port is occupied", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "shift-mcp-")); + temporaryDirectories.push(directory); + const first = new ShiftMcpServer({ + execute, + descriptorPath: path.join(directory, "first.json"), + port: 0, + }); + startedServers.push(first); + const connection = await first.start(); + const secondPath = path.join(directory, "second.json"); + const second = new ShiftMcpServer({ + execute, + descriptorPath: secondPath, + port: Number(new URL(connection.url).port), + }); + startedServers.push(second); + + await expect(second.start()).rejects.toMatchObject({ code: "EADDRINUSE" }); + await expect(stat(secondPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); }); diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 2d20f0197..7777bdc15 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -1,14 +1,10 @@ import { randomBytes, timingSafeEqual } from "node:crypto"; -import { rmSync } from "node:fs"; -import { mkdir, rename, writeFile } from "node:fs/promises"; -import { createServer, type Server } from "node:http"; +import { lstat, mkdir, readFile, rename, writeFile } from "node:fs/promises"; import path from "node:path"; -import { - localhostHostValidation, - localhostOriginValidation, - toNodeHandler, -} from "@modelcontextprotocol/node"; +import { createMcpFastifyApp } from "@modelcontextprotocol/fastify"; +import { toNodeHandler } from "@modelcontextprotocol/node"; import { createMcpHandler, McpServer } from "@modelcontextprotocol/server"; +import type { FastifyInstance } from "fastify"; import * as z from "zod/v4"; import { SHIFT_CODE_TYPES } from "./declarations"; import type { ShiftMcpConnection } from "./types"; @@ -25,6 +21,7 @@ export interface ShiftMcpLogger { export interface ShiftMcpServerOptions { execute(code: string): Promise; descriptorPath: string; + port: number; logger?: ShiftMcpLogger; } @@ -32,56 +29,51 @@ export interface ShiftMcpServerOptions { export class ShiftMcpServer { readonly #execute: (code: string) => Promise; readonly #descriptorPath: string; + readonly #port: number; readonly #logger: ShiftMcpLogger | undefined; - #httpServer: Server | null = null; + #httpServer: FastifyInstance | null = null; #closeHandler: (() => Promise) | null = null; #connection: ShiftMcpConnection | null = null; /** * Creates an unstarted server bound to a host-owned isolated executor. * - * @param options - execution callback, private descriptor path, and optional diagnostics sink. + * @param options - execution callback, private descriptor path, port, and optional diagnostics sink. */ constructor(options: ShiftMcpServerOptions) { this.#execute = options.execute; this.#descriptorPath = options.descriptorPath; + this.#port = options.port; this.#logger = options.logger; } - /** Starts a random loopback port and publishes same-user connection details. */ + /** Starts the loopback server and publishes persistent same-user connection details. */ async start(): Promise { if (this.#connection) return this.#connection; - const token = randomBytes(32).toString("base64url"); + const token = await readOrCreateToken(this.#descriptorPath); const handler = createMcpHandler(() => this.#createProtocolServer()); const nodeHandler = toNodeHandler(handler, { maxRequestBodySize: 128 * 1024, onerror: (error) => this.#logger?.error("MCP request failed", error), }); - const validateHost = localhostHostValidation(); - const validateOrigin = localhostOriginValidation(); - const httpServer = createServer((request, response) => { - if (!validateHost(request, response) || !validateOrigin(request, response)) return; - - const requestPath = new URL(request.url ?? "/", `http://${LOOPBACK_HOST}`).pathname; - if (requestPath !== MCP_PATH) { - response.writeHead(404).end(); - return; - } - + const httpServer = createMcpFastifyApp(); + httpServer.all(MCP_PATH, { bodyLimit: 128 * 1024 }, async (request, reply) => { if (!hasBearerToken(request.headers.authorization, token)) { - response.writeHead(401, { "content-type": "application/json" }); - response.end(JSON.stringify({ error: "invalid Shift MCP connection secret" })); - return; + return reply.code(401).send({ error: "invalid Shift MCP connection secret" }); } - void nodeHandler(request, response); + reply.hijack(); + await nodeHandler(request.raw, reply.raw, request.body); }); try { - const port = await listen(httpServer); + await httpServer.listen({ host: LOOPBACK_HOST, port: this.#port }); + const address = httpServer.server.address(); + if (!address || typeof address === "string") + throw new Error("Shift MCP server has no TCP port"); const connection = { - url: `http://${LOOPBACK_HOST}:${port}${MCP_PATH}`, + url: `http://${LOOPBACK_HOST}:${address.port}${MCP_PATH}`, token, descriptorPath: this.#descriptorPath, } satisfies ShiftMcpConnection; @@ -93,13 +85,13 @@ export class ShiftMcpServer { this.#logger?.info("MCP server started", { url: connection.url }); return connection; } catch (error) { - httpServer.close(); + await httpServer.close(); await handler.close(); throw error; } } - /** Stops accepting requests and removes the run-scoped connection descriptor. */ + /** Stops accepting requests while retaining the user's connection credential. */ async stop(): Promise { const httpServer = this.#httpServer; const closeHandler = this.#closeHandler; @@ -108,9 +100,7 @@ export class ShiftMcpServer { this.#closeHandler = null; this.#connection = null; - const closingServer = httpServer ? closeServer(httpServer) : Promise.resolve(); - if (connection) rmSync(connection.descriptorPath, { force: true }); - await closingServer; + if (httpServer) await httpServer.close(); if (closeHandler) await closeHandler(); if (connection) this.#logger?.info("MCP server stopped"); } @@ -162,37 +152,28 @@ function hasBearerToken(header: string | undefined, token: string): boolean { return timingSafeEqual(supplied, expected); } -async function listen(server: Server): Promise { - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, LOOPBACK_HOST, () => { - server.off("error", reject); - resolve(); - }); - }); - - const address = server.address(); - if (!address || typeof address === "string") throw new Error("Shift MCP server has no TCP port"); - return address.port; -} - -async function closeServer(server: Server): Promise { - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error); - return; - } +async function readOrCreateToken(descriptorPath: string): Promise { + try { + const file = await lstat(descriptorPath); + if (!file.isFile() || (process.platform !== "win32" && (file.mode & 0o077) !== 0)) { + throw new Error(`insecure Shift MCP descriptor: ${descriptorPath}`); + } - resolve(); - }); - }); + const connection = JSON.parse(await readFile(descriptorPath, "utf8")) as ShiftMcpConnection; + if (typeof connection?.token !== "string" || !/^[\w-]{43}$/.test(connection.token)) { + throw new Error(`invalid Shift MCP descriptor: ${descriptorPath}`); + } + return connection.token; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return randomBytes(32).toString("base64url"); + } } async function publishConnection(connection: ShiftMcpConnection): Promise { const directory = path.dirname(connection.descriptorPath); const temporaryPath = `${connection.descriptorPath}.${process.pid}.tmp`; await mkdir(directory, { recursive: true, mode: 0o700 }); - await writeFile(temporaryPath, `${JSON.stringify(connection)}\n`, { mode: 0o600 }); + await writeFile(temporaryPath, `${JSON.stringify(connection)}\n`, { mode: 0o600, flag: "wx" }); await rename(temporaryPath, connection.descriptorPath); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a3ca868a..5b2185ce4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,9 +21,6 @@ importers: .: devDependencies: - '@shift/mcp-client': - specifier: workspace:* - version: link:packages/mcp-client '@typescript/native-preview': specifier: 7.0.0-dev.20260707.2 version: 7.0.0-dev.20260707.2 @@ -136,6 +133,9 @@ importers: '@electron-toolkit/tsconfig': specifier: ^1.0.1 version: 1.0.1(@types/node@25.3.0) + '@modelcontextprotocol/client': + specifier: 2.2.0 + version: 2.2.0 '@playwright/test': specifier: ^1.59.1 version: 1.59.1 @@ -315,6 +315,9 @@ importers: '@jitl/quickjs-singlefile-cjs-release-sync': specifier: 0.32.0 version: 0.32.0 + '@modelcontextprotocol/fastify': + specifier: 2.0.0 + version: 2.0.0(@modelcontextprotocol/server@2.2.0)(fastify@5.12.5) '@modelcontextprotocol/node': specifier: 2.1.0 version: 2.1.0(@modelcontextprotocol/server@2.2.0)(hono@4.13.12) @@ -324,6 +327,9 @@ importers: '@shift/runtime': specifier: workspace:* version: link:../runtime + fastify: + specifier: ^5.12.5 + version: 5.12.5 quickjs-emscripten-core: specifier: 0.32.0 version: 0.32.0 @@ -331,6 +337,9 @@ importers: specifier: ^4.1.12 version: 4.3.6 devDependencies: + '@modelcontextprotocol/client': + specifier: 2.2.0 + version: 2.2.0 '@shift/types': specifier: workspace:* version: link:../types @@ -347,22 +356,6 @@ importers: specifier: ^4.1.10 version: 4.1.10(@types/node@25.3.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.49.0)(tsx@4.21.0)) - packages/mcp-client: - dependencies: - '@modelcontextprotocol/client': - specifier: 2.2.0 - version: 2.2.0 - devDependencies: - '@shift/mcp': - specifier: workspace:* - version: link:../mcp - oxlint: - specifier: ^1.85.0 - version: 1.85.0 - vitest: - specifier: ^4.1.10 - version: 4.1.10(@types/node@25.3.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.49.0)(tsx@4.21.0)) - packages/rules: dependencies: '@shift/geo': @@ -1136,6 +1129,24 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fastify/ajv-compiler@4.0.6': + resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.1.0': + resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==} + + '@fastify/forwarded@3.0.2': + resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.1': + resolution: {integrity: sha512-zv07Y9GEuDsJPegZoDFd4SDWaZOW8N2pa0GSrYmKpId/tjt1Hgo3BjZBVjdVpfVrHaA+Qv5jawtS2O50J5xM9g==} + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -1351,6 +1362,13 @@ packages: resolution: {integrity: sha512-iLhmprRmWI8EcosOA3wVvww22z02NkgqhV4fBH6f/odQBsi7xnJc0HGmi21yWO+/iKw2yzqVE127Zhna5/+JXw==} engines: {node: '>=20'} + '@modelcontextprotocol/fastify@2.0.0': + resolution: {integrity: sha512-x41AmyQ+olgAjX8EBkVpCztxIcWaInUlVpEatUBSWINZAmTnQYSYqOp9eigb7dxE2W+CtzhP1H+9uUgHRxJPVg==} + engines: {node: '>=20'} + peerDependencies: + '@modelcontextprotocol/server': ^2.0.0 + fastify: ^5.2.0 + '@modelcontextprotocol/node@2.1.0': resolution: {integrity: sha512-6xg3iWVcOfiL8Y7rI6xUqXD0lI2AY5q3djsa/cOi1IJUSwx06t0gvhSwU/6mIIohrFlt40/ANUG56DQ0HLhILQ==} engines: {node: '>=20'} @@ -2284,6 +2302,9 @@ packages: resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} engines: {node: '>=14.18.0'} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.59.1': resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} engines: {node: '>=18'} @@ -3130,6 +3151,9 @@ packages: resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} engines: {node: ^20.17.0 || >=22.9.0} + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + acorn@8.18.0: resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} @@ -3230,6 +3254,13 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.3.0: + resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} + aws4@1.13.2: resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} @@ -3415,6 +3446,10 @@ packages: cookie-es@3.1.1: resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -3732,6 +3767,9 @@ packages: fast-content-type-parse@3.0.0: resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3739,6 +3777,12 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-json-stringify@7.0.1: + resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -3758,6 +3802,9 @@ packages: resolution: {integrity: sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==} hasBin: true + fastify@5.12.5: + resolution: {integrity: sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==} + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -3788,6 +3835,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-my-way@9.9.0: + resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==} + engines: {node: '>=20'} + flora-colossus@3.0.2: resolution: {integrity: sha512-Jk78K/Tzt6saxQPGChlJw69xuFGpWyTSAS8EdU0h/FyXwD2K46yNOXmo6nRHcZ9ooekyBAzMkwmiGNt7wOC5zg==} engines: {node: '>=22.12.0'} @@ -3986,6 +4037,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ipaddr.js@2.5.0: + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} + engines: {node: '>= 10'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -4087,6 +4142,9 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -4125,6 +4183,9 @@ packages: lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} @@ -4394,6 +4455,10 @@ packages: resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} engines: {node: '>=12.20.0'} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -4506,6 +4571,16 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -4548,6 +4623,12 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} @@ -4579,6 +4660,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} @@ -4631,6 +4715,13 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -4679,6 +4770,10 @@ packages: resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -4740,6 +4835,14 @@ packages: safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -4757,6 +4860,9 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver-compare@1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} @@ -4777,6 +4883,9 @@ packages: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -4810,6 +4919,9 @@ packages: snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4821,6 +4933,10 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -4922,6 +5038,10 @@ packages: engines: {node: '>=10'} hasBin: true + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} @@ -4973,6 +5093,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toad-cache@3.7.4: + resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} + engines: {node: '>=20'} + tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -5953,6 +6077,29 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 + '@fastify/ajv-compiler@4.0.6': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.5 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.1.0': + dependencies: + fast-json-stringify: 7.0.1 + + '@fastify/forwarded@3.0.2': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.1': + dependencies: + '@fastify/forwarded': 3.0.2 + ipaddr.js: 2.5.0 + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -6190,6 +6337,11 @@ snapshots: dependencies: zod: 4.3.6 + '@modelcontextprotocol/fastify@2.0.0(@modelcontextprotocol/server@2.2.0)(fastify@5.12.5)': + dependencies: + '@modelcontextprotocol/server': 2.2.0 + fastify: 5.12.5 + '@modelcontextprotocol/node@2.1.0(@modelcontextprotocol/server@2.2.0)(hono@4.13.12)': dependencies: '@hono/node-server': 1.19.17(hono@4.13.12) @@ -6788,6 +6940,8 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 + '@pinojs/redact@0.4.0': {} + '@playwright/test@1.59.1': dependencies: playwright: 1.59.1 @@ -7441,6 +7595,8 @@ snapshots: abbrev@4.0.0: {} + abstract-logging@2.0.1: {} + acorn@8.18.0: optional: true @@ -7562,6 +7718,13 @@ snapshots: at-least-node@1.0.0: {} + atomic-sleep@1.0.0: {} + + avvio@9.3.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.19.1 + aws4@1.13.2: {} balanced-match@4.0.3: {} @@ -7725,6 +7888,8 @@ snapshots: cookie-es@3.1.1: {} + cookie@1.1.1: {} + core-util-is@1.0.3: {} cosmiconfig@8.3.6(typescript@5.9.3): @@ -8104,6 +8269,8 @@ snapshots: fast-content-type-parse@3.0.0: {} + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -8114,6 +8281,19 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-json-stringify@7.0.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.5 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -8140,6 +8320,24 @@ snapshots: strnum: 2.4.1 xml-naming: 0.3.0 + fastify@5.12.5: + dependencies: + '@fastify/ajv-compiler': 4.0.6 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.1.0 + '@fastify/proxy-addr': 5.1.1 + abstract-logging: 2.0.1 + avvio: 9.3.0 + fast-json-stringify: 7.0.1 + find-my-way: 9.9.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.1.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.7.4 + toad-cache: 3.7.4 + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -8170,6 +8368,12 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-my-way@9.9.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + flora-colossus@3.0.2(supports-color@8.1.1): dependencies: debug: 4.4.1(supports-color@8.1.1) @@ -8419,6 +8623,8 @@ snapshots: internmap@2.0.3: {} + ipaddr.js@2.5.0: {} + is-arrayish@0.2.1: {} is-core-module@2.17.0: @@ -8534,6 +8740,10 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + json-schema-traverse@1.0.0: {} json-stringify-safe@5.0.1: @@ -8578,6 +8788,12 @@ snapshots: lazy-val@1.0.5: {} + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + lightningcss-darwin-arm64@1.30.1: optional: true @@ -8795,6 +9011,8 @@ snapshots: obug@3.0.0: {} + on-exit-leak-free@2.1.2: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -8951,6 +9169,26 @@ snapshots: picomatch@4.0.7: {} + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + pkce-challenge@5.0.1: {} pkijs@3.4.0: @@ -8996,6 +9234,10 @@ snapshots: process-nextick-args@2.0.1: {} + process-warning@4.0.1: {} + + process-warning@5.1.0: {} + progress@2.0.3: {} promise-retry@2.0.1: @@ -9026,6 +9268,8 @@ snapshots: queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + quick-lru@5.1.1: {} quickjs-emscripten-core@0.32.0: @@ -9075,6 +9319,10 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + real-require@0.2.0: {} + + real-require@1.0.0: {} + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -9118,6 +9366,8 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + ret@0.5.0: {} + retry@0.12.0: {} reusify@1.1.0: {} @@ -9213,6 +9463,12 @@ snapshots: safe-buffer@5.1.2: {} + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} sanitize-filename@1.6.4: @@ -9227,6 +9483,8 @@ snapshots: scheduler@0.27.0: {} + secure-json-parse@4.1.0: {} + semver-compare@1.0.0: optional: true @@ -9241,6 +9499,8 @@ snapshots: type-fest: 0.13.1 optional: true + set-cookie-parser@2.7.2: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -9269,6 +9529,10 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -9278,6 +9542,8 @@ snapshots: source-map@0.6.1: {} + split2@4.2.0: {} + sprintf-js@1.0.3: {} sprintf-js@1.1.3: @@ -9379,6 +9645,10 @@ snapshots: source-map-support: 0.5.21 optional: true + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + tiny-async-pool@1.3.0: dependencies: semver: 5.7.2 @@ -9421,6 +9691,8 @@ snapshots: dependencies: is-number: 7.0.0 + toad-cache@3.7.4: {} + tough-cookie@5.1.2: dependencies: tldts: 6.1.86 From 43d08031e3adfedb5393903dc01ff4467aa43838 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sun, 4 Oct 2026 21:26:28 +0300 Subject: [PATCH 6/6] refactor(mcp): extract host-neutral sandbox --- apps/desktop/package.json | 1 + apps/desktop/src/main/app/App.ts | 29 ++++++--- apps/desktop/src/main/docs/DOCS.md | 4 +- .../src/main/sandbox/SandboxRuntimeProcess.ts | 2 +- apps/desktop/src/utility/sandbox.ts | 2 +- docs/architecture/index.md | 1 + packages/mcp/docs/DOCS.md | 7 +-- packages/mcp/package.json | 8 +-- packages/mcp/src/runtime.ts | 1 - packages/mcp/src/server.test.ts | 2 +- packages/runtime/docs/DOCS.md | 5 +- packages/sandbox/docs/DOCS.md | 62 +++++++++++++++++++ packages/sandbox/package.json | 35 +++++++++++ .../src/execute.test.ts} | 15 +++-- .../src/code.ts => sandbox/src/execute.ts} | 21 ++++--- packages/sandbox/src/index.ts | 1 + packages/sandbox/tsconfig.json | 9 +++ packages/sandbox/vitest.config.ts | 7 +++ pnpm-lock.yaml | 41 +++++++++--- 19 files changed, 208 insertions(+), 45 deletions(-) delete mode 100644 packages/mcp/src/runtime.ts create mode 100644 packages/sandbox/docs/DOCS.md create mode 100644 packages/sandbox/package.json rename packages/{mcp/src/code.test.ts => sandbox/src/execute.test.ts} (89%) rename packages/{mcp/src/code.ts => sandbox/src/execute.ts} (85%) create mode 100644 packages/sandbox/src/index.ts create mode 100644 packages/sandbox/tsconfig.json create mode 100644 packages/sandbox/vitest.config.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 142464db7..d5c019ef0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -65,6 +65,7 @@ "@shift/glyph-state": "workspace:*", "@shift/mcp": "workspace:*", "@shift/runtime": "workspace:*", + "@shift/sandbox": "workspace:*", "@shift/types": "workspace:*", "@shift/ui": "workspace:*", "@shift/validation": "workspace:*", diff --git a/apps/desktop/src/main/app/App.ts b/apps/desktop/src/main/app/App.ts index dbcefeca7..b6ffd7c97 100644 --- a/apps/desktop/src/main/app/App.ts +++ b/apps/desktop/src/main/app/App.ts @@ -218,6 +218,7 @@ export class App { path.join(app.getPath("userData"), "recent-documents.json"), ); this.#recents.onChanged(() => this.#publishRecents()); + await this.#startSandbox(); await this.#startMcp(); const restoredSessions = await this.#workspaces.restoreRecoveries(); @@ -262,6 +263,7 @@ export class App { app.on("will-quit", () => { this.#log.info("will quit: disposing app services"); void this.#stopMcp(); + this.#stopSandbox(); for (const session of this.#workspaces.list()) { this.#workspaces.unregister(session.workspaceId); } @@ -845,7 +847,8 @@ export class App { launcher.close(); } - async #startMcp(): Promise { + /** Starts the app-owned execution process independently of MCP availability. */ + async #startSandbox(): Promise { const sandbox = new SandboxRuntimeProcess({ sessions: { list: () => Promise.resolve(this.#agentSessions()), @@ -867,6 +870,19 @@ export class App { this.#windowForAgentRequest(windowId).agent.getLayer(glyphId, sourceId), }, }); + this.#sandbox = sandbox; + try { + await sandbox.start(); + } catch (error) { + sandbox.stop(); + this.#log.error("failed to start sandbox", error); + } + } + + async #startMcp(): Promise { + const sandbox = this.#sandbox; + if (!sandbox) throw new Error("Sandbox runtime was not initialized"); + let port: number; switch (app.getName()) { case "Shift": @@ -893,12 +909,9 @@ export class App { }); try { - await sandbox.start(); await mcp.start(); - this.#sandbox = sandbox; this.#mcp = mcp; } catch (error) { - sandbox.stop(); try { await mcp.stop(); } catch (stopError) { @@ -910,12 +923,9 @@ export class App { async #stopMcp(): Promise { const mcp = this.#mcp; - const sandbox = this.#sandbox; this.#mcp = null; - this.#sandbox = null; const stoppingMcp = mcp?.stop(); - sandbox?.stop(); if (!stoppingMcp) return; try { @@ -925,6 +935,11 @@ export class App { } } + #stopSandbox(): void { + this.#sandbox?.stop(); + this.#sandbox = null; + } + #agentSessions(): ShiftSession[] { const focusedWindowId = BrowserWindow.getFocusedWindow()?.id ?? null; const sessions: ShiftSession[] = []; diff --git a/apps/desktop/src/main/docs/DOCS.md b/apps/desktop/src/main/docs/DOCS.md index 793631bea..0cf8bad65 100644 --- a/apps/desktop/src/main/docs/DOCS.md +++ b/apps/desktop/src/main/docs/DOCS.md @@ -23,8 +23,8 @@ Electron main process: app startup, windows, menus, document dialogs, and worksp - **Architecture Invariant:** Disposable Slug pages live under the app-wide `derived-cache/slug-atlases` root beside `working-documents`, never inside authored `.shift` content. Utility processes share the one-GiB byte-budgeted LRU; each process validates an artifact index once and then verifies and decompresses its fixed pages independently. Staging paths use readable `run-{pid}-{id}/page-{index}-{id}.zst` names, and every retry owns a distinct file until publication. The LRU scans after an artifact is opened or published, never after every page stream. Stale, corrupt, and evicted entries rebuild. - **Architecture Invariant:** Recovery discovery prunes only storage that cannot contain authored work: empty workspace directories, document bindings with no working store or recovery overlay, and SQLite sidecars whose primary file is absent. Working stores and recovery overlays are recoverable and never expire by age. A stale binding whose exact recovery overlay is absent is detached from a surviving working store so unsaved-workspace discovery can recover that store. Malformed or unknown artifacts are retained and reported rather than deleted. - **Architecture Invariant:** IPC channels are type-safe. `ipcMain.handle` calls use the typed wrapper from `shared/ipc/main`, and channel names and payload types live in `shared/ipc/contract.ts` and `shared/workspace/protocol.ts`. -- **Architecture Invariant:** Main owns one local MCP server per app instance. It binds only to loopback on the distribution's fixed port (Shift `17461`, Nightly `17462`, Dev `17463`, Nightly Dev `17464`; E2E uses an explicit ephemeral port), retains a private bearer token under the distribution-specific user-data root across launches, resolves every request through an explicit window/session identity, and routes renderer observations over a per-window typed request lane. Port collisions disable MCP without preventing the app from opening. -- **Architecture Invariant:** Generated agent and plugin code never executes in Electron main or a renderer. `SandboxRuntimeProcess` supervises a dedicated utility process, serves only typed capabilities back into main, and terminates the process when a hard execution deadline expires. +- **Architecture Invariant:** Main owns one local MCP server per app instance. It binds only to loopback on the distribution's fixed port (Shift `17461`, Nightly `17462`, Dev `17463`, Nightly Dev `17464`; E2E uses an explicit ephemeral port), retains a private bearer token under the distribution-specific user-data root across launches, resolves every request through an explicit window/session identity, and routes renderer observations over a per-window typed request lane. Port collisions disable MCP without stopping the app-owned sandbox or preventing the app from opening. +- **Architecture Invariant:** Code submitted to the Shift sandbox never executes in Electron main or a renderer. `SandboxRuntimeProcess` is app-owned, supervises a dedicated utility process, serves only typed capabilities back into main, and terminates the process when a hard execution deadline expires; the next execution restarts it. No long-lived interactive plugin execution mode exists yet. ## Codemap diff --git a/apps/desktop/src/main/sandbox/SandboxRuntimeProcess.ts b/apps/desktop/src/main/sandbox/SandboxRuntimeProcess.ts index 49313f150..5625cea35 100644 --- a/apps/desktop/src/main/sandbox/SandboxRuntimeProcess.ts +++ b/apps/desktop/src/main/sandbox/SandboxRuntimeProcess.ts @@ -12,7 +12,7 @@ import { createShiftLogger, type ShiftLogger } from "../logging"; const HARD_EXECUTION_TIMEOUT_MS = 5_000; -/** Owns the isolated process used for agent and future plugin code execution. */ +/** Owns the isolated process used for bounded Shift code execution. */ export class SandboxRuntimeProcess { readonly #capabilities: ShiftCapabilities; readonly #log: ShiftLogger; diff --git a/apps/desktop/src/utility/sandbox.ts b/apps/desktop/src/utility/sandbox.ts index 7f5a8ee1c..6dfa644db 100644 --- a/apps/desktop/src/utility/sandbox.ts +++ b/apps/desktop/src/utility/sandbox.ts @@ -1,4 +1,4 @@ -import { executeShiftCode } from "@shift/mcp/runtime"; +import { executeShiftCode } from "@shift/sandbox"; import type { ShiftCapabilities } from "@shift/runtime"; import { Channel, parentPortTransport, serveChannel } from "../shared/workspace/channel"; import type { diff --git a/docs/architecture/index.md b/docs/architecture/index.md index 7ced48a60..1e6357793 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -56,6 +56,7 @@ Central routing table for Shift's distributed documentation. Before creating new | `packages/glyph-state/**` | [`packages/glyph-state/docs/DOCS.md`](../../packages/glyph-state/docs/DOCS.md) | Glyph-domain geometry (contour traversal, segment parsing, bounds) | | `packages/mcp/**` | [`packages/mcp/docs/DOCS.md`](../../packages/mcp/docs/DOCS.md) | Local code-mode access to the live desktop application | | `packages/runtime/**` | [`packages/runtime/docs/DOCS.md`](../../packages/runtime/docs/DOCS.md) | Shared protocol and plugin capability contracts | +| `packages/sandbox/**` | [`packages/sandbox/docs/DOCS.md`](../../packages/sandbox/docs/DOCS.md) | Bounded code execution independent of protocol adapters | | `packages/ui/**` | [`packages/ui/docs/DOCS.md`](../../packages/ui/docs/DOCS.md) | UI component library wrapping Base UI primitives | | `packages/validation/**` | [`packages/validation/docs/DOCS.md`](../../packages/validation/docs/DOCS.md) | Point sequence validation and persistence schemas | | `packages/rules/**` | [`packages/rules/docs/DOCS.md`](../../packages/rules/docs/DOCS.md) | Point editing rules engine for geometric constraints | diff --git a/packages/mcp/docs/DOCS.md b/packages/mcp/docs/DOCS.md index aac9a95b6..beebc4fa9 100644 --- a/packages/mcp/docs/DOCS.md +++ b/packages/mcp/docs/DOCS.md @@ -8,7 +8,7 @@ Local code-mode access to the live Shift desktop application. - **Architecture Invariant:** `@shift/mcp` is an adapter over `ShiftCapabilities` from `@shift/runtime`. It does not own font, document, editor, window, persistence state, or the reusable plugin contract. - **Architecture Invariant:** The Fastify MCP adapter binds only to `127.0.0.1`, validates localhost Host and Origin headers, and requires a persistent, private bearer token. Release, Nightly, Dev, and Nightly Dev use distinct fixed ports; a collision leaves MCP unavailable rather than selecting another port. Explicit test instances use port `0`. -- **Architecture Invariant:** Agent-written code runs in a fresh QuickJS runtime with bounded time, memory, source size, and result size. Desktop hosts that runtime in a dedicated utility process so generated code cannot block or crash Electron main. It has no Node.js, filesystem, environment, Electron, or network globals. +- **Architecture Invariant:** `@shift/mcp` owns only the protocol adapter. `@shift/sandbox` owns bounded QuickJS execution against `ShiftCapabilities`; the desktop app owns the utility-process supervisor independently of whether the MCP listener starts. - **Architecture Invariant:** Every editor request names a window explicitly. Focus changes never retarget an in-flight or subsequent call. - **Architecture Invariant:** MCP is not Shift's canonical font API. Shared document and editor capabilities remain usable by future plugin and protocol hosts without MCP. The desktop host asks `Font.readAuthoredLayers()` for accepted authored snapshots; agent clients connect through native MCP support rather than a Shift-specific client CLI. @@ -18,8 +18,6 @@ Local code-mode access to the live Shift desktop application. src/ declarations.ts -- loads @shift/runtime's generated declaration for shift.describe types.ts -- MCP connection contract - code.ts -- bounded QuickJS execution over ShiftCapabilities - runtime.ts -- isolated-runtime-only package surface server.ts -- MCP tools, Fastify loopback HTTP, persistent token, connection descriptor index.ts -- main-process-safe public package surface ``` @@ -60,7 +58,7 @@ async () => { ## Desktop ownership -Electron main starts one `ShiftMcpServer` and one `SandboxRuntimeProcess` after `app.whenReady()`. It writes `mcp.json` under the distribution-specific user-data directory with mode `0600` on POSIX. The descriptor contains the loopback URL and persistent token; an existing valid, private token is reused on restart, while an invalid or insecure descriptor prevents MCP startup. Shutdown leaves the credential in place. The token is local connection material, not a user login. MCP delegates execution to the sandbox utility process, and main serves only the typed capability requests that return from that process. A hard host deadline terminates the sandbox if its internal QuickJS deadline cannot settle. +Electron main starts an app-owned `SandboxRuntimeProcess` and then one `ShiftMcpServer` after `app.whenReady()`. MCP startup failure leaves the sandbox available for other execution hosts. The MCP server writes `mcp.json` under the distribution-specific user-data directory with mode `0600` on POSIX. The descriptor contains the loopback URL and persistent token; an existing valid, private token is reused on restart, while an invalid or insecure descriptor prevents MCP startup. Shutdown leaves the credential in place. The token is local connection material, not a user login. MCP delegates execution to the app-owned sandbox utility process, and main serves only the typed capability requests that return from that process. A hard host deadline terminates the process if its internal QuickJS deadline cannot settle; a later execution restarts it. Each renderer serves an agent request lane over a transferred `MessagePort`. Main pairs renderer observations with the explicit window and font-session identities before returning them. Launcher windows are excluded from session discovery. @@ -97,6 +95,7 @@ pnpm typecheck ## Related - [`packages/runtime/docs/DOCS.md`](../../runtime/docs/DOCS.md) -- canonical protocol and plugin capability contracts. +- [`packages/sandbox/docs/DOCS.md`](../../sandbox/docs/DOCS.md) -- reusable execution boundary and process lifecycle. - [`docs/mcp.md`](../../../docs/mcp.md) -- one-time user-scoped native MCP setup for installed Shift builds. - [`apps/desktop/src/main/docs/DOCS.md`](../../../apps/desktop/src/main/docs/DOCS.md) -- Electron lifecycle, window/session identity, and renderer lanes. - [`apps/desktop/src/preload/docs/DOCS.md`](../../../apps/desktop/src/preload/docs/DOCS.md) -- authenticated `MessagePort` transfer into the renderer. diff --git a/packages/mcp/package.json b/packages/mcp/package.json index bdd220234..f3cb8938f 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -11,10 +11,6 @@ ".": { "types": "./src/index.ts", "import": "./src/index.ts" - }, - "./runtime": { - "types": "./src/runtime.ts", - "import": "./src/runtime.ts" } }, "scripts": { @@ -25,18 +21,16 @@ "test:watch": "vitest --config vitest.config.ts" }, "dependencies": { - "@jitl/quickjs-singlefile-cjs-release-sync": "0.32.0", "@modelcontextprotocol/fastify": "2.0.0", "@modelcontextprotocol/node": "2.1.0", "@modelcontextprotocol/server": "2.2.0", "fastify": "^5.12.5", "@shift/runtime": "workspace:*", - "quickjs-emscripten-core": "0.32.0", "zod": "^4.1.12" }, "devDependencies": { "@modelcontextprotocol/client": "2.2.0", - "@shift/types": "workspace:*", + "@shift/sandbox": "workspace:*", "@types/node": "^25.3.0", "oxlint": "^1.85.0", "typescript": "^5.5.4", diff --git a/packages/mcp/src/runtime.ts b/packages/mcp/src/runtime.ts deleted file mode 100644 index 93f1414b1..000000000 --- a/packages/mcp/src/runtime.ts +++ /dev/null @@ -1 +0,0 @@ -export { executeShiftCode } from "./code"; diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts index fe24e99ff..ff7858acd 100644 --- a/packages/mcp/src/server.test.ts +++ b/packages/mcp/src/server.test.ts @@ -3,7 +3,7 @@ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/cli import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { executeShiftCode } from "./code"; +import { executeShiftCode } from "@shift/sandbox"; import { ShiftMcpServer } from "./server"; import type { ShiftCapabilities } from "@shift/runtime"; import type { ShiftMcpConnection } from "./types"; diff --git a/packages/runtime/docs/DOCS.md b/packages/runtime/docs/DOCS.md index 95128aa6e..00b3cf670 100644 --- a/packages/runtime/docs/DOCS.md +++ b/packages/runtime/docs/DOCS.md @@ -1,6 +1,6 @@ # @shift/runtime - + Host-neutral capability contracts shared by Shift protocol adapters and future plugin hosts. @@ -78,5 +78,6 @@ pnpm typecheck ## Related - [`packages/types/docs/DOCS.md`](../../types/docs/DOCS.md) -- canonical domain identities and snapshots. -- [`packages/mcp/docs/DOCS.md`](../../mcp/docs/DOCS.md) -- local MCP adapter and code-mode executor. +- [`packages/sandbox/docs/DOCS.md`](../../sandbox/docs/DOCS.md) -- host-neutral code execution over these contracts. +- [`packages/mcp/docs/DOCS.md`](../../mcp/docs/DOCS.md) -- local MCP transport adapter. - [`apps/desktop/src/main/docs/DOCS.md`](../../../apps/desktop/src/main/docs/DOCS.md) -- desktop capability routing and sandbox process ownership. diff --git a/packages/sandbox/docs/DOCS.md b/packages/sandbox/docs/DOCS.md new file mode 100644 index 000000000..f74ce4c3a --- /dev/null +++ b/packages/sandbox/docs/DOCS.md @@ -0,0 +1,62 @@ +# Sandbox + + + +Bounded Shift code execution over host-supplied typed capabilities, independent of MCP transport. + +## Architecture Invariants + +- **Architecture Invariant:** `@shift/sandbox` executes against `ShiftCapabilities` from `@shift/runtime`. It owns no MCP endpoint, Electron app state, renderer, document, or font persistence. Hosts decide how to supply capabilities and when execution is allowed. +- **Architecture Invariant:** Each `executeShiftCode()` call gets a fresh QuickJS realm with only the explicitly installed `shift` read API. Code has no Node.js, filesystem, environment, Electron, or network globals; output must be JSON-compatible. +- **Architecture Invariant:** Source, memory, stack, result, and execution time are bounded. The Electron host additionally supervises execution in a utility process and stops that process on its hard deadline. Neither this one-shot API nor the current read-only capability contract promises persistent plugin state or mutation authority. + +## Codemap + +```text +src/ + execute.ts -- QuickJS realm, capability installation, quotas, and JSON result + execute.test.ts -- sandbox and capability-boundary behavior + index.ts -- host-neutral execution entrypoint +``` + +## Key Types + +- `ShiftCapabilities` -- host-supplied, explicitly targeted read operations; defined by `@shift/runtime`. +- `executeShiftCode()` -- one-shot execution of an async function against those capabilities. +- `SandboxRuntimeProcess` -- Electron host supervisor, not part of this package. + +## How it works + +### Desktop ownership + +`App` owns `SandboxRuntimeProcess` independently of MCP. The process starts when the app is ready; a timeout or crash stops it, and its next `execute()` starts a replacement. App quit stops it. An MCP listener failure leaves the app-owned sandbox available for another host. The utility process routes capability requests through typed main-process calls; main resolves the explicitly targeted renderer and workspace data. + +This is the first **one-shot** execution mode, not a full interactive plugin lifecycle. A future scripting host can use the same executor and capability contract. Long-lived tool contributions would need explicit registration, cancellation, preview, and disposal semantics rather than inheriting the lifetime of an MCP request. + +## Workflow recipes + +### Add a capability + +1. Add its contract and input schema in `@shift/runtime`. +2. Install a validated callback in `executeShiftCode()` without exposing host objects or globals. +3. Wire the desktop utility/main host and test the capability through the executor and a real desktop boundary. + +## Gotchas + +- A fresh realm means scripts cannot keep globals, subscriptions, or tool instances across calls. +- The runtime package owns capability contracts, not the QuickJS engine. Electron owns the supervised process, not this package. +- Authored layers and preview geometry have different semantics; the sandbox does not decide which is authoritative. + +## Verification + +```sh +pnpm --filter @shift/sandbox test +pnpm --filter @shift/sandbox typecheck +pnpm --filter @shift/sandbox lint:check +``` + +## Related + +- [`packages/runtime/docs/DOCS.md`](../../runtime/docs/DOCS.md) -- canonical capability contracts. +- [`packages/mcp/docs/DOCS.md`](../../mcp/docs/DOCS.md) -- first transport adapter using the sandbox. +- [`apps/desktop/src/main/docs/DOCS.md`](../../../apps/desktop/src/main/docs/DOCS.md) -- desktop sandbox process ownership. diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json new file mode 100644 index 000000000..54ffbc583 --- /dev/null +++ b/packages/sandbox/package.json @@ -0,0 +1,35 @@ +{ + "name": "@shift/sandbox", + "version": "0.0.1", + "license": "MIT OR Apache-2.0", + "private": true, + "description": "Bounded Shift code execution over typed capabilities", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsgo --noEmit", + "lint": "oxlint --fix src/", + "lint:check": "oxlint --deny-warnings src/", + "test": "vitest run --config vitest.config.ts", + "test:watch": "vitest --config vitest.config.ts" + }, + "dependencies": { + "@jitl/quickjs-singlefile-cjs-release-sync": "0.32.0", + "@shift/runtime": "workspace:*", + "quickjs-emscripten-core": "0.32.0" + }, + "devDependencies": { + "@shift/types": "workspace:*", + "@types/node": "^25.3.0", + "oxlint": "^1.85.0", + "typescript": "^5.5.4", + "vitest": "^4.1.10" + } +} diff --git a/packages/mcp/src/code.test.ts b/packages/sandbox/src/execute.test.ts similarity index 89% rename from packages/mcp/src/code.test.ts rename to packages/sandbox/src/execute.test.ts index 8f1db86b6..505a5cc41 100644 --- a/packages/mcp/src/code.test.ts +++ b/packages/sandbox/src/execute.test.ts @@ -1,7 +1,7 @@ import { asGlyphId, asNodeId, asPointId, asSourceId } from "@shift/types"; import type { ShiftCapabilities } from "@shift/runtime"; import { describe, expect, it } from "vitest"; -import { executeShiftCode } from "./code"; +import { executeShiftCode } from "./execute"; const capabilities: ShiftCapabilities = { sessions: { @@ -87,7 +87,7 @@ const capabilities: ShiftCapabilities = { }, }; -describe("Shift code mode exposes bounded live capabilities", () => { +describe("Shift sandbox executes bounded code over live capabilities", () => { it("composes session discovery and editor inspection", async () => { const result = await executeShiftCode( capabilities, @@ -133,15 +133,22 @@ describe("Shift code mode exposes bounded live capabilities", () => { ); }); + it("starts each execution in a fresh realm", async () => { + await executeShiftCode(capabilities, "async () => { globalThis.ephemeral = 1; return null; }"); + await expect( + executeShiftCode(capabilities, "async () => typeof globalThis.ephemeral"), + ).resolves.toBe("undefined"); + }); + it("requires a JSON-compatible result", async () => { await expect(executeShiftCode(capabilities, "async () => undefined")).rejects.toThrow( - "shift.execute must return a JSON value", + "Shift script must return a JSON value", ); }); it("stops code that never settles", async () => { await expect( executeShiftCode(capabilities, "async () => await new Promise(() => {})"), - ).rejects.toThrow("shift.execute timed out"); + ).rejects.toThrow("Shift script timed out"); }); }); diff --git a/packages/mcp/src/code.ts b/packages/sandbox/src/execute.ts similarity index 85% rename from packages/mcp/src/code.ts rename to packages/sandbox/src/execute.ts index 8d9dbda6e..027c00ad7 100644 --- a/packages/mcp/src/code.ts +++ b/packages/sandbox/src/execute.ts @@ -13,13 +13,20 @@ const MAX_CODE_BYTES = 16 * 1024; const MAX_RESULT_BYTES = 64 * 1024; let quickJsPromise: Promise | null = null; -/** Executes agent-written JavaScript against only the supplied Shift capabilities. */ +/** + * Executes one-shot JavaScript against only the supplied Shift capabilities. + * + * @param capabilities - Host-owned operations exposed as `shift` in a fresh realm. + * @param code - Async zero-argument function source with a bounded size. + * @returns the JSON-compatible result; no realm state survives the call. + * @throws {Error} when execution fails, times out, or exceeds its resource limits. + */ export async function executeShiftCode( capabilities: ShiftCapabilities, code: string, ): Promise { if (Buffer.byteLength(code, "utf8") > MAX_CODE_BYTES) { - throw new Error(`shift.execute code exceeds ${MAX_CODE_BYTES} bytes`); + throw new Error(`Shift script exceeds ${MAX_CODE_BYTES} bytes`); } const QuickJS = await loadQuickJS(); @@ -73,16 +80,16 @@ export async function executeShiftCode( }); (async () => { const entry = (${code}); - if (typeof entry !== "function") throw new Error("shift.execute code must evaluate to a function"); + if (typeof entry !== "function") throw new Error("Shift script must evaluate to a function"); const result = await entry(); const json = JSON.stringify(result); - if (json === undefined) throw new Error("shift.execute must return a JSON value"); + if (json === undefined) throw new Error("Shift script must return a JSON value"); return json; })(); `; try { - const evaluation = vm.evalCode(bootstrap, "shift-agent.js"); + const evaluation = vm.evalCode(bootstrap, "shift-script.js"); const promiseHandle = vm.unwrapResult(evaluation); const settledPromise = vm.resolvePromise(promiseHandle); vm.runtime.executePendingJobs(); @@ -99,7 +106,7 @@ export async function executeShiftCode( resultHandle.dispose(); if (Buffer.byteLength(json, "utf8") > MAX_RESULT_BYTES) { - throw new Error(`shift.execute result exceeds ${MAX_RESULT_BYTES} bytes`); + throw new Error(`Shift script result exceeds ${MAX_RESULT_BYTES} bytes`); } return JSON.parse(json) as unknown; @@ -155,7 +162,7 @@ async function withDeadline(promise: Promise, deadline: number): Promise((_, reject) => { - timeout = setTimeout(() => reject(new Error("shift.execute timed out")), timeoutMs); + timeout = setTimeout(() => reject(new Error("Shift script timed out")), timeoutMs); }); try { diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts new file mode 100644 index 000000000..645fff6e6 --- /dev/null +++ b/packages/sandbox/src/index.ts @@ -0,0 +1 @@ +export { executeShiftCode } from "./execute"; diff --git a/packages/sandbox/tsconfig.json b/packages/sandbox/tsconfig.json new file mode 100644 index 000000000..f53b6d51f --- /dev/null +++ b/packages/sandbox/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig/library.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/sandbox/vitest.config.ts b/packages/sandbox/vitest.config.ts new file mode 100644 index 000000000..ae847ff6d --- /dev/null +++ b/packages/sandbox/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b2185ce4..f3d3c750c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@shift/runtime': specifier: workspace:* version: link:../../packages/runtime + '@shift/sandbox': + specifier: workspace:* + version: link:../../packages/sandbox '@shift/types': specifier: workspace:* version: link:../../packages/types @@ -312,9 +315,6 @@ importers: packages/mcp: dependencies: - '@jitl/quickjs-singlefile-cjs-release-sync': - specifier: 0.32.0 - version: 0.32.0 '@modelcontextprotocol/fastify': specifier: 2.0.0 version: 2.0.0(@modelcontextprotocol/server@2.2.0)(fastify@5.12.5) @@ -330,9 +330,6 @@ importers: fastify: specifier: ^5.12.5 version: 5.12.5 - quickjs-emscripten-core: - specifier: 0.32.0 - version: 0.32.0 zod: specifier: ^4.1.12 version: 4.3.6 @@ -340,9 +337,9 @@ importers: '@modelcontextprotocol/client': specifier: 2.2.0 version: 2.2.0 - '@shift/types': + '@shift/sandbox': specifier: workspace:* - version: link:../types + version: link:../sandbox '@types/node': specifier: ^25.3.0 version: 25.3.0 @@ -397,6 +394,34 @@ importers: specifier: ^5.5.4 version: 5.9.3 + packages/sandbox: + dependencies: + '@jitl/quickjs-singlefile-cjs-release-sync': + specifier: 0.32.0 + version: 0.32.0 + '@shift/runtime': + specifier: workspace:* + version: link:../runtime + quickjs-emscripten-core: + specifier: 0.32.0 + version: 0.32.0 + devDependencies: + '@shift/types': + specifier: workspace:* + version: link:../types + '@types/node': + specifier: ^25.3.0 + version: 25.3.0 + oxlint: + specifier: ^1.85.0 + version: 1.85.0 + typescript: + specifier: ^5.5.4 + version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@25.3.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.49.0)(tsx@4.21.0)) + packages/sdk: dependencies: '@base-ui-components/react':