From 12dcac39c2b587c2be4ca702e10ef4da564107a2 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Sun, 15 Mar 2026 21:06:06 -0600 Subject: [PATCH] Migrate dispatch-event and dispatch-share scripts to MCP tools Add dispatch_event and dispatch_share as built-in MCP tools on the agent-scoped endpoint, replacing the shell scripts agents previously called via PATH. Updates CLAUDE.md, AGENTS.md, README, and launch guidance to reference the new MCP tools. Old shell scripts kept for backward compatibility with a TODO to remove them. Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 18 ++--- CLAUDE.md | 20 +++--- README.md | 8 +-- docs/12-new-machine-setup.md | 6 +- public/index.html | 3 +- src/agents/manager.ts | 4 +- src/mcp/repo-tools.ts | 4 +- src/mcp/server.ts | 129 ++++++++++++++++++++++++++++++++++- src/server.ts | 62 ++++++++++++++++- 9 files changed, 222 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7f9857bd..c84dd7bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,21 +3,21 @@ ## CRITICAL: Dispatch Status Events (Mandatory) -- You MUST call `dispatch-event` for every task turn that involves analysis, tool use, or file changes. +- You MUST call the `dispatch_event` MCP tool for every task turn that involves analysis, tool use, or file changes. - Required checkpoints: 1. First action in the turn: - `dispatch-event working ""` + `dispatch_event` with type `working` and message describing what you are starting 2. If blocked by error/tool/env: - `dispatch-event blocked ""` + `dispatch_event` with type `blocked` and message describing the reason 3. If waiting for user input/decision: - `dispatch-event waiting_user ""` + `dispatch_event` with type `waiting_user` and message describing what is needed 4. Final action before final response: - - Success: `dispatch-event done ""` - - No-op/informational turn: `dispatch-event idle ""` + - Success: `dispatch_event` with type `done` and message describing what was completed + - No-op/informational turn: `dispatch_event` with type `idle` and message describing why no actions were taken - Hard requirements: - Do not send a final response unless `done`, `waiting_user`, `blocked`, or `idle` has been emitted in the same turn. - - If `dispatch-event` fails, report that failure explicitly in the response. - - Include a `Status log` section in the final response with the exact stdout lines from each `dispatch-event` call. + - If `dispatch_event` fails, report that failure explicitly in the response. + - Include a `Status log` section in the final response with the result from each `dispatch_event` call. ## UI Validation - For any UI/layout/style interaction change, validate behavior in Playwright before marking the task complete. @@ -47,7 +47,7 @@ Before marking any task as done, run the following checks and fix any failures: ## Temporary Files - Never write temporary files (screenshots, test scripts, scratch files) to the repo root. - Use `/tmp/` or `$DISPATCH_MEDIA_DIR` for ephemeral files. -- Playwright screenshots should be published via `dispatch-share`, not saved locally. +- Playwright screenshots should be published via the `dispatch_share` MCP tool, not saved locally. ## Dev Server Management (CRITICAL) - **NEVER run `npm run dev` directly** in your terminal — it will block your session and killing it can kill your agent process. diff --git a/CLAUDE.md b/CLAUDE.md index c332a56b..8f05b7b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,26 +3,26 @@ ## CRITICAL: Dispatch Status Events (Mandatory) -- You MUST call `dispatch-event` for every task turn that involves analysis, tool use, or file changes. +- You MUST call the `dispatch_event` MCP tool for every task turn that involves analysis, tool use, or file changes. - Required checkpoints: 1. First action in the turn: - `dispatch-event working ""` + `dispatch_event` with type `working` and message describing what you are starting 2. If blocked by error/tool/env: - `dispatch-event blocked ""` + `dispatch_event` with type `blocked` and message describing the reason 3. If waiting for user input/decision: - `dispatch-event waiting_user ""` + `dispatch_event` with type `waiting_user` and message describing what is needed 4. Final action before final response: - - Success: `dispatch-event done ""` - - No-op/informational turn: `dispatch-event idle ""` + - Success: `dispatch_event` with type `done` and message describing what was completed + - No-op/informational turn: `dispatch_event` with type `idle` and message describing why no actions were taken - Hard requirements: - Do not send a final response unless `done`, `waiting_user`, `blocked`, or `idle` has been emitted in the same turn. - - If `dispatch-event` fails, report that failure explicitly in the response. - - Include a `Status log` section in the final response with the exact stdout lines from each `dispatch-event` call. + - If `dispatch_event` fails, report that failure explicitly in the response. + - Include a `Status log` section in the final response with the result from each `dispatch_event` call. ## UI Validation - For any UI/layout/style/feature change, validate behavior in Playwright before marking the task complete. - Include at least one Playwright interaction that covers the changed UI path (for example: open/close panes, modal flow, or action button state changes). -- Capture at least one screenshot per validation flow and publish it with `dispatch-share "description"`. Never leave screenshots local-only. +- Capture at least one screenshot per validation flow and publish it with the `dispatch_share` MCP tool. Never leave screenshots local-only. - For pages with SSE/WebSocket activity, do not use Playwright `waitUntil: "networkidle"` for readiness checks. - Use `waitUntil: "domcontentloaded"` (or `"load"`) and wait for concrete UI-ready signals (visible control/text/state) instead. @@ -48,7 +48,7 @@ Before marking any task as done, run the following checks and fix any failures: ## Temporary Files - Never write temporary files (screenshots, test scripts, scratch files) to the repo root. - Use `/tmp/` or `$DISPATCH_MEDIA_DIR` for ephemeral files. -- Playwright screenshots should be published via `dispatch-share`, not saved locally. +- Playwright screenshots should be published via the `dispatch_share` MCP tool, not saved locally. ## Dev Server Management (CRITICAL) - **NEVER run `npm run dev` directly** in your terminal — it will block your session and killing it can kill your agent process. diff --git a/README.md b/README.md index 4afc51e9..1bf25064 100644 --- a/README.md +++ b/README.md @@ -124,12 +124,12 @@ For setting up Dispatch as a persistent service on a dedicated Mac (e.g. Mac min ## Media Sharing -Agents share screenshots and media with the Dispatch UI via `dispatch-share`, which is automatically available in every agent's `PATH`: +Agents share screenshots and media with the Dispatch UI via the `dispatch_share` MCP tool, which is automatically available to every agent through the Dispatch MCP server: -- `dispatch-share "description"` — publish a Playwright screenshot -- `dispatch-share --sim "description" [udid]` — capture and publish an iOS Simulator screenshot +- `dispatch_share` with `filePath` and `description` — publish a Playwright screenshot +- `dispatch_share` with `source: "simulator"` and `description` — capture and publish an iOS Simulator screenshot -These commands only work inside running agent sessions (they require `DISPATCH_AGENT_ID` which Dispatch sets automatically). The browser Media panel auto-refreshes to show new images. +These tools only work inside running agent sessions (they require agent-scoped MCP context which Dispatch provides automatically). The browser Media panel auto-refreshes to show new images. ## Operations diff --git a/docs/12-new-machine-setup.md b/docs/12-new-machine-setup.md index c427789f..61affaeb 100644 --- a/docs/12-new-machine-setup.md +++ b/docs/12-new-machine-setup.md @@ -276,8 +276,10 @@ xcode-select --install # Ensure CLI tools npm rebuild node-pty # Rebuild native module ``` -### Agent can't find dispatch-share/dispatch-event -`dispatchBinDir` is derived automatically from the server's install location. Verify that `~/.dispatch/server/bin/` contains the dispatch helper scripts. If the production checkout is corrupt, re-run `bin/install-launchd`. +### Agent can't use dispatch_event/dispatch_share MCP tools +These tools are served via the Dispatch MCP server at `/api/mcp/:agentId`. They are available automatically to agents launched by Dispatch — no PATH or bin directory needed. If the tools are missing, verify that the Dispatch server is running and the agent's MCP config points to the correct URL. + +> **Legacy**: The old `dispatch-share` and `dispatch-event` shell scripts in `bin/` still exist for backward compatibility but agents should use the MCP tools instead. --- diff --git a/public/index.html b/public/index.html index a270dbe0..697a5f44 100644 --- a/public/index.html +++ b/public/index.html @@ -51,8 +51,7 @@

