|
| 1 | +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. |
| 2 | +// See LICENSE.txt for license information. |
| 3 | + |
| 4 | +import { spawn, type ChildProcess } from "child_process"; |
| 5 | +import { createInterface } from "readline"; |
| 6 | +import { resolve } from "path"; |
| 7 | +import { |
| 8 | + createAgentEvent, |
| 9 | + type AgentEvent, |
| 10 | + type AgentStatus, |
| 11 | + type UserMessageData, |
| 12 | + type AssistantMessageData, |
| 13 | + type ThinkingData, |
| 14 | + type ToolUseData, |
| 15 | + type StatusData, |
| 16 | +} from "../src/lib/agent-events"; |
| 17 | + |
| 18 | +const CLAUDE_CLI_PATH = resolve( |
| 19 | + process.cwd(), |
| 20 | + "node_modules/@anthropic-ai/claude-code/cli.js", |
| 21 | +); |
| 22 | + |
| 23 | +interface AgentProcess { |
| 24 | + sessionId: string; |
| 25 | + claudeSessionId: string | null; |
| 26 | + status: AgentStatus; |
| 27 | + history: AgentEvent[]; |
| 28 | + subscribers: Set<(event: AgentEvent) => void>; |
| 29 | + childProcess: ChildProcess | null; |
| 30 | + workspacePath: string; |
| 31 | +} |
| 32 | + |
| 33 | +export interface SpawnConfig { |
| 34 | + workspacePath: string; |
| 35 | + model?: string; |
| 36 | + systemPrompt?: string; |
| 37 | +} |
| 38 | + |
| 39 | +export class AgentProcessManager { |
| 40 | + private agents = new Map<string, AgentProcess>(); |
| 41 | + |
| 42 | + getHistory(sessionId: string): AgentEvent[] { |
| 43 | + return this.agents.get(sessionId)?.history ?? []; |
| 44 | + } |
| 45 | + |
| 46 | + getStatus(sessionId: string): AgentStatus { |
| 47 | + return this.agents.get(sessionId)?.status ?? "idle"; |
| 48 | + } |
| 49 | + |
| 50 | + subscribe(sessionId: string, callback: (event: AgentEvent) => void): () => void { |
| 51 | + const agent = this.agents.get(sessionId); |
| 52 | + if (!agent) { |
| 53 | + const placeholder: AgentProcess = { |
| 54 | + sessionId, |
| 55 | + claudeSessionId: null, |
| 56 | + status: "idle", |
| 57 | + history: [], |
| 58 | + subscribers: new Set([callback]), |
| 59 | + childProcess: null, |
| 60 | + workspacePath: "", |
| 61 | + }; |
| 62 | + this.agents.set(sessionId, placeholder); |
| 63 | + return () => { placeholder.subscribers.delete(callback); }; |
| 64 | + } |
| 65 | + agent.subscribers.add(callback); |
| 66 | + return () => { agent.subscribers.delete(callback); }; |
| 67 | + } |
| 68 | + |
| 69 | + addUserEvent(sessionId: string, text: string): void { |
| 70 | + const event = createAgentEvent(sessionId, "user_message", { text } as UserMessageData); |
| 71 | + this.appendAndBroadcast(sessionId, event); |
| 72 | + } |
| 73 | + |
| 74 | + async spawn(sessionId: string, config: SpawnConfig): Promise<void> { |
| 75 | + const existing = this.agents.get(sessionId); |
| 76 | + const agent: AgentProcess = { |
| 77 | + sessionId, |
| 78 | + claudeSessionId: existing?.claudeSessionId ?? null, |
| 79 | + status: "spawning", |
| 80 | + history: existing?.history ?? [], |
| 81 | + subscribers: existing?.subscribers ?? new Set(), |
| 82 | + childProcess: null, |
| 83 | + workspacePath: config.workspacePath, |
| 84 | + }; |
| 85 | + this.agents.set(sessionId, agent); |
| 86 | + this.broadcastStatus(sessionId, "spawning"); |
| 87 | + } |
| 88 | + |
| 89 | + async send(sessionId: string, message: string): Promise<void> { |
| 90 | + const agent = this.agents.get(sessionId); |
| 91 | + if (!agent) throw new Error(`No agent for session ${sessionId}`); |
| 92 | + |
| 93 | + this.addUserEvent(sessionId, message); |
| 94 | + |
| 95 | + agent.status = "active"; |
| 96 | + this.broadcastStatus(sessionId, "active"); |
| 97 | + |
| 98 | + const args = [ |
| 99 | + CLAUDE_CLI_PATH, |
| 100 | + "--print", |
| 101 | + "--output-format", "stream-json", |
| 102 | + "--verbose", |
| 103 | + "--dangerously-skip-permissions", |
| 104 | + ]; |
| 105 | + |
| 106 | + if (agent.claudeSessionId) { |
| 107 | + args.push("--resume", agent.claudeSessionId); |
| 108 | + } |
| 109 | + |
| 110 | + args.push(message); |
| 111 | + |
| 112 | + const proc = spawn("node", args, { |
| 113 | + cwd: agent.workspacePath, |
| 114 | + env: { ...process.env }, |
| 115 | + stdio: ["ignore", "pipe", "pipe"], |
| 116 | + }); |
| 117 | + |
| 118 | + agent.childProcess = proc; |
| 119 | + |
| 120 | + const rl = createInterface({ input: proc.stdout! }); |
| 121 | + rl.on("line", (line: string) => { |
| 122 | + if (!line.trim()) return; |
| 123 | + try { |
| 124 | + const sdkEvent = JSON.parse(line); |
| 125 | + this.processSDKEvent(sessionId, sdkEvent); |
| 126 | + |
| 127 | + if (sdkEvent.type === "system" && sdkEvent.subtype === "init" && sdkEvent.session_id) { |
| 128 | + agent.claudeSessionId = sdkEvent.session_id; |
| 129 | + } |
| 130 | + } catch { |
| 131 | + // Non-JSON output — ignore |
| 132 | + } |
| 133 | + }); |
| 134 | + |
| 135 | + proc.stderr?.on("data", (data: Buffer) => { |
| 136 | + console.warn(`[AgentProcessManager] Claude CLI stderr: ${data.toString().slice(0, 200)}`); |
| 137 | + }); |
| 138 | + |
| 139 | + return new Promise<void>((resolvePromise) => { |
| 140 | + proc.on("close", () => { |
| 141 | + agent.childProcess = null; |
| 142 | + if (agent.status !== "error") { |
| 143 | + agent.status = "idle"; |
| 144 | + this.broadcastStatus(sessionId, "idle"); |
| 145 | + } |
| 146 | + resolvePromise(); |
| 147 | + }); |
| 148 | + |
| 149 | + proc.on("error", (err) => { |
| 150 | + agent.childProcess = null; |
| 151 | + agent.status = "error"; |
| 152 | + const errorEvent = createAgentEvent(sessionId, "error", { message: err.message }); |
| 153 | + this.appendAndBroadcast(sessionId, errorEvent); |
| 154 | + this.broadcastStatus(sessionId, "error"); |
| 155 | + resolvePromise(); |
| 156 | + }); |
| 157 | + }); |
| 158 | + } |
| 159 | + |
| 160 | + kill(sessionId: string): void { |
| 161 | + const agent = this.agents.get(sessionId); |
| 162 | + if (!agent) return; |
| 163 | + if (agent.childProcess) { |
| 164 | + agent.childProcess.kill("SIGTERM"); |
| 165 | + agent.childProcess = null; |
| 166 | + } |
| 167 | + agent.status = "idle"; |
| 168 | + this.broadcastStatus(sessionId, "idle"); |
| 169 | + } |
| 170 | + |
| 171 | + destroyAll(): void { |
| 172 | + for (const [id] of this.agents) { |
| 173 | + this.kill(id); |
| 174 | + } |
| 175 | + this.agents.clear(); |
| 176 | + } |
| 177 | + |
| 178 | + private processSDKEvent(sessionId: string, sdkEvent: any): void { |
| 179 | + if (sdkEvent.type === "assistant" && sdkEvent.message?.content) { |
| 180 | + for (const block of sdkEvent.message.content) { |
| 181 | + if (block.type === "text") { |
| 182 | + const event = createAgentEvent(sessionId, "assistant_message", { |
| 183 | + text: block.text, |
| 184 | + } as AssistantMessageData); |
| 185 | + this.appendAndBroadcast(sessionId, event); |
| 186 | + } else if (block.type === "thinking") { |
| 187 | + const event = createAgentEvent(sessionId, "thinking", { |
| 188 | + text: block.thinking, |
| 189 | + } as ThinkingData); |
| 190 | + this.appendAndBroadcast(sessionId, event); |
| 191 | + } else if (block.type === "tool_use") { |
| 192 | + const event = createAgentEvent(sessionId, "tool_use", { |
| 193 | + toolName: block.name, |
| 194 | + summary: this.summarizeToolInput(block.name, block.input), |
| 195 | + callId: block.id, |
| 196 | + } as ToolUseData); |
| 197 | + this.appendAndBroadcast(sessionId, event); |
| 198 | + } |
| 199 | + } |
| 200 | + } |
| 201 | + } |
| 202 | + |
| 203 | + private summarizeToolInput(toolName: string, input: any): string { |
| 204 | + if (!input) return toolName; |
| 205 | + if (typeof input.file_path === "string") { |
| 206 | + const filename = input.file_path.split("/").pop() ?? input.file_path; |
| 207 | + return `${filename}`; |
| 208 | + } |
| 209 | + if (typeof input.command === "string") { |
| 210 | + const cmd = input.command.length > 60 |
| 211 | + ? input.command.slice(0, 57) + "..." |
| 212 | + : input.command; |
| 213 | + return cmd; |
| 214 | + } |
| 215 | + if (typeof input.pattern === "string") return input.pattern; |
| 216 | + if (typeof input.query === "string") return input.query; |
| 217 | + return toolName; |
| 218 | + } |
| 219 | + |
| 220 | + private appendAndBroadcast(sessionId: string, event: AgentEvent): void { |
| 221 | + let agent = this.agents.get(sessionId); |
| 222 | + if (!agent) { |
| 223 | + agent = { |
| 224 | + sessionId, |
| 225 | + claudeSessionId: null, |
| 226 | + status: "idle", |
| 227 | + history: [], |
| 228 | + subscribers: new Set(), |
| 229 | + childProcess: null, |
| 230 | + workspacePath: "", |
| 231 | + }; |
| 232 | + this.agents.set(sessionId, agent); |
| 233 | + } |
| 234 | + agent.history.push(event); |
| 235 | + for (const cb of agent.subscribers) { |
| 236 | + try { cb(event); } catch { /* subscriber error */ } |
| 237 | + } |
| 238 | + } |
| 239 | + |
| 240 | + private broadcastStatus(sessionId: string, status: AgentStatus): void { |
| 241 | + const event = createAgentEvent(sessionId, "status", { status } as StatusData); |
| 242 | + this.appendAndBroadcast(sessionId, event); |
| 243 | + } |
| 244 | +} |
| 245 | + |
| 246 | +const agentProcessManager = new AgentProcessManager(); |
| 247 | +export default agentProcessManager; |
0 commit comments