feat: add conversation management, persistence, and mobile app spec
Major enhancements to voice assistant with conversation persistence and mobile app planning: - Add conversation management with multiple active conversations - Implement conversation persistence (save/load from disk) - Add conversation selector UI for switching between conversations - Enhance agent prompt with per-conversation tmux sessions - Add mobile app technical specification (React Native + Expo) - Improve UI with active processes view and agent stream - Add ACP (Agent Control Protocol) server implementation Mobile spec details: - React Native with Expo SDK 54+ - NativeWind v5 for styling - Share Zod schemas/types from server - Android in-call experience (InCallManager) - Direct WebSocket connection via Tailscale - Same audio quality as web prototype 🤖 Generated with [Claude Code](https://claude.com/claude-code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
co-authored by
Claude
Happy
parent
37a363da6d
commit
4f2c2fc14a
@@ -0,0 +1,367 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { homedir } from "os";
|
||||
import { resolve } from "path";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk";
|
||||
|
||||
export interface AgentMcpServerOptions {
|
||||
agentManager: AgentManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand tilde (~) to home directory and resolve path
|
||||
*/
|
||||
function expandPath(path: string): string {
|
||||
if (path.startsWith("~/") || path === "~") {
|
||||
return resolve(homedir(), path.slice(2));
|
||||
}
|
||||
return resolve(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the update type from a SessionNotification
|
||||
*/
|
||||
function getUpdateType(notification: SessionNotification): string {
|
||||
return notification.update.sessionUpdate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and configure the Agent MCP Server
|
||||
* Exposes Claude Code agent management tools to the LLM
|
||||
*/
|
||||
export async function createAgentMcpServer(
|
||||
options: AgentMcpServerOptions
|
||||
): Promise<McpServer> {
|
||||
const { agentManager } = options;
|
||||
|
||||
const server = new McpServer({
|
||||
name: "agent-mcp",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
// Tool: create_coding_agent
|
||||
server.registerTool(
|
||||
"create_coding_agent",
|
||||
{
|
||||
title: "Create Coding Agent",
|
||||
description:
|
||||
"Creates a new Claude Code agent via ACP. The agent runs as a separate process and can execute coding tasks autonomously in a specified directory. Returns immediately with agent ID and status. If an initial prompt is provided, the agent will start working on it automatically.",
|
||||
inputSchema: {
|
||||
cwd: z
|
||||
.string()
|
||||
.describe(
|
||||
"REQUIRED: Working directory for the agent. Can be absolute path, tilde-prefixed path, or relative path (e.g., '~/dev/project', '/path/to/repo', './subdir')."
|
||||
),
|
||||
initialPrompt: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional initial task or prompt for the agent to start working on immediately after creation. If provided, agent will begin processing this task right away."
|
||||
),
|
||||
permissionsMode: z
|
||||
.enum(["auto_approve", "ask_user", "reject_all"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Permission mode for file operations. 'auto_approve' (default): automatically allow all operations. 'ask_user': prompt user for each permission (not yet implemented, falls back to auto_approve). 'reject_all': reject all permission requests."
|
||||
),
|
||||
},
|
||||
outputSchema: {
|
||||
agentId: z.string().describe("Unique identifier for the created agent"),
|
||||
status: z
|
||||
.string()
|
||||
.describe(
|
||||
"Current agent status: 'initializing', 'ready', 'processing', etc."
|
||||
),
|
||||
cwd: z.string().describe("The resolved absolute working directory the agent is running in"),
|
||||
permissionsMode: z.string().describe("The permission mode the agent is using"),
|
||||
},
|
||||
},
|
||||
async ({ cwd, initialPrompt, permissionsMode }) => {
|
||||
// Expand and resolve the working directory
|
||||
const resolvedCwd = expandPath(cwd);
|
||||
|
||||
const agentId = await agentManager.createAgent({
|
||||
cwd: resolvedCwd,
|
||||
initialPrompt,
|
||||
permissionsMode: permissionsMode || "auto_approve",
|
||||
});
|
||||
|
||||
const status = agentManager.getAgentStatus(agentId);
|
||||
|
||||
const result = {
|
||||
agentId,
|
||||
status,
|
||||
cwd: resolvedCwd,
|
||||
permissionsMode: permissionsMode || "auto_approve",
|
||||
};
|
||||
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: result,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Tool: send_agent_prompt
|
||||
server.registerTool(
|
||||
"send_agent_prompt",
|
||||
{
|
||||
title: "Send Agent Prompt",
|
||||
description:
|
||||
"Sends a task or prompt to an existing agent. The agent will process the prompt and execute the required actions. Use this to give the agent new tasks, provide feedback, or steer its work. Only works if agent is in 'ready' or 'completed' state.",
|
||||
inputSchema: {
|
||||
agentId: z.string().describe("Agent ID returned from create_coding_agent"),
|
||||
prompt: z
|
||||
.string()
|
||||
.describe(
|
||||
"The task, instruction, or feedback to send to the agent. Be specific about what you want the agent to accomplish."
|
||||
),
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean().describe("Whether the prompt was sent successfully"),
|
||||
},
|
||||
},
|
||||
async ({ agentId, prompt }) => {
|
||||
await agentManager.sendPrompt(agentId, prompt);
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
};
|
||||
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: result,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Tool: get_agent_status
|
||||
server.registerTool(
|
||||
"get_agent_status",
|
||||
{
|
||||
title: "Get Agent Status",
|
||||
description:
|
||||
"Get the current status and information about a specific agent. Returns status, creation time, session ID, and any error messages.",
|
||||
inputSchema: {
|
||||
agentId: z.string().describe("Agent ID to query"),
|
||||
},
|
||||
outputSchema: {
|
||||
status: z.string().describe("Current agent status"),
|
||||
info: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
status: z.string(),
|
||||
createdAt: z.date(),
|
||||
type: z.literal("claude"),
|
||||
sessionId: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
.describe("Detailed agent information"),
|
||||
},
|
||||
},
|
||||
async ({ agentId }) => {
|
||||
const status = agentManager.getAgentStatus(agentId);
|
||||
const agents = agentManager.listAgents();
|
||||
const agentInfo = agents.find((a) => a.id === agentId);
|
||||
|
||||
if (!agentInfo) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const result = {
|
||||
status,
|
||||
info: agentInfo,
|
||||
};
|
||||
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: result,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Tool: list_agents
|
||||
server.registerTool(
|
||||
"list_agents",
|
||||
{
|
||||
title: "List Agents",
|
||||
description:
|
||||
"List all active agents managed by this session. Shows agent IDs, statuses, creation times, and any errors. Useful for monitoring multiple concurrent agents.",
|
||||
inputSchema: {},
|
||||
outputSchema: {
|
||||
agents: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
status: z.string(),
|
||||
createdAt: z.date(),
|
||||
type: z.literal("claude"),
|
||||
sessionId: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
const agents = agentManager.listAgents();
|
||||
|
||||
const result = {
|
||||
agents,
|
||||
};
|
||||
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: result,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Tool: cancel_agent
|
||||
server.registerTool(
|
||||
"cancel_agent",
|
||||
{
|
||||
title: "Cancel Agent",
|
||||
description:
|
||||
"Cancel the current task of a running agent. The agent remains alive and returns to 'ready' state, allowing you to send new prompts. Use this when you want to interrupt an agent's work without killing it.",
|
||||
inputSchema: {
|
||||
agentId: z.string().describe("Agent ID to cancel"),
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean().describe("Whether cancellation succeeded"),
|
||||
},
|
||||
},
|
||||
async ({ agentId }) => {
|
||||
await agentManager.cancelAgent(agentId);
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
};
|
||||
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: result,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Tool: kill_agent
|
||||
server.registerTool(
|
||||
"kill_agent",
|
||||
{
|
||||
title: "Kill Agent",
|
||||
description:
|
||||
"Terminate an agent completely. This kills the agent's process and removes it from the manager. The agent cannot be used after this operation. Use this when you're done with an agent or if it's in a failed state.",
|
||||
inputSchema: {
|
||||
agentId: z.string().describe("Agent ID to kill"),
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean().describe("Whether the agent was killed successfully"),
|
||||
},
|
||||
},
|
||||
async ({ agentId }) => {
|
||||
await agentManager.killAgent(agentId);
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
};
|
||||
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: result,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Tool: get_agent_activity
|
||||
server.registerTool(
|
||||
"get_agent_activity",
|
||||
{
|
||||
title: "Get Agent Activity",
|
||||
description:
|
||||
"Get the complete activity log for an agent, including all messages, tool calls, and results. This shows you everything the agent has done - what it said, what tools it called, and what output it produced. Essential for understanding what a completed or failed agent actually accomplished.",
|
||||
inputSchema: {
|
||||
agentId: z.string().describe("Agent ID to query"),
|
||||
limit: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe(
|
||||
"Maximum number of updates to return (most recent first). Omit to get all updates."
|
||||
),
|
||||
},
|
||||
outputSchema: {
|
||||
agentId: z.string(),
|
||||
updateCount: z.number().describe("Total number of updates"),
|
||||
updates: z.array(
|
||||
z.object({
|
||||
timestamp: z.string(),
|
||||
type: z.string(),
|
||||
data: z.any(),
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ agentId, limit }) => {
|
||||
const updates = agentManager.getAgentUpdates(agentId);
|
||||
|
||||
// Get the most recent updates if limit is specified
|
||||
const selectedUpdates = limit
|
||||
? updates.slice(-limit).reverse()
|
||||
: updates;
|
||||
|
||||
const result = {
|
||||
agentId,
|
||||
updateCount: updates.length,
|
||||
updates: selectedUpdates.map((update) => ({
|
||||
timestamp: update.timestamp.toISOString(),
|
||||
type: getUpdateType(update.notification),
|
||||
data: update.notification,
|
||||
})),
|
||||
};
|
||||
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: result,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Tool: set_agent_permission_mode
|
||||
server.registerTool(
|
||||
"set_agent_permission_mode",
|
||||
{
|
||||
title: "Set Agent Permission Mode",
|
||||
description:
|
||||
"Change how the agent handles file operation permission requests. This is a client-side setting that controls how we respond to the agent's permission requests. 'auto_approve' automatically allows all operations (use with caution). 'reject_all' blocks all file operations. 'ask_user' prompts the user for each permission (not yet implemented, currently falls back to auto_approve).",
|
||||
inputSchema: {
|
||||
agentId: z.string().describe("Agent ID to configure"),
|
||||
mode: z
|
||||
.enum(["auto_approve", "ask_user", "reject_all"])
|
||||
.describe(
|
||||
"Permission mode: 'auto_approve' (allow all), 'ask_user' (prompt user), 'reject_all' (deny all)"
|
||||
),
|
||||
},
|
||||
outputSchema: {
|
||||
agentId: z.string(),
|
||||
previousMode: z.string().describe("The previous permission mode"),
|
||||
newMode: z.string().describe("The new permission mode"),
|
||||
},
|
||||
},
|
||||
async ({ agentId, mode }) => {
|
||||
const previousMode = agentManager.getPermissionMode(agentId);
|
||||
agentManager.setPermissionMode(agentId, mode);
|
||||
|
||||
const result = {
|
||||
agentId,
|
||||
previousMode,
|
||||
newMode: mode,
|
||||
};
|
||||
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: result,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
Reference in New Issue
Block a user