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
3 changes: 3 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,6 +164,9 @@ Every agent launched by Dispatch gets access to MCP tools via an agent-scoped en
| `dispatch_pin` | Surface key info in the sidebar (URLs, ports, PRs, files) |
| `dispatch_share` | Upload screenshots and media to the agent's media pane |
| `dispatch_list_media` | List media files shared with or by this agent |
| `dispatch_delete_media` | Permanently remove a shared media file |
| `dispatch_list_pins` | List current sidebar pins for this agent |
| `dispatch_delete_pin` | Permanently remove a pin by its listed stable ID |
| `list_personas` | List available persona reviewers for this project |
| `dispatch_launch_persona` | Launch a persona child agent for automated review |
| `dispatch_review_list_feedback` | List human review feedback items with statuses and threads |
Expand Down
91 changes: 64 additions & 27 deletions apps/server/src/agents/manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -101,7 +101,10 @@ const MAX_PINS = 50;
function normalizeInitialPins(pins: AgentPin[]): AgentPin[] {
const byLabel = new Map<string, AgentPin>();
for (const pin of pins) {
byLabel.set(pin.label.toLowerCase(), pin);
byLabel.set(pin.label.toLowerCase(), {
...pin,
id: pin.id ?? randomUUID(),
});
}
const deduped = Array.from(byLabel.values());
if (deduped.length > MAX_PINS) {
Expand DownExpand Up@@ -1078,42 +1081,76 @@ export class AgentManager {
}

async upsertPin(id: string, pin: AgentPin): Promise<AgentRecord> {
const current = await this.getAgent(id);
if (!current) throw new AgentError("Agent not found.", 404);

const pins = (current.pins ?? []).filter(
(p) => p.label.toLowerCase() !== pin.label.toLowerCase()
);
if (pins.length >= MAX_PINS) {
throw new AgentError(`Maximum of ${MAX_PINS} pins reached.`, 400);
}
pins.push(pin);

await this.pool.query(
`UPDATE agents SET pins = $2::jsonb, updated_at = NOW() WHERE id = $1`,
[id, JSON.stringify(pins)]
);
await this.mutatePins(id, (currentPins) => {
const existing = currentPins.find(
(p) => p.label.toLowerCase() === pin.label.toLowerCase()
);
const pins = currentPins.filter(
(p) => p.label.toLowerCase() !== pin.label.toLowerCase()
);
if (pins.length >= MAX_PINS) {
throw new AgentError(`Maximum of ${MAX_PINS} pins reached.`, 400);
}
pins.push({ ...pin, id: existing?.id ?? pin.id ?? randomUUID() });
return pins;
});

return (await this.getAgent(id)) as AgentRecord;
}

async deletePin(id: string, label: string): Promise<AgentRecord> {
const current = await this.getAgent(id);
if (!current) throw new AgentError("Agent not found.", 404);
async deletePinById(id: string, pinId: string): Promise<AgentRecord> {
await this.mutatePins(id, (currentPins) => {
const pins = currentPins.filter((p) => p.id !== pinId);
if (pins.length === currentPins.length) {
throw new AgentError("Pin not found.", 404);
}
return pins;
});

const lowerLabel = label.toLowerCase();
const pins = (current.pins ?? []).filter(
(p) => p.label.toLowerCase() !== lowerLabel
);
return (await this.getAgent(id)) as AgentRecord;
}

await this.pool.query(
`UPDATE agents SET pins = $2::jsonb, updated_at = NOW() WHERE id = $1`,
[id, JSON.stringify(pins)]
);
async deletePinByLabel(id: string, label: string): Promise<AgentRecord> {
await this.mutatePins(id, (currentPins) => {
const pins = currentPins.filter(
(pin) => pin.label.toLowerCase() !== label.toLowerCase()
);
if (pins.length === currentPins.length) {
throw new AgentError("Pin not found.", 404);
}
return pins;
});

return (await this.getAgent(id)) as AgentRecord;
}

private async mutatePins(
id: string,
mutate: (pins: AgentPin[]) => AgentPin[]
): Promise<void> {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
const result = await client.query<{ pins: AgentPin[] }>(
"SELECT pins FROM agents WHERE id = $1 FOR UPDATE",
[id]
);
if (result.rows.length === 0)
throw new AgentError("Agent not found.", 404);
const pins = mutate(result.rows[0]!.pins ?? []);
await client.query(
"UPDATE agents SET pins = $2::jsonb, updated_at = NOW() WHERE id = $1",
[id, JSON.stringify(pins)]
);
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK").catch(() => {});
throw error;
} finally {
client.release();
}
}

async reconcileAgents(): Promise<void> {
// Two passes: status reconciliation + orphan-session cleanup. The
// SSE broadcaster doesn't need the changed-record list at this
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/agents/tmux/command-builder.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -173,7 +173,7 @@ export function buildLaunchGuidance(
"Report status with dispatch_event. Types: working (making progress — includes debugging, fixing test failures, investigating errors), blocked (completely stuck with no further approach to try — NOT for errors or test failures you plan to fix next), waiting_user (need a decision or approval), done (task complete), idle (no-op, just answered a question). Emit working at turn start and when shifting phases. Emit a terminal event before your final response. Your reported status is verified against session activity and auto-corrected when it doesn't match."
);
rules.push(
"Pin key info with dispatch_pin so it surfaces in the sidebar — especially values users may need to copy/paste: URLs, commands, branch names, IDs, tokens, simulator UDIDs. Types: url (dev servers, docs), port (server ports), pr (PR links), filename (key files), code (short snippets, env vars, IDs), string (status, decisions), markdown (short structured summaries). Update or delete stale pins. For longer artifacts, write a file via dispatch_share and pin a reference."
"Pin key info with dispatch_pin so it surfaces in the sidebar — especially values users may need to copy/paste: URLs, commands, branch names, IDs, tokens, simulator UDIDs. Types: url (dev servers, docs), port (server ports), pr (PR links), filename (key files), code (short snippets, env vars, IDs), string (status, decisions), markdown (short structured summaries). To delete a stale pin, call dispatch_list_pins then dispatch_delete_pin with its id. For longer artifacts, write a file via dispatch_share and pin a reference."
);
rules.push(
"Playwright: default headless. Capture at least one screenshot per UI flow via dispatch_share. Call browser_close when done."
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/agents/types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,7 @@ export type PinType =
| "markdown";

export type AgentPin = {
id?: string;
label: string;
value: string;
type: PinType;
Expand Down
23 changes: 23 additions & 0 deletions apps/server/src/db/migrations/0036_pin-ids.sql
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
-- Normalize legacy pins and give each usable entry a unique stable ID.
UPDATE agents
SET pins = CASE
WHEN jsonb_typeof(pins) <> 'array' THEN '[]'::jsonb
ELSE COALESCE((
SELECT jsonb_agg(
pin || jsonb_build_object(
'id',
CASE WHEN existing_id <> '' AND id_count = 1 THEN existing_id
ELSE 'pin_' || md5(agents.id || ordinal::text || jsonb_extract_path_text(pin, 'label') || jsonb_extract_path_text(pin, 'value')) END
) ORDER BY ordinal
)
FROM (
SELECT pin, ordinal, COALESCE(jsonb_extract_path_text(pin, 'id'), '') AS existing_id,
COUNT(*) OVER (PARTITION BY COALESCE(jsonb_extract_path_text(pin, 'id'), '')) AS id_count
FROM jsonb_array_elements(pins) WITH ORDINALITY AS items(pin, ordinal)
WHERE jsonb_typeof(pin) = 'object'
AND jsonb_extract_path_text(pin, 'label') IS NOT NULL
AND COALESCE(jsonb_extract_path_text(pin, 'value'), '') <> ''
AND jsonb_extract_path_text(pin, 'type') IN ('string', 'url', 'port', 'code', 'pr', 'filename', 'markdown')
) AS valid_pins
), '[]'::jsonb)
END;
9 changes: 9 additions & 0 deletions apps/server/src/routes/mcp.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,8 @@ type McpRouteDeps = {
mcpRenameSession: unknown;
mcpShareMedia: unknown;
mcpListMedia: unknown;
mcpDeleteMedia: unknown;
mcpListPins: unknown;
mcpListPersonas: unknown;
mcpLaunchPersona: unknown;
mcpLaunchAgent: unknown;
Expand All@@ -58,6 +60,7 @@ type McpRouteDeps = {
mcpListReviewFeedback: unknown;
mcpUpsertPin: unknown;
mcpDeletePin: unknown;
mcpDeletePinByLabel: unknown;
mcpGetParentContext: unknown;
mcpJobComplete: unknown;
mcpJobFailed: unknown;
Expand DownExpand Up@@ -196,8 +199,11 @@ export async function registerMcpRoutes(
renameSession: deps.mcpRenameSession,
shareMedia: deps.mcpShareMedia,
listMedia: deps.mcpListMedia,
deleteMedia: deps.mcpDeleteMedia,
upsertPin: deps.mcpUpsertPin,
deletePin: deps.mcpDeletePin,
deletePinByLabel: deps.mcpDeletePinByLabel,
listPins: deps.mcpListPins,
listPersonas: deps.mcpListPersonas,
launchPersona: deps.mcpLaunchPersona,
launchAgent: deps.mcpLaunchAgent,
Expand DownExpand Up@@ -279,6 +285,7 @@ export async function registerMcpRoutes(
renameSession: deps.mcpRenameSession,
shareMedia: deps.mcpShareMedia,
listMedia: deps.mcpListMedia,
deleteMedia: deps.mcpDeleteMedia,
listPersonas: deps.mcpListPersonas,
launchPersona: deps.mcpLaunchPersona,
launchAgent: deps.mcpLaunchAgent,
Expand All@@ -292,6 +299,8 @@ export async function registerMcpRoutes(
listAgentsForAgent: deps.mcpListAgentsForAgent,
upsertPin: deps.mcpUpsertPin,
deletePin: deps.mcpDeletePin,
deletePinByLabel: deps.mcpDeletePinByLabel,
listPins: deps.mcpListPins,
getParentContext: deps.mcpGetParentContext,
getActivitySummary: (params: Record<string, unknown>) =>
telemetry.getActivitySummary(deps.pool, params as never) as Promise<
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -489,6 +489,7 @@ async function registerRoutes() {
mcpRenameSession: mcpHandlers.renameSession,
mcpShareMedia: mcpHandlers.shareMedia,
mcpListMedia: mcpHandlers.listMedia,
mcpDeleteMedia: mcpHandlers.deleteMedia,
mcpListPersonas: mcpHandlers.listPersonas,
mcpLaunchPersona: mcpHandlers.launchPersona,
mcpLaunchAgent: mcpHandlers.launchAgent,
Expand All@@ -502,6 +503,8 @@ async function registerRoutes() {
mcpListAgentsForAgent: mcpHandlers.listAgentsForAgent,
mcpUpsertPin: mcpHandlers.upsertPin,
mcpDeletePin: mcpHandlers.deletePin,
mcpDeletePinByLabel: mcpHandlers.deletePinByLabel,
mcpListPins: mcpHandlers.listPins,
mcpGetParentContext: mcpHandlers.getParentContext,
mcpJobComplete: mcpHandlers.jobComplete,
mcpJobFailed: mcpHandlers.jobFailed,
Expand Down
88 changes: 84 additions & 4 deletions apps/server/src/server/mcp-handlers.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import path from "node:path";
import { randomUUID } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";

import type { FastifyBaseLogger } from "fastify";
import type { Pool } from "pg";
Expand DownExpand Up@@ -181,17 +181,41 @@ async function handleUpsertPin(
}

async function handleDeletePin(
deps: CreateMcpHandlersDeps,
agentId: string,
pinId: string
): Promise<void> {
const agent = await deps.agentManager.deletePinById(agentId, pinId);
deps.publishUiEvent({
type: "agent.upsert",
agent: deps.withStreamFlag(agent),
});
}

async function handleDeletePinByLabel(
deps: CreateMcpHandlersDeps,
agentId: string,
label: string
): Promise<void> {
const agent = await deps.agentManager.deletePin(agentId, label);
const agent = await deps.agentManager.deletePinByLabel(agentId, label);
deps.publishUiEvent({
type: "agent.upsert",
agent: deps.withStreamFlag(agent),
});
}

async function handleListPins(
deps: CreateMcpHandlersDeps,
agentId: string
): Promise<Array<{ id: string; label: string; value: string; type: string }>> {
const agent = await deps.agentManager.getAgent(agentId);
if (!agent) throw new Error("Agent not found.");
return (agent.pins ?? []).map((pin) => {
if (!pin.id) throw new Error("Pin is missing its stable ID.");
return { id: pin.id, label: pin.label, value: pin.value, type: pin.type };
});
}

async function handleRenameSession(
deps: CreateMcpHandlersDeps,
agentId: string,
Expand DownExpand Up@@ -635,6 +659,54 @@ async function handleListMedia(
}));
}

async function handleDeleteMedia(
deps: CreateMcpHandlersDeps,
agentId: string,
fileName: string
): Promise<void> {
const agent = await deps.agentManager.getAgent(agentId);
if (!agent) throw new Error("Agent not found.");

const result = await deps.pool.query<{ file_name: string }>(
"SELECT file_name FROM media WHERE agent_id = $1 AND file_name = $2",
[agentId, fileName]
);
if (result.rows.length === 0) {
throw new Error(
"No media file found with the given fileName for this agent."
);
}

const storedFileName = result.rows[0].file_name;
const mediaDir = resolveMediaDir(agentId, agent.mediaDir, deps.mediaRoot);
const filePath = path.join(mediaDir, storedFileName);
const resolvedMediaDir = path.resolve(mediaDir);
if (!path.resolve(filePath).startsWith(resolvedMediaDir + path.sep)) {
throw new Error("Invalid media file path.");
}

try {
await unlink(filePath);
} catch (error: unknown) {
if (
!(
error &&
typeof error === "object" &&
"code" in error &&
error.code === "ENOENT"
)
) {
throw error;
}
}

await deps.pool.query(
"DELETE FROM media WHERE agent_id = $1 AND file_name = $2",
[agentId, storedFileName]
);
deps.publishUiEvent({ type: "media.changed", agentId });
}

// ---------------------------------------------------------------------------
// Thin delegation layer
// ---------------------------------------------------------------------------
Expand DownExpand Up@@ -669,8 +741,13 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) {
pin: { label: string; value: string; type: string }
) => handleUpsertPin(deps, agentId, pin),

deletePin: (agentId: string, label: string) =>
handleDeletePin(deps, agentId, label),
deletePin: (agentId: string, pinId: string) =>
handleDeletePin(deps, agentId, pinId),

deletePinByLabel: (agentId: string, label: string) =>
handleDeletePinByLabel(deps, agentId, label),

listPins: (agentId: string) => handleListPins(deps, agentId),

renameSession: (agentId: string, name: string) =>
handleRenameSession(deps, agentId, name),
Expand DownExpand Up@@ -730,5 +807,8 @@ export function createMcpHandlers(deps: CreateMcpHandlersDeps) {

listMedia: (agentId: string, opts: { source?: string }) =>
handleListMedia(deps, agentId, opts),

deleteMedia: (agentId: string, fileName: string) =>
handleDeleteMedia(deps, agentId, fileName),
};
}
Loading
Loading