Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,21 @@
<!-- Keep behavioral rules in sync with CLAUDE.md (used by Claude Code agents). -->

## 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 "<what you are starting>"`
`dispatch_event` with type `working` and message describing what you are starting
2. If blocked by error/tool/env:
`dispatch-event blocked "<concise reason>"`
`dispatch_event` with type `blocked` and message describing the reason
3. If waiting for user input/decision:
`dispatch-event waiting_user "<what is needed>"`
`dispatch_event` with type `waiting_user` and message describing what is needed
4. Final action before final response:
- Success: `dispatch-event done "<what was completed>"`
- No-op/informational turn: `dispatch-event idle "<why no actions were taken>"`
- 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.
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 10 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,26 @@
<!-- Keep behavioral rules in sync with AGENTS.md (used by Codex agents). -->

## 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 "<what you are starting>"`
`dispatch_event` with type `working` and message describing what you are starting
2. If blocked by error/tool/env:
`dispatch-event blocked "<concise reason>"`
`dispatch_event` with type `blocked` and message describing the reason
3. If waiting for user input/decision:
`dispatch-event waiting_user "<what is needed>"`
`dispatch_event` with type `waiting_user` and message describing what is needed
4. Final action before final response:
- Success: `dispatch-event done "<what was completed>"`
- No-op/informational turn: `dispatch-event idle "<why no actions were taken>"`
- 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 <image-path> "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.

Expand All @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <image-path> "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

Expand Down
6 changes: 4 additions & 2 deletions docs/12-new-machine-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
3 changes: 1 addition & 2 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,7 @@ <h2>Agents</h2>
<button data-media-refresh-btn type="button">Refresh media</button>
</div>
<div class="media-hint">
Use <code>dispatch-share &lt;image-path&gt;</code> or <code>dispatch-share --sim</code>. Files are saved to
<code>$DISPATCH_MEDIA_DIR</code> (compat: <code>$HOSTESS_MEDIA_DIR</code>) and auto-render here.
Use the <code>dispatch_share</code> MCP tool to publish media. Files auto-render here.
</div>
<div class="media-grid" data-media-grid></div>
</section>
Expand Down
4 changes: 2 additions & 2 deletions src/agents/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <image-path> \"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;
Expand Down
4 changes: 3 additions & 1 deletion src/mcp/repo-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
129 changes: 128 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }
) => Promise<void>;
shareMedia?: (
agentId: string,
opts: { filePath: string; description: string; source?: string; name?: string }
) => Promise<MediaResult>;
};

export async function handleMcpRequest(
Expand Down Expand Up @@ -225,6 +241,117 @@ async function createDispatchMcpServer(context: McpRequestContext): Promise<McpS
}
);

// TODO: Remove bin/dispatch-event and bin/dispatch-share once all agents use these MCP tools.
if (context.agent && context.upsertEvent) {
const agentId = context.agent.id;
const upsertEvent = context.upsertEvent;

server.registerTool(
"dispatch_event",
{
description:
"Report agent status to Dispatch. Must be called at the start of each turn (working), when blocked (blocked), waiting for user input (waiting_user), and before the final response (done or idle).",
inputSchema: {
type: z.enum(["working", "blocked", "waiting_user", "done", "idle"]).describe("The status event type."),
message: z.string().describe("A short description of what is happening."),
metadata: z
.record(z.string(), z.unknown())
.optional()
.describe("Optional metadata object.")
}
},
async (args) => {
try {
await upsertEvent(agentId, {
type: args.type,
message: args.message,
metadata: args.metadata as Record<string, unknown> | 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) {
Expand Down
62 changes: 61 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,9 @@ async function registerRoutes() {
reply.hijack();
await handleMcpRequest(request.raw, reply.raw, request.body, {
agent,
repoRoot
repoRoot,
upsertEvent: mcpUpsertEvent,
shareMedia: mcpShareMedia
});
});

Expand Down Expand Up @@ -1975,3 +1977,61 @@ async function shutdown(code: number): Promise<void> {
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<string, unknown> }
): Promise<void> {
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
};
}
Loading