refactor: split request handle logic to controller

This commit is contained in:
d-kimsuon
2025-10-17 23:46:38 +09:00
parent c745824dbe
commit 1bd122daa0
22 changed files with 1308 additions and 693 deletions

View File

@@ -1,15 +1,25 @@
import { NodeContext } from "@effect/platform-node"; import { NodeContext } from "@effect/platform-node";
import { Effect } from "effect"; import { Effect, Layer } from "effect";
import { handle } from "hono/vercel"; import { handle } from "hono/vercel";
import { ClaudeCodeController } from "../../../server/core/claude-code/presentation/ClaudeCodeController";
import { ClaudeCodePermissionController } from "../../../server/core/claude-code/presentation/ClaudeCodePermissionController";
import { ClaudeCodeSessionProcessController } from "../../../server/core/claude-code/presentation/ClaudeCodeSessionProcessController";
import { ClaudeCodeLifeCycleService } from "../../../server/core/claude-code/services/ClaudeCodeLifeCycleService"; import { ClaudeCodeLifeCycleService } from "../../../server/core/claude-code/services/ClaudeCodeLifeCycleService";
import { ClaudeCodePermissionService } from "../../../server/core/claude-code/services/ClaudeCodePermissionService"; import { ClaudeCodePermissionService } from "../../../server/core/claude-code/services/ClaudeCodePermissionService";
import { ClaudeCodeService } from "../../../server/core/claude-code/services/ClaudeCodeService";
import { ClaudeCodeSessionProcessService } from "../../../server/core/claude-code/services/ClaudeCodeSessionProcessService"; import { ClaudeCodeSessionProcessService } from "../../../server/core/claude-code/services/ClaudeCodeSessionProcessService";
import { SSEController } from "../../../server/core/events/presentation/SSEController";
import { EventBus } from "../../../server/core/events/services/EventBus"; import { EventBus } from "../../../server/core/events/services/EventBus";
import { FileWatcherService } from "../../../server/core/events/services/fileWatcher"; import { FileWatcherService } from "../../../server/core/events/services/fileWatcher";
import { FileSystemController } from "../../../server/core/file-system/presentation/FileSystemController";
import { GitController } from "../../../server/core/git/presentation/GitController";
import { HonoConfigService } from "../../../server/core/hono/services/HonoConfigService";
import { ProjectRepository } from "../../../server/core/project/infrastructure/ProjectRepository"; import { ProjectRepository } from "../../../server/core/project/infrastructure/ProjectRepository";
import { ProjectController } from "../../../server/core/project/presentation/ProjectController";
import { ProjectMetaService } from "../../../server/core/project/services/ProjectMetaService"; import { ProjectMetaService } from "../../../server/core/project/services/ProjectMetaService";
import { SessionRepository } from "../../../server/core/session/infrastructure/SessionRepository"; import { SessionRepository } from "../../../server/core/session/infrastructure/SessionRepository";
import { VirtualConversationDatabase } from "../../../server/core/session/infrastructure/VirtualConversationDatabase"; import { VirtualConversationDatabase } from "../../../server/core/session/infrastructure/VirtualConversationDatabase";
import { SessionController } from "../../../server/core/session/presentation/SessionController";
import { SessionMetaService } from "../../../server/core/session/services/SessionMetaService"; import { SessionMetaService } from "../../../server/core/session/services/SessionMetaService";
import { honoApp } from "../../../server/hono/app"; import { honoApp } from "../../../server/hono/app";
import { InitializeService } from "../../../server/hono/initialize"; import { InitializeService } from "../../../server/hono/initialize";
@@ -17,10 +27,27 @@ import { routes } from "../../../server/hono/route";
const program = routes(honoApp); const program = routes(honoApp);
/** Max count of pipe is 20, so merge some layers here */
const storageLayer = Layer.mergeAll(
ProjectMetaService.Live,
SessionMetaService.Live,
VirtualConversationDatabase.Live,
);
await Effect.runPromise( await Effect.runPromise(
program.pipe( program.pipe(
// 依存の浅い順にコンテナに pipe する必要がある // 依存の浅い順にコンテナに pipe する必要がある
/** Presentation */
Effect.provide(ProjectController.Live),
Effect.provide(SessionController.Live),
Effect.provide(GitController.Live),
Effect.provide(ClaudeCodeController.Live),
Effect.provide(ClaudeCodeSessionProcessController.Live),
Effect.provide(ClaudeCodePermissionController.Live),
Effect.provide(FileSystemController.Live),
Effect.provide(SSEController.Live),
/** Application */ /** Application */
Effect.provide(InitializeService.Live), Effect.provide(InitializeService.Live),
@@ -28,10 +55,12 @@ await Effect.runPromise(
Effect.provide(ClaudeCodeLifeCycleService.Live), Effect.provide(ClaudeCodeLifeCycleService.Live),
Effect.provide(ClaudeCodePermissionService.Live), Effect.provide(ClaudeCodePermissionService.Live),
Effect.provide(ClaudeCodeSessionProcessService.Live), Effect.provide(ClaudeCodeSessionProcessService.Live),
Effect.provide(ClaudeCodeService.Live),
// Shared Services // Shared Services
Effect.provide(FileWatcherService.Live), Effect.provide(FileWatcherService.Live),
Effect.provide(EventBus.Live), Effect.provide(EventBus.Live),
Effect.provide(HonoConfigService.Live),
/** Infrastructure */ /** Infrastructure */
@@ -40,9 +69,7 @@ await Effect.runPromise(
Effect.provide(SessionRepository.Live), Effect.provide(SessionRepository.Live),
// StorageService // StorageService
Effect.provide(ProjectMetaService.Live), Effect.provide(storageLayer),
Effect.provide(SessionMetaService.Live),
Effect.provide(VirtualConversationDatabase.Live),
/** Platform */ /** Platform */
Effect.provide(NodeContext.layer), Effect.provide(NodeContext.layer),

View File

@@ -1,5 +1,5 @@
import type { DirectoryListingResult } from "../../server/core/directory-browser/functions/getDirectoryListing"; import type { DirectoryListingResult } from "../../server/core/file-system/functions/getDirectoryListing";
import type { FileCompletionResult } from "../../server/core/file-completion/functions/getFileCompletion"; import type { FileCompletionResult } from "../../server/core/file-system/functions/getFileCompletion";
import { honoClient } from "./client"; import { honoClient } from "./client";
export const projectListQuery = { export const projectListQuery = {
@@ -21,7 +21,7 @@ export const directoryListingQuery = (currentPath?: string) =>
({ ({
queryKey: ["directory-listing", currentPath], queryKey: ["directory-listing", currentPath],
queryFn: async (): Promise<DirectoryListingResult> => { queryFn: async (): Promise<DirectoryListingResult> => {
const response = await honoClient.api["directory-browser"].$get({ const response = await honoClient.api.fs["directory-browser"].$get({
query: currentPath ? { currentPath } : {}, query: currentPath ? { currentPath } : {},
}); });
@@ -182,11 +182,8 @@ export const fileCompletionQuery = (projectId: string, basePath: string) =>
({ ({
queryKey: ["file-completion", projectId, basePath], queryKey: ["file-completion", projectId, basePath],
queryFn: async (): Promise<FileCompletionResult> => { queryFn: async (): Promise<FileCompletionResult> => {
const response = await honoClient.api.projects[":projectId"][ const response = await honoClient.api.fs["file-completion"].$get({
"file-completion" query: { basePath, projectId },
].$get({
param: { projectId },
query: { basePath },
}); });
if (!response.ok) { if (!response.ok) {

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { parseMcpListOutput } from "./parseMcpListOutput";
describe("parseMcpListOutput", () => {
it("should parse claude mcp list output correctly", async () => {
const output = `2.0.21 (Claude Code)
Checking MCP server health...
context7: npx -y @upstash/context7-mcp@latest - ✓ Connected
`;
const result = parseMcpListOutput(output);
expect(result).toEqual([
{
name: "context7",
command: "npx -y @upstash/context7-mcp@latest",
},
]);
});
it("should handle multiple MCP servers", async () => {
const output = `2.0.21 (Claude Code)
Checking MCP server health...
context7: npx -y @upstash/context7-mcp@latest - ✓ Connected
filesystem: /usr/local/bin/mcp-server-fs - ✓ Connected
database: docker run db-mcp - ✗ Failed
`;
const result = parseMcpListOutput(output);
expect(result).toEqual([
{
name: "context7",
command: "npx -y @upstash/context7-mcp@latest",
},
{
name: "filesystem",
command: "/usr/local/bin/mcp-server-fs",
},
{
name: "database",
command: "docker run db-mcp",
},
]);
});
it("should return empty array for output with no MCP servers", async () => {
const output = `2.0.21 (Claude Code)
Checking MCP server health...
`;
const result = parseMcpListOutput(output);
expect(result).toEqual([]);
});
it("should skip malformed lines", async () => {
const output = `2.0.21 (Claude Code)
Checking MCP server health...
context7: npx -y @upstash/context7-mcp@latest - ✓ Connected
invalid line without colon
: command without name
name without command:
`;
const result = parseMcpListOutput(output);
expect(result).toEqual([
{
name: "context7",
command: "npx -y @upstash/context7-mcp@latest",
},
]);
});
});

View File

@@ -0,0 +1,32 @@
export interface McpServer {
name: string;
command: string;
}
export const parseMcpListOutput = (output: string) => {
const servers: McpServer[] = [];
const lines = output.trim().split("\n");
for (const line of lines) {
// Skip header lines and status indicators
if (line.includes("Checking MCP server health") || line.trim() === "") {
continue;
}
// Parse lines like "context7: npx -y @upstash/context7-mcp@latest - ✓ Connected"
const colonIndex = line.indexOf(":");
if (colonIndex > 0) {
const name = line.substring(0, colonIndex).trim();
const rest = line.substring(colonIndex + 1).trim();
// Remove status indicators (✓ Connected, ✗ Failed, etc.)
const command = rest.replace(/\s*-\s*[✓✗].*$/, "").trim();
if (name && command) {
servers.push({ name, command });
}
}
}
return servers;
};

View File

@@ -37,6 +37,22 @@ export const Config = Effect.gen(function* () {
}; };
}); });
export const getMcpListOutput = (projectCwd: string) =>
Effect.gen(function* () {
const { claudeCodeExecutablePath } = yield* Config;
const output = yield* Command.string(
Command.make(
"cd",
projectCwd,
"&&",
claudeCodeExecutablePath,
"mcp",
"list",
),
);
return output;
});
export const getAvailableFeatures = ( export const getAvailableFeatures = (
claudeCodeVersion: ClaudeCodeVersion.ClaudeCodeVersion | null, claudeCodeVersion: ClaudeCodeVersion.ClaudeCodeVersion | null,
) => ({ ) => ({

View File

@@ -0,0 +1,94 @@
import { Context, Effect, Layer } from "effect";
import { claudeCommandsDirPath } from "../../../lib/config/paths";
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
import type { InferEffect } from "../../../lib/effect/types";
import { ProjectRepository } from "../../project/infrastructure/ProjectRepository";
import { ClaudeCodeService } from "../services/ClaudeCodeService";
import { FileSystem, Path } from "@effect/platform";
const LayerImpl = Effect.gen(function* () {
const projectRepository = yield* ProjectRepository;
const claudeCodeService = yield* ClaudeCodeService;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const getClaudeCommands = (options: { projectId: string }) =>
Effect.gen(function* () {
const { projectId } = options;
const { project } = yield* projectRepository.getProject(projectId);
const globalCommands: string[] = yield* fs
.readDirectory(claudeCommandsDirPath)
.pipe(
Effect.map((items) =>
items
.filter((item) => item.endsWith(".md"))
.map((item) => item.replace(/\.md$/, "")),
),
)
.pipe(
Effect.match({
onSuccess: (items) => items,
onFailure: () => {
return [];
},
}),
);
const projectCommands: string[] =
project.meta.projectPath === null
? []
: yield* fs
.readDirectory(
path.resolve(project.meta.projectPath, ".claude", "commands"),
)
.pipe(
Effect.map((items) =>
items
.filter((item) => item.endsWith(".md"))
.map((item) => item.replace(/\.md$/, "")),
),
)
.pipe(
Effect.match({
onSuccess: (items) => items,
onFailure: () => {
return [];
},
}),
);
return {
response: {
globalCommands: globalCommands,
projectCommands: projectCommands,
defaultCommands: ["init", "compact"],
},
status: 200,
} as const satisfies ControllerResponse;
});
const getMcpListRoute = (options: { projectId: string }) =>
Effect.gen(function* () {
const { projectId } = options;
const servers = yield* claudeCodeService.getMcpList(projectId);
return {
response: { servers },
status: 200,
} as const satisfies ControllerResponse;
});
return {
getClaudeCommands,
getMcpListRoute,
};
});
export type IClaudeCodeController = InferEffect<typeof LayerImpl>;
export class ClaudeCodeController extends Context.Tag("ClaudeCodeController")<
ClaudeCodeController,
IClaudeCodeController
>() {
static Live = Layer.effect(this, LayerImpl);
}

View File

@@ -0,0 +1,40 @@
import { Context, Effect, Layer } from "effect";
import type { PermissionResponse } from "../../../../types/permissions";
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
import type { InferEffect } from "../../../lib/effect/types";
import { ClaudeCodePermissionService } from "../services/ClaudeCodePermissionService";
const LayerImpl = Effect.gen(function* () {
const claudeCodePermissionService = yield* ClaudeCodePermissionService;
const permissionResponse = (options: {
permissionResponse: PermissionResponse;
}) =>
Effect.sync(() => {
const { permissionResponse } = options;
Effect.runFork(
claudeCodePermissionService.respondToPermissionRequest(
permissionResponse,
),
);
return {
status: 200,
response: {
message: "Permission response received",
},
} as const satisfies ControllerResponse;
});
return {
permissionResponse,
};
});
export type IClaudeCodePermissionController = InferEffect<typeof LayerImpl>;
export class ClaudeCodePermissionController extends Context.Tag(
"ClaudeCodePermissionController",
)<ClaudeCodePermissionController, IClaudeCodePermissionController>() {
static Live = Layer.effect(this, LayerImpl);
}

View File

@@ -0,0 +1,125 @@
import { Context, Effect, Layer } from "effect";
import type { PublicSessionProcess } from "../../../../types/session-process";
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
import type { InferEffect } from "../../../lib/effect/types";
import { HonoConfigService } from "../../hono/services/HonoConfigService";
import { ProjectRepository } from "../../project/infrastructure/ProjectRepository";
import { ClaudeCodeLifeCycleService } from "../services/ClaudeCodeLifeCycleService";
const LayerImpl = Effect.gen(function* () {
const projectRepository = yield* ProjectRepository;
const claudeCodeLifeCycleService = yield* ClaudeCodeLifeCycleService;
const honoConfigService = yield* HonoConfigService;
const getSessionProcesses = () =>
Effect.gen(function* () {
const publicSessionProcesses =
yield* claudeCodeLifeCycleService.getPublicSessionProcesses();
return {
response: {
processes: publicSessionProcesses.map(
(p): PublicSessionProcess => ({
id: p.def.sessionProcessId,
projectId: p.def.projectId,
sessionId: p.sessionId,
status: p.type === "paused" ? "paused" : "running",
}),
),
},
status: 200,
} as const satisfies ControllerResponse;
});
const createSessionProcess = (options: {
projectId: string;
message: string;
baseSessionId?: string | undefined;
}) =>
Effect.gen(function* () {
const { projectId, message, baseSessionId } = options;
const { project } = yield* projectRepository.getProject(projectId);
const config = yield* honoConfigService.getConfig();
if (project.meta.projectPath === null) {
return {
response: { error: "Project path not found" },
status: 400 as const,
} as const satisfies ControllerResponse;
}
const result = yield* claudeCodeLifeCycleService.startTask({
baseSession: {
cwd: project.meta.projectPath,
projectId,
sessionId: baseSessionId,
},
config: config,
message,
});
const { sessionId } = yield* result.yieldSessionInitialized();
return {
status: 201 as const,
response: {
sessionProcess: {
id: result.sessionProcess.def.sessionProcessId,
projectId,
sessionId,
},
},
} as const satisfies ControllerResponse;
});
const continueSessionProcess = (options: {
projectId: string;
continueMessage: string;
baseSessionId: string;
sessionProcessId: string;
}) =>
Effect.gen(function* () {
const { projectId, continueMessage, baseSessionId, sessionProcessId } =
options;
const { project } = yield* projectRepository.getProject(projectId);
if (project.meta.projectPath === null) {
return {
response: { error: "Project path not found" },
status: 400,
} as const satisfies ControllerResponse;
}
const result = yield* claudeCodeLifeCycleService.continueTask({
sessionProcessId,
message: continueMessage,
baseSessionId,
});
return {
response: {
sessionProcess: {
id: result.sessionProcess.def.sessionProcessId,
projectId: result.sessionProcess.def.projectId,
sessionId: baseSessionId,
},
},
status: 200,
} as const satisfies ControllerResponse;
});
return {
getSessionProcesses,
createSessionProcess,
continueSessionProcess,
};
});
export type IClaudeCodeSessionProcessController = InferEffect<typeof LayerImpl>;
export class ClaudeCodeSessionProcessController extends Context.Tag(
"ClaudeCodeSessionProcessController",
)<ClaudeCodeSessionProcessController, IClaudeCodeSessionProcessController>() {
static Live = Layer.effect(this, LayerImpl);
}

View File

@@ -342,6 +342,10 @@ const LayerImpl = Effect.gen(function* () {
await sessionInitializedPromise.promise, await sessionInitializedPromise.promise,
awaitSessionFileCreated: async () => awaitSessionFileCreated: async () =>
await sessionFileCreatedPromise.promise, await sessionFileCreatedPromise.promise,
yieldSessionInitialized: () =>
Effect.promise(() => sessionInitializedPromise.promise),
yieldSessionFileCreated: () =>
Effect.promise(() => sessionFileCreatedPromise.promise),
}; };
}); });
}; };

View File

@@ -0,0 +1,41 @@
import { Context, Data, Effect, Layer } from "effect";
import type { InferEffect } from "../../../lib/effect/types";
import { ProjectRepository } from "../../project/infrastructure/ProjectRepository";
import { parseMcpListOutput } from "../functions/parseMcpListOutput";
import * as ClaudeCode from "../models/ClaudeCode";
class ProjectPathNotFoundError extends Data.TaggedError(
"ProjectPathNotFoundError",
)<{
projectId: string;
}> {}
const LayerImpl = Effect.gen(function* () {
const projectRepository = yield* ProjectRepository;
const getMcpList = (projectId: string) =>
Effect.gen(function* () {
const { project } = yield* projectRepository.getProject(projectId);
if (project.meta.projectPath === null) {
return yield* Effect.fail(new ProjectPathNotFoundError({ projectId }));
}
const output = yield* ClaudeCode.getMcpListOutput(
project.meta.projectPath,
);
return parseMcpListOutput(output);
});
return {
getMcpList,
};
});
export type IClaudeCodeService = InferEffect<typeof LayerImpl>;
export class ClaudeCodeService extends Context.Tag("ClaudeCodeService")<
ClaudeCodeService,
IClaudeCodeService
>() {
static Live = Layer.effect(this, LayerImpl);
}

View File

@@ -0,0 +1,108 @@
import { Context, Effect, Layer } from "effect";
import type { SSEStreamingApi } from "hono/streaming";
import type { InferEffect } from "../../../lib/effect/types";
import { adaptInternalEventToSSE } from "../functions/adaptInternalEventToSSE";
import { TypeSafeSSE } from "../functions/typeSafeSSE";
import { EventBus } from "../services/EventBus";
import type { InternalEventDeclaration } from "../types/InternalEventDeclaration";
const LayerImpl = Effect.gen(function* () {
const eventBus = yield* EventBus;
const handleSSE = (rawStream: SSEStreamingApi) =>
Effect.gen(function* () {
const typeSafeSSE = yield* TypeSafeSSE;
// Send connect event
yield* typeSafeSSE.writeSSE("connect", {
timestamp: new Date().toISOString(),
});
const onHeartbeat = () => {
Effect.runFork(
typeSafeSSE.writeSSE("heartbeat", {
timestamp: new Date().toISOString(),
}),
);
};
const onSessionListChanged = (
event: InternalEventDeclaration["sessionListChanged"],
) => {
Effect.runFork(
typeSafeSSE.writeSSE("sessionListChanged", {
projectId: event.projectId,
}),
);
};
const onSessionChanged = (
event: InternalEventDeclaration["sessionChanged"],
) => {
Effect.runFork(
typeSafeSSE.writeSSE("sessionChanged", {
projectId: event.projectId,
sessionId: event.sessionId,
}),
);
};
const onSessionProcessChanged = (
event: InternalEventDeclaration["sessionProcessChanged"],
) => {
Effect.runFork(
typeSafeSSE.writeSSE("sessionProcessChanged", {
processes: event.processes,
}),
);
};
const onPermissionRequested = (
event: InternalEventDeclaration["permissionRequested"],
) => {
Effect.runFork(
typeSafeSSE.writeSSE("permissionRequested", {
permissionRequest: event.permissionRequest,
}),
);
};
yield* eventBus.on("sessionListChanged", onSessionListChanged);
yield* eventBus.on("sessionChanged", onSessionChanged);
yield* eventBus.on("sessionProcessChanged", onSessionProcessChanged);
yield* eventBus.on("heartbeat", onHeartbeat);
yield* eventBus.on("permissionRequested", onPermissionRequested);
const { connectionPromise } = adaptInternalEventToSSE(rawStream, {
timeout: 5 /* min */ * 60 /* sec */ * 1000,
cleanUp: async () => {
await Effect.runPromise(
Effect.gen(function* () {
yield* eventBus.off("sessionListChanged", onSessionListChanged);
yield* eventBus.off("sessionChanged", onSessionChanged);
yield* eventBus.off(
"sessionProcessChanged",
onSessionProcessChanged,
);
yield* eventBus.off("heartbeat", onHeartbeat);
yield* eventBus.off("permissionRequested", onPermissionRequested);
}),
);
},
});
yield* Effect.promise(() => connectionPromise);
});
return {
handleSSE,
};
});
export type ISSEController = InferEffect<typeof LayerImpl>;
export class SSEController extends Context.Tag("SSEController")<
SSEController,
ISSEController
>() {
static Live = Layer.effect(this, LayerImpl);
}

View File

@@ -0,0 +1,89 @@
import { homedir } from "node:os";
import { Context, Effect, Layer } from "effect";
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
import type { InferEffect } from "../../../lib/effect/types";
import { ProjectRepository } from "../../project/infrastructure/ProjectRepository";
import { getDirectoryListing } from "../functions/getDirectoryListing";
import { getFileCompletion } from "../functions/getFileCompletion";
const LayerImpl = Effect.gen(function* () {
const projectRepository = yield* ProjectRepository;
const getFileCompletionRoute = (options: {
projectId: string;
basePath: string;
}) =>
Effect.gen(function* () {
const { projectId, basePath } = options;
const { project } = yield* projectRepository.getProject(projectId);
if (project.meta.projectPath === null) {
return {
response: { error: "Project path not found" },
status: 400,
} as const satisfies ControllerResponse;
}
const projectPath = project.meta.projectPath;
try {
const result = yield* Effect.promise(() =>
getFileCompletion(projectPath, basePath),
);
return {
response: result,
status: 200,
} as const satisfies ControllerResponse;
} catch (error) {
console.error("File completion error:", error);
return {
response: { error: "Failed to get file completion" },
status: 500,
} as const satisfies ControllerResponse;
}
});
const getDirectoryListingRoute = (options: {
currentPath?: string | undefined;
}) =>
Effect.promise(async () => {
const { currentPath } = options;
const rootPath = "/";
const defaultPath = homedir();
try {
const targetPath = currentPath ?? defaultPath;
const relativePath = targetPath.startsWith(rootPath)
? targetPath.slice(rootPath.length)
: targetPath;
const result = await getDirectoryListing(rootPath, relativePath);
return {
response: result,
status: 200,
} as const satisfies ControllerResponse;
} catch (error) {
console.error("Directory listing error:", error);
return {
response: { error: "Failed to list directory" },
status: 500,
} as const satisfies ControllerResponse;
}
});
return {
getFileCompletionRoute,
getDirectoryListingRoute,
};
});
export type IFileSystemController = InferEffect<typeof LayerImpl>;
export class FileSystemController extends Context.Tag("FileSystemController")<
FileSystemController,
IFileSystemController
>() {
static Live = Layer.effect(this, LayerImpl);
}

View File

@@ -0,0 +1,139 @@
import { Context, Effect, Layer } from "effect";
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
import type { InferEffect } from "../../../lib/effect/types";
import { ProjectRepository } from "../../project/infrastructure/ProjectRepository";
import { getBranches } from "../functions/getBranches";
import { getCommits } from "../functions/getCommits";
import { getDiff } from "../functions/getDiff";
const LayerImpl = Effect.gen(function* () {
const projectRepository = yield* ProjectRepository;
const getGitBranches = (options: { projectId: string }) =>
Effect.gen(function* () {
const { projectId } = options;
const { project } = yield* projectRepository.getProject(projectId);
if (project.meta.projectPath === null) {
return {
response: { error: "Project path not found" },
status: 400,
} as const satisfies ControllerResponse;
}
const projectPath = project.meta.projectPath;
try {
const result = yield* Effect.promise(() => getBranches(projectPath));
return {
response: result,
status: 200,
} as const satisfies ControllerResponse;
} catch (error) {
console.error("Get branches error:", error);
if (error instanceof Error) {
return {
response: { error: error.message },
status: 400,
} as const satisfies ControllerResponse;
}
return {
response: { error: "Failed to get branches" },
status: 500,
} as const satisfies ControllerResponse;
}
});
const getGitCommits = (options: { projectId: string }) =>
Effect.gen(function* () {
const { projectId } = options;
const { project } = yield* projectRepository.getProject(projectId);
if (project.meta.projectPath === null) {
return {
response: { error: "Project path not found" },
status: 400,
} as const satisfies ControllerResponse;
}
const projectPath = project.meta.projectPath;
try {
const result = yield* Effect.promise(() => getCommits(projectPath));
return {
response: result,
status: 200,
} as const satisfies ControllerResponse;
} catch (error) {
console.error("Get commits error:", error);
if (error instanceof Error) {
return {
response: { error: error.message },
status: 400,
} as const satisfies ControllerResponse;
}
return {
response: { error: "Failed to get commits" },
status: 500,
} as const satisfies ControllerResponse;
}
});
const getGitDiff = (options: {
projectId: string;
fromRef: string;
toRef: string;
}) =>
Effect.gen(function* () {
const { projectId, fromRef, toRef } = options;
const { project } = yield* projectRepository.getProject(projectId);
try {
if (project.meta.projectPath === null) {
return {
response: { error: "Project path not found" },
status: 400,
} as const satisfies ControllerResponse;
}
const projectPath = project.meta.projectPath;
const result = yield* Effect.promise(() =>
getDiff(projectPath, fromRef, toRef),
);
return {
response: result,
status: 200,
} as const satisfies ControllerResponse;
} catch (error) {
console.error("Get diff error:", error);
if (error instanceof Error) {
return {
response: { error: error.message },
status: 400,
} as const satisfies ControllerResponse;
}
return {
response: { error: "Failed to get diff" },
status: 500,
} as const satisfies ControllerResponse;
}
});
return {
getGitBranches,
getGitCommits,
getGitDiff,
};
});
export type IGitController = InferEffect<typeof LayerImpl>;
export class GitController extends Context.Tag("GitController")<
GitController,
IGitController
>() {
static Live = Layer.effect(this, LayerImpl);
}

View File

@@ -0,0 +1,36 @@
import { Context, Effect, Layer, Ref } from "effect";
import type { Config } from "../../../lib/config/config";
import type { InferEffect } from "../../../lib/effect/types";
const LayerImpl = Effect.gen(function* () {
const configRef = yield* Ref.make<Config>({
hideNoUserMessageSession: true,
unifySameTitleSession: true,
enterKeyBehavior: "shift-enter-send",
permissionMode: "default",
});
const setConfig = (newConfig: Config) =>
Effect.gen(function* () {
yield* Ref.update(configRef, () => newConfig);
});
const getConfig = () =>
Effect.gen(function* () {
const config = yield* Ref.get(configRef);
return config;
});
return {
getConfig,
setConfig,
};
});
export type IHonoConfigService = InferEffect<typeof LayerImpl>;
export class HonoConfigService extends Context.Tag("HonoConfigService")<
HonoConfigService,
IHonoConfigService
>() {
static Live = Layer.effect(this, LayerImpl);
}

View File

@@ -1,49 +0,0 @@
import { execSync } from "node:child_process";
import { decodeProjectId } from "../../project/functions/id";
export interface McpServer {
name: string;
command: string;
}
export const getMcpList = async (
projectId: string,
): Promise<{ servers: McpServer[] }> => {
try {
const output = execSync("claude mcp list", {
encoding: "utf8",
timeout: 10000,
cwd: decodeProjectId(projectId),
});
const servers: McpServer[] = [];
const lines = output.trim().split("\n");
for (const line of lines) {
// Skip header lines and status indicators
if (line.includes("Checking MCP server health") || line.trim() === "") {
continue;
}
// Parse lines like "context7: npx -y @upstash/context7-mcp@latest - ✓ Connected"
const colonIndex = line.indexOf(":");
if (colonIndex > 0) {
const name = line.substring(0, colonIndex).trim();
const rest = line.substring(colonIndex + 1).trim();
// Remove status indicators (✓ Connected, ✗ Failed, etc.)
const command = rest.replace(/\s*-\s*[✓✗].*$/, "").trim();
if (name && command) {
servers.push({ name, command });
}
}
}
return { servers };
} catch (error) {
console.error("Failed to get MCP list:", error);
// Return empty list if command fails
return { servers: [] };
}
};

View File

@@ -0,0 +1,164 @@
import { Context, Effect, Layer } from "effect";
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
import type { InferEffect } from "../../../lib/effect/types";
import { computeClaudeProjectFilePath } from "../../claude-code/functions/computeClaudeProjectFilePath";
import { ClaudeCodeLifeCycleService } from "../../claude-code/services/ClaudeCodeLifeCycleService";
import { HonoConfigService } from "../../hono/services/HonoConfigService";
import { SessionRepository } from "../../session/infrastructure/SessionRepository";
import { encodeProjectId } from "../functions/id";
import { ProjectRepository } from "../infrastructure/ProjectRepository";
const LayerImpl = Effect.gen(function* () {
const projectRepository = yield* ProjectRepository;
const claudeCodeLifeCycleService = yield* ClaudeCodeLifeCycleService;
const honoConfigService = yield* HonoConfigService;
const sessionRepository = yield* SessionRepository;
const getProjects = () =>
Effect.gen(function* () {
const { projects } = yield* projectRepository.getProjects();
return {
status: 200,
response: { projects },
} as const satisfies ControllerResponse;
});
const getProject = (options: { projectId: string; cursor?: string }) =>
Effect.gen(function* () {
const { projectId, cursor } = options;
const config = yield* honoConfigService.getConfig();
const { project } = yield* projectRepository.getProject(projectId);
const { sessions } = yield* sessionRepository.getSessions(projectId, {
cursor,
});
let filteredSessions = sessions;
// Filter sessions based on hideNoUserMessageSession setting
if (config.hideNoUserMessageSession) {
filteredSessions = filteredSessions.filter((session) => {
return session.meta.firstCommand !== null;
});
}
// Unify sessions with same title if unifySameTitleSession is enabled
if (config.unifySameTitleSession) {
const sessionMap = new Map<string, (typeof filteredSessions)[0]>();
for (const session of filteredSessions) {
// Generate title for comparison
const title =
session.meta.firstCommand !== null
? (() => {
const cmd = session.meta.firstCommand;
switch (cmd.kind) {
case "command":
return cmd.commandArgs === undefined
? cmd.commandName
: `${cmd.commandName} ${cmd.commandArgs}`;
case "local-command":
return cmd.stdout;
case "text":
return cmd.content;
default:
return session.id;
}
})()
: session.id;
const existingSession = sessionMap.get(title);
if (existingSession) {
// Keep the session with the latest modification date
if (session.lastModifiedAt && existingSession.lastModifiedAt) {
if (session.lastModifiedAt > existingSession.lastModifiedAt) {
sessionMap.set(title, session);
}
} else if (
session.lastModifiedAt &&
!existingSession.lastModifiedAt
) {
sessionMap.set(title, session);
}
// If no modification dates, keep the existing one
} else {
sessionMap.set(title, session);
}
}
filteredSessions = Array.from(sessionMap.values());
}
const hasMore = sessions.length >= 20;
return {
status: 200,
response: {
project,
sessions: filteredSessions,
nextCursor: hasMore ? sessions.at(-1)?.id : undefined,
},
} as const satisfies ControllerResponse;
});
const getProjectLatestSession = (options: { projectId: string }) =>
Effect.gen(function* () {
const { projectId } = options;
const { sessions } = yield* sessionRepository.getSessions(projectId, {
maxCount: 1,
});
return {
status: 200,
response: {
latestSession: sessions[0] ?? null,
},
} as const satisfies ControllerResponse;
});
const createProject = (options: { projectPath: string }) =>
Effect.gen(function* () {
const { projectPath } = options;
// No project validation needed - startTask will create a new project
// if it doesn't exist when running /init command
const claudeProjectFilePath = computeClaudeProjectFilePath(projectPath);
const projectId = encodeProjectId(claudeProjectFilePath);
const config = yield* honoConfigService.getConfig();
const result = yield* claudeCodeLifeCycleService.startTask({
baseSession: {
cwd: projectPath,
projectId,
sessionId: undefined,
},
config: config,
message: "/init",
});
const { sessionId } = yield* result.yieldSessionFileCreated();
return {
status: 201,
response: {
projectId,
sessionId,
},
} as const satisfies ControllerResponse;
});
return {
getProjects,
getProject,
getProjectLatestSession,
createProject,
};
});
export type IProjectController = InferEffect<typeof LayerImpl>;
export class ProjectController extends Context.Tag("ProjectController")<
ProjectController,
IProjectController
>() {
static Live = Layer.effect(this, LayerImpl);
}

View File

@@ -0,0 +1,35 @@
import { Context, Effect, Layer } from "effect";
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
import type { InferEffect } from "../../../lib/effect/types";
import { SessionRepository } from "../../session/infrastructure/SessionRepository";
const LayerImpl = Effect.gen(function* () {
const sessionRepository = yield* SessionRepository;
const getSession = (options: { projectId: string; sessionId: string }) =>
Effect.gen(function* () {
const { projectId, sessionId } = options;
const { session } = yield* sessionRepository.getSession(
projectId,
sessionId,
);
return {
status: 200,
response: { session },
} as const satisfies ControllerResponse;
});
return {
getSession,
};
});
export type ISessionController = InferEffect<typeof LayerImpl>;
export class SessionController extends Context.Tag("SessionController")<
SessionController,
ISessionController
>() {
static Live = Layer.effect(this, LayerImpl);
}

View File

@@ -1,6 +1,3 @@
import { readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { resolve } from "node:path";
import type { CommandExecutor, FileSystem, Path } from "@effect/platform"; import type { CommandExecutor, FileSystem, Path } from "@effect/platform";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { Effect, Runtime } from "effect"; import { Effect, Runtime } from "effect";
@@ -8,28 +5,21 @@ import { setCookie } from "hono/cookie";
import { streamSSE } from "hono/streaming"; import { streamSSE } from "hono/streaming";
import prexit from "prexit"; import prexit from "prexit";
import { z } from "zod"; import { z } from "zod";
import type { PublicSessionProcess } from "../../types/session-process"; import { ClaudeCodeController } from "../core/claude-code/presentation/ClaudeCodeController";
import { computeClaudeProjectFilePath } from "../core/claude-code/functions/computeClaudeProjectFilePath"; import { ClaudeCodePermissionController } from "../core/claude-code/presentation/ClaudeCodePermissionController";
import { ClaudeCodeSessionProcessController } from "../core/claude-code/presentation/ClaudeCodeSessionProcessController";
import { ClaudeCodeLifeCycleService } from "../core/claude-code/services/ClaudeCodeLifeCycleService"; import { ClaudeCodeLifeCycleService } from "../core/claude-code/services/ClaudeCodeLifeCycleService";
import { ClaudeCodePermissionService } from "../core/claude-code/services/ClaudeCodePermissionService";
import { getDirectoryListing } from "../core/directory-browser/functions/getDirectoryListing";
import { adaptInternalEventToSSE } from "../core/events/functions/adaptInternalEventToSSE";
import { TypeSafeSSE } from "../core/events/functions/typeSafeSSE"; import { TypeSafeSSE } from "../core/events/functions/typeSafeSSE";
import { EventBus } from "../core/events/services/EventBus"; import { SSEController } from "../core/events/presentation/SSEController";
import type { InternalEventDeclaration } from "../core/events/types/InternalEventDeclaration"; import { FileSystemController } from "../core/file-system/presentation/FileSystemController";
import { getFileCompletion } from "../core/file-completion/functions/getFileCompletion"; import { GitController } from "../core/git/presentation/GitController";
import { getBranches } from "../core/git/functions/getBranches"; import { HonoConfigService } from "../core/hono/services/HonoConfigService";
import { getCommits } from "../core/git/functions/getCommits"; import { ProjectController } from "../core/project/presentation/ProjectController";
import { getDiff } from "../core/git/functions/getDiff";
import { getMcpList } from "../core/mcp/functions/getMcpList";
import { encodeProjectId } from "../core/project/functions/id";
import { ProjectRepository } from "../core/project/infrastructure/ProjectRepository";
import type { ProjectMetaService } from "../core/project/services/ProjectMetaService";
import { SessionRepository } from "../core/session/infrastructure/SessionRepository";
import type { VirtualConversationDatabase } from "../core/session/infrastructure/VirtualConversationDatabase"; import type { VirtualConversationDatabase } from "../core/session/infrastructure/VirtualConversationDatabase";
import { SessionController } from "../core/session/presentation/SessionController";
import type { SessionMetaService } from "../core/session/services/SessionMetaService"; import type { SessionMetaService } from "../core/session/services/SessionMetaService";
import { configSchema } from "../lib/config/config"; import { configSchema } from "../lib/config/config";
import { claudeCommandsDirPath } from "../lib/config/paths"; import { effectToResponse } from "../lib/effect/toEffectResponse";
import { env } from "../lib/env"; import { env } from "../lib/env";
import type { HonoAppType } from "./app"; import type { HonoAppType } from "./app";
import { InitializeService } from "./initialize"; import { InitializeService } from "./initialize";
@@ -37,15 +27,24 @@ import { configMiddleware } from "./middleware/config.middleware";
export const routes = (app: HonoAppType) => export const routes = (app: HonoAppType) =>
Effect.gen(function* () { Effect.gen(function* () {
const sessionRepository = yield* SessionRepository; // controllers
const projectRepository = yield* ProjectRepository; const projectController = yield* ProjectController;
const sessionController = yield* SessionController;
const gitController = yield* GitController;
const claudeCodeSessionProcessController =
yield* ClaudeCodeSessionProcessController;
const claudeCodePermissionController =
yield* ClaudeCodePermissionController;
const sseController = yield* SSEController;
const fileSystemController = yield* FileSystemController;
const claudeCodeController = yield* ClaudeCodeController;
// services
const honoConfigService = yield* HonoConfigService;
const claudeCodeLifeCycleService = yield* ClaudeCodeLifeCycleService; const claudeCodeLifeCycleService = yield* ClaudeCodeLifeCycleService;
const claudeCodePermissionService = yield* ClaudeCodePermissionService;
const initializeService = yield* InitializeService; const initializeService = yield* InitializeService;
const eventBus = yield* EventBus;
const runtime = yield* Effect.runtime< const runtime = yield* Effect.runtime<
| ProjectMetaService
| SessionMetaService | SessionMetaService
| VirtualConversationDatabase | VirtualConversationDatabase
| FileSystem.FileSystem | FileSystem.FileSystem
@@ -65,7 +64,13 @@ export const routes = (app: HonoAppType) =>
app app
// middleware // middleware
.use(configMiddleware) .use(configMiddleware)
.use(async (_c, next) => { .use(async (c, next) => {
await Effect.runPromise(
honoConfigService.setConfig({
...c.get("config"),
}),
);
await next(); await next();
}) })
@@ -86,16 +91,35 @@ export const routes = (app: HonoAppType) =>
}); });
}) })
/**
* ProjectController Routes
*/
.get("/projects", async (c) => { .get("/projects", async (c) => {
const program = Effect.gen(function* () { const response = await effectToResponse(
return yield* projectRepository.getProjects(); c,
}); projectController.getProjects(),
);
const { projects } = await Runtime.runPromise(runtime)(program); return response;
return c.json({ projects });
}) })
.get(
"/projects/:projectId",
zValidator("query", z.object({ cursor: z.string().optional() })),
async (c) => {
const response = await effectToResponse(
c,
projectController
.getProject({
...c.req.param(),
...c.req.valid("query"),
})
.pipe(Effect.provide(runtime)),
);
return response;
},
)
.post( .post(
"/projects", "/projects",
zValidator( zValidator(
@@ -105,369 +129,68 @@ export const routes = (app: HonoAppType) =>
}), }),
), ),
async (c) => { async (c) => {
const { projectPath } = c.req.valid("json"); const response = await effectToResponse(
c,
// No project validation needed - startTask will create a new project projectController.createProject({
// if it doesn't exist when running /init command ...c.req.valid("json"),
const claudeProjectFilePath = }),
computeClaudeProjectFilePath(projectPath); );
const projectId = encodeProjectId(claudeProjectFilePath); return response;
const program = Effect.gen(function* () {
const result = yield* claudeCodeLifeCycleService.startTask({
baseSession: {
cwd: projectPath,
projectId,
sessionId: undefined,
},
config: c.get("config"),
message: "/init",
});
return {
result,
status: 200 as const,
};
});
const result = await Runtime.runPromise(runtime)(program);
if (result.status === 200) {
const { sessionId } =
await result.result.awaitSessionFileCreated();
return c.json({
projectId: result.result.sessionProcess.def.projectId,
sessionId,
});
}
return c.json({ error: "Failed to create project" }, 500);
},
)
.get(
"/directory-browser",
zValidator(
"query",
z.object({
currentPath: z.string().optional(),
}),
),
async (c) => {
const { currentPath } = c.req.valid("query");
const rootPath = "/";
const defaultPath = homedir();
try {
const targetPath = currentPath || defaultPath;
const relativePath = targetPath.startsWith(rootPath)
? targetPath.slice(rootPath.length)
: targetPath;
const result = await getDirectoryListing(rootPath, relativePath);
return c.json(result);
} catch (error) {
console.error("Directory listing error:", error);
if (error instanceof Error) {
return c.json({ error: error.message }, 400);
}
return c.json({ error: "Failed to list directory" }, 500);
}
},
)
.get(
"/projects/:projectId",
zValidator("query", z.object({ cursor: z.string().optional() })),
async (c) => {
const { projectId } = c.req.param();
const { cursor } = c.req.valid("query");
const config = c.get("config");
const program = Effect.gen(function* () {
const { project } =
yield* projectRepository.getProject(projectId);
const { sessions } = yield* sessionRepository.getSessions(
projectId,
{ cursor },
);
let filteredSessions = sessions;
// Filter sessions based on hideNoUserMessageSession setting
if (config.hideNoUserMessageSession) {
filteredSessions = filteredSessions.filter((session) => {
return session.meta.firstCommand !== null;
});
}
// Unify sessions with same title if unifySameTitleSession is enabled
if (config.unifySameTitleSession) {
const sessionMap = new Map<
string,
(typeof filteredSessions)[0]
>();
for (const session of filteredSessions) {
// Generate title for comparison
const title =
session.meta.firstCommand !== null
? (() => {
const cmd = session.meta.firstCommand;
switch (cmd.kind) {
case "command":
return cmd.commandArgs === undefined
? cmd.commandName
: `${cmd.commandName} ${cmd.commandArgs}`;
case "local-command":
return cmd.stdout;
case "text":
return cmd.content;
default:
return session.id;
}
})()
: session.id;
const existingSession = sessionMap.get(title);
if (existingSession) {
// Keep the session with the latest modification date
if (
session.lastModifiedAt &&
existingSession.lastModifiedAt
) {
if (
session.lastModifiedAt > existingSession.lastModifiedAt
) {
sessionMap.set(title, session);
}
} else if (
session.lastModifiedAt &&
!existingSession.lastModifiedAt
) {
sessionMap.set(title, session);
}
// If no modification dates, keep the existing one
} else {
sessionMap.set(title, session);
}
}
filteredSessions = Array.from(sessionMap.values());
}
const hasMore = sessions.length >= 20;
return {
project,
sessions: filteredSessions,
nextCursor: hasMore ? sessions.at(-1)?.id : undefined,
};
});
const result = await Runtime.runPromise(runtime)(program);
return c.json(result);
}, },
) )
.get("/projects/:projectId/latest-session", async (c) => { .get("/projects/:projectId/latest-session", async (c) => {
const { projectId } = c.req.param(); const response = await effectToResponse(
c,
const program = Effect.gen(function* () { projectController
const { sessions } = yield* sessionRepository.getSessions( .getProjectLatestSession({
projectId, ...c.req.param(),
{ maxCount: 1 }, })
); .pipe(Effect.provide(runtime)),
);
return { return response;
latestSession: sessions[0] ?? null,
};
});
const result = await Runtime.runPromise(runtime)(program);
return c.json(result);
}) })
/**
* SessionController Routes
*/
.get("/projects/:projectId/sessions/:sessionId", async (c) => { .get("/projects/:projectId/sessions/:sessionId", async (c) => {
const { projectId, sessionId } = c.req.param(); const response = await effectToResponse(
c,
const program = Effect.gen(function* () { sessionController
const { session } = yield* sessionRepository.getSession( .getSession({ ...c.req.param() })
projectId, .pipe(Effect.provide(runtime)),
sessionId, );
); return response;
return { session };
});
const result = await Runtime.runPromise(runtime)(program);
return c.json(result);
}) })
.get( /**
"/projects/:projectId/file-completion", * GitController Routes
zValidator( */
"query",
z.object({
basePath: z.string().optional().default("/"),
}),
),
async (c) => {
const { projectId } = c.req.param();
const { basePath } = c.req.valid("query");
const program = Effect.gen(function* () {
const { project } =
yield* projectRepository.getProject(projectId);
if (project.meta.projectPath === null) {
return {
error: "Project path not found",
status: 400 as const,
};
}
const projectPath = project.meta.projectPath;
try {
const result = yield* Effect.promise(() =>
getFileCompletion(projectPath, basePath),
);
return { data: result, status: 200 as const };
} catch (error) {
console.error("File completion error:", error);
return {
error: "Failed to get file completion",
status: 500 as const,
};
}
});
const result = await Runtime.runPromise(runtime)(program);
if (result.status === 200) {
return c.json(result.data);
}
return c.json({ error: result.error }, result.status);
},
)
.get("/projects/:projectId/claude-commands", async (c) => {
const { projectId } = c.req.param();
const program = Effect.gen(function* () {
const { project } = yield* projectRepository.getProject(projectId);
const [globalCommands, projectCommands] = yield* Effect.promise(
() =>
Promise.allSettled([
readdir(claudeCommandsDirPath, {
withFileTypes: true,
}).then((dirents) =>
dirents
.filter((d) => d.isFile() && d.name.endsWith(".md"))
.map((d) => d.name.replace(/\.md$/, "")),
),
project.meta.projectPath !== null
? readdir(
resolve(
project.meta.projectPath,
".claude",
"commands",
),
{
withFileTypes: true,
},
).then((dirents) =>
dirents
.filter((d) => d.isFile() && d.name.endsWith(".md"))
.map((d) => d.name.replace(/\.md$/, "")),
)
: [],
]),
);
return {
globalCommands:
globalCommands.status === "fulfilled"
? globalCommands.value
: [],
projectCommands:
projectCommands.status === "fulfilled"
? projectCommands.value
: [],
defaultCommands: ["init", "compact"],
};
});
const result = await Runtime.runPromise(runtime)(program);
return c.json(result);
})
.get("/projects/:projectId/git/branches", async (c) => { .get("/projects/:projectId/git/branches", async (c) => {
const { projectId } = c.req.param(); const response = await effectToResponse(
c,
const program = Effect.gen(function* () { gitController
const { project } = yield* projectRepository.getProject(projectId); .getGitBranches({
...c.req.param(),
if (project.meta.projectPath === null) { })
return { error: "Project path not found", status: 400 as const }; .pipe(Effect.provide(runtime)),
} );
return response;
const projectPath = project.meta.projectPath;
try {
const result = yield* Effect.promise(() =>
getBranches(projectPath),
);
return { data: result, status: 200 as const };
} catch (error) {
console.error("Get branches error:", error);
if (error instanceof Error) {
return { error: error.message, status: 400 as const };
}
return { error: "Failed to get branches", status: 500 as const };
}
});
const result = await Runtime.runPromise(runtime)(program);
if (result.status === 200) {
return c.json(result.data);
}
return c.json({ error: result.error }, result.status);
}) })
.get("/projects/:projectId/git/commits", async (c) => { .get("/projects/:projectId/git/commits", async (c) => {
const { projectId } = c.req.param(); const response = await effectToResponse(
c,
const program = Effect.gen(function* () { gitController
const { project } = yield* projectRepository.getProject(projectId); .getGitCommits({
...c.req.param(),
if (project.meta.projectPath === null) { })
return { error: "Project path not found", status: 400 as const }; .pipe(Effect.provide(runtime)),
} );
return response;
const projectPath = project.meta.projectPath;
try {
const result = yield* Effect.promise(() =>
getCommits(projectPath),
);
return { data: result, status: 200 as const };
} catch (error) {
console.error("Get commits error:", error);
if (error instanceof Error) {
return { error: error.message, status: 400 as const };
}
return { error: "Failed to get commits", status: 500 as const };
}
});
const result = await Runtime.runPromise(runtime)(program);
if (result.status === 200) {
return c.json(result.data);
}
return c.json({ error: result.error }, result.status);
}) })
.post( .post(
@@ -480,64 +203,55 @@ export const routes = (app: HonoAppType) =>
}), }),
), ),
async (c) => { async (c) => {
const { projectId } = c.req.param(); const response = await effectToResponse(
const { fromRef, toRef } = c.req.valid("json"); c,
gitController
const program = Effect.gen(function* () { .getGitDiff({
const { project } = ...c.req.param(),
yield* projectRepository.getProject(projectId); ...c.req.valid("json"),
})
try { .pipe(Effect.provide(runtime)),
if (project.meta.projectPath === null) { );
return { return response;
error: "Project path not found",
status: 400 as const,
};
}
const projectPath = project.meta.projectPath;
const result = yield* Effect.promise(() =>
getDiff(projectPath, fromRef, toRef),
);
return { data: result, status: 200 as const };
} catch (error) {
console.error("Get diff error:", error);
if (error instanceof Error) {
return { error: error.message, status: 400 as const };
}
return { error: "Failed to get diff", status: 500 as const };
}
});
const result = await Runtime.runPromise(runtime)(program);
if (result.status === 200) {
return c.json(result.data);
}
return c.json({ error: result.error }, result.status);
}, },
) )
.get("/projects/:projectId/mcp/list", async (c) => { /**
const { projectId } = c.req.param(); * ClaudeCodeController Routes
const { servers } = await getMcpList(projectId); */
return c.json({ servers });
.get("/projects/:projectId/claude-commands", async (c) => {
const response = await effectToResponse(
c,
claudeCodeController.getClaudeCommands({
...c.req.param(),
}),
);
return response;
}) })
.get("/cc/session-processes", async (c) => { .get("/projects/:projectId/mcp/list", async (c) => {
const publicProcesses = await Runtime.runPromise(runtime)( const response = await effectToResponse(
claudeCodeLifeCycleService.getPublicSessionProcesses(), c,
claudeCodeController
.getMcpListRoute({
...c.req.param(),
})
.pipe(Effect.provide(runtime)),
); );
return c.json({ return response;
processes: publicProcesses.map( })
(process): PublicSessionProcess => ({
id: process.def.sessionProcessId, /**
projectId: process.def.projectId, * ClaudeCodeSessionProcessController Routes
sessionId: process.sessionId, */
status: process.type === "paused" ? "paused" : "running",
}), .get("/cc/session-processes", async (c) => {
), const response = await effectToResponse(
}); c,
claudeCodeSessionProcessController.getSessionProcesses(),
);
return response;
}) })
// new or resume // new or resume
@@ -552,51 +266,13 @@ export const routes = (app: HonoAppType) =>
}), }),
), ),
async (c) => { async (c) => {
const { projectId, message, baseSessionId } = c.req.valid("json"); const response = await effectToResponse(
c,
const program = Effect.gen(function* () { claudeCodeSessionProcessController.createSessionProcess(
const { project } = c.req.valid("json"),
yield* projectRepository.getProject(projectId); ),
);
if (project.meta.projectPath === null) { return response;
return {
error: "Project path not found",
status: 400 as const,
};
}
const result = yield* claudeCodeLifeCycleService.startTask({
baseSession: {
cwd: project.meta.projectPath,
projectId,
sessionId: baseSessionId,
},
config: c.get("config"),
message,
});
return {
result,
status: 200 as const,
};
});
const result = await Runtime.runPromise(runtime)(program);
if (result.status === 200) {
const { sessionId } =
await result.result.awaitSessionInitialized();
return c.json({
sessionProcess: {
id: result.result.sessionProcess.def.sessionProcessId,
projectId: result.result.sessionProcess.def.projectId,
sessionId,
},
});
}
return c.json({ error: result.error }, result.status);
}, },
) )
@@ -612,45 +288,16 @@ export const routes = (app: HonoAppType) =>
}), }),
), ),
async (c) => { async (c) => {
const { sessionProcessId } = c.req.param(); const response = await effectToResponse(
const { projectId, continueMessage, baseSessionId } = c,
c.req.valid("json"); claudeCodeSessionProcessController
.continueSessionProcess({
const program = Effect.gen(function* () { ...c.req.param(),
const { project } = ...c.req.valid("json"),
yield* projectRepository.getProject(projectId); })
.pipe(Effect.provide(runtime)),
if (project.meta.projectPath === null) { );
return { return response;
error: "Project path not found",
status: 400 as const,
};
}
const result = yield* claudeCodeLifeCycleService.continueTask({
sessionProcessId,
message: continueMessage,
baseSessionId,
});
return {
data: {
sessionProcess: {
id: result.sessionProcess.def.sessionProcessId,
projectId: result.sessionProcess.def.projectId,
sessionId: baseSessionId,
},
},
status: 200 as const,
};
});
const result = await Runtime.runPromise(runtime)(program);
if (result.status === 200) {
return c.json(result.data);
}
return c.json({ error: result.error }, result.status);
}, },
) )
@@ -666,6 +313,10 @@ export const routes = (app: HonoAppType) =>
}, },
) )
/**
* ClaudeCodePermissionController Routes
*/
.post( .post(
"/cc/permission-response", "/cc/permission-response",
zValidator( zValidator(
@@ -676,135 +327,79 @@ export const routes = (app: HonoAppType) =>
}), }),
), ),
async (c) => { async (c) => {
const permissionResponse = c.req.valid("json"); const response = await effectToResponse(
Effect.runFork( c,
claudeCodePermissionService.respondToPermissionRequest( claudeCodePermissionController.permissionResponse({
permissionResponse, permissionResponse: c.req.valid("json"),
), }),
); );
return c.json({ message: "Permission response received" }); return response;
}, },
) )
/**
* SSEController Routes
*/
.get("/sse", async (c) => { .get("/sse", async (c) => {
return streamSSE( return streamSSE(
c, c,
async (rawStream) => { async (rawStream) => {
const handleSSE = Effect.gen(function* () { await Runtime.runPromise(runtime)(
const typeSafeSSE = yield* TypeSafeSSE; sseController
.handleSSE(rawStream)
// Send connect event .pipe(Effect.provide(TypeSafeSSE.make(rawStream))),
yield* typeSafeSSE.writeSSE("connect", {
timestamp: new Date().toISOString(),
});
const onHeartbeat = () => {
Effect.runFork(
typeSafeSSE.writeSSE("heartbeat", {
timestamp: new Date().toISOString(),
}),
);
};
const onSessionListChanged = (
event: InternalEventDeclaration["sessionListChanged"],
) => {
Effect.runFork(
typeSafeSSE.writeSSE("sessionListChanged", {
projectId: event.projectId,
}),
);
};
const onSessionChanged = (
event: InternalEventDeclaration["sessionChanged"],
) => {
Effect.runFork(
typeSafeSSE.writeSSE("sessionChanged", {
projectId: event.projectId,
sessionId: event.sessionId,
}),
);
};
const onSessionProcessChanged = (
event: InternalEventDeclaration["sessionProcessChanged"],
) => {
Effect.runFork(
typeSafeSSE.writeSSE("sessionProcessChanged", {
processes: event.processes,
}),
);
};
const onPermissionRequested = (
event: InternalEventDeclaration["permissionRequested"],
) => {
Effect.runFork(
typeSafeSSE.writeSSE("permissionRequested", {
permissionRequest: event.permissionRequest,
}),
);
};
yield* eventBus.on("sessionListChanged", onSessionListChanged);
yield* eventBus.on("sessionChanged", onSessionChanged);
yield* eventBus.on(
"sessionProcessChanged",
onSessionProcessChanged,
);
yield* eventBus.on("heartbeat", onHeartbeat);
yield* eventBus.on(
"permissionRequested",
onPermissionRequested,
);
const { connectionPromise } = adaptInternalEventToSSE(
rawStream,
{
timeout: 5 /* min */ * 60 /* sec */ * 1000,
cleanUp: async () => {
await Effect.runPromise(
Effect.gen(function* () {
yield* eventBus.off(
"sessionListChanged",
onSessionListChanged,
);
yield* eventBus.off(
"sessionChanged",
onSessionChanged,
);
yield* eventBus.off(
"sessionProcessChanged",
onSessionProcessChanged,
);
yield* eventBus.off("heartbeat", onHeartbeat);
yield* eventBus.off(
"permissionRequested",
onPermissionRequested,
);
}),
);
},
},
);
return {
connectionPromise,
};
});
const { connectionPromise } = await Runtime.runPromise(runtime)(
handleSSE.pipe(Effect.provide(TypeSafeSSE.make(rawStream))),
); );
await connectionPromise;
}, },
async (err) => { async (err) => {
console.error("Streaming error:", err); console.error("Streaming error:", err);
}, },
); );
}) })
/**
* FileSystemController Routes
*/
.get(
"/fs/file-completion",
zValidator(
"query",
z.object({
projectId: z.string(),
basePath: z.string().optional().default("/"),
}),
),
async (c) => {
const response = await effectToResponse(
c,
fileSystemController.getFileCompletionRoute({
...c.req.valid("query"),
}),
);
return response;
},
)
.get(
"/fs/directory-browser",
zValidator(
"query",
z.object({
currentPath: z.string().optional(),
}),
),
async (c) => {
const response = await effectToResponse(
c,
fileSystemController.getDirectoryListingRoute({
...c.req.valid("query"),
}),
);
return response;
},
)
); );
}); });

View File

@@ -0,0 +1,43 @@
import { Effect } from "effect";
import type { Context, Input } from "hono";
import type { ContentfulStatusCode } from "hono/utils/http-status";
import type { HonoContext } from "../../hono/app";
export type ControllerResponse = {
status: ContentfulStatusCode;
response: object;
};
declare const dummyCtx: Context<HonoContext, string, Input>;
const dummyJson = <S extends ContentfulStatusCode, T extends object>(
s: S,
t: T,
) => dummyCtx.json(t, s);
type ResponseType<
S extends ContentfulStatusCode,
T extends object,
> = ReturnType<typeof dummyJson<S, T>>;
export const effectToResponse = async <
const P extends string,
const I extends Input,
const CR extends ControllerResponse,
const E,
Ret = CR extends infer I
? I extends { status: infer S; response: infer T }
? S extends ContentfulStatusCode
? T extends object
? ResponseType<S, T>
: never
: never
: never
: never,
>(
ctx: Context<HonoContext, P, I>,
effect: Effect.Effect<CR, E, never>,
) => {
const result = await Effect.runPromise(effect);
const result2 = ctx.json(result.response, result.status);
return result2 as Ret;
};