refactor: implement proper ACP session modes and fix type safety

Replace client-side permission mode hack with proper ACP session modes:
- Add typed Client interface implementation to ACPClient
- Import proper types from ACP SDK (SessionNotification, RequestPermissionRequest, etc.)
- Fix critical sessionUpdate bug where params.update was undefined
- Replace PermissionMode with SessionMode from ACP spec
- Add getCurrentMode(), getAvailableModes(), setSessionMode() to AgentManager
- Update MCP tools: replace set_agent_permission_mode with set_agent_mode
- Add session_state message type to send live agents/commands to client
- Update UI to display session mode badges (ask/code/architect)
- Remove all permissionsMode references from tests

Agents now have ONE mode (session mode) that controls their behavior,
system prompts, and tool availability - not separate permission/session modes.

Generated with [Claude Code](https://claude.ai/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 15:49:55 +02:00
co-authored by Claude Happy
parent 4f2c2fc14a
commit cf977968c6
9 changed files with 393 additions and 120 deletions
@@ -175,7 +175,6 @@ describe("AgentManager", () => {
// Create agent without initial prompt first, so we can subscribe // Create agent without initial prompt first, so we can subscribe
const agentId = await manager.createAgent({ const agentId = await manager.createAgent({
cwd: tmpDir, cwd: tmpDir,
permissionsMode: "auto_approve",
}); });
expect(agentId).toBeDefined(); expect(agentId).toBeDefined();
@@ -229,7 +228,6 @@ describe("AgentManager", () => {
const agentId = await manager.createAgent({ const agentId = await manager.createAgent({
cwd: tmpDir, cwd: tmpDir,
permissionsMode: "auto_approve",
}); });
expect(agentId).toBeDefined(); expect(agentId).toBeDefined();
@@ -275,7 +273,6 @@ describe("AgentManager", () => {
const agentId = await manager.createAgent({ const agentId = await manager.createAgent({
cwd: tmpDir, cwd: tmpDir,
permissionsMode: "reject_all",
}); });
expect(agentId).toBeDefined(); expect(agentId).toBeDefined();
@@ -338,7 +335,6 @@ describe("AgentManager", () => {
const agentId = await manager.createAgent({ const agentId = await manager.createAgent({
cwd: tmpDir, cwd: tmpDir,
permissionsMode: "auto_approve",
}); });
expect(agentId).toBeDefined(); expect(agentId).toBeDefined();
@@ -393,7 +389,6 @@ describe("AgentManager", () => {
const agentId = await manager.createAgent({ const agentId = await manager.createAgent({
cwd: tmpDir, cwd: tmpDir,
permissionsMode: "auto_approve",
}); });
expect(agentId).toBeDefined(); expect(agentId).toBeDefined();
@@ -439,7 +434,6 @@ describe("AgentManager", () => {
const agentId = await manager.createAgent({ const agentId = await manager.createAgent({
cwd: tmpDir, cwd: tmpDir,
permissionsMode: "auto_approve",
}); });
expect(agentId).toBeDefined(); expect(agentId).toBeDefined();
@@ -1,6 +1,18 @@
import { spawn, ChildProcess } from "child_process"; import { spawn, ChildProcess } from "child_process";
import { Writable, Readable } from "stream"; import { Writable, Readable } from "stream";
import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION } from "@agentclientprotocol/sdk"; import {
ClientSideConnection,
ndJsonStream,
PROTOCOL_VERSION,
type Client,
type SessionNotification,
type RequestPermissionRequest,
type RequestPermissionResponse,
type ReadTextFileRequest,
type ReadTextFileResponse,
type WriteTextFileRequest,
type WriteTextFileResponse,
} from "@agentclientprotocol/sdk";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import type { import type {
AgentStatus, AgentStatus,
@@ -8,7 +20,7 @@ import type {
AgentUpdate, AgentUpdate,
CreateAgentOptions, CreateAgentOptions,
AgentUpdateCallback, AgentUpdateCallback,
PermissionMode, SessionMode,
} from "./types.js"; } from "./types.js";
interface ManagedAgent { interface ManagedAgent {
@@ -19,7 +31,8 @@ interface ManagedAgent {
connection: ClientSideConnection; connection: ClientSideConnection;
sessionId?: string; sessionId?: string;
error?: string; error?: string;
permissionsMode: PermissionMode; currentModeId?: string;
availableModes?: SessionMode[];
subscribers: Set<AgentUpdateCallback>; subscribers: Set<AgentUpdateCallback>;
updates: AgentUpdate[]; updates: AgentUpdate[];
} }
@@ -27,53 +40,30 @@ interface ManagedAgent {
/** /**
* Client implementation for ACP callbacks * Client implementation for ACP callbacks
*/ */
class ACPClient { class ACPClient implements Client {
constructor( constructor(
private agentId: string, private agentId: string,
private onUpdate: (agentId: string, update: any) => void, private onUpdate: (agentId: string, update: SessionNotification) => void
private getPermissionMode: () => PermissionMode
) {} ) {}
async requestPermission(params: any) { async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
const mode = this.getPermissionMode(); // TODO: Forward permission requests to the UI for user approval
console.log(`[Agent ${this.agentId}] Permission requested (mode: ${mode}):`, params); // For now, auto-approve all permissions
console.log(`[Agent ${this.agentId}] Permission requested (auto-approving):`, params);
if (mode === "auto_approve") { return {
// Auto-approve all permissions outcome: {
return { outcome: "selected" as const,
outcome: { optionId: params.options[0]?.optionId || "",
outcome: "selected" as const, },
optionId: params.options[0]?.optionId || "", };
},
};
} else if (mode === "reject_all") {
// Reject all permissions
const rejectOption = params.options.find((opt: any) =>
opt.kind === "reject_once" || opt.kind === "reject_all"
);
return {
outcome: {
outcome: "selected" as const,
optionId: rejectOption?.optionId || params.options[params.options.length - 1]?.optionId || "",
},
};
} else {
// ask_user mode - for now, auto-approve (TODO: implement user prompts)
console.log(`[Agent ${this.agentId}] User permission prompt not yet implemented, auto-approving`);
return {
outcome: {
outcome: "selected" as const,
optionId: params.options[0]?.optionId || "",
},
};
}
} }
async sessionUpdate(params: any) { async sessionUpdate(params: SessionNotification): Promise<void> {
this.onUpdate(this.agentId, params.update); this.onUpdate(this.agentId, params);
} }
async readTextFile(params: any) { async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
console.log(`[Agent ${this.agentId}] Read text file:`, params.path); console.log(`[Agent ${this.agentId}] Read text file:`, params.path);
const fs = await import("fs/promises"); const fs = await import("fs/promises");
try { try {
@@ -85,7 +75,7 @@ class ACPClient {
} }
} }
async writeTextFile(params: any) { async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
console.log(`[Agent ${this.agentId}] Write text file:`, params.path); console.log(`[Agent ${this.agentId}] Write text file:`, params.path);
const fs = await import("fs/promises"); const fs = await import("fs/promises");
try { try {
@@ -111,7 +101,6 @@ export class AgentManager {
async createAgent(options: CreateAgentOptions): Promise<string> { async createAgent(options: CreateAgentOptions): Promise<string> {
const agentId = uuidv4(); const agentId = uuidv4();
const cwd = options.cwd; const cwd = options.cwd;
const permissionsMode = options.permissionsMode || "auto_approve";
// Spawn the ACP process // Spawn the ACP process
const agentProcess = spawn("npx", ["@zed-industries/claude-code-acp"], { const agentProcess = spawn("npx", ["@zed-industries/claude-code-acp"], {
@@ -129,10 +118,6 @@ export class AgentManager {
agentId, agentId,
(id, update) => { (id, update) => {
this.handleSessionNotification(id, update); this.handleSessionNotification(id, update);
},
() => {
const agent = this.agents.get(agentId);
return agent?.permissionsMode || "auto_approve";
} }
); );
const connection = new ClientSideConnection(() => client, stream); const connection = new ClientSideConnection(() => client, stream);
@@ -144,7 +129,6 @@ export class AgentManager {
createdAt: new Date(), createdAt: new Date(),
process: agentProcess, process: agentProcess,
connection, connection,
permissionsMode,
subscribers: new Set(), subscribers: new Set(),
updates: [], updates: [],
}; };
@@ -189,11 +173,24 @@ export class AgentManager {
const sessionResponse = await agent.connection.newSession({ const sessionResponse = await agent.connection.newSession({
cwd, cwd,
mcpServers: [], mcpServers: [],
...(options.initialMode ? { initialMode: options.initialMode } : {}),
}); });
agent.sessionId = sessionResponse.sessionId; agent.sessionId = sessionResponse.sessionId;
// Store session modes from response
if (sessionResponse.modes) {
agent.currentModeId = sessionResponse.modes.currentModeId;
agent.availableModes = sessionResponse.modes.availableModes;
console.log(
`[Agent ${agentId}] Session created with mode: ${agent.currentModeId}`,
`Available modes:`, agent.availableModes?.map(m => m.id).join(', ')
);
} else {
console.log(`[Agent ${agentId}] Session created:`, sessionResponse.sessionId);
}
agent.status = "ready"; agent.status = "ready";
console.log(`[Agent ${agentId}] Session created:`, sessionResponse.sessionId);
// If an initial prompt was provided, send it // If an initial prompt was provided, send it
if (options.initialPrompt) { if (options.initialPrompt) {
@@ -320,6 +317,8 @@ export class AgentManager {
type: "claude" as const, type: "claude" as const,
sessionId: agent.sessionId, sessionId: agent.sessionId,
error: agent.error, error: agent.error,
currentModeId: agent.currentModeId,
availableModes: agent.availableModes,
})); }));
} }
@@ -355,32 +354,69 @@ export class AgentManager {
} }
/** /**
* Set the permission mode for an agent * Get the current session mode for an agent
*/ */
setPermissionMode(agentId: string, mode: PermissionMode): void { getCurrentMode(agentId: string): string | undefined {
const agent = this.agents.get(agentId); const agent = this.agents.get(agentId);
if (!agent) { if (!agent) {
throw new Error(`Agent ${agentId} not found`); throw new Error(`Agent ${agentId} not found`);
} }
agent.permissionsMode = mode; return agent.currentModeId;
console.log(`[Agent ${agentId}] Permission mode set to: ${mode}`);
} }
/** /**
* Get the permission mode for an agent * Get available session modes for an agent
*/ */
getPermissionMode(agentId: string): PermissionMode { getAvailableModes(agentId: string): SessionMode[] {
const agent = this.agents.get(agentId); const agent = this.agents.get(agentId);
if (!agent) { if (!agent) {
throw new Error(`Agent ${agentId} not found`); throw new Error(`Agent ${agentId} not found`);
} }
return agent.permissionsMode; return agent.availableModes || [];
}
/**
* Set the session mode for an agent
* Validates that the mode is available before setting
*/
async setSessionMode(agentId: string, modeId: string): Promise<void> {
const agent = this.agents.get(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
if (!agent.sessionId) {
throw new Error(`Agent ${agentId} has no active session`);
}
// Validate mode is available
const availableModes = agent.availableModes || [];
const mode = availableModes.find((m) => m.id === modeId);
if (!mode && availableModes.length > 0) {
const availableIds = availableModes.map((m) => m.id).join(", ");
throw new Error(
`Mode '${modeId}' not available for agent ${agentId}. Available modes: ${availableIds}`
);
}
try {
await agent.connection.setSessionMode({
sessionId: agent.sessionId,
modeId,
});
agent.currentModeId = modeId;
console.log(`[Agent ${agentId}] Session mode changed to: ${modeId}`);
} catch (error) {
console.error(`[Agent ${agentId}] Failed to set mode:`, error);
throw error;
}
} }
/** /**
* Handle session notifications from the ACP connection * Handle session notifications from the ACP connection
*/ */
private handleSessionNotification(agentId: string, update: any): void { private handleSessionNotification(agentId: string, update: SessionNotification): void {
const agent = this.agents.get(agentId); const agent = this.agents.get(agentId);
if (!agent) return; if (!agent) return;
@@ -393,10 +429,16 @@ export class AgentManager {
// Store the update in history // Store the update in history
agent.updates.push(agentUpdate); agent.updates.push(agentUpdate);
// Handle mode change notifications
if ((update as any).type === "currentModeUpdate" && (update as any).currentModeId) {
agent.currentModeId = (update as any).currentModeId;
console.log(`[Agent ${agentId}] Mode changed to: ${agent.currentModeId}`);
}
// Track completion based on final message state // Track completion based on final message state
// ACP protocol indicates completion when the last agent_message_chunk arrives // ACP protocol indicates completion when the last agent_message_chunk arrives
// We detect this by tracking a completion timer that fires if no more updates arrive // We detect this by tracking a completion timer that fires if no more updates arrive
const updateType = update.sessionUpdate?.sessionUpdate; const updateType = update.update.sessionUpdate;
// Clear and reset completion timer on any update during processing // Clear and reset completion timer on any update during processing
if (agent.status === "processing") { if (agent.status === "processing") {
@@ -418,7 +460,7 @@ export class AgentManager {
// Log update for debugging // Log update for debugging
console.log( console.log(
`[Agent ${agentId}] Session update:`, `[Agent ${agentId}] Session update:`,
updateType || update.sessionUpdate || update updateType
); );
// Notify all subscribers // Notify all subscribers
@@ -59,11 +59,11 @@ export async function createAgentMcpServer(
.describe( .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." "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 initialMode: z
.enum(["auto_approve", "ask_user", "reject_all"]) .string()
.optional() .optional()
.describe( .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." "Initial session mode for the agent (e.g., 'ask', 'code', 'architect'). If not specified, the agent will use its default mode. The available modes depend on the specific agent implementation."
), ),
}, },
outputSchema: { outputSchema: {
@@ -74,26 +74,34 @@ export async function createAgentMcpServer(
"Current agent status: 'initializing', 'ready', 'processing', etc." "Current agent status: 'initializing', 'ready', 'processing', etc."
), ),
cwd: z.string().describe("The resolved absolute working directory the agent is running in"), 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"), currentModeId: z.string().optional().describe("The agent's current session mode"),
availableModes: z.array(z.object({
id: z.string(),
name: z.string(),
description: z.string().optional(),
})).optional().describe("Available session modes for this agent"),
}, },
}, },
async ({ cwd, initialPrompt, permissionsMode }) => { async ({ cwd, initialPrompt, initialMode }) => {
// Expand and resolve the working directory // Expand and resolve the working directory
const resolvedCwd = expandPath(cwd); const resolvedCwd = expandPath(cwd);
const agentId = await agentManager.createAgent({ const agentId = await agentManager.createAgent({
cwd: resolvedCwd, cwd: resolvedCwd,
initialPrompt, initialPrompt,
permissionsMode: permissionsMode || "auto_approve", initialMode,
}); });
const status = agentManager.getAgentStatus(agentId); const status = agentManager.getAgentStatus(agentId);
const currentModeId = agentManager.getCurrentMode(agentId);
const availableModes = agentManager.getAvailableModes(agentId);
const result = { const result = {
agentId, agentId,
status, status,
cwd: resolvedCwd, cwd: resolvedCwd,
permissionsMode: permissionsMode || "auto_approve", currentModeId,
availableModes,
}; };
return { return {
@@ -156,6 +164,12 @@ export async function createAgentMcpServer(
type: z.literal("claude"), type: z.literal("claude"),
sessionId: z.string().optional(), sessionId: z.string().optional(),
error: z.string().optional(), error: z.string().optional(),
currentModeId: z.string().optional(),
availableModes: z.array(z.object({
id: z.string(),
name: z.string(),
description: z.string().optional(),
})).optional(),
}) })
.describe("Detailed agent information"), .describe("Detailed agent information"),
}, },
@@ -198,6 +212,12 @@ export async function createAgentMcpServer(
type: z.literal("claude"), type: z.literal("claude"),
sessionId: z.string().optional(), sessionId: z.string().optional(),
error: z.string().optional(), error: z.string().optional(),
currentModeId: z.string().optional(),
availableModes: z.array(z.object({
id: z.string(),
name: z.string(),
description: z.string().optional(),
})).optional(),
}) })
), ),
}, },
@@ -325,35 +345,35 @@ export async function createAgentMcpServer(
} }
); );
// Tool: set_agent_permission_mode // Tool: set_agent_mode
server.registerTool( server.registerTool(
"set_agent_permission_mode", "set_agent_mode",
{ {
title: "Set Agent Permission Mode", title: "Set Agent Session Mode",
description: 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).", "Change the agent's session mode (e.g., from 'ask' to 'code', or 'architect' to 'code'). Each mode affects the agent's behavior - 'ask' mode requests permission before changes, 'code' mode writes code directly, 'architect' mode plans without implementation. The available modes depend on the specific agent. Use get_agent_status or list_agents to see available modes for each agent.",
inputSchema: { inputSchema: {
agentId: z.string().describe("Agent ID to configure"), agentId: z.string().describe("Agent ID to configure"),
mode: z modeId: z
.enum(["auto_approve", "ask_user", "reject_all"]) .string()
.describe( .describe(
"Permission mode: 'auto_approve' (allow all), 'ask_user' (prompt user), 'reject_all' (deny all)" "The session mode to set (e.g., 'ask', 'code', 'architect'). Must be one of the agent's available modes."
), ),
}, },
outputSchema: { outputSchema: {
agentId: z.string(), success: z.boolean().describe("Whether the mode change succeeded"),
previousMode: z.string().describe("The previous permission mode"), previousMode: z.string().optional().describe("The previous session mode"),
newMode: z.string().describe("The new permission mode"), newMode: z.string().describe("The new session mode"),
}, },
}, },
async ({ agentId, mode }) => { async ({ agentId, modeId }) => {
const previousMode = agentManager.getPermissionMode(agentId); const previousMode = agentManager.getCurrentMode(agentId);
agentManager.setPermissionMode(agentId, mode); await agentManager.setSessionMode(agentId, modeId);
const result = { const result = {
agentId, success: true,
previousMode, previousMode,
newMode: mode, newMode: modeId,
}; };
return { return {
@@ -84,7 +84,6 @@ async function testDirectoryAndInitialPrompt(ctx: TestContext) {
// Create agent without initial prompt first, so we can subscribe // Create agent without initial prompt first, so we can subscribe
const agentId = await ctx.manager.createAgent({ const agentId = await ctx.manager.createAgent({
cwd: ctx.tmpDir, cwd: ctx.tmpDir,
permissionsMode: "auto_approve",
}); });
console.log(`✓ Agent created: ${agentId}`); console.log(`✓ Agent created: ${agentId}`);
@@ -151,7 +150,6 @@ async function testPermissionAutoApprove(ctx: TestContext) {
const agentId = await ctx.manager.createAgent({ const agentId = await ctx.manager.createAgent({
cwd: ctx.tmpDir, cwd: ctx.tmpDir,
permissionsMode: "auto_approve",
}); });
console.log(`✓ Agent created: ${agentId}`); console.log(`✓ Agent created: ${agentId}`);
@@ -208,7 +206,6 @@ async function testPermissionRejectAll(ctx: TestContext) {
const agentId = await ctx.manager.createAgent({ const agentId = await ctx.manager.createAgent({
cwd: ctx.tmpDir, cwd: ctx.tmpDir,
permissionsMode: "reject_all",
}); });
console.log(`✓ Agent created: ${agentId}`); console.log(`✓ Agent created: ${agentId}`);
@@ -282,7 +279,6 @@ async function testMultiplePrompts(ctx: TestContext) {
const agentId = await ctx.manager.createAgent({ const agentId = await ctx.manager.createAgent({
cwd: ctx.tmpDir, cwd: ctx.tmpDir,
permissionsMode: "auto_approve",
}); });
console.log(`✓ Agent created: ${agentId}`); console.log(`✓ Agent created: ${agentId}`);
@@ -341,7 +337,6 @@ async function testUpdateStreaming(ctx: TestContext) {
const agentId = await ctx.manager.createAgent({ const agentId = await ctx.manager.createAgent({
cwd: ctx.tmpDir, cwd: ctx.tmpDir,
permissionsMode: "auto_approve",
}); });
console.log(`✓ Agent created: ${agentId}`); console.log(`✓ Agent created: ${agentId}`);
@@ -408,7 +403,6 @@ async function testStateManagement(ctx: TestContext) {
const agentId = await ctx.manager.createAgent({ const agentId = await ctx.manager.createAgent({
cwd: ctx.tmpDir, cwd: ctx.tmpDir,
permissionsMode: "auto_approve",
}); });
console.log(`✓ Agent created: ${agentId}`); console.log(`✓ Agent created: ${agentId}`);
@@ -21,6 +21,8 @@ export interface AgentInfo {
type: "claude"; type: "claude";
sessionId?: string; sessionId?: string;
error?: string; error?: string;
currentModeId?: string;
availableModes?: SessionMode[];
} }
/** /**
@@ -34,12 +36,13 @@ export interface AgentUpdate {
} }
/** /**
* Permission modes for agent file operations * Session mode definition from ACP
*/ */
export type PermissionMode = export interface SessionMode {
| "auto_approve" // Automatically approve all permissions id: string;
| "ask_user" // Prompt user for each permission name: string;
| "reject_all"; // Reject all permission requests description?: string | null;
}
/** /**
* Options for creating an agent * Options for creating an agent
@@ -47,7 +50,7 @@ export type PermissionMode =
export interface CreateAgentOptions { export interface CreateAgentOptions {
cwd: string; cwd: string;
initialPrompt?: string; initialPrompt?: string;
permissionsMode?: PermissionMode; initialMode?: string;
} }
/** /**
@@ -123,6 +123,12 @@ export const AgentCreatedMessageSchema = z.object({
agentId: z.string(), agentId: z.string(),
status: z.string(), status: z.string(),
type: z.literal("claude"), type: z.literal("claude"),
currentModeId: z.string().optional(),
availableModes: z.array(z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable().optional(),
})).optional(),
}), }),
}); });
@@ -151,6 +157,38 @@ export const AgentStatusMessageSchema = z.object({
}), }),
}); });
export const SessionStateMessageSchema = z.object({
type: z.literal("session_state"),
payload: z.object({
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(),
currentModeId: z.string().optional(),
availableModes: z.array(z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable().optional(),
})).optional(),
})
),
commands: z.array(
z.object({
id: z.string(),
name: z.string(),
workingDirectory: z.string(),
currentCommand: z.string(),
isDead: z.boolean(),
exitCode: z.number().nullable(),
})
),
}),
});
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ActivityLogMessageSchema, ActivityLogMessageSchema,
AssistantChunkMessageSchema, AssistantChunkMessageSchema,
@@ -162,6 +200,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
AgentCreatedMessageSchema, AgentCreatedMessageSchema,
AgentUpdateMessageSchema, AgentUpdateMessageSchema,
AgentStatusMessageSchema, AgentStatusMessageSchema,
SessionStateMessageSchema,
]); ]);
export type SessionOutboundMessage = z.infer< export type SessionOutboundMessage = z.infer<
@@ -179,6 +218,7 @@ export type ConversationLoadedMessage = z.infer<typeof ConversationLoadedMessage
export type AgentCreatedMessage = z.infer<typeof AgentCreatedMessageSchema>; export type AgentCreatedMessage = z.infer<typeof AgentCreatedMessageSchema>;
export type AgentUpdateMessage = z.infer<typeof AgentUpdateMessageSchema>; export type AgentUpdateMessage = z.infer<typeof AgentUpdateMessageSchema>;
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>; export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>;
export type SessionStateMessage = z.infer<typeof SessionStateMessageSchema>;
// Type exports for payload types // Type exports for payload types
export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>; export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
@@ -70,6 +70,7 @@ export class Session {
// Per-session MCP client and tools // Per-session MCP client and tools
private terminalMcpClient: Awaited<ReturnType<typeof experimental_createMCPClient>> | null = null; private terminalMcpClient: Awaited<ReturnType<typeof experimental_createMCPClient>> | null = null;
private terminalTools: Record<string, any> | null = null; private terminalTools: Record<string, any> | null = null;
private terminalManager: any | null = null;
private agentMcpClient: Awaited<ReturnType<typeof experimental_createMCPClient>> | null = null; private agentMcpClient: Awaited<ReturnType<typeof experimental_createMCPClient>> | null = null;
private agentTools: Record<string, any> | null = null; private agentTools: Record<string, any> | null = null;
private agentManager: AgentManager | null = null; private agentManager: AgentManager | null = null;
@@ -156,6 +157,11 @@ export class Session {
*/ */
private async initializeTerminalMcp(): Promise<void> { private async initializeTerminalMcp(): Promise<void> {
try { try {
// Create Terminal Manager directly
const { TerminalManager } = await import("./terminal-mcp/terminal-manager.js");
this.terminalManager = new TerminalManager(this.conversationId);
await this.terminalManager.initialize();
// Create Terminal MCP server with conversation-specific session // Create Terminal MCP server with conversation-specific session
const server = await createTerminalMcpServer({ const server = await createTerminalMcpServer({
sessionName: this.conversationId sessionName: this.conversationId
@@ -281,6 +287,50 @@ export class Session {
messageCount: this.messages.length, messageCount: this.messages.length,
}, },
}); });
// Send current session state (live agents and commands)
await this.sendSessionState();
}
/**
* Send current session state (live agents and commands) to client
*/
private async sendSessionState(): Promise<void> {
try {
// Get live agents with session modes
const agents = this.agentManager?.listAgents() || [];
// Get live commands from terminal manager
let commands: any[] = [];
if (this.terminalManager) {
try {
commands = await this.terminalManager.listCommands();
} catch (error) {
console.error(
`[Session ${this.clientId}] Failed to list commands:`,
error
);
}
}
// Emit session state
this.emit({
type: "session_state",
payload: {
agents,
commands,
},
});
console.log(
`[Session ${this.clientId}] Sent session state: ${agents.length} agents, ${commands.length} commands`
);
} catch (error) {
console.error(
`[Session ${this.clientId}] Failed to send session state:`,
error
);
}
} }
/** /**
@@ -637,6 +687,8 @@ export class Session {
if (result.structuredContent?.agentId) { if (result.structuredContent?.agentId) {
const agentId = result.structuredContent.agentId; const agentId = result.structuredContent.agentId;
const status = result.structuredContent.status; const status = result.structuredContent.status;
const currentModeId = result.structuredContent.currentModeId;
const availableModes = result.structuredContent.availableModes;
// Subscribe to agent updates // Subscribe to agent updates
this.subscribeToAgent(agentId); this.subscribeToAgent(agentId);
@@ -648,6 +700,8 @@ export class Session {
agentId, agentId,
status, status,
type: "claude", type: "claude",
currentModeId,
availableModes,
}, },
}); });
} }
+54 -2
View File
@@ -74,9 +74,26 @@ function App() {
const [agents, setAgents] = useState< const [agents, setAgents] = useState<
Map< Map<
string, string,
{ id: string; status: AgentStatus; createdAt: Date; type: "claude" } {
id: string;
status: AgentStatus;
createdAt: Date;
type: "claude";
currentModeId?: string;
availableModes?: Array<{ id: string; name: string; description?: string | null }>;
}
> >
>(new Map()); >(new Map());
const [commands, setCommands] = useState<
Array<{
id: string;
name: string;
workingDirectory: string;
currentCommand: string;
isDead: boolean;
exitCode: number | null;
}>
>([]);
const [agentUpdates, setAgentUpdates] = useState< const [agentUpdates, setAgentUpdates] = useState<
Map<string, Array<{ timestamp: Date; notification: SessionNotification }>> Map<string, Array<{ timestamp: Date; notification: SessionNotification }>>
>(new Map()); >(new Map());
@@ -345,10 +362,39 @@ function App() {
} }
}); });
// Listen for session state
const unsubSessionState = ws.on("session_state", (message) => {
if (message.type !== "session_state") return;
const { agents: liveAgents, commands: liveCommands } = message.payload;
// Update agents with session modes
setAgents((prev) => {
const newMap = new Map(prev);
liveAgents.forEach((agent) => {
newMap.set(agent.id, {
id: agent.id,
status: agent.status as AgentStatus,
createdAt: new Date(agent.createdAt),
type: agent.type,
currentModeId: agent.currentModeId,
availableModes: agent.availableModes,
});
});
return newMap;
});
// Update commands
setCommands(liveCommands);
console.log(
`[App] Session state loaded: ${liveAgents.length} agents, ${liveCommands.length} commands`
);
});
// Listen for agent created // Listen for agent created
const unsubAgentCreated = ws.on("agent_created", (message) => { const unsubAgentCreated = ws.on("agent_created", (message) => {
if (message.type !== "agent_created") return; if (message.type !== "agent_created") return;
const { agentId, status, type } = message.payload; const { agentId, status, type, currentModeId, availableModes } = message.payload;
setAgents((prev) => { setAgents((prev) => {
const newMap = new Map(prev); const newMap = new Map(prev);
@@ -357,6 +403,8 @@ function App() {
status: status as AgentStatus, status: status as AgentStatus,
createdAt: new Date(), createdAt: new Date(),
type, type,
currentModeId,
availableModes,
}); });
return newMap; return newMap;
}); });
@@ -411,6 +459,7 @@ function App() {
unsubConversationLoaded(); unsubConversationLoaded();
unsubArtifact(); unsubArtifact();
unsubAudioOutput(); unsubAudioOutput();
unsubSessionState();
unsubAgentCreated(); unsubAgentCreated();
unsubAgentUpdate(); unsubAgentUpdate();
unsubAgentStatus(); unsubAgentStatus();
@@ -599,6 +648,8 @@ function App() {
setCurrentAssistantMessage(""); setCurrentAssistantMessage("");
setArtifacts(new Map()); setArtifacts(new Map());
setCurrentArtifact(null); setCurrentArtifact(null);
setAgents(new Map());
setCommands([]);
}; };
const handleSelectProcess = (id: string, type: "terminal" | "agent") => { const handleSelectProcess = (id: string, type: "terminal" | "agent") => {
@@ -868,6 +919,7 @@ function App() {
<ActiveProcesses <ActiveProcesses
terminals={[]} terminals={[]}
agents={Array.from(agents.values())} agents={Array.from(agents.values())}
commands={commands}
activeProcessId={activeProcessId} activeProcessId={activeProcessId}
activeProcessType={activeView === "orchestrator" ? null : activeView} activeProcessType={activeView === "orchestrator" ? null : activeView}
onSelectProcess={handleSelectProcess} onSelectProcess={handleSelectProcess}
@@ -1,9 +1,23 @@
import { Terminal, Bot } from "lucide-react"; import { Terminal, Bot, Code } from "lucide-react";
import type { AgentStatus } from "../../server/acp/types.js"; import type { AgentStatus } from "../../server/acp/types.js";
export interface ActiveProcessesProps { export interface ActiveProcessesProps {
terminals: Array<{ id: string; name: string }>; terminals: Array<{ id: string; name: string }>;
agents: Array<{ id: string; status: AgentStatus; type: "claude" }>; agents: Array<{
id: string;
status: AgentStatus;
type: "claude";
currentModeId?: string;
availableModes?: Array<{ id: string; name: string; description?: string | null }>;
}>;
commands: Array<{
id: string;
name: string;
workingDirectory: string;
currentCommand: string;
isDead: boolean;
exitCode: number | null;
}>;
activeProcessId: string | null; activeProcessId: string | null;
activeProcessType: "terminal" | "agent" | null; activeProcessType: "terminal" | "agent" | null;
onSelectProcess: (id: string, type: "terminal" | "agent") => void; onSelectProcess: (id: string, type: "terminal" | "agent") => void;
@@ -33,9 +47,27 @@ function getAgentStatusLabel(status: AgentStatus): string {
return status; return status;
} }
function getModeName(modeId?: string, availableModes?: Array<{ id: string; name: string }>): string {
if (!modeId) return "unknown";
const mode = availableModes?.find(m => m.id === modeId);
return mode?.name || modeId;
}
function getModeColor(modeId?: string): string {
if (!modeId) return "#9ca3af"; // gray
// Color based on common mode types
if (modeId.includes("ask")) return "#f59e0b"; // orange - asks permission
if (modeId.includes("code")) return "#22c55e"; // green - writes code
if (modeId.includes("architect") || modeId.includes("plan")) return "#3b82f6"; // blue - plans
return "#9ca3af"; // gray - unknown
}
export function ActiveProcesses({ export function ActiveProcesses({
terminals, terminals,
agents, agents,
commands,
activeProcessId, activeProcessId,
activeProcessType, activeProcessType,
onSelectProcess, onSelectProcess,
@@ -73,28 +105,70 @@ export function ActiveProcesses({
</button> </button>
))} ))}
{agents.map((agent) => ( {agents.map((agent) => {
<button const modeName = getModeName(agent.currentModeId, agent.availableModes);
key={`agent-${agent.id}`} const modesList = agent.availableModes?.map(m => m.name).join(", ") || "unknown";
className={`process-pill agent ${
activeProcessType === "agent" && activeProcessId === agent.id return (
? "active" <button
: "" key={`agent-${agent.id}`}
}`} className={`process-pill agent ${
onClick={() => onSelectProcess(agent.id, "agent")} activeProcessType === "agent" && activeProcessId === agent.id
type="button" ? "active"
data-status={agent.status} : ""
}`}
onClick={() => onSelectProcess(agent.id, "agent")}
type="button"
data-status={agent.status}
title={`Status: ${getAgentStatusLabel(agent.status)} | Mode: ${modeName} | Available: ${modesList}`}
>
<Bot size={14} />
<span className="process-name">
{agent.id.substring(0, 8)}
</span>
<span
className={`process-status-indicator ${agent.status}`}
style={{ backgroundColor: getAgentStatusColor(agent.status) }}
title={getAgentStatusLabel(agent.status)}
/>
<span
className="session-mode-badge"
style={{
backgroundColor: getModeColor(agent.currentModeId),
opacity: 0.3,
width: "6px",
height: "6px",
borderRadius: "50%",
marginLeft: "4px",
}}
title={`Mode: ${modeName}`}
/>
</button>
);
})}
{commands.map((command) => (
<div
key={`command-${command.id}`}
className="process-pill command"
title={`${command.currentCommand} | ${command.workingDirectory}${command.isDead ? ` | Exit code: ${command.exitCode}` : ""}`}
> >
<Bot size={14} /> <Code size={14} />
<span className="process-name"> <span className="process-name">
{agent.id.substring(0, 8)} {command.name || command.currentCommand.substring(0, 20)}
</span> </span>
<span <span
className={`process-status-indicator ${agent.status}`} className={`process-status-indicator ${command.isDead ? "dead" : "running"}`}
style={{ backgroundColor: getAgentStatusColor(agent.status) }} style={{
title={getAgentStatusLabel(agent.status)} backgroundColor: command.isDead
? command.exitCode === 0
? "#22c55e"
: "#ef4444"
: "#3b82f6",
}}
title={command.isDead ? `Exited: ${command.exitCode}` : "Running"}
/> />
</button> </div>
))} ))}
</div> </div>
</div> </div>