Merge branch 'main' into feat/claude-usage-clean

This commit is contained in:
Mohamad Yahia
2025-12-21 11:15:41 +04:00
committed by GitHub
63 changed files with 4984 additions and 3517 deletions

View File

@@ -3,22 +3,18 @@
* Manages conversation sessions and streams responses via WebSocket
*/
import path from "path";
import * as secureFs from "../lib/secure-fs.js";
import type { EventEmitter } from "../lib/events.js";
import type { ExecuteOptions } from "@automaker/types";
import {
readImageAsBase64,
buildPromptWithImages,
isAbortError,
} from "@automaker/utils";
import { ProviderFactory } from "../providers/provider-factory.js";
import { createChatOptions } from "../lib/sdk-options.js";
import { isPathAllowed, PathNotAllowedError } from "@automaker/platform";
import path from 'path';
import * as secureFs from '../lib/secure-fs.js';
import type { EventEmitter } from '../lib/events.js';
import type { ExecuteOptions } from '@automaker/types';
import { readImageAsBase64, buildPromptWithImages, isAbortError } from '@automaker/utils';
import { ProviderFactory } from '../providers/provider-factory.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
import { PathNotAllowedError } from '@automaker/platform';
interface Message {
id: string;
role: "user" | "assistant";
role: 'user' | 'assistant';
content: string;
images?: Array<{
data: string;
@@ -58,8 +54,8 @@ export class AgentService {
private events: EventEmitter;
constructor(dataDir: string, events: EventEmitter) {
this.stateDir = path.join(dataDir, "agent-sessions");
this.metadataFile = path.join(dataDir, "sessions-metadata.json");
this.stateDir = path.join(dataDir, 'agent-sessions');
this.metadataFile = path.join(dataDir, 'sessions-metadata.json');
this.events = events;
}
@@ -86,12 +82,8 @@ export class AgentService {
const effectiveWorkingDirectory = workingDirectory || process.cwd();
const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory);
// Validate that the working directory is allowed
if (!isPathAllowed(resolvedWorkingDirectory)) {
throw new Error(
`Working directory ${effectiveWorkingDirectory} is not allowed`
);
}
// Validate that the working directory is allowed using centralized validation
validateWorkingDirectory(resolvedWorkingDirectory);
this.sessions.set(sessionId, {
messages,
@@ -132,7 +124,7 @@ export class AgentService {
}
if (session.isRunning) {
throw new Error("Agent is already processing a message");
throw new Error('Agent is already processing a message');
}
// Update session model if provided
@@ -142,7 +134,7 @@ export class AgentService {
}
// Read images and convert to base64
const images: Message["images"] = [];
const images: Message['images'] = [];
if (imagePaths && imagePaths.length > 0) {
for (const imagePath of imagePaths) {
try {
@@ -153,10 +145,7 @@ export class AgentService {
filename: imageData.filename,
});
} catch (error) {
console.error(
`[AgentService] Failed to load image ${imagePath}:`,
error
);
console.error(`[AgentService] Failed to load image ${imagePath}:`, error);
}
}
}
@@ -164,7 +153,7 @@ export class AgentService {
// Add user message
const userMessage: Message = {
id: this.generateId(),
role: "user",
role: 'user',
content: message,
images: images.length > 0 ? images : undefined,
timestamp: new Date().toISOString(),
@@ -182,7 +171,7 @@ export class AgentService {
// Emit user message event
this.emitAgentEvent(sessionId, {
type: "message",
type: 'message',
message: userMessage,
});
@@ -212,15 +201,14 @@ export class AgentService {
// Build options for provider
const options: ExecuteOptions = {
prompt: "", // Will be set below based on images
prompt: '', // Will be set below based on images
model: effectiveModel,
cwd: workingDirectory || session.workingDirectory,
systemPrompt: this.getSystemPrompt(),
maxTurns: maxTurns,
allowedTools: allowedTools,
abortController: session.abortController!,
conversationHistory:
conversationHistory.length > 0 ? conversationHistory : undefined,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
};
@@ -239,30 +227,28 @@ export class AgentService {
const stream = provider.executeQuery(options);
let currentAssistantMessage: Message | null = null;
let responseText = "";
let responseText = '';
const toolUses: Array<{ name: string; input: unknown }> = [];
for await (const msg of stream) {
// Capture SDK session ID from any message and persist it
if (msg.session_id && !session.sdkSessionId) {
session.sdkSessionId = msg.session_id;
console.log(
`[AgentService] Captured SDK session ID: ${msg.session_id}`
);
console.log(`[AgentService] Captured SDK session ID: ${msg.session_id}`);
// Persist the SDK session ID to ensure conversation continuity across server restarts
await this.updateSession(sessionId, { sdkSessionId: msg.session_id });
}
if (msg.type === "assistant") {
if (msg.type === 'assistant') {
if (msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
if (block.type === 'text') {
responseText += block.text;
if (!currentAssistantMessage) {
currentAssistantMessage = {
id: this.generateId(),
role: "assistant",
role: 'assistant',
content: responseText,
timestamp: new Date().toISOString(),
};
@@ -272,27 +258,27 @@ export class AgentService {
}
this.emitAgentEvent(sessionId, {
type: "stream",
type: 'stream',
messageId: currentAssistantMessage.id,
content: responseText,
isComplete: false,
});
} else if (block.type === "tool_use") {
} else if (block.type === 'tool_use') {
const toolUse = {
name: block.name || "unknown",
name: block.name || 'unknown',
input: block.input,
};
toolUses.push(toolUse);
this.emitAgentEvent(sessionId, {
type: "tool_use",
type: 'tool_use',
tool: toolUse,
});
}
}
}
} else if (msg.type === "result") {
if (msg.subtype === "success" && msg.result) {
} else if (msg.type === 'result') {
if (msg.subtype === 'success' && msg.result) {
if (currentAssistantMessage) {
currentAssistantMessage.content = msg.result;
responseText = msg.result;
@@ -300,7 +286,7 @@ export class AgentService {
}
this.emitAgentEvent(sessionId, {
type: "complete",
type: 'complete',
messageId: currentAssistantMessage?.id,
content: responseText,
toolUses,
@@ -324,14 +310,14 @@ export class AgentService {
return { success: false, aborted: true };
}
console.error("[AgentService] Error:", error);
console.error('[AgentService] Error:', error);
session.isRunning = false;
session.abortController = null;
const errorMessage: Message = {
id: this.generateId(),
role: "assistant",
role: 'assistant',
content: `Error: ${(error as Error).message}`,
timestamp: new Date().toISOString(),
isError: true,
@@ -341,7 +327,7 @@ export class AgentService {
await this.saveSession(sessionId, session.messages);
this.emitAgentEvent(sessionId, {
type: "error",
type: 'error',
error: (error as Error).message,
message: errorMessage,
});
@@ -356,7 +342,7 @@ export class AgentService {
getHistory(sessionId: string) {
const session = this.sessions.get(sessionId);
if (!session) {
return { success: false, error: "Session not found" };
return { success: false, error: 'Session not found' };
}
return {
@@ -372,7 +358,7 @@ export class AgentService {
async stopExecution(sessionId: string) {
const session = this.sessions.get(sessionId);
if (!session) {
return { success: false, error: "Session not found" };
return { success: false, error: 'Session not found' };
}
if (session.abortController) {
@@ -404,7 +390,7 @@ export class AgentService {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
try {
const data = (await secureFs.readFile(sessionFile, "utf-8")) as string;
const data = (await secureFs.readFile(sessionFile, 'utf-8')) as string;
return JSON.parse(data);
} catch {
return [];
@@ -415,23 +401,16 @@ export class AgentService {
const sessionFile = path.join(this.stateDir, `${sessionId}.json`);
try {
await secureFs.writeFile(
sessionFile,
JSON.stringify(messages, null, 2),
"utf-8"
);
await secureFs.writeFile(sessionFile, JSON.stringify(messages, null, 2), 'utf-8');
await this.updateSessionTimestamp(sessionId);
} catch (error) {
console.error("[AgentService] Failed to save session:", error);
console.error('[AgentService] Failed to save session:', error);
}
}
async loadMetadata(): Promise<Record<string, SessionMetadata>> {
try {
const data = (await secureFs.readFile(
this.metadataFile,
"utf-8"
)) as string;
const data = (await secureFs.readFile(this.metadataFile, 'utf-8')) as string;
return JSON.parse(data);
} catch {
return {};
@@ -439,11 +418,7 @@ export class AgentService {
}
async saveMetadata(metadata: Record<string, SessionMetadata>): Promise<void> {
await secureFs.writeFile(
this.metadataFile,
JSON.stringify(metadata, null, 2),
"utf-8"
);
await secureFs.writeFile(this.metadataFile, JSON.stringify(metadata, null, 2), 'utf-8');
}
async updateSessionTimestamp(sessionId: string): Promise<void> {
@@ -463,8 +438,7 @@ export class AgentService {
}
return sessions.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
}
@@ -478,21 +452,15 @@ export class AgentService {
const metadata = await this.loadMetadata();
// Determine the effective working directory
const effectiveWorkingDirectory =
workingDirectory || projectPath || process.cwd();
const effectiveWorkingDirectory = workingDirectory || projectPath || process.cwd();
const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory);
// Validate that the working directory is allowed
if (!isPathAllowed(resolvedWorkingDirectory)) {
throw new PathNotAllowedError(effectiveWorkingDirectory);
}
// Validate that the working directory is allowed using centralized validation
validateWorkingDirectory(resolvedWorkingDirectory);
// Validate that projectPath is allowed if provided
if (projectPath) {
const resolvedProjectPath = path.resolve(projectPath);
if (!isPathAllowed(resolvedProjectPath)) {
throw new PathNotAllowedError(projectPath);
}
validateWorkingDirectory(projectPath);
}
const session: SessionMetadata = {
@@ -569,11 +537,8 @@ export class AgentService {
return true;
}
private emitAgentEvent(
sessionId: string,
data: Record<string, unknown>
): void {
this.events.emit("agent:stream", { sessionId, ...data });
private emitAgentEvent(sessionId: string, data: Record<string, unknown>): void {
this.events.emit('agent:stream', { sessionId, ...data });
}
private getSystemPrompt(): string {

File diff suppressed because it is too large Load Diff

View File

@@ -7,10 +7,10 @@
* Developers should configure their projects to use the PORT environment variable.
*/
import { spawn, execSync, type ChildProcess } from "child_process";
import { existsSync } from "fs";
import path from "path";
import net from "net";
import { spawn, execSync, type ChildProcess } from 'child_process';
import * as secureFs from '../lib/secure-fs.js';
import path from 'path';
import net from 'net';
export interface DevServerInfo {
worktreePath: string;
@@ -40,12 +40,12 @@ class DevServerService {
// Then check if the system has it in use
return new Promise((resolve) => {
const server = net.createServer();
server.once("error", () => resolve(false));
server.once("listening", () => {
server.once('error', () => resolve(false));
server.once('listening', () => {
server.close();
resolve(true);
});
server.listen(port, "127.0.0.1");
server.listen(port, '127.0.0.1');
});
}
@@ -54,21 +54,21 @@ class DevServerService {
*/
private killProcessOnPort(port: number): void {
try {
if (process.platform === "win32") {
if (process.platform === 'win32') {
// Windows: find and kill process on port
const result = execSync(`netstat -ano | findstr :${port}`, { encoding: "utf-8" });
const lines = result.trim().split("\n");
const result = execSync(`netstat -ano | findstr :${port}`, { encoding: 'utf-8' });
const lines = result.trim().split('\n');
const pids = new Set<string>();
for (const line of lines) {
const parts = line.trim().split(/\s+/);
const pid = parts[parts.length - 1];
if (pid && pid !== "0") {
if (pid && pid !== '0') {
pids.add(pid);
}
}
for (const pid of pids) {
try {
execSync(`taskkill /F /PID ${pid}`, { stdio: "ignore" });
execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' });
console.log(`[DevServerService] Killed process ${pid} on port ${port}`);
} catch {
// Process may have already exited
@@ -77,11 +77,11 @@ class DevServerService {
} else {
// macOS/Linux: use lsof to find and kill process
try {
const result = execSync(`lsof -ti:${port}`, { encoding: "utf-8" });
const pids = result.trim().split("\n").filter(Boolean);
const result = execSync(`lsof -ti:${port}`, { encoding: 'utf-8' });
const pids = result.trim().split('\n').filter(Boolean);
for (const pid of pids) {
try {
execSync(`kill -9 ${pid}`, { stdio: "ignore" });
execSync(`kill -9 ${pid}`, { stdio: 'ignore' });
console.log(`[DevServerService] Killed process ${pid} on port ${port}`);
} catch {
// Process may have already exited
@@ -127,37 +127,47 @@ class DevServerService {
throw new Error(`No available ports found between ${BASE_PORT} and ${MAX_PORT}`);
}
/**
* Helper to check if a file exists using secureFs
*/
private async fileExists(filePath: string): Promise<boolean> {
try {
await secureFs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* Detect the package manager used in a directory
*/
private detectPackageManager(
dir: string
): "npm" | "yarn" | "pnpm" | "bun" | null {
if (existsSync(path.join(dir, "bun.lockb"))) return "bun";
if (existsSync(path.join(dir, "pnpm-lock.yaml"))) return "pnpm";
if (existsSync(path.join(dir, "yarn.lock"))) return "yarn";
if (existsSync(path.join(dir, "package-lock.json"))) return "npm";
if (existsSync(path.join(dir, "package.json"))) return "npm"; // Default
private async detectPackageManager(dir: string): Promise<'npm' | 'yarn' | 'pnpm' | 'bun' | null> {
if (await this.fileExists(path.join(dir, 'bun.lockb'))) return 'bun';
if (await this.fileExists(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
if (await this.fileExists(path.join(dir, 'yarn.lock'))) return 'yarn';
if (await this.fileExists(path.join(dir, 'package-lock.json'))) return 'npm';
if (await this.fileExists(path.join(dir, 'package.json'))) return 'npm'; // Default
return null;
}
/**
* Get the dev script command for a directory
*/
private getDevCommand(dir: string): { cmd: string; args: string[] } | null {
const pm = this.detectPackageManager(dir);
private async getDevCommand(dir: string): Promise<{ cmd: string; args: string[] } | null> {
const pm = await this.detectPackageManager(dir);
if (!pm) return null;
switch (pm) {
case "bun":
return { cmd: "bun", args: ["run", "dev"] };
case "pnpm":
return { cmd: "pnpm", args: ["run", "dev"] };
case "yarn":
return { cmd: "yarn", args: ["dev"] };
case "npm":
case 'bun':
return { cmd: 'bun', args: ['run', 'dev'] };
case 'pnpm':
return { cmd: 'pnpm', args: ['run', 'dev'] };
case 'yarn':
return { cmd: 'yarn', args: ['dev'] };
case 'npm':
default:
return { cmd: "npm", args: ["run", "dev"] };
return { cmd: 'npm', args: ['run', 'dev'] };
}
}
@@ -192,7 +202,7 @@ class DevServerService {
}
// Verify the worktree exists
if (!existsSync(worktreePath)) {
if (!(await this.fileExists(worktreePath))) {
return {
success: false,
error: `Worktree path does not exist: ${worktreePath}`,
@@ -200,8 +210,8 @@ class DevServerService {
}
// Check for package.json
const packageJsonPath = path.join(worktreePath, "package.json");
if (!existsSync(packageJsonPath)) {
const packageJsonPath = path.join(worktreePath, 'package.json');
if (!(await this.fileExists(packageJsonPath))) {
return {
success: false,
error: `No package.json found in: ${worktreePath}`,
@@ -209,7 +219,7 @@ class DevServerService {
}
// Get dev command
const devCommand = this.getDevCommand(worktreePath);
const devCommand = await this.getDevCommand(worktreePath);
if (!devCommand) {
return {
success: false,
@@ -224,7 +234,7 @@ class DevServerService {
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Port allocation failed",
error: error instanceof Error ? error.message : 'Port allocation failed',
};
}
@@ -241,14 +251,10 @@ class DevServerService {
// Small delay to ensure related ports are freed
await new Promise((resolve) => setTimeout(resolve, 100));
console.log(`[DevServerService] Starting dev server on port ${port}`);
console.log(`[DevServerService] Working directory (cwd): ${worktreePath}`);
console.log(
`[DevServerService] Starting dev server on port ${port}`
);
console.log(
`[DevServerService] Working directory (cwd): ${worktreePath}`
);
console.log(
`[DevServerService] Command: ${devCommand.cmd} ${devCommand.args.join(" ")} with PORT=${port}`
`[DevServerService] Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`
);
// Spawn the dev process with PORT environment variable
@@ -260,7 +266,7 @@ class DevServerService {
const devProcess = spawn(devCommand.cmd, devCommand.args, {
cwd: worktreePath,
env,
stdio: ["ignore", "pipe", "pipe"],
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
@@ -269,29 +275,27 @@ class DevServerService {
// Log output for debugging
if (devProcess.stdout) {
devProcess.stdout.on("data", (data: Buffer) => {
devProcess.stdout.on('data', (data: Buffer) => {
console.log(`[DevServer:${port}] ${data.toString().trim()}`);
});
}
if (devProcess.stderr) {
devProcess.stderr.on("data", (data: Buffer) => {
devProcess.stderr.on('data', (data: Buffer) => {
const msg = data.toString().trim();
console.error(`[DevServer:${port}] ${msg}`);
});
}
devProcess.on("error", (error) => {
devProcess.on('error', (error) => {
console.error(`[DevServerService] Process error:`, error);
status.error = error.message;
this.allocatedPorts.delete(port);
this.runningServers.delete(worktreePath);
});
devProcess.on("exit", (code) => {
console.log(
`[DevServerService] Process for ${worktreePath} exited with code ${code}`
);
devProcess.on('exit', (code) => {
console.log(`[DevServerService] Process for ${worktreePath} exited with code ${code}`);
status.exited = true;
this.allocatedPorts.delete(port);
this.runningServers.delete(worktreePath);
@@ -348,7 +352,9 @@ class DevServerService {
// If we don't have a record of this server, it may have crashed/exited on its own
// Return success so the frontend can clear its state
if (!server) {
console.log(`[DevServerService] No server record for ${worktreePath}, may have already stopped`);
console.log(
`[DevServerService] No server record for ${worktreePath}, may have already stopped`
);
return {
success: true,
result: {
@@ -362,7 +368,7 @@ class DevServerService {
// Kill the process
if (server.process && !server.process.killed) {
server.process.kill("SIGTERM");
server.process.kill('SIGTERM');
}
// Free the port
@@ -447,13 +453,13 @@ export function getDevServerService(): DevServerService {
}
// Cleanup on process exit
process.on("SIGTERM", async () => {
process.on('SIGTERM', async () => {
if (devServerServiceInstance) {
await devServerServiceInstance.stopAll();
}
});
process.on("SIGINT", async () => {
process.on('SIGINT', async () => {
if (devServerServiceInstance) {
await devServerServiceInstance.stopAll();
}