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:
Mohamed Boudra
2025-10-21 14:50:07 +02:00
co-authored by Claude Happy
parent 37a363da6d
commit 4f2c2fc14a
34 changed files with 6963 additions and 420 deletions
@@ -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;
}