Agents

- Use dispatch-share <image-path> or dispatch-share --sim. Files are saved to - $DISPATCH_MEDIA_DIR (compat: $HOSTESS_MEDIA_DIR) and auto-render here. + Use the dispatch_share MCP tool to publish media. Files auto-render here.
diff --git a/src/agents/manager.ts b/src/agents/manager.ts index a0bfb892..9a3ccc94 100644 --- a/src/agents/manager.ts +++ b/src/agents/manager.ts @@ -746,8 +746,8 @@ export class AgentManager { // AGENTS.md (auto-loaded by Codex) and CLAUDE.md (auto-loaded by Claude Code). const launchGuidance = "Dispatch startup rules: Playwright default is headless unless the user explicitly asks for headed mode. " + - "Capture at least one screenshot per UI validation flow; publish every screenshot with dispatch-share \"description\" for Playwright or dispatch-share --sim \"description\" [udid] for iOS Simulator — never leave screenshots local-only. " + - "Call dispatch-event at the start of each turn (working), when blocked or waiting for input (blocked/waiting_user), and before your final response (done on success, idle for no-op turns). Never send a final response without a terminal status event. " + + "Capture at least one screenshot per UI validation flow; publish every screenshot with the dispatch_share MCP tool (filePath + description for Playwright, or source 'simulator' for iOS Simulator) — never leave screenshots local-only. " + + "Call the dispatch_event MCP tool at the start of each turn (working), when blocked or waiting for input (blocked/waiting_user), and before your final response (done on success, idle for no-op turns). Never send a final response without a terminal status event. " + "For SSE/WebSocket pages, never use waitUntil: \"networkidle\"; use \"domcontentloaded\" or \"load\" and explicit UI-ready checks."; const userLocalBin = process.env.HOME ? path.join(process.env.HOME, ".local/bin") : null; diff --git a/src/mcp/repo-tools.ts b/src/mcp/repo-tools.ts index 6142eb8e..dfa30bee 100644 --- a/src/mcp/repo-tools.ts +++ b/src/mcp/repo-tools.ts @@ -10,7 +10,9 @@ const BUILTIN_TOOL_NAMES = new Set([ "create_pr", "enable_pr_automerge", "merge_pr_now", - "get_pr_status" + "get_pr_status", + "dispatch_event", + "dispatch_share" ]); type RepoToolFile = { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 7d8d6159..2b7ad1d4 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -19,9 +19,25 @@ import { } from "../git/worktree.js"; import { loadRepoTools } from "./repo-tools.js"; -type McpRequestContext = { +export type MediaResult = { + fileName: string; + url: string; + sizeBytes: number; + source: string; + description: string; +}; + +export type McpRequestContext = { agent: AgentRecord | null; repoRoot: string | null; + upsertEvent?: ( + agentId: string, + event: { type: string; message: string; metadata?: Record } + ) => Promise; + shareMedia?: ( + agentId: string, + opts: { filePath: string; description: string; source?: string; name?: string } + ) => Promise; }; export async function handleMcpRequest( @@ -225,6 +241,117 @@ async function createDispatchMcpServer(context: McpRequestContext): Promise { + try { + await upsertEvent(agentId, { + type: args.type, + message: args.message, + metadata: args.metadata as Record | undefined + }); + return { + content: [{ type: "text", text: `Updated ${agentId}: ${args.type} - ${args.message}` }] + }; + } catch (error) { + return toToolError(error); + } + } + ); + } + + if (context.agent && context.shareMedia) { + const agentId = context.agent.id; + const shareMedia = context.shareMedia; + + server.registerTool( + "dispatch_share", + { + description: + "Upload an image file to Dispatch for sharing. Supports png, jpg, jpeg, gif, and webp. Use source 'simulator' with a simulator UDID to capture a screenshot directly from an iOS Simulator.", + inputSchema: { + filePath: z + .string() + .optional() + .describe("Absolute path to the image file to upload. Not required when source is 'simulator'."), + description: z.string().describe("A short description of the shared media."), + source: z + .enum(["screenshot", "simulator"]) + .default("screenshot") + .describe("The source type of the media."), + name: z + .string() + .optional() + .describe("Preferred file name for the upload. Derived from the file path if omitted."), + simulatorUdid: z + .string() + .optional() + .describe("Simulator UDID for simulator screenshots. Defaults to 'booted'.") + } + }, + async (args) => { + try { + let filePath = args.filePath; + + if (args.source === "simulator") { + const { execFile } = await import("node:child_process"); + const { promisify } = await import("node:util"); + const execFileAsync = promisify(execFile); + const udid = args.simulatorUdid ?? "booted"; + const timestamp = new Date() + .toISOString() + .replace(/[:.]/g, "-") + .replace("T", "-") + .replace("Z", ""); + const tmpPath = `${process.env.TMPDIR ?? "/tmp"}/sim-${timestamp}.png`; + await execFileAsync("xcrun", ["simctl", "io", udid, "screenshot", "--type=png", tmpPath]); + filePath = tmpPath; + } + + if (!filePath) { + return toToolError(new Error("filePath is required when source is not 'simulator'.")); + } + + const result = await shareMedia(agentId, { + filePath, + description: args.description, + source: args.source, + name: args.name + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result) + } + ], + structuredContent: result + }; + } catch (error) { + return toToolError(error); + } + } + ); + } + if (context.agent && context.repoRoot) { const repoTools = await loadRepoTools(context.repoRoot); for (const tool of repoTools) { diff --git a/src/server.ts b/src/server.ts index 46db5c4d..047cdac2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -535,7 +535,9 @@ async function registerRoutes() { reply.hijack(); await handleMcpRequest(request.raw, reply.raw, request.body, { agent, - repoRoot + repoRoot, + upsertEvent: mcpUpsertEvent, + shareMedia: mcpShareMedia }); }); @@ -1975,3 +1977,61 @@ async function shutdown(code: number): Promise { function isAgentLatestEventType(value: unknown): value is AgentLatestEventType { return typeof value === "string" && AGENT_LATEST_EVENT_TYPES.includes(value as AgentLatestEventType); } + +async function mcpUpsertEvent( + agentId: string, + event: { type: string; message: string; metadata?: Record } +): Promise { + if (!isAgentLatestEventType(event.type)) { + throw new Error(`type must be one of: ${AGENT_LATEST_EVENT_TYPES.join(", ")}.`); + } + const agent = await agentManager.upsertLatestEvent(agentId, { + type: event.type, + message: event.message.trim(), + metadata: event.metadata + }); + uiEventBroker.publish({ type: "agent.upsert", agent: withStreamFlag(agent) }); +} + +async function mcpShareMedia( + agentId: string, + opts: { filePath: string; description: string; source?: string; name?: string } +): Promise<{ fileName: string; url: string; sizeBytes: number; source: string; description: string }> { + const agent = await agentManager.getAgent(agentId); + if (!agent) throw new Error("Agent not found."); + + if (!isImageFile(opts.filePath)) { + throw new Error("Unsupported file type. Use png/jpg/jpeg/gif/webp."); + } + + const validSources = ["screenshot", "stream", "simulator"]; + const source = opts.source && validSources.includes(opts.source) ? opts.source : "screenshot"; + + const buffer = await readFile(opts.filePath); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-").replace("T", "-").replace("Z", ""); + const baseName = opts.name ?? path.basename(opts.filePath); + const safeName = baseName.replace(/ /g, "-").replace(/[^A-Za-z0-9._-]/g, "") || `shared-${timestamp}.png`; + const ext = path.extname(safeName); + const base = path.basename(safeName, ext); + const fileName = `${base}-${timestamp}${ext}`; + + const mediaDir = resolveMediaDir(agentId, agent.mediaDir); + await mkdir(mediaDir, { recursive: true }); + await writeFile(path.join(mediaDir, fileName), buffer); + + await pool.query( + `INSERT INTO media (agent_id, file_name, source, size_bytes, description) + VALUES ($1, $2, $3, $4, $5)`, + [agentId, fileName, source, buffer.length, opts.description] + ); + + uiEventBroker.publish({ type: "media.changed", agentId }); + + return { + fileName, + url: `/api/v1/agents/${agentId}/media/${encodeURIComponent(fileName)}`, + sizeBytes: buffer.length, + source, + description: opts.description + }; +}