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 + }; +}