Last modified:{" "}
- {project.meta.lastModifiedAt
- ? new Date(project.meta.lastModifiedAt).toLocaleDateString()
+ {project.lastModifiedAt
+ ? new Date(project.lastModifiedAt).toLocaleDateString()
: ""}
diff --git a/src/lib/api/queries.ts b/src/lib/api/queries.ts
index 297921a..3ee0d2a 100644
--- a/src/lib/api/queries.ts
+++ b/src/lib/api/queries.ts
@@ -16,12 +16,15 @@ export const projectListQuery = {
},
} as const;
-export const projectDetailQuery = (projectId: string) =>
+export const projectDetailQuery = (projectId: string, cursor?: string) =>
({
- queryKey: ["projects", projectId],
+ queryKey: cursor
+ ? ["projects", projectId, cursor]
+ : ["projects", projectId],
queryFn: async () => {
const response = await honoClient.api.projects[":projectId"].$get({
param: { projectId },
+ query: { cursor },
});
if (!response.ok) {
diff --git a/src/server/hono/initialize.ts b/src/server/hono/initialize.ts
index 2b32e02..5e992b2 100644
--- a/src/server/hono/initialize.ts
+++ b/src/server/hono/initialize.ts
@@ -1,5 +1,8 @@
+import prexit from "prexit";
+import { claudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController";
import { eventBus } from "../service/events/EventBus";
import { fileWatcher } from "../service/events/fileWatcher";
+import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration";
import type { ProjectRepository } from "../service/project/ProjectRepository";
import { projectMetaStorage } from "../service/project/projectMetaStorage";
import type { SessionRepository } from "../service/session/SessionRepository";
@@ -11,14 +14,18 @@ export const initialize = async (deps: {
}): Promise => {
fileWatcher.startWatching();
- setInterval(() => {
+ const intervalId = setInterval(() => {
eventBus.emit("heartbeat", {});
}, 10 * 1000);
- eventBus.on("sessionChanged", (event) => {
+ const onSessionChanged = (
+ event: InternalEventDeclaration["sessionChanged"],
+ ) => {
projectMetaStorage.invalidateProject(event.projectId);
sessionMetaStorage.invalidateSession(event.projectId, event.sessionId);
- });
+ };
+
+ eventBus.on("sessionChanged", onSessionChanged);
try {
console.log("Initializing projects cache");
@@ -38,4 +45,11 @@ export const initialize = async (deps: {
} catch {
// do nothing
}
+
+ prexit(() => {
+ clearInterval(intervalId);
+ eventBus.off("sessionChanged", onSessionChanged);
+ fileWatcher.stop();
+ claudeCodeTaskController.abortAllTasks();
+ });
};
diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts
index c437935..bec1951 100644
--- a/src/server/hono/route.ts
+++ b/src/server/hono/route.ts
@@ -4,9 +4,9 @@ import { zValidator } from "@hono/zod-validator";
import { setCookie } from "hono/cookie";
import { streamSSE } from "hono/streaming";
import { z } from "zod";
-import { type Config, configSchema } from "../config/config";
+import { configSchema } from "../config/config";
import { env } from "../lib/env";
-import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController";
+import { claudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController";
import type { SerializableAliveTask } from "../service/claude-code/types";
import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE";
import { eventBus } from "../service/events/EventBus";
@@ -25,16 +25,6 @@ import { initialize } from "./initialize";
import { configMiddleware } from "./middleware/config.middleware";
export const routes = async (app: HonoAppType) => {
- let taskController: ClaudeCodeTaskController | null = null;
- const getTaskController = (config: Config) => {
- if (!taskController) {
- taskController = new ClaudeCodeTaskController(config);
- } else {
- taskController.updateConfig(config);
- }
- return taskController;
- };
-
const sessionRepository = new SessionRepository();
const projectRepository = new ProjectRepository();
@@ -49,6 +39,10 @@ export const routes = async (app: HonoAppType) => {
app
// middleware
.use(configMiddleware)
+ .use(async (c, next) => {
+ claudeCodeTaskController.updateConfig(c.get("config"));
+ await next();
+ })
// routes
.get("/config", async (c) => {
@@ -72,85 +66,93 @@ export const routes = async (app: HonoAppType) => {
return c.json({ projects });
})
- .get("/projects/:projectId", async (c) => {
- const { projectId } = c.req.param();
+ .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 [{ project }, { sessions }] = await Promise.all([
- projectRepository.getProject(projectId),
- sessionRepository.getSessions(projectId).then(({ sessions }) => {
- let filteredSessions = sessions;
+ const [{ project }, { sessions, nextCursor }] = await Promise.all([
+ projectRepository.getProject(projectId),
+ sessionRepository
+ .getSessions(projectId, { cursor })
+ .then(({ sessions }) => {
+ let filteredSessions = sessions;
- // Filter sessions based on hideNoUserMessageSession setting
- if (c.get("config").hideNoUserMessageSession) {
- filteredSessions = filteredSessions.filter((session) => {
- return session.meta.firstCommand !== null;
- });
- }
+ // Filter sessions based on hideNoUserMessageSession setting
+ if (c.get("config").hideNoUserMessageSession) {
+ filteredSessions = filteredSessions.filter((session) => {
+ return session.meta.firstCommand !== null;
+ });
+ }
- // Unify sessions with same title if unifySameTitleSession is enabled
- if (c.get("config").unifySameTitleSession) {
- const sessionMap = new Map<
- string,
- (typeof filteredSessions)[0]
- >();
+ // Unify sessions with same title if unifySameTitleSession is enabled
+ if (c.get("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;
+ 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);
}
- })()
- : session.id;
-
- const existingSession = sessionMap.get(title);
- if (existingSession) {
- // Keep the session with the latest modification date
- if (
- session.meta.lastModifiedAt &&
- existingSession.meta.lastModifiedAt
- ) {
- if (
- new Date(session.meta.lastModifiedAt) >
- new Date(existingSession.meta.lastModifiedAt)
- ) {
+ } else if (
+ session.lastModifiedAt &&
+ !existingSession.lastModifiedAt
+ ) {
+ sessionMap.set(title, session);
+ }
+ // If no modification dates, keep the existing one
+ } else {
sessionMap.set(title, session);
}
- } else if (
- session.meta.lastModifiedAt &&
- !existingSession.meta.lastModifiedAt
- ) {
- sessionMap.set(title, session);
}
- // If no modification dates, keep the existing one
- } else {
- sessionMap.set(title, session);
+
+ filteredSessions = Array.from(sessionMap.values());
}
- }
- filteredSessions = Array.from(sessionMap.values());
- }
+ return {
+ sessions: filteredSessions,
+ nextCursor: sessions.at(-1)?.id,
+ };
+ }),
+ ] as const);
- return {
- sessions: filteredSessions,
- };
- }),
- ] as const);
-
- return c.json({ project, sessions });
- })
+ return c.json({ project, sessions, nextCursor });
+ },
+ )
.get("/projects/:projectId/sessions/:sessionId", async (c) => {
const { projectId, sessionId } = c.req.param();
@@ -324,9 +326,7 @@ export const routes = async (app: HonoAppType) => {
return c.json({ error: "Project path not found" }, 400);
}
- const task = await getTaskController(
- c.get("config"),
- ).startOrContinueTask(
+ const task = await claudeCodeTaskController.startOrContinueTask(
{
projectId,
cwd: project.meta.projectPath,
@@ -358,9 +358,7 @@ export const routes = async (app: HonoAppType) => {
return c.json({ error: "Project path not found" }, 400);
}
- const task = await getTaskController(
- c.get("config"),
- ).startOrContinueTask(
+ const task = await claudeCodeTaskController.startOrContinueTask(
{
projectId,
sessionId,
@@ -378,7 +376,7 @@ export const routes = async (app: HonoAppType) => {
.get("/tasks/alive", async (c) => {
return c.json({
- aliveTasks: getTaskController(c.get("config")).aliveTasks.map(
+ aliveTasks: claudeCodeTaskController.aliveTasks.map(
(task): SerializableAliveTask => ({
id: task.id,
status: task.status,
@@ -393,7 +391,7 @@ export const routes = async (app: HonoAppType) => {
zValidator("json", z.object({ sessionId: z.string() })),
async (c) => {
const { sessionId } = c.req.valid("json");
- getTaskController(c.get("config")).abortTask(sessionId);
+ claudeCodeTaskController.abortTask(sessionId);
return c.json({ message: "Task aborted" });
},
)
@@ -409,7 +407,7 @@ export const routes = async (app: HonoAppType) => {
),
async (c) => {
const permissionResponse = c.req.valid("json");
- getTaskController(c.get("config")).respondToPermissionRequest(
+ claudeCodeTaskController.respondToPermissionRequest(
permissionResponse,
);
return c.json({ message: "Permission response received" });
diff --git a/src/server/service/claude-code/ClaudeCodeExecutor.ts b/src/server/service/claude-code/ClaudeCodeExecutor.ts
index b9de1b2..55285b3 100644
--- a/src/server/service/claude-code/ClaudeCodeExecutor.ts
+++ b/src/server/service/claude-code/ClaudeCodeExecutor.ts
@@ -41,14 +41,18 @@ export class ClaudeCodeExecutor {
}
public query(prompt: CCQueryPrompt, options: CCQueryOptions) {
- const { canUseTool, ...baseOptions } = options;
+ const { canUseTool, permissionMode, ...baseOptions } = options;
return query({
prompt,
options: {
pathToClaudeCodeExecutable: this.pathToClaudeCodeExecutable,
...baseOptions,
- ...(this.availableFeatures.canUseTool ? { canUseTool } : {}),
+ ...(this.availableFeatures.canUseTool
+ ? { canUseTool, permissionMode }
+ : {
+ permissionMode: "bypassPermissions",
+ }),
},
});
}
diff --git a/src/server/service/claude-code/ClaudeCodeTaskController.ts b/src/server/service/claude-code/ClaudeCodeTaskController.ts
index f7ad96c..cb630a7 100644
--- a/src/server/service/claude-code/ClaudeCodeTaskController.ts
+++ b/src/server/service/claude-code/ClaudeCodeTaskController.ts
@@ -1,4 +1,3 @@
-import prexit from "prexit";
import { ulid } from "ulid";
import type { Config } from "../../config/config";
import { eventBus } from "../events/EventBus";
@@ -14,22 +13,21 @@ import type {
RunningClaudeCodeTask,
} from "./types";
-export class ClaudeCodeTaskController {
+class ClaudeCodeTaskController {
private claudeCode: ClaudeCodeExecutor;
private tasks: ClaudeCodeTask[] = [];
private config: Config;
private pendingPermissionRequests: Map = new Map();
private permissionResponses: Map = new Map();
- constructor(config: Config) {
+ constructor() {
this.claudeCode = new ClaudeCodeExecutor();
- this.config = config;
-
- prexit(() => {
- this.aliveTasks.forEach((task) => {
- task.abortController.abort();
- });
- });
+ this.config = {
+ hideNoUserMessageSession: false,
+ unifySameTitleSession: false,
+ enterKeyBehavior: "shift-enter-send",
+ permissionMode: "default",
+ };
}
public updateConfig(config: Config) {
@@ -292,9 +290,9 @@ export class ClaudeCodeTaskController {
],
meta: {
firstCommand: null,
- lastModifiedAt: new Date().toISOString(),
messageCount: 0,
},
+ lastModifiedAt: new Date(),
});
}
@@ -407,6 +405,12 @@ export class ClaudeCodeTaskController {
});
}
+ public abortAllTasks() {
+ for (const task of this.aliveTasks) {
+ task.abortController.abort();
+ }
+ }
+
private upsertExistingTask(task: ClaudeCodeTask) {
const target = this.tasks.find((t) => t.id === task.id);
@@ -425,3 +429,5 @@ export class ClaudeCodeTaskController {
}
}
}
+
+export const claudeCodeTaskController = new ClaudeCodeTaskController();
diff --git a/src/server/service/project/ProjectRepository.ts b/src/server/service/project/ProjectRepository.ts
index d6d7055..df87935 100644
--- a/src/server/service/project/ProjectRepository.ts
+++ b/src/server/service/project/ProjectRepository.ts
@@ -1,4 +1,4 @@
-import { existsSync } from "node:fs";
+import { existsSync, statSync } from "node:fs";
import { access, constants, readdir } from "node:fs/promises";
import { resolve } from "node:path";
import { claudeProjectsDirPath } from "../paths";
@@ -19,6 +19,7 @@ export class ProjectRepository {
project: {
id: projectId,
claudeProjectPath: fullPath,
+ lastModifiedAt: statSync(fullPath).mtime,
meta,
},
};
@@ -50,6 +51,7 @@ export class ProjectRepository {
return {
id,
claudeProjectPath: fullPath,
+ lastModifiedAt: statSync(fullPath).mtime,
meta: await projectMetaStorage.getProjectMeta(id),
};
}),
@@ -58,12 +60,8 @@ export class ProjectRepository {
return {
projects: projects.sort((a, b) => {
return (
- (b.meta.lastModifiedAt
- ? new Date(b.meta.lastModifiedAt).getTime()
- : 0) -
- (a.meta.lastModifiedAt
- ? new Date(a.meta.lastModifiedAt).getTime()
- : 0)
+ (b.lastModifiedAt ? b.lastModifiedAt.getTime() : 0) -
+ (a.lastModifiedAt ? a.lastModifiedAt.getTime() : 0)
);
}),
};
diff --git a/src/server/service/project/projectMetaStorage.ts b/src/server/service/project/projectMetaStorage.ts
index e12814c..85bd698 100644
--- a/src/server/service/project/projectMetaStorage.ts
+++ b/src/server/service/project/projectMetaStorage.ts
@@ -37,8 +37,6 @@ class ProjectMetaStorage {
return a.stats.mtime.getTime() - b.stats.mtime.getTime();
});
- const lastModifiedUnixTime = files.at(-1)?.stats.mtime.getTime();
-
let projectPath: string | null = null;
for (const file of files) {
@@ -54,9 +52,6 @@ class ProjectMetaStorage {
const projectMeta: ProjectMeta = {
projectName: projectPath ? basename(projectPath) : null,
projectPath,
- lastModifiedAt: lastModifiedUnixTime
- ? new Date(lastModifiedUnixTime).toISOString()
- : null,
sessionCount: files.length,
};
diff --git a/src/server/service/schema.ts b/src/server/service/schema.ts
index 3060cf5..ef2fd37 100644
--- a/src/server/service/schema.ts
+++ b/src/server/service/schema.ts
@@ -4,12 +4,10 @@ import { parsedCommandSchema } from "./parseCommandXml";
export const projectMetaSchema = z.object({
projectName: z.string().nullable(),
projectPath: z.string().nullable(),
- lastModifiedAt: z.string().nullable(),
sessionCount: z.number(),
});
export const sessionMetaSchema = z.object({
messageCount: z.number(),
firstCommand: parsedCommandSchema.nullable(),
- lastModifiedAt: z.string().nullable(),
});
diff --git a/src/server/service/session/PredictSessionsDatabase.ts b/src/server/service/session/PredictSessionsDatabase.ts
index 161b273..8dbca71 100644
--- a/src/server/service/session/PredictSessionsDatabase.ts
+++ b/src/server/service/session/PredictSessionsDatabase.ts
@@ -14,12 +14,8 @@ class PredictSessionsDatabase {
);
}
- public getPredictSession(sessionId: string): SessionDetail {
- const session = this.storage.get(sessionId);
- if (!session) {
- throw new Error("Session not found");
- }
- return session;
+ public getPredictSession(sessionId: string): SessionDetail | null {
+ return this.storage.get(sessionId) ?? null;
}
public createPredictSession(session: SessionDetail) {
diff --git a/src/server/service/session/SessionRepository.ts b/src/server/service/session/SessionRepository.ts
index 2fc9c28..7374cfd 100644
--- a/src/server/service/session/SessionRepository.ts
+++ b/src/server/service/session/SessionRepository.ts
@@ -1,4 +1,4 @@
-import { existsSync } from "node:fs";
+import { existsSync, statSync } from "node:fs";
import { readdir, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { parseJsonl } from "../parseJsonl";
@@ -8,11 +8,6 @@ import { decodeSessionId, encodeSessionId } from "./id";
import { predictSessionsDatabase } from "./PredictSessionsDatabase";
import { sessionMetaStorage } from "./sessionMetaStorage";
-const getTime = (date: string | null) => {
- if (date === null) return 0;
- return new Date(date).getTime();
-};
-
export class SessionRepository {
public async getSession(
projectId: string,
@@ -33,14 +28,16 @@ export class SessionRepository {
throw new Error("Session not found");
}
const content = await readFile(sessionPath, "utf-8");
+ const allLines = content.split("\n").filter((line) => line.trim());
- const conversations = parseJsonl(content);
+ const conversations = parseJsonl(allLines.join("\n"));
const sessionDetail: SessionDetail = {
id: sessionId,
jsonlFilePath: sessionPath,
meta: await sessionMetaStorage.getSessionMeta(projectId, sessionId),
conversations,
+ lastModifiedAt: statSync(sessionPath).mtime,
};
return {
@@ -50,36 +47,88 @@ export class SessionRepository {
public async getSessions(
projectId: string,
+ options?: {
+ maxCount?: number;
+ cursor?: string;
+ },
): Promise<{ sessions: Session[] }> {
+ const { maxCount = 20, cursor } = options ?? {};
+
try {
const claudeProjectPath = decodeProjectId(projectId);
const dirents = await readdir(claudeProjectPath, { withFileTypes: true });
const sessions = await Promise.all(
dirents
.filter((d) => d.isFile() && d.name.endsWith(".jsonl"))
- .map(async (d) => ({
- id: encodeSessionId(resolve(claudeProjectPath, d.name)),
- jsonlFilePath: resolve(claudeProjectPath, d.name),
- meta: await sessionMetaStorage.getSessionMeta(
- projectId,
- encodeSessionId(resolve(claudeProjectPath, d.name)),
- ),
- })),
+ .map(async (d) => {
+ const sessionId = encodeSessionId(
+ resolve(claudeProjectPath, d.name),
+ );
+ const stats = statSync(resolve(claudeProjectPath, d.name));
+
+ return {
+ id: sessionId,
+ jsonlFilePath: resolve(claudeProjectPath, d.name),
+ lastModifiedAt: stats.mtime,
+ };
+ }),
+ ).then((fetched) =>
+ fetched.sort(
+ (a, b) => b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime(),
+ ),
);
- const sessionMap = new Map(
- sessions.map((session) => [session.id, session]),
+
+ const sessionMap = new Map(
+ sessions.map((session) => [session.id, session] as const),
);
+ const index =
+ cursor !== undefined
+ ? sessions.findIndex((session) => session.id === cursor)
+ : -1;
+
+ if (index !== -1) {
+ return {
+ sessions: await Promise.all(
+ sessions
+ .slice(index + 1, Math.min(index + 1 + maxCount, sessions.length))
+ .map(async (item) => {
+ return {
+ ...item,
+ meta: await sessionMetaStorage.getSessionMeta(
+ projectId,
+ item.id,
+ ),
+ };
+ }),
+ ),
+ };
+ }
+
const predictSessions = predictSessionsDatabase
.getPredictSessions(projectId)
- .filter((session) => !sessionMap.has(session.id));
+ .filter((session) => !sessionMap.has(session.id))
+ .sort((a, b) => {
+ return b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime();
+ });
return {
- sessions: [...predictSessions, ...sessions].sort((a, b) => {
- return (
- getTime(b.meta.lastModifiedAt) - getTime(a.meta.lastModifiedAt)
- );
- }),
+ sessions: [
+ ...predictSessions,
+ ...(await Promise.all(
+ sessions
+ .slice(0, Math.min(maxCount, sessions.length))
+ .map(async (item) => {
+ return {
+ ...item,
+ meta: await sessionMetaStorage.getSessionMeta(
+ projectId,
+ item.id,
+ ),
+ };
+ }),
+ )),
+ ],
};
} catch (error) {
console.warn(`Failed to read sessions for project ${projectId}:`, error);
diff --git a/src/server/service/session/sessionMetaStorage.ts b/src/server/service/session/sessionMetaStorage.ts
index 975db76..00bbb47 100644
--- a/src/server/service/session/sessionMetaStorage.ts
+++ b/src/server/service/session/sessionMetaStorage.ts
@@ -1,4 +1,3 @@
-import { statSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { FileCacheStorage } from "../../lib/storage/FileCacheStorage";
import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage";
@@ -38,18 +37,12 @@ class SessionMetaStorage {
const sessionPath = decodeSessionId(projectId, sessionId);
- const stats = statSync(sessionPath);
- const lastModifiedUnixTime = stats.mtime.getTime();
-
const content = await readFile(sessionPath, "utf-8");
const lines = content.split("\n");
const sessionMeta: SessionMeta = {
messageCount: lines.length,
firstCommand: this.getFirstCommand(sessionPath, lines),
- lastModifiedAt: lastModifiedUnixTime
- ? new Date(lastModifiedUnixTime).toISOString()
- : null,
};
this.sessionMetaCache.save(sessionId, sessionMeta);
diff --git a/src/server/service/types.ts b/src/server/service/types.ts
index b5ca5d9..2668e30 100644
--- a/src/server/service/types.ts
+++ b/src/server/service/types.ts
@@ -5,6 +5,7 @@ import type { projectMetaSchema, sessionMetaSchema } from "./schema";
export type Project = {
id: string;
claudeProjectPath: string;
+ lastModifiedAt: Date;
meta: ProjectMeta;
};
@@ -13,6 +14,7 @@ export type ProjectMeta = z.infer;
export type Session = {
id: string;
jsonlFilePath: string;
+ lastModifiedAt: Date;
meta: SessionMeta;
};
From f34943c9cc846dc59263005e50380c57c22bbe18 Mon Sep 17 00:00:00 2001
From: d-kimsuon
Date: Wed, 15 Oct 2025 03:47:09 +0900
Subject: [PATCH 05/21] chore: using refetchQueries instead of
invalidateQueries
---
src/app/projects/[projectId]/components/ProjectPage.tsx | 2 +-
src/components/SettingsControls.tsx | 6 +++---
src/lib/api/queries.ts | 4 +---
3 files changed, 5 insertions(+), 7 deletions(-)
diff --git a/src/app/projects/[projectId]/components/ProjectPage.tsx b/src/app/projects/[projectId]/components/ProjectPage.tsx
index 417f0de..0d4077d 100644
--- a/src/app/projects/[projectId]/components/ProjectPage.tsx
+++ b/src/app/projects/[projectId]/components/ProjectPage.tsx
@@ -47,7 +47,7 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
// biome-ignore lint/correctness/useExhaustiveDependencies: invalidate when config changed
useEffect(() => {
- void queryClient.invalidateQueries({
+ void queryClient.refetchQueries({
queryKey: ["projects", projectId],
});
}, [config.hideNoUserMessageSession, config.unifySameTitleSession]);
diff --git a/src/components/SettingsControls.tsx b/src/components/SettingsControls.tsx
index 415eda2..b9c849d 100644
--- a/src/components/SettingsControls.tsx
+++ b/src/components/SettingsControls.tsx
@@ -37,13 +37,13 @@ export const SettingsControls: FC = ({
const queryClient = useQueryClient();
const onConfigChanged = useCallback(async () => {
- await queryClient.invalidateQueries({
+ await queryClient.refetchQueries({
queryKey: configQuery.queryKey,
});
- await queryClient.invalidateQueries({
+ await queryClient.refetchQueries({
queryKey: projectListQuery.queryKey,
});
- void queryClient.invalidateQueries({
+ void queryClient.refetchQueries({
queryKey: projectDetailQuery(openingProjectId).queryKey,
});
}, [queryClient, openingProjectId]);
diff --git a/src/lib/api/queries.ts b/src/lib/api/queries.ts
index 3ee0d2a..5d754d5 100644
--- a/src/lib/api/queries.ts
+++ b/src/lib/api/queries.ts
@@ -18,9 +18,7 @@ export const projectListQuery = {
export const projectDetailQuery = (projectId: string, cursor?: string) =>
({
- queryKey: cursor
- ? ["projects", projectId, cursor]
- : ["projects", projectId],
+ queryKey: ["projects", projectId],
queryFn: async () => {
const response = await honoClient.api.projects[":projectId"].$get({
param: { projectId },
From 7c05168e4e7710e62ba11855a3e2aafac4eef6a6 Mon Sep 17 00:00:00 2001
From: d-kimsuon
Date: Wed, 15 Oct 2025 12:59:05 +0900
Subject: [PATCH 06/21] fix: fix bug conversation log syncronization
---
scripts/build.sh | 4 +++
src/app/components/SSEEventListeners.tsx | 7 +---
.../projects/[projectId]/hooks/useProject.ts | 5 +--
src/server/hono/route.ts | 13 +++++++-
.../claude-code/ClaudeCodeTaskController.ts | 24 +++++++++-----
.../events/InternalEventDeclaration.ts | 3 +-
src/server/service/project/id.test.ts | 33 +++++++++++++++++++
.../session/PredictSessionsDatabase.ts | 6 +++-
.../service/session/SessionRepository.ts | 3 +-
src/server/service/session/id.test.ts | 26 +++++++++++++++
src/test-setups/vitest.setup.ts | 3 ++
src/types/sse.ts | 3 +-
12 files changed, 109 insertions(+), 21 deletions(-)
create mode 100644 src/server/service/project/id.test.ts
create mode 100644 src/server/service/session/id.test.ts
create mode 100644 src/test-setups/vitest.setup.ts
diff --git a/scripts/build.sh b/scripts/build.sh
index bf1ace9..1dd13e1 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -6,6 +6,10 @@ if [ -d "dist/.next" ]; then
rm -rf dist/.next
fi
+if [ -d "dist/standalone" ]; then
+ rm -rf dist/standalone
+fi
+
pnpm exec next build
cp -r public .next/standalone/
cp -r .next/static .next/standalone/.next/
diff --git a/src/app/components/SSEEventListeners.tsx b/src/app/components/SSEEventListeners.tsx
index a80be14..77d6fbc 100644
--- a/src/app/components/SSEEventListeners.tsx
+++ b/src/app/components/SSEEventListeners.tsx
@@ -25,13 +25,8 @@ export const SSEEventListeners: FC = ({ children }) => {
});
});
- useServerEventListener("taskChanged", async ({ aliveTasks, changed }) => {
+ useServerEventListener("taskChanged", async ({ aliveTasks }) => {
setAliveTasks(aliveTasks);
-
- await queryClient.invalidateQueries({
- queryKey: sessionDetailQuery(changed.projectId, changed.sessionId)
- .queryKey,
- });
});
return <>{children}>;
diff --git a/src/app/projects/[projectId]/hooks/useProject.ts b/src/app/projects/[projectId]/hooks/useProject.ts
index 9036b75..ff1da81 100644
--- a/src/app/projects/[projectId]/hooks/useProject.ts
+++ b/src/app/projects/[projectId]/hooks/useProject.ts
@@ -3,9 +3,10 @@ import { projectDetailQuery } from "../../../../lib/api/queries";
export const useProject = (projectId: string) => {
return useSuspenseInfiniteQuery({
- queryKey: ["projects", projectId],
+ queryKey: projectDetailQuery(projectId).queryKey,
queryFn: async ({ pageParam }) => {
- return await projectDetailQuery(projectId, pageParam).queryFn();
+ const result = await projectDetailQuery(projectId, pageParam).queryFn();
+ return result;
},
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts
index bec1951..ac7a713 100644
--- a/src/server/hono/route.ts
+++ b/src/server/hono/route.ts
@@ -442,8 +442,19 @@ export const routes = async (app: HonoAppType) => {
) => {
stream.writeSSE("taskChanged", {
aliveTasks: event.aliveTasks,
- changed: event.changed,
+ changed: {
+ status: event.changed.status,
+ sessionId: event.changed.sessionId,
+ projectId: event.changed.projectId,
+ },
});
+
+ if (event.changed.sessionId !== undefined) {
+ stream.writeSSE("sessionChanged", {
+ projectId: event.changed.projectId,
+ sessionId: event.changed.sessionId,
+ });
+ }
};
eventBus.on("sessionListChanged", onSessionListChanged);
diff --git a/src/server/service/claude-code/ClaudeCodeTaskController.ts b/src/server/service/claude-code/ClaudeCodeTaskController.ts
index cb630a7..2e987d4 100644
--- a/src/server/service/claude-code/ClaudeCodeTaskController.ts
+++ b/src/server/service/claude-code/ClaudeCodeTaskController.ts
@@ -1,6 +1,9 @@
+import { resolve } from "node:path";
import { ulid } from "ulid";
import type { Config } from "../../config/config";
import { eventBus } from "../events/EventBus";
+import { parseCommandXml } from "../parseCommandXml";
+import { decodeProjectId } from "../project/id";
import { predictSessionsDatabase } from "../session/PredictSessionsDatabase";
import { ClaudeCodeExecutor } from "./ClaudeCodeExecutor";
import { createMessageGenerator } from "./createMessageGenerator";
@@ -270,7 +273,10 @@ class ClaudeCodeTaskController {
// because it takes time for the Claude Code file to be updated, simulate the message
predictSessionsDatabase.createPredictSession({
id: message.session_id,
- jsonlFilePath: message.session_id,
+ jsonlFilePath: resolve(
+ decodeProjectId(currentSession.projectId),
+ `${message.session_id}.jsonl`,
+ ),
conversations: [
{
type: "user",
@@ -289,11 +295,15 @@ class ClaudeCodeTaskController {
},
],
meta: {
- firstCommand: null,
+ firstCommand: parseCommandXml(userMessage),
messageCount: 0,
},
lastModifiedAt: new Date(),
});
+
+ eventBus.emit("sessionListChanged", {
+ projectId: task.projectId,
+ });
}
if (!resolved) {
@@ -421,12 +431,10 @@ class ClaudeCodeTaskController {
Object.assign(target, task);
}
- if (task.status === "paused" || task.status === "running") {
- eventBus.emit("taskChanged", {
- aliveTasks: this.aliveTasks,
- changed: task,
- });
- }
+ eventBus.emit("taskChanged", {
+ aliveTasks: this.aliveTasks,
+ changed: task,
+ });
}
}
diff --git a/src/server/service/events/InternalEventDeclaration.ts b/src/server/service/events/InternalEventDeclaration.ts
index 347fe18..d221353 100644
--- a/src/server/service/events/InternalEventDeclaration.ts
+++ b/src/server/service/events/InternalEventDeclaration.ts
@@ -1,5 +1,6 @@
import type {
AliveClaudeCodeTask,
+ ClaudeCodeTask,
PermissionRequest,
} from "../claude-code/types";
@@ -18,7 +19,7 @@ export type InternalEventDeclaration = {
taskChanged: {
aliveTasks: AliveClaudeCodeTask[];
- changed: AliveClaudeCodeTask;
+ changed: ClaudeCodeTask;
};
permissionRequested: {
diff --git a/src/server/service/project/id.test.ts b/src/server/service/project/id.test.ts
new file mode 100644
index 0000000..b260238
--- /dev/null
+++ b/src/server/service/project/id.test.ts
@@ -0,0 +1,33 @@
+import { resolve } from "node:path";
+import {
+ decodeProjectId,
+ encodeProjectId,
+ encodeProjectIdFromSessionFilePath,
+} from "./id";
+
+const sampleProjectPath =
+ "/path/to/claude-code-project-dir/projects/sample-project";
+const sampleProjectId =
+ "L3BhdGgvdG8vY2xhdWRlLWNvZGUtcHJvamVjdC1kaXIvcHJvamVjdHMvc2FtcGxlLXByb2plY3Q";
+
+describe("encodeProjectId", () => {
+ it("should encode project id from project path", () => {
+ expect(encodeProjectId(sampleProjectPath)).toBe(sampleProjectId);
+ });
+});
+
+describe("decodeProjectId", () => {
+ it("should decode project absolute path from project id", () => {
+ expect(decodeProjectId(sampleProjectId)).toBe(sampleProjectPath);
+ });
+});
+
+describe("encodeProjectIdFromSessionFilePath", () => {
+ it("should encode project id from session file path", () => {
+ expect(
+ encodeProjectIdFromSessionFilePath(
+ resolve(sampleProjectPath, "sample-session-id.jsonl"),
+ ),
+ ).toBe(sampleProjectId);
+ });
+});
diff --git a/src/server/service/session/PredictSessionsDatabase.ts b/src/server/service/session/PredictSessionsDatabase.ts
index 8dbca71..d26ec5f 100644
--- a/src/server/service/session/PredictSessionsDatabase.ts
+++ b/src/server/service/session/PredictSessionsDatabase.ts
@@ -7,8 +7,12 @@ import type { Session, SessionDetail } from "../types";
class PredictSessionsDatabase {
private storage = new Map();
+ private get allPredictSessions() {
+ return Array.from(this.storage.values());
+ }
+
public getPredictSessions(projectId: string): Session[] {
- return Array.from(this.storage.values()).filter(
+ return this.allPredictSessions.filter(
({ jsonlFilePath }) =>
encodeProjectIdFromSessionFilePath(jsonlFilePath) === projectId,
);
diff --git a/src/server/service/session/SessionRepository.ts b/src/server/service/session/SessionRepository.ts
index 7374cfd..6f43cf2 100644
--- a/src/server/service/session/SessionRepository.ts
+++ b/src/server/service/session/SessionRepository.ts
@@ -19,7 +19,8 @@ export class SessionRepository {
if (!existsSync(sessionPath)) {
const predictSession =
predictSessionsDatabase.getPredictSession(sessionId);
- if (predictSession) {
+
+ if (predictSession !== null) {
return {
session: predictSession,
};
diff --git a/src/server/service/session/id.test.ts b/src/server/service/session/id.test.ts
new file mode 100644
index 0000000..f79aa35
--- /dev/null
+++ b/src/server/service/session/id.test.ts
@@ -0,0 +1,26 @@
+import { resolve } from "node:path";
+import { decodeSessionId, encodeSessionId } from "./id";
+
+const sampleProjectId =
+ "L3BhdGgvdG8vY2xhdWRlLWNvZGUtcHJvamVjdC1kaXIvcHJvamVjdHMvc2FtcGxlLXByb2plY3Q";
+const sampleProjectPath =
+ "/path/to/claude-code-project-dir/projects/sample-project";
+const sampleSessionId = "1af7fc5e-8455-4414-9ccd-011d40f70b2a";
+const sampleSessionFilePath = resolve(
+ sampleProjectPath,
+ `${sampleSessionId}.jsonl`,
+);
+
+describe("encodeSessionId", () => {
+ it("should encode session id from jsonl file path", () => {
+ expect(encodeSessionId(sampleSessionFilePath)).toBe(sampleSessionId);
+ });
+});
+
+describe("decodeSessionId", () => {
+ it("should decode session file absolute path from project id and session id", () => {
+ expect(decodeSessionId(sampleProjectId, sampleSessionId)).toBe(
+ sampleSessionFilePath,
+ );
+ });
+});
diff --git a/src/test-setups/vitest.setup.ts b/src/test-setups/vitest.setup.ts
new file mode 100644
index 0000000..22bd5e6
--- /dev/null
+++ b/src/test-setups/vitest.setup.ts
@@ -0,0 +1,3 @@
+afterEach(() => {
+ vi.clearAllMocks();
+});
diff --git a/src/types/sse.ts b/src/types/sse.ts
index 5b26e20..d4def0e 100644
--- a/src/types/sse.ts
+++ b/src/types/sse.ts
@@ -1,5 +1,6 @@
import type {
AliveClaudeCodeTask,
+ ClaudeCodeTask,
PermissionRequest,
} from "../server/service/claude-code/types";
@@ -21,7 +22,7 @@ export type SSEEventDeclaration = {
taskChanged: {
aliveTasks: AliveClaudeCodeTask[];
- changed: AliveClaudeCodeTask;
+ changed: Pick;
};
permission_requested: {
From 8d592ce89ba8bcb72fa1d3c1656fd591c7069485 Mon Sep 17 00:00:00 2001
From: d-kimsuon
Date: Tue, 14 Oct 2025 12:18:29 +0900
Subject: [PATCH 07/21] fix: disable tool approve for old claude code version
---
.../components/chatForm/useChatMutations.ts | 8 +-
src/server/hono/route.ts | 16 ++-
.../service/claude-code/ClaudeCodeExecutor.ts | 18 +--
.../claude-code/ClaudeCodeTaskController.ts | 110 +++++++-----------
.../service/claude-code/ClaudeCodeVersion.ts | 4 -
src/server/service/claude-code/types.ts | 3 +
6 files changed, 66 insertions(+), 93 deletions(-)
diff --git a/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts b/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts
index 72d49ab..f77a818 100644
--- a/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts
+++ b/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts
@@ -32,7 +32,13 @@ export const useNewChatMutation = (
},
onSuccess: async (response) => {
onSuccess?.();
- router.push(`/projects/${projectId}/sessions/${response.sessionId}`);
+ router.push(
+ `/projects/${projectId}/sessions/${response.sessionId}` +
+ response.userMessageId !==
+ undefined
+ ? `#message-${response.userMessageId}`
+ : "",
+ );
},
});
};
diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts
index ac7a713..9521f74 100644
--- a/src/server/hono/route.ts
+++ b/src/server/hono/route.ts
@@ -4,9 +4,9 @@ import { zValidator } from "@hono/zod-validator";
import { setCookie } from "hono/cookie";
import { streamSSE } from "hono/streaming";
import { z } from "zod";
-import { configSchema } from "../config/config";
+import { type Config, configSchema } from "../config/config";
import { env } from "../lib/env";
-import { claudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController";
+import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController";
import type { SerializableAliveTask } from "../service/claude-code/types";
import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE";
import { eventBus } from "../service/events/EventBus";
@@ -28,11 +28,15 @@ export const routes = async (app: HonoAppType) => {
const sessionRepository = new SessionRepository();
const projectRepository = new ProjectRepository();
+ const fileWatcher = getFileWatcher();
+ const eventBus = getEventBus();
+
if (env.get("NEXT_PHASE") !== "phase-production-build") {
- await initialize({
- sessionRepository,
- projectRepository,
- });
+ fileWatcher.startWatching();
+
+ setInterval(() => {
+ eventBus.emit("heartbeat", {});
+ }, 10 * 1000);
}
return (
diff --git a/src/server/service/claude-code/ClaudeCodeExecutor.ts b/src/server/service/claude-code/ClaudeCodeExecutor.ts
index 55285b3..d3c1678 100644
--- a/src/server/service/claude-code/ClaudeCodeExecutor.ts
+++ b/src/server/service/claude-code/ClaudeCodeExecutor.ts
@@ -23,17 +23,13 @@ export class ClaudeCodeExecutor {
);
}
- public get version() {
- return this.claudeCodeVersion?.version;
- }
-
- public get availableFeatures() {
+ public get features() {
return {
- canUseTool:
+ enableToolApproval:
this.claudeCodeVersion?.greaterThanOrEqual(
new ClaudeCodeVersion({ major: 1, minor: 0, patch: 82 }),
) ?? false,
- uuidOnSDKMessage:
+ extractUuidFromSDKMessage:
this.claudeCodeVersion?.greaterThanOrEqual(
new ClaudeCodeVersion({ major: 1, minor: 0, patch: 86 }),
) ?? false,
@@ -41,18 +37,14 @@ export class ClaudeCodeExecutor {
}
public query(prompt: CCQueryPrompt, options: CCQueryOptions) {
- const { canUseTool, permissionMode, ...baseOptions } = options;
+ const { canUseTool, ...baseOptions } = options;
return query({
prompt,
options: {
pathToClaudeCodeExecutable: this.pathToClaudeCodeExecutable,
...baseOptions,
- ...(this.availableFeatures.canUseTool
- ? { canUseTool, permissionMode }
- : {
- permissionMode: "bypassPermissions",
- }),
+ ...(this.features.enableToolApproval ? { canUseTool } : {}),
},
});
}
diff --git a/src/server/service/claude-code/ClaudeCodeTaskController.ts b/src/server/service/claude-code/ClaudeCodeTaskController.ts
index 2e987d4..4883b31 100644
--- a/src/server/service/claude-code/ClaudeCodeTaskController.ts
+++ b/src/server/service/claude-code/ClaudeCodeTaskController.ts
@@ -1,10 +1,7 @@
-import { resolve } from "node:path";
+import prexit from "prexit";
import { ulid } from "ulid";
import type { Config } from "../../config/config";
-import { eventBus } from "../events/EventBus";
-import { parseCommandXml } from "../parseCommandXml";
-import { decodeProjectId } from "../project/id";
-import { predictSessionsDatabase } from "../session/PredictSessionsDatabase";
+import { getEventBus, type IEventBus } from "../events/EventBus";
import { ClaudeCodeExecutor } from "./ClaudeCodeExecutor";
import { createMessageGenerator } from "./createMessageGenerator";
import type {
@@ -16,21 +13,23 @@ import type {
RunningClaudeCodeTask,
} from "./types";
-class ClaudeCodeTaskController {
+export class ClaudeCodeTaskController {
private claudeCode: ClaudeCodeExecutor;
private tasks: ClaudeCodeTask[] = [];
private config: Config;
private pendingPermissionRequests: Map = new Map();
private permissionResponses: Map = new Map();
- constructor() {
+ constructor(config: Config) {
this.claudeCode = new ClaudeCodeExecutor();
- this.config = {
- hideNoUserMessageSession: false,
- unifySameTitleSession: false,
- enterKeyBehavior: "shift-enter-send",
- permissionMode: "default",
- };
+ this.eventBus = getEventBus();
+ this.config = config;
+
+ prexit(() => {
+ this.aliveTasks.forEach((task) => {
+ task.abortController.abort();
+ });
+ });
}
public updateConfig(config: Config) {
@@ -170,20 +169,9 @@ class ClaudeCodeTaskController {
);
if (existingTask) {
- console.log(
- `Alive task for session(id=${currentSession.sessionId}) continued.`,
- );
const result = await this.continueTask(existingTask, message);
return result;
} else {
- if (currentSession.sessionId === undefined) {
- console.log(`New task started.`);
- } else {
- console.log(
- `New task started for existing session(id=${currentSession.sessionId}).`,
- );
- }
-
const result = await this.startTask(currentSession, message);
return result;
}
@@ -265,41 +253,29 @@ class ClaudeCodeTaskController {
});
}
- if (
- message.type === "system" &&
- message.subtype === "init" &&
- currentSession.sessionId === undefined
- ) {
- // because it takes time for the Claude Code file to be updated, simulate the message
- predictSessionsDatabase.createPredictSession({
- id: message.session_id,
- jsonlFilePath: resolve(
- decodeProjectId(currentSession.projectId),
- `${message.session_id}.jsonl`,
- ),
- conversations: [
- {
- type: "user",
- message: {
- role: "user",
- content: userMessage,
- },
- isSidechain: false,
- userType: "external",
- cwd: message.cwd,
- sessionId: message.session_id,
- version: this.claudeCode.version?.toString() ?? "unknown",
- uuid: message.uuid,
- timestamp: new Date().toISOString(),
- parentUuid: null,
- },
- ],
- meta: {
- firstCommand: parseCommandXml(userMessage),
- messageCount: 0,
- },
- lastModifiedAt: new Date(),
- });
+ // 初回の system message だとまだ history ファイルが作成されていないので
+ if (message.type === "user" || message.type === "assistant") {
+ // 本来は message.uuid の存在チェックをしたいが、古いバージョンでは存在しないことがある
+ if (!resolved) {
+ const runningTask: RunningClaudeCodeTask = {
+ status: "running",
+ id: task.id,
+ projectId: task.projectId,
+ cwd: task.cwd,
+ generateMessages: task.generateMessages,
+ setNextMessage: task.setNextMessage,
+ resolveFirstMessage: task.resolveFirstMessage,
+ setFirstMessagePromise: task.setFirstMessagePromise,
+ awaitFirstMessage: task.awaitFirstMessage,
+ onMessageHandlers: task.onMessageHandlers,
+ userMessageId: message.uuid,
+ sessionId: message.session_id,
+ abortController: abortController,
+ };
+ this.tasks.push(runningTask);
+ aliveTaskResolve(runningTask);
+ resolved = true;
+ }
eventBus.emit("sessionListChanged", {
projectId: task.projectId,
@@ -415,12 +391,6 @@ class ClaudeCodeTaskController {
});
}
- public abortAllTasks() {
- for (const task of this.aliveTasks) {
- task.abortController.abort();
- }
- }
-
private upsertExistingTask(task: ClaudeCodeTask) {
const target = this.tasks.find((t) => t.id === task.id);
@@ -431,10 +401,12 @@ class ClaudeCodeTaskController {
Object.assign(target, task);
}
- eventBus.emit("taskChanged", {
- aliveTasks: this.aliveTasks,
- changed: task,
- });
+ if (task.status === "paused" || task.status === "running") {
+ this.eventBus.emit("taskChanged", {
+ aliveTasks: this.aliveTasks,
+ changed: task,
+ });
+ }
}
}
diff --git a/src/server/service/claude-code/ClaudeCodeVersion.ts b/src/server/service/claude-code/ClaudeCodeVersion.ts
index 9193a7e..db3b4f3 100644
--- a/src/server/service/claude-code/ClaudeCodeVersion.ts
+++ b/src/server/service/claude-code/ClaudeCodeVersion.ts
@@ -43,10 +43,6 @@ export class ClaudeCodeVersion {
return this.version.patch;
}
- public toString() {
- return `${this.major}.${this.minor}.${this.patch}`;
- }
-
public equals(other: ClaudeCodeVersion) {
return (
this.version.major === other.version.major &&
diff --git a/src/server/service/claude-code/types.ts b/src/server/service/claude-code/types.ts
index c920d14..83889df 100644
--- a/src/server/service/claude-code/types.ts
+++ b/src/server/service/claude-code/types.ts
@@ -20,18 +20,21 @@ export type PendingClaudeCodeTask = BaseClaudeCodeTask & {
export type RunningClaudeCodeTask = BaseClaudeCodeTask & {
status: "running";
sessionId: string;
+ userMessageId: string | undefined;
abortController: AbortController;
};
export type PausedClaudeCodeTask = BaseClaudeCodeTask & {
status: "paused";
sessionId: string;
+ userMessageId: string | undefined;
abortController: AbortController;
};
type CompletedClaudeCodeTask = BaseClaudeCodeTask & {
status: "completed";
sessionId: string;
+ userMessageId: string | undefined;
abortController: AbortController;
resolveFirstMessage: () => void;
};
From 94cc1c063063e4f9f6a33e7fc84023940077aea2 Mon Sep 17 00:00:00 2001
From: d-kimsuon
Date: Wed, 15 Oct 2025 01:18:14 +0900
Subject: [PATCH 08/21] feat: improve interactivity by predict sessions
---
.../components/chatForm/useChatMutations.ts | 8 +--
.../service/claude-code/ClaudeCodeExecutor.ts | 12 ++--
.../claude-code/ClaudeCodeTaskController.ts | 71 ++++++++++++-------
.../service/claude-code/ClaudeCodeVersion.ts | 4 ++
src/server/service/claude-code/types.ts | 3 -
.../session/PredictSessionsDatabase.ts | 14 ++--
.../service/session/SessionRepository.ts | 39 +++++-----
7 files changed, 86 insertions(+), 65 deletions(-)
diff --git a/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts b/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts
index f77a818..72d49ab 100644
--- a/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts
+++ b/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts
@@ -32,13 +32,7 @@ export const useNewChatMutation = (
},
onSuccess: async (response) => {
onSuccess?.();
- router.push(
- `/projects/${projectId}/sessions/${response.sessionId}` +
- response.userMessageId !==
- undefined
- ? `#message-${response.userMessageId}`
- : "",
- );
+ router.push(`/projects/${projectId}/sessions/${response.sessionId}`);
},
});
};
diff --git a/src/server/service/claude-code/ClaudeCodeExecutor.ts b/src/server/service/claude-code/ClaudeCodeExecutor.ts
index d3c1678..b9de1b2 100644
--- a/src/server/service/claude-code/ClaudeCodeExecutor.ts
+++ b/src/server/service/claude-code/ClaudeCodeExecutor.ts
@@ -23,13 +23,17 @@ export class ClaudeCodeExecutor {
);
}
- public get features() {
+ public get version() {
+ return this.claudeCodeVersion?.version;
+ }
+
+ public get availableFeatures() {
return {
- enableToolApproval:
+ canUseTool:
this.claudeCodeVersion?.greaterThanOrEqual(
new ClaudeCodeVersion({ major: 1, minor: 0, patch: 82 }),
) ?? false,
- extractUuidFromSDKMessage:
+ uuidOnSDKMessage:
this.claudeCodeVersion?.greaterThanOrEqual(
new ClaudeCodeVersion({ major: 1, minor: 0, patch: 86 }),
) ?? false,
@@ -44,7 +48,7 @@ export class ClaudeCodeExecutor {
options: {
pathToClaudeCodeExecutable: this.pathToClaudeCodeExecutable,
...baseOptions,
- ...(this.features.enableToolApproval ? { canUseTool } : {}),
+ ...(this.availableFeatures.canUseTool ? { canUseTool } : {}),
},
});
}
diff --git a/src/server/service/claude-code/ClaudeCodeTaskController.ts b/src/server/service/claude-code/ClaudeCodeTaskController.ts
index 4883b31..50cbaa0 100644
--- a/src/server/service/claude-code/ClaudeCodeTaskController.ts
+++ b/src/server/service/claude-code/ClaudeCodeTaskController.ts
@@ -1,7 +1,8 @@
import prexit from "prexit";
import { ulid } from "ulid";
import type { Config } from "../../config/config";
-import { getEventBus, type IEventBus } from "../events/EventBus";
+import { eventBus } from "../events/EventBus";
+import { predictSessionsDatabase } from "../session/PredictSessionsDatabase";
import { ClaudeCodeExecutor } from "./ClaudeCodeExecutor";
import { createMessageGenerator } from "./createMessageGenerator";
import type {
@@ -169,9 +170,20 @@ export class ClaudeCodeTaskController {
);
if (existingTask) {
+ console.log(
+ `Alive task for session(id=${currentSession.sessionId}) continued.`,
+ );
const result = await this.continueTask(existingTask, message);
return result;
} else {
+ if (currentSession.sessionId === undefined) {
+ console.log(`New task started.`);
+ } else {
+ console.log(
+ `New task started for existing session(id=${currentSession.sessionId}).`,
+ );
+ }
+
const result = await this.startTask(currentSession, message);
return result;
}
@@ -253,32 +265,37 @@ export class ClaudeCodeTaskController {
});
}
- // 初回の system message だとまだ history ファイルが作成されていないので
- if (message.type === "user" || message.type === "assistant") {
- // 本来は message.uuid の存在チェックをしたいが、古いバージョンでは存在しないことがある
- if (!resolved) {
- const runningTask: RunningClaudeCodeTask = {
- status: "running",
- id: task.id,
- projectId: task.projectId,
- cwd: task.cwd,
- generateMessages: task.generateMessages,
- setNextMessage: task.setNextMessage,
- resolveFirstMessage: task.resolveFirstMessage,
- setFirstMessagePromise: task.setFirstMessagePromise,
- awaitFirstMessage: task.awaitFirstMessage,
- onMessageHandlers: task.onMessageHandlers,
- userMessageId: message.uuid,
- sessionId: message.session_id,
- abortController: abortController,
- };
- this.tasks.push(runningTask);
- aliveTaskResolve(runningTask);
- resolved = true;
- }
-
- eventBus.emit("sessionListChanged", {
- projectId: task.projectId,
+ if (
+ message.type === "system" &&
+ message.subtype === "init" &&
+ currentSession.sessionId === undefined
+ ) {
+ // because it takes time for the Claude Code file to be updated, simulate the message
+ predictSessionsDatabase.createPredictSession({
+ id: message.session_id,
+ jsonlFilePath: message.session_id,
+ conversations: [
+ {
+ type: "user",
+ message: {
+ role: "user",
+ content: userMessage,
+ },
+ isSidechain: false,
+ userType: "external",
+ cwd: message.cwd,
+ sessionId: message.session_id,
+ version: this.claudeCode.version?.toString() ?? "unknown",
+ uuid: message.uuid,
+ timestamp: new Date().toISOString(),
+ parentUuid: null,
+ },
+ ],
+ meta: {
+ firstCommand: null,
+ lastModifiedAt: new Date().toISOString(),
+ messageCount: 0,
+ },
});
}
diff --git a/src/server/service/claude-code/ClaudeCodeVersion.ts b/src/server/service/claude-code/ClaudeCodeVersion.ts
index db3b4f3..9193a7e 100644
--- a/src/server/service/claude-code/ClaudeCodeVersion.ts
+++ b/src/server/service/claude-code/ClaudeCodeVersion.ts
@@ -43,6 +43,10 @@ export class ClaudeCodeVersion {
return this.version.patch;
}
+ public toString() {
+ return `${this.major}.${this.minor}.${this.patch}`;
+ }
+
public equals(other: ClaudeCodeVersion) {
return (
this.version.major === other.version.major &&
diff --git a/src/server/service/claude-code/types.ts b/src/server/service/claude-code/types.ts
index 83889df..c920d14 100644
--- a/src/server/service/claude-code/types.ts
+++ b/src/server/service/claude-code/types.ts
@@ -20,21 +20,18 @@ export type PendingClaudeCodeTask = BaseClaudeCodeTask & {
export type RunningClaudeCodeTask = BaseClaudeCodeTask & {
status: "running";
sessionId: string;
- userMessageId: string | undefined;
abortController: AbortController;
};
export type PausedClaudeCodeTask = BaseClaudeCodeTask & {
status: "paused";
sessionId: string;
- userMessageId: string | undefined;
abortController: AbortController;
};
type CompletedClaudeCodeTask = BaseClaudeCodeTask & {
status: "completed";
sessionId: string;
- userMessageId: string | undefined;
abortController: AbortController;
resolveFirstMessage: () => void;
};
diff --git a/src/server/service/session/PredictSessionsDatabase.ts b/src/server/service/session/PredictSessionsDatabase.ts
index d26ec5f..161b273 100644
--- a/src/server/service/session/PredictSessionsDatabase.ts
+++ b/src/server/service/session/PredictSessionsDatabase.ts
@@ -7,19 +7,19 @@ import type { Session, SessionDetail } from "../types";
class PredictSessionsDatabase {
private storage = new Map();
- private get allPredictSessions() {
- return Array.from(this.storage.values());
- }
-
public getPredictSessions(projectId: string): Session[] {
- return this.allPredictSessions.filter(
+ return Array.from(this.storage.values()).filter(
({ jsonlFilePath }) =>
encodeProjectIdFromSessionFilePath(jsonlFilePath) === projectId,
);
}
- public getPredictSession(sessionId: string): SessionDetail | null {
- return this.storage.get(sessionId) ?? null;
+ public getPredictSession(sessionId: string): SessionDetail {
+ const session = this.storage.get(sessionId);
+ if (!session) {
+ throw new Error("Session not found");
+ }
+ return session;
}
public createPredictSession(session: SessionDetail) {
diff --git a/src/server/service/session/SessionRepository.ts b/src/server/service/session/SessionRepository.ts
index 6f43cf2..df9be24 100644
--- a/src/server/service/session/SessionRepository.ts
+++ b/src/server/service/session/SessionRepository.ts
@@ -1,4 +1,4 @@
-import { existsSync, statSync } from "node:fs";
+import { existsSync } from "node:fs";
import { readdir, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { parseJsonl } from "../parseJsonl";
@@ -19,6 +19,15 @@ export class SessionRepository {
if (!existsSync(sessionPath)) {
const predictSession =
predictSessionsDatabase.getPredictSession(sessionId);
+ if (predictSession) {
+ return {
+ session: predictSession,
+ };
+ }
+
+ throw new Error("Session not found");
+ }
+ const content = await readFile(sessionPath, "utf-8");
if (predictSession !== null) {
return {
@@ -78,6 +87,13 @@ export class SessionRepository {
(a, b) => b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime(),
),
);
+ const sessionMap = new Map(
+ sessions.map((session) => [session.id, session]),
+ );
+
+ const predictSessions = predictSessionsDatabase
+ .getPredictSessions(projectId)
+ .filter((session) => !sessionMap.has(session.id));
const sessionMap = new Map(
sessions.map((session) => [session.id, session] as const),
@@ -114,22 +130,11 @@ export class SessionRepository {
});
return {
- sessions: [
- ...predictSessions,
- ...(await Promise.all(
- sessions
- .slice(0, Math.min(maxCount, sessions.length))
- .map(async (item) => {
- return {
- ...item,
- meta: await sessionMetaStorage.getSessionMeta(
- projectId,
- item.id,
- ),
- };
- }),
- )),
- ],
+ sessions: [...predictSessions, ...sessions].sort((a, b) => {
+ return (
+ getTime(b.meta.lastModifiedAt) - getTime(a.meta.lastModifiedAt)
+ );
+ }),
};
} catch (error) {
console.warn(`Failed to read sessions for project ${projectId}:`, error);
From 21070d09ff57de0dbc21d50515e2a943e593d2eb Mon Sep 17 00:00:00 2001
From: d-kimsuon
Date: Wed, 15 Oct 2025 23:22:27 +0900
Subject: [PATCH 09/21] refactor: add effect-ts and refactor codes
---
package.json | 4 +
pnpm-lock.yaml | 463 +++++++
src/app/api/[[...route]]/route.ts | 47 +-
src/app/components/SSEEventListeners.tsx | 8 +-
src/app/components/SyncSessionProcess.tsx | 18 +
src/app/layout.tsx | 14 +-
.../[projectId]/components/chatForm/index.ts | 5 +-
.../components/chatForm/useChatMutations.ts | 53 +-
.../components/newChat/NewChat.tsx | 13 +-
.../components/SessionPageContent.tsx | 56 +-
.../components/resumeChat/ContinueChat.tsx | 46 +
.../components/resumeChat/ResumeChat.tsx | 26 +-
.../components/sessionSidebar/McpTab.tsx | 10 +-
.../sessionSidebar/MobileSidebar.tsx | 2 +-
.../sessionSidebar/SessionSidebar.tsx | 2 +-
.../components/sessionSidebar/SessionsTab.tsx | 24 +-
.../[sessionId]/hooks/useAliveTask.ts | 31 -
.../sessions/[sessionId]/hooks/useSession.ts | 12 +-
.../[sessionId]/hooks/useSessionProcess.ts | 23 +
.../[sessionId]/store/aliveTasksAtom.ts | 4 -
.../[sessionId]/store/sessionProcessesAtom.ts | 4 +
src/hooks/usePermissionRequests.ts | 2 +-
src/lib/api/queries.ts | 33 +-
src/lib/controllablePromise.ts | 25 +
.../entry/UserEntrySchema.ts | 2 +
src/server/hono/initialize.test.ts | 362 ++++++
src/server/hono/initialize.ts | 183 ++-
src/server/hono/route.ts | 1078 ++++++++++-------
src/server/lib/effect/types.ts | 6 +
src/server/lib/env/schema.ts | 1 +
src/server/lib/storage/FileCacheStorage.ts | 82 --
.../FileCacheStorage/PersistantService.ts | 64 +
.../storage/FileCacheStorage/index.test.ts | 516 ++++++++
.../lib/storage/FileCacheStorage/index.ts | 94 ++
.../lib/storage/InMemoryCacheStorage.ts | 19 -
.../service/claude-code/ClaudeCode.test.ts | 94 ++
src/server/service/claude-code/ClaudeCode.ts | 81 ++
.../service/claude-code/ClaudeCodeExecutor.ts | 55 -
.../claude-code/ClaudeCodeLifeCycleService.ts | 367 ++++++
.../ClaudeCodePermissionService.ts | 158 +++
.../ClaudeCodeSessionProcessService.ts | 463 +++++++
.../claude-code/ClaudeCodeTaskController.ts | 430 -------
.../service/claude-code/ClaudeCodeVersion.ts | 71 --
.../service/claude-code/MessageGenerator.ts | 83 ++
.../claude-code/createMessageGenerator.ts | 36 +-
.../claude-code/models/CCSessionProcess.ts | 108 ++
.../claude-code/models/ClaudeCodeTask.ts | 59 +
.../models/ClaudeCodeVersion.test.ts | 84 ++
.../claude-code/models/ClaudeCodeVersion.ts | 47 +
src/server/service/claude-code/types.ts | 71 --
src/server/service/events/EventBus.test.ts | 282 +++++
src/server/service/events/EventBus.ts | 104 +-
.../events/InternalEventDeclaration.ts | 14 +-
.../service/events/adaptInternalEventToSSE.ts | 34 -
src/server/service/events/fileWatcher.test.ts | 176 +++
src/server/service/events/fileWatcher.ts | 157 ++-
src/server/service/events/typeSafeSSE.test.ts | 248 ++++
src/server/service/events/typeSafeSSE.ts | 53 +-
src/server/service/mcp/getMcpList.ts | 6 +-
src/server/service/parseJsonl.ts | 4 +-
.../project/ProjectMetaService.test.ts | 221 ++++
.../service/project/ProjectMetaService.ts | 154 +++
.../service/project/ProjectRepository.test.ts | 329 +++++
.../service/project/ProjectRepository.ts | 126 +-
.../service/project/projectMetaStorage.ts | 104 --
.../session/PredictSessionsDatabase.ts | 34 -
.../session/SessionMetaService.test.ts | 247 ++++
.../service/session/SessionMetaService.ts | 185 +++
.../service/session/SessionRepository.test.ts | 604 +++++++++
.../service/session/SessionRepository.ts | 424 +++++--
.../VirtualConversationDatabase.test.ts | 245 ++++
.../session/VirtualConversationDatabase.ts | 116 ++
.../service/session/sessionMetaStorage.ts | 123 --
src/server/service/types.ts | 1 +
src/types/session-process.ts | 6 +
src/types/sse.ts | 12 +-
76 files changed, 7598 insertions(+), 1950 deletions(-)
create mode 100644 src/app/components/SyncSessionProcess.tsx
create mode 100644 src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ContinueChat.tsx
delete mode 100644 src/app/projects/[projectId]/sessions/[sessionId]/hooks/useAliveTask.ts
create mode 100644 src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionProcess.ts
delete mode 100644 src/app/projects/[projectId]/sessions/[sessionId]/store/aliveTasksAtom.ts
create mode 100644 src/app/projects/[projectId]/sessions/[sessionId]/store/sessionProcessesAtom.ts
create mode 100644 src/lib/controllablePromise.ts
create mode 100644 src/server/hono/initialize.test.ts
create mode 100644 src/server/lib/effect/types.ts
delete mode 100644 src/server/lib/storage/FileCacheStorage.ts
create mode 100644 src/server/lib/storage/FileCacheStorage/PersistantService.ts
create mode 100644 src/server/lib/storage/FileCacheStorage/index.test.ts
create mode 100644 src/server/lib/storage/FileCacheStorage/index.ts
delete mode 100644 src/server/lib/storage/InMemoryCacheStorage.ts
create mode 100644 src/server/service/claude-code/ClaudeCode.test.ts
create mode 100644 src/server/service/claude-code/ClaudeCode.ts
delete mode 100644 src/server/service/claude-code/ClaudeCodeExecutor.ts
create mode 100644 src/server/service/claude-code/ClaudeCodeLifeCycleService.ts
create mode 100644 src/server/service/claude-code/ClaudeCodePermissionService.ts
create mode 100644 src/server/service/claude-code/ClaudeCodeSessionProcessService.ts
delete mode 100644 src/server/service/claude-code/ClaudeCodeTaskController.ts
delete mode 100644 src/server/service/claude-code/ClaudeCodeVersion.ts
create mode 100644 src/server/service/claude-code/MessageGenerator.ts
create mode 100644 src/server/service/claude-code/models/CCSessionProcess.ts
create mode 100644 src/server/service/claude-code/models/ClaudeCodeTask.ts
create mode 100644 src/server/service/claude-code/models/ClaudeCodeVersion.test.ts
create mode 100644 src/server/service/claude-code/models/ClaudeCodeVersion.ts
delete mode 100644 src/server/service/claude-code/types.ts
create mode 100644 src/server/service/events/EventBus.test.ts
create mode 100644 src/server/service/events/fileWatcher.test.ts
create mode 100644 src/server/service/events/typeSafeSSE.test.ts
create mode 100644 src/server/service/project/ProjectMetaService.test.ts
create mode 100644 src/server/service/project/ProjectMetaService.ts
create mode 100644 src/server/service/project/ProjectRepository.test.ts
delete mode 100644 src/server/service/project/projectMetaStorage.ts
delete mode 100644 src/server/service/session/PredictSessionsDatabase.ts
create mode 100644 src/server/service/session/SessionMetaService.test.ts
create mode 100644 src/server/service/session/SessionMetaService.ts
create mode 100644 src/server/service/session/SessionRepository.test.ts
create mode 100644 src/server/service/session/VirtualConversationDatabase.test.ts
create mode 100644 src/server/service/session/VirtualConversationDatabase.ts
delete mode 100644 src/server/service/session/sessionMetaStorage.ts
create mode 100644 src/types/session-process.ts
diff --git a/package.json b/package.json
index bd2edac..efab3cb 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,8 @@
},
"dependencies": {
"@anthropic-ai/claude-code": "^1.0.98",
+ "@effect/platform": "^0.92.1",
+ "@effect/platform-node": "^0.98.3",
"@hono/zod-validator": "^0.7.2",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -51,6 +53,8 @@
"@tanstack/react-query": "^5.85.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "effect": "^3.18.4",
+ "es-toolkit": "^1.40.0",
"hono": "^4.9.5",
"jotai": "^2.13.1",
"lucide-react": "^0.542.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6faec8f..4b275ac 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,6 +11,12 @@ importers:
'@anthropic-ai/claude-code':
specifier: ^1.0.98
version: 1.0.128
+ '@effect/platform':
+ specifier: ^0.92.1
+ version: 0.92.1(effect@3.18.4)
+ '@effect/platform-node':
+ specifier: ^0.98.3
+ version: 0.98.3(@effect/cluster@0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)
'@hono/zod-validator':
specifier: ^0.7.2
version: 0.7.2(hono@4.9.5)(zod@4.1.5)
@@ -47,6 +53,12 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
+ effect:
+ specifier: ^3.18.4
+ version: 3.18.4
+ es-toolkit:
+ specifier: ^1.40.0
+ version: 1.40.0
hono:
specifier: ^4.9.5
version: 4.9.5
@@ -225,6 +237,71 @@ packages:
conventional-commits-parser:
optional: true
+ '@effect/cluster@0.50.4':
+ resolution: {integrity: sha512-9uS2pRN4BCguAGqFCLFlQkReXG993UFj/TLtiwaXsacytKhdlGBU5zDDI/TckbM0wUv4g2nZPRRywqU8qnrvjQ==}
+ peerDependencies:
+ '@effect/platform': ^0.92.1
+ '@effect/rpc': ^0.71.0
+ '@effect/sql': ^0.46.0
+ '@effect/workflow': ^0.11.3
+ effect: ^3.18.4
+
+ '@effect/experimental@0.56.0':
+ resolution: {integrity: sha512-ZT9wTUVyDptzdkW4Tfvz5fNzygW9vt5jWcFmKI9SlhZMu9unVJgsBhxWCNYCyfPnxw3n/Z6SEKsqgt8iKQc4MA==}
+ peerDependencies:
+ '@effect/platform': ^0.92.0
+ effect: ^3.18.0
+ ioredis: ^5
+ lmdb: ^3
+ peerDependenciesMeta:
+ ioredis:
+ optional: true
+ lmdb:
+ optional: true
+
+ '@effect/platform-node-shared@0.51.4':
+ resolution: {integrity: sha512-xElU9+cNPa1BnUHAZ3sVVanuuKof8oWQhK7rbyHNqgWM7CZTjv7x9VMDs0X05+1OcTQnnW3E+SrZKIPCfcYlDQ==}
+ peerDependencies:
+ '@effect/cluster': ^0.50.3
+ '@effect/platform': ^0.92.1
+ '@effect/rpc': ^0.71.0
+ '@effect/sql': ^0.46.0
+ effect: ^3.18.2
+
+ '@effect/platform-node@0.98.3':
+ resolution: {integrity: sha512-90eMWmFSVHrUEreICCd2qLPiw7qcaAv9XTx9OJ+LLv7igQgt4qkisRSK0oxAr5hqU9TdUrsgFDohqe7q7h3ZRg==}
+ peerDependencies:
+ '@effect/cluster': ^0.50.3
+ '@effect/platform': ^0.92.1
+ '@effect/rpc': ^0.71.0
+ '@effect/sql': ^0.46.0
+ effect: ^3.18.1
+
+ '@effect/platform@0.92.1':
+ resolution: {integrity: sha512-XXWCBVwyhaKZISN7aM1fv/3fWDGyxr84ObywnUrL8aHvJLoIeskWFAP/fqw3c5MFCrJ3ZV97RWLbv6JiBQugdg==}
+ peerDependencies:
+ effect: ^3.18.1
+
+ '@effect/rpc@0.71.0':
+ resolution: {integrity: sha512-m6mFX0ShdA+fnYAyamz7SRKF4FepaDB/ZhBri6iue26tBF2LrOFJUWewbwv8/LdLSedkO4eukhsHXuEYortL/w==}
+ peerDependencies:
+ '@effect/platform': ^0.92.0
+ effect: ^3.18.0
+
+ '@effect/sql@0.46.0':
+ resolution: {integrity: sha512-nm9TuTTG7gLmJlIPkf71wA5lXArSvkpm1oYoIF+rhf01wef+1ujz9Mv1SfuzYbzsk7W9+OXUIRMxz/nSlKkiGQ==}
+ peerDependencies:
+ '@effect/experimental': ^0.56.0
+ '@effect/platform': ^0.92.0
+ effect: ^3.18.0
+
+ '@effect/workflow@0.11.3':
+ resolution: {integrity: sha512-3uyj0yOc2QRtQVOw6NEJVEMOhN/F7khhnf3QSU+2T3wvuDag9iBUzJFvSls8PgNCO3j/GgeaWzbcXwxqpFQYOQ==}
+ peerDependencies:
+ '@effect/platform': ^0.92.1
+ '@effect/rpc': ^0.71.0
+ effect: ^3.18.1
+
'@emnapi/runtime@1.5.0':
resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==}
@@ -741,6 +818,36 @@ packages:
'@jridgewell/trace-mapping@0.3.30':
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
+ '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
+ resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3':
+ resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3':
+ resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3':
+ resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==}
+ cpu: [arm]
+ os: [linux]
+
+ '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3':
+ resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==}
+ cpu: [x64]
+ os: [linux]
+
+ '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
+ resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==}
+ cpu: [x64]
+ os: [win32]
+
'@next/env@15.5.2':
resolution: {integrity: sha512-Qe06ew4zt12LeO6N7j8/nULSOe3fMXE4dM6xgpBQNvdzyK1sv5y4oAP3bq4LamrvGCZtmRYnW8URFCeX5nFgGg==}
@@ -853,6 +960,88 @@ packages:
'@octokit/types@14.1.0':
resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==}
+ '@parcel/watcher-android-arm64@2.5.1':
+ resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ '@parcel/watcher-darwin-arm64@2.5.1':
+ resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@parcel/watcher-darwin-x64@2.5.1':
+ resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@parcel/watcher-freebsd-x64@2.5.1':
+ resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@parcel/watcher-linux-arm-glibc@2.5.1':
+ resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ '@parcel/watcher-linux-arm-musl@2.5.1':
+ resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ '@parcel/watcher-linux-arm64-glibc@2.5.1':
+ resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@parcel/watcher-linux-arm64-musl@2.5.1':
+ resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@parcel/watcher-linux-x64-glibc@2.5.1':
+ resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ '@parcel/watcher-linux-x64-musl@2.5.1':
+ resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ '@parcel/watcher-win32-arm64@2.5.1':
+ resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@parcel/watcher-win32-ia32@2.5.1':
+ resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@parcel/watcher-win32-x64@2.5.1':
+ resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==}
+ engines: {node: '>= 10.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ '@parcel/watcher@2.5.1':
+ resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
+ engines: {node: '>= 10.0.0'}
+
'@phun-ky/typeof@1.2.8':
resolution: {integrity: sha512-7J6ca1tK0duM2BgVB+CuFMh3idlIVASOP2QvOCbNWDc6JnvjtKa9nufPoJQQ4xrwBonwgT1TIhRRcEtzdVgWsA==}
engines: {node: ^20.9.0 || >=22.0.0, npm: '>=10.8.2'}
@@ -1342,6 +1531,9 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
+ '@standard-schema/spec@1.0.0':
+ resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
+
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -1862,6 +2054,11 @@ packages:
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
+ detect-libc@1.0.3:
+ resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
+ engines: {node: '>=0.10'}
+ hasBin: true
+
detect-libc@2.0.4:
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
engines: {node: '>=8'}
@@ -1884,6 +2081,9 @@ packages:
resolution: {integrity: sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==}
engines: {node: '>=12'}
+ effect@3.18.4:
+ resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==}
+
emoji-regex@10.5.0:
resolution: {integrity: sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==}
@@ -1897,6 +2097,9 @@ packages:
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+ es-toolkit@1.40.0:
+ resolution: {integrity: sha512-8o6w0KFmU0CiIl0/Q/BCEOabF2IJaELM1T2PWj6e8KqzHv1gdx+7JtFnDwOx1kJH/isJ5NwlDG1nCr1HrRF94Q==}
+
esbuild@0.25.9:
resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==}
engines: {node: '>=18'}
@@ -1956,6 +2159,10 @@ packages:
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+ fast-check@3.23.2:
+ resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
+ engines: {node: '>=8.0.0'}
+
fast-content-type-parse@2.0.1:
resolution: {integrity: sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==}
@@ -1979,6 +2186,9 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
+ find-my-way-ts@0.1.6:
+ resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==}
+
format@0.2.2:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
@@ -2498,6 +2708,10 @@ packages:
micromark@4.0.2:
resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
+ micromatch@4.0.8:
+ resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
+ engines: {node: '>=8.6'}
+
mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'}
@@ -2506,6 +2720,11 @@ packages:
resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==}
engines: {node: '>= 0.6'}
+ mime@3.0.0:
+ resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
+ engines: {node: '>=10.0.0'}
+ hasBin: true
+
mimic-fn@4.0.0:
resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
engines: {node: '>=12'}
@@ -2554,6 +2773,16 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+ msgpackr-extract@3.0.3:
+ resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==}
+ hasBin: true
+
+ msgpackr@1.11.5:
+ resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==}
+
+ multipasta@0.2.7:
+ resolution: {integrity: sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==}
+
mute-stream@2.0.0:
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
engines: {node: ^18.17.0 || >=20.5.0}
@@ -2598,9 +2827,16 @@ packages:
sass:
optional: true
+ node-addon-api@7.1.1:
+ resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
+
node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
+ node-gyp-build-optional-packages@5.2.2:
+ resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==}
+ hasBin: true
+
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@@ -2785,6 +3021,9 @@ packages:
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
+ pure-rand@6.1.0:
+ resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
+
rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
@@ -3132,6 +3371,10 @@ packages:
resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==}
engines: {node: '>=18.17'}
+ undici@7.16.0:
+ resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==}
+ engines: {node: '>=20.18.1'}
+
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
@@ -3186,6 +3429,10 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ uuid@11.1.0:
+ resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
+ hasBin: true
+
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -3291,6 +3538,18 @@ packages:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
+ ws@8.18.3:
+ resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
wsl-utils@0.1.0:
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
engines: {node: '>=18'}
@@ -3393,6 +3652,75 @@ snapshots:
conventional-commits-filter: 5.0.0
conventional-commits-parser: 6.2.0
+ '@effect/cluster@0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)':
+ dependencies:
+ '@effect/platform': 0.92.1(effect@3.18.4)
+ '@effect/rpc': 0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
+ '@effect/sql': 0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
+ '@effect/workflow': 0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)
+ effect: 3.18.4
+
+ '@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)':
+ dependencies:
+ '@effect/platform': 0.92.1(effect@3.18.4)
+ effect: 3.18.4
+ uuid: 11.1.0
+
+ '@effect/platform-node-shared@0.51.4(@effect/cluster@0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)':
+ dependencies:
+ '@effect/cluster': 0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)
+ '@effect/platform': 0.92.1(effect@3.18.4)
+ '@effect/rpc': 0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
+ '@effect/sql': 0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
+ '@parcel/watcher': 2.5.1
+ effect: 3.18.4
+ multipasta: 0.2.7
+ ws: 8.18.3
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ '@effect/platform-node@0.98.3(@effect/cluster@0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)':
+ dependencies:
+ '@effect/cluster': 0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)
+ '@effect/platform': 0.92.1(effect@3.18.4)
+ '@effect/platform-node-shared': 0.51.4(@effect/cluster@0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)
+ '@effect/rpc': 0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
+ '@effect/sql': 0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
+ effect: 3.18.4
+ mime: 3.0.0
+ undici: 7.16.0
+ ws: 8.18.3
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ '@effect/platform@0.92.1(effect@3.18.4)':
+ dependencies:
+ effect: 3.18.4
+ find-my-way-ts: 0.1.6
+ msgpackr: 1.11.5
+ multipasta: 0.2.7
+
+ '@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)':
+ dependencies:
+ '@effect/platform': 0.92.1(effect@3.18.4)
+ effect: 3.18.4
+ msgpackr: 1.11.5
+
+ '@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)':
+ dependencies:
+ '@effect/experimental': 0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
+ '@effect/platform': 0.92.1(effect@3.18.4)
+ effect: 3.18.4
+ uuid: 11.1.0
+
+ '@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)':
+ dependencies:
+ '@effect/platform': 0.92.1(effect@3.18.4)
+ '@effect/rpc': 0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)
+ effect: 3.18.4
+
'@emnapi/runtime@1.5.0':
dependencies:
tslib: 2.8.1
@@ -3779,6 +4107,24 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3':
+ optional: true
+
+ '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3':
+ optional: true
+
+ '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3':
+ optional: true
+
+ '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3':
+ optional: true
+
+ '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3':
+ optional: true
+
+ '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
+ optional: true
+
'@next/env@15.5.2': {}
'@next/swc-darwin-arm64@15.5.2':
@@ -3877,6 +4223,66 @@ snapshots:
dependencies:
'@octokit/openapi-types': 25.1.0
+ '@parcel/watcher-android-arm64@2.5.1':
+ optional: true
+
+ '@parcel/watcher-darwin-arm64@2.5.1':
+ optional: true
+
+ '@parcel/watcher-darwin-x64@2.5.1':
+ optional: true
+
+ '@parcel/watcher-freebsd-x64@2.5.1':
+ optional: true
+
+ '@parcel/watcher-linux-arm-glibc@2.5.1':
+ optional: true
+
+ '@parcel/watcher-linux-arm-musl@2.5.1':
+ optional: true
+
+ '@parcel/watcher-linux-arm64-glibc@2.5.1':
+ optional: true
+
+ '@parcel/watcher-linux-arm64-musl@2.5.1':
+ optional: true
+
+ '@parcel/watcher-linux-x64-glibc@2.5.1':
+ optional: true
+
+ '@parcel/watcher-linux-x64-musl@2.5.1':
+ optional: true
+
+ '@parcel/watcher-win32-arm64@2.5.1':
+ optional: true
+
+ '@parcel/watcher-win32-ia32@2.5.1':
+ optional: true
+
+ '@parcel/watcher-win32-x64@2.5.1':
+ optional: true
+
+ '@parcel/watcher@2.5.1':
+ dependencies:
+ detect-libc: 1.0.3
+ is-glob: 4.0.3
+ micromatch: 4.0.8
+ node-addon-api: 7.1.1
+ optionalDependencies:
+ '@parcel/watcher-android-arm64': 2.5.1
+ '@parcel/watcher-darwin-arm64': 2.5.1
+ '@parcel/watcher-darwin-x64': 2.5.1
+ '@parcel/watcher-freebsd-x64': 2.5.1
+ '@parcel/watcher-linux-arm-glibc': 2.5.1
+ '@parcel/watcher-linux-arm-musl': 2.5.1
+ '@parcel/watcher-linux-arm64-glibc': 2.5.1
+ '@parcel/watcher-linux-arm64-musl': 2.5.1
+ '@parcel/watcher-linux-x64-glibc': 2.5.1
+ '@parcel/watcher-linux-x64-musl': 2.5.1
+ '@parcel/watcher-win32-arm64': 2.5.1
+ '@parcel/watcher-win32-ia32': 2.5.1
+ '@parcel/watcher-win32-x64': 2.5.1
+
'@phun-ky/typeof@1.2.8': {}
'@playwright/test@1.55.0':
@@ -4309,6 +4715,8 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
+ '@standard-schema/spec@1.0.0': {}
+
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -4831,6 +5239,8 @@ snapshots:
destr@2.0.5: {}
+ detect-libc@1.0.3: {}
+
detect-libc@2.0.4: {}
detect-node-es@1.1.0: {}
@@ -4847,6 +5257,11 @@ snapshots:
dotenv@17.2.1: {}
+ effect@3.18.4:
+ dependencies:
+ '@standard-schema/spec': 1.0.0
+ fast-check: 3.23.2
+
emoji-regex@10.5.0: {}
emoji-regex@8.0.0: {}
@@ -4858,6 +5273,8 @@ snapshots:
es-module-lexer@1.7.0: {}
+ es-toolkit@1.40.0: {}
+
esbuild@0.25.9:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.9
@@ -4946,6 +5363,10 @@ snapshots:
extend@3.0.2: {}
+ fast-check@3.23.2:
+ dependencies:
+ pure-rand: 6.1.0
+
fast-content-type-parse@2.0.1: {}
fault@1.0.4:
@@ -4964,6 +5385,8 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
+ find-my-way-ts@0.1.6: {}
+
format@0.2.2: {}
fs-minipass@2.1.0:
@@ -5642,12 +6065,19 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ micromatch@4.0.8:
+ dependencies:
+ braces: 3.0.3
+ picomatch: 2.3.1
+
mime-db@1.54.0: {}
mime-types@3.0.1:
dependencies:
mime-db: 1.54.0
+ mime@3.0.0: {}
+
mimic-fn@4.0.0: {}
mimic-function@5.0.1: {}
@@ -5684,6 +6114,24 @@ snapshots:
ms@2.1.3: {}
+ msgpackr-extract@3.0.3:
+ dependencies:
+ node-gyp-build-optional-packages: 5.2.2
+ optionalDependencies:
+ '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3
+ '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3
+ '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3
+ '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3
+ '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3
+ '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3
+ optional: true
+
+ msgpackr@1.11.5:
+ optionalDependencies:
+ msgpackr-extract: 3.0.3
+
+ multipasta@0.2.7: {}
+
mute-stream@2.0.0: {}
nanoid@3.3.11: {}
@@ -5723,8 +6171,15 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
+ node-addon-api@7.1.1: {}
+
node-fetch-native@1.6.7: {}
+ node-gyp-build-optional-packages@5.2.2:
+ dependencies:
+ detect-libc: 2.0.4
+ optional: true
+
normalize-path@3.0.0: {}
npm-normalize-package-bin@4.0.0: {}
@@ -5945,6 +6400,8 @@ snapshots:
proxy-from-env@1.1.0: {}
+ pure-rand@6.1.0: {}
+
rc9@2.1.2:
dependencies:
defu: 6.1.4
@@ -6370,6 +6827,8 @@ snapshots:
undici@6.21.3: {}
+ undici@7.16.0: {}
+
unicorn-magic@0.3.0: {}
unified@11.0.5:
@@ -6428,6 +6887,8 @@ snapshots:
dependencies:
react: 19.1.1
+ uuid@11.1.0: {}
+
vfile-message@4.0.3:
dependencies:
'@types/unist': 3.0.3
@@ -6542,6 +7003,8 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
+ ws@8.18.3: {}
+
wsl-utils@0.1.0:
dependencies:
is-wsl: 3.1.0
diff --git a/src/app/api/[[...route]]/route.ts b/src/app/api/[[...route]]/route.ts
index 76f4e5e..12ae66e 100644
--- a/src/app/api/[[...route]]/route.ts
+++ b/src/app/api/[[...route]]/route.ts
@@ -1,8 +1,53 @@
+import { NodeContext } from "@effect/platform-node";
+import { Effect } from "effect";
import { handle } from "hono/vercel";
import { honoApp } from "../../../server/hono/app";
+import { InitializeService } from "../../../server/hono/initialize";
import { routes } from "../../../server/hono/route";
+import { ClaudeCodeLifeCycleService } from "../../../server/service/claude-code/ClaudeCodeLifeCycleService";
+import { ClaudeCodePermissionService } from "../../../server/service/claude-code/ClaudeCodePermissionService";
+import { ClaudeCodeSessionProcessService } from "../../../server/service/claude-code/ClaudeCodeSessionProcessService";
+import { EventBus } from "../../../server/service/events/EventBus";
+import { FileWatcherService } from "../../../server/service/events/fileWatcher";
+import { ProjectMetaService } from "../../../server/service/project/ProjectMetaService";
+import { ProjectRepository } from "../../../server/service/project/ProjectRepository";
+import { VirtualConversationDatabase } from "../../../server/service/session/PredictSessionsDatabase";
+import { SessionMetaService } from "../../../server/service/session/SessionMetaService";
+import { SessionRepository } from "../../../server/service/session/SessionRepository";
-await routes(honoApp);
+const program = routes(honoApp);
+
+await Effect.runPromise(
+ program.pipe(
+ // 依存の浅い順にコンテナに pipe する必要がある
+
+ /** Application */
+ Effect.provide(InitializeService.Live),
+
+ /** Domain */
+ Effect.provide(ClaudeCodeLifeCycleService.Live),
+ Effect.provide(ClaudeCodePermissionService.Live),
+ Effect.provide(ClaudeCodeSessionProcessService.Live),
+
+ // Shared Services
+ Effect.provide(FileWatcherService.Live),
+ Effect.provide(EventBus.Live),
+
+ /** Infrastructure */
+
+ // Repository
+ Effect.provide(ProjectRepository.Live),
+ Effect.provide(SessionRepository.Live),
+
+ // StorageService
+ Effect.provide(ProjectMetaService.Live),
+ Effect.provide(SessionMetaService.Live),
+ Effect.provide(VirtualConversationDatabase.Live),
+
+ /** Platform */
+ Effect.provide(NodeContext.layer),
+ ),
+);
export const GET = handle(honoApp);
export const POST = handle(honoApp);
diff --git a/src/app/components/SSEEventListeners.tsx b/src/app/components/SSEEventListeners.tsx
index 77d6fbc..50bf595 100644
--- a/src/app/components/SSEEventListeners.tsx
+++ b/src/app/components/SSEEventListeners.tsx
@@ -5,11 +5,11 @@ import { useSetAtom } from "jotai";
import type { FC, PropsWithChildren } from "react";
import { projectDetailQuery, sessionDetailQuery } from "../../lib/api/queries";
import { useServerEventListener } from "../../lib/sse/hook/useServerEventListener";
-import { aliveTasksAtom } from "../projects/[projectId]/sessions/[sessionId]/store/aliveTasksAtom";
+import { sessionProcessesAtom } from "../projects/[projectId]/sessions/[sessionId]/store/sessionProcessesAtom";
export const SSEEventListeners: FC = ({ children }) => {
const queryClient = useQueryClient();
- const setAliveTasks = useSetAtom(aliveTasksAtom);
+ const setSessionProcesses = useSetAtom(sessionProcessesAtom);
useServerEventListener("sessionListChanged", async (event) => {
// invalidate session list
@@ -25,8 +25,8 @@ export const SSEEventListeners: FC = ({ children }) => {
});
});
- useServerEventListener("taskChanged", async ({ aliveTasks }) => {
- setAliveTasks(aliveTasks);
+ useServerEventListener("sessionProcessChanged", async ({ processes }) => {
+ setSessionProcesses(processes);
});
return <>{children}>;
diff --git a/src/app/components/SyncSessionProcess.tsx b/src/app/components/SyncSessionProcess.tsx
new file mode 100644
index 0000000..e0ff79a
--- /dev/null
+++ b/src/app/components/SyncSessionProcess.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import { useSetAtom } from "jotai";
+import { type FC, type PropsWithChildren, useEffect } from "react";
+import type { PublicSessionProcess } from "../../types/session-process";
+import { sessionProcessesAtom } from "../projects/[projectId]/sessions/[sessionId]/store/sessionProcessesAtom";
+
+export const SyncSessionProcess: FC<
+ PropsWithChildren<{ initProcesses: PublicSessionProcess[] }>
+> = ({ children, initProcesses }) => {
+ const setSessionProcesses = useSetAtom(sessionProcessesAtom);
+
+ useEffect(() => {
+ setSessionProcesses(initProcesses);
+ }, [initProcesses, setSessionProcesses]);
+
+ return <>{children}>;
+};
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 030d292..f882369 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -7,8 +7,10 @@ import { SSEProvider } from "../lib/sse/components/SSEProvider";
import { RootErrorBoundary } from "./components/RootErrorBoundary";
import "./globals.css";
+import { honoClient } from "../lib/api/client";
import { configQuery } from "../lib/api/queries";
import { SSEEventListeners } from "./components/SSEEventListeners";
+import { SyncSessionProcess } from "./components/SyncSessionProcess";
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
@@ -40,6 +42,10 @@ export default async function RootLayout({
queryFn: configQuery.queryFn,
});
+ const initSessionProcesses = await honoClient.api.cc["session-processes"]
+ .$get({})
+ .then((response) => response.json());
+
return (
- {children}
+
+
+ {children}
+
+
diff --git a/src/app/projects/[projectId]/components/chatForm/index.ts b/src/app/projects/[projectId]/components/chatForm/index.ts
index 1b68d06..ca75a3b 100644
--- a/src/app/projects/[projectId]/components/chatForm/index.ts
+++ b/src/app/projects/[projectId]/components/chatForm/index.ts
@@ -4,4 +4,7 @@ export type { CommandCompletionRef } from "./CommandCompletion";
export { CommandCompletion } from "./CommandCompletion";
export type { FileCompletionRef } from "./FileCompletion";
export { FileCompletion } from "./FileCompletion";
-export { useNewChatMutation, useResumeChatMutation } from "./useChatMutations";
+export {
+ useContinueSessionProcessMutation,
+ useCreateSessionProcessMutation,
+} from "./useChatMutations";
diff --git a/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts b/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts
index 72d49ab..e124182 100644
--- a/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts
+++ b/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts
@@ -2,20 +2,24 @@ import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { honoClient } from "../../../../../lib/api/client";
-export const useNewChatMutation = (
+export const useCreateSessionProcessMutation = (
projectId: string,
onSuccess?: () => void,
) => {
const router = useRouter();
return useMutation({
- mutationFn: async (options: { message: string }) => {
- const response = await honoClient.api.projects[":projectId"][
- "new-session"
- ].$post(
+ mutationFn: async (options: {
+ message: string;
+ baseSessionId?: string;
+ }) => {
+ const response = await honoClient.api.cc["session-processes"].$post(
{
- param: { projectId },
- json: { message: options.message },
+ json: {
+ projectId,
+ baseSessionId: options.baseSessionId,
+ message: options.message,
+ },
},
{
init: {
@@ -32,22 +36,32 @@ export const useNewChatMutation = (
},
onSuccess: async (response) => {
onSuccess?.();
- router.push(`/projects/${projectId}/sessions/${response.sessionId}`);
+ router.push(
+ `/projects/${projectId}/sessions/${response.sessionProcess.sessionId}`,
+ );
},
});
};
-export const useResumeChatMutation = (projectId: string, sessionId: string) => {
- const router = useRouter();
-
+export const useContinueSessionProcessMutation = (
+ projectId: string,
+ baseSessionId: string,
+) => {
return useMutation({
- mutationFn: async (options: { message: string }) => {
- const response = await honoClient.api.projects[":projectId"].sessions[
- ":sessionId"
- ].resume.$post(
+ mutationFn: async (options: {
+ message: string;
+ sessionProcessId: string;
+ }) => {
+ const response = await honoClient.api.cc["session-processes"][
+ ":sessionProcessId"
+ ].continue.$post(
{
- param: { projectId, sessionId },
- json: { resumeMessage: options.message },
+ param: { sessionProcessId: options.sessionProcessId },
+ json: {
+ projectId: projectId,
+ baseSessionId: baseSessionId,
+ continueMessage: options.message,
+ },
},
{
init: {
@@ -62,10 +76,5 @@ export const useResumeChatMutation = (projectId: string, sessionId: string) => {
return response.json();
},
- onSuccess: async (response) => {
- if (sessionId !== response.sessionId) {
- router.push(`/projects/${projectId}/sessions/${response.sessionId}`);
- }
- },
});
};
diff --git a/src/app/projects/[projectId]/components/newChat/NewChat.tsx b/src/app/projects/[projectId]/components/newChat/NewChat.tsx
index 62f4e30..6f73a1e 100644
--- a/src/app/projects/[projectId]/components/newChat/NewChat.tsx
+++ b/src/app/projects/[projectId]/components/newChat/NewChat.tsx
@@ -1,16 +1,19 @@
import type { FC } from "react";
import { useConfig } from "../../../../hooks/useConfig";
-import { ChatInput, useNewChatMutation } from "../chatForm";
+import { ChatInput, useCreateSessionProcessMutation } from "../chatForm";
export const NewChat: FC<{
projectId: string;
onSuccess?: () => void;
}> = ({ projectId, onSuccess }) => {
- const startNewChat = useNewChatMutation(projectId, onSuccess);
+ const createSessionProcess = useCreateSessionProcessMutation(
+ projectId,
+ onSuccess,
+ );
const { config } = useConfig();
const handleSubmit = async (message: string) => {
- await startNewChat.mutateAsync({ message });
+ await createSessionProcess.mutateAsync({ message });
};
const getPlaceholder = () => {
@@ -25,8 +28,8 @@ export const NewChat: FC<{
{
- const response = await honoClient.api.tasks.abort.$post({
- json: { sessionId },
+ mutationFn: async (sessionProcessId: string) => {
+ const response = await honoClient.api.cc["session-processes"][
+ ":sessionProcessId"
+ ].abort.$post({
+ param: { sessionProcessId },
+ json: { projectId },
});
if (!response.ok) {
@@ -52,13 +56,18 @@ export const SessionPageContent: FC<{
return response.json();
},
});
+ const sessionProcess = useSessionProcess();
- const { isRunningTask, isPausedTask } = useAliveTask(sessionId);
const { currentPermissionRequest, isDialogOpen, onPermissionResponse } =
usePermissionRequests();
+ const relatedSessionProcess = useMemo(
+ () => sessionProcess.getSessionProcess(sessionId),
+ [sessionProcess, sessionId],
+ );
+
// Set up task completion notifications
- useTaskNotifications(isRunningTask);
+ useTaskNotifications(relatedSessionProcess?.status === "running");
const [previousConversationLength, setPreviousConversationLength] =
useState(0);
@@ -69,7 +78,7 @@ export const SessionPageContent: FC<{
// 自動スクロール処理
useEffect(() => {
if (
- (isRunningTask || isPausedTask) &&
+ relatedSessionProcess?.status === "running" &&
conversations.length !== previousConversationLength
) {
setPreviousConversationLength(conversations.length);
@@ -81,7 +90,11 @@ export const SessionPageContent: FC<{
});
}
}
- }, [conversations, isRunningTask, isPausedTask, previousConversationLength]);
+ }, [
+ conversations,
+ relatedSessionProcess?.status,
+ previousConversationLength,
+ ]);
return (
@@ -136,7 +149,7 @@ export const SessionPageContent: FC<{
- {isRunningTask && (
+ {relatedSessionProcess?.status === "running" && (
@@ -148,7 +161,7 @@ export const SessionPageContent: FC<{
variant="ghost"
size="sm"
onClick={() => {
- abortTask.mutate(sessionId);
+ abortTask.mutate(relatedSessionProcess.id);
}}
>
@@ -157,7 +170,7 @@ export const SessionPageContent: FC<{
)}
- {isPausedTask && (
+ {relatedSessionProcess?.status === "paused" && (
@@ -169,7 +182,7 @@ export const SessionPageContent: FC<{
variant="ghost"
size="sm"
onClick={() => {
- abortTask.mutate(sessionId);
+ abortTask.mutate(relatedSessionProcess.id);
}}
>
@@ -190,7 +203,7 @@ export const SessionPageContent: FC<{
getToolResult={getToolResult}
/>
- {isRunningTask && (
+ {relatedSessionProcess?.status === "running" && (
@@ -207,12 +220,15 @@ export const SessionPageContent: FC<{
)}
-
+ {relatedSessionProcess !== undefined ? (
+
+ ) : (
+
+ )}
diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ContinueChat.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ContinueChat.tsx
new file mode 100644
index 0000000..bdbf87f
--- /dev/null
+++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ContinueChat.tsx
@@ -0,0 +1,46 @@
+import type { FC } from "react";
+import { useConfig } from "../../../../../../hooks/useConfig";
+import {
+ ChatInput,
+ useContinueSessionProcessMutation,
+} from "../../../../components/chatForm";
+
+export const ContinueChat: FC<{
+ projectId: string;
+ sessionId: string;
+ sessionProcessId: string;
+}> = ({ projectId, sessionId, sessionProcessId }) => {
+ const continueSessionProcess = useContinueSessionProcessMutation(
+ projectId,
+ sessionId,
+ );
+ const { config } = useConfig();
+
+ const handleSubmit = async (message: string) => {
+ await continueSessionProcess.mutateAsync({ message, sessionProcessId });
+ };
+
+ const getPlaceholder = () => {
+ const isEnterSend = config?.enterKeyBehavior === "enter-send";
+ if (isEnterSend) {
+ return "Type your message... (Start with / for commands, Enter to send)";
+ }
+ return "Type your message... (Start with / for commands, Shift+Enter to send)";
+ };
+
+ return (
+
+
+
+ );
+};
diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ResumeChat.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ResumeChat.tsx
index 0f4397e..b2a788e 100644
--- a/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ResumeChat.tsx
+++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ResumeChat.tsx
@@ -2,27 +2,21 @@ import type { FC } from "react";
import { useConfig } from "../../../../../../hooks/useConfig";
import {
ChatInput,
- useResumeChatMutation,
+ useCreateSessionProcessMutation,
} from "../../../../components/chatForm";
export const ResumeChat: FC<{
projectId: string;
sessionId: string;
- isPausedTask: boolean;
- isRunningTask: boolean;
-}> = ({ projectId, sessionId, isPausedTask, isRunningTask }) => {
- const resumeChat = useResumeChatMutation(projectId, sessionId);
+}> = ({ projectId, sessionId }) => {
+ const createSessionProcess = useCreateSessionProcessMutation(projectId);
const { config } = useConfig();
const handleSubmit = async (message: string) => {
- await resumeChat.mutateAsync({ message });
- };
-
- const getButtonText = () => {
- if (isPausedTask || isRunningTask) {
- return "Send";
- }
- return "Resume";
+ await createSessionProcess.mutateAsync({
+ message,
+ baseSessionId: sessionId,
+ });
};
const getPlaceholder = () => {
@@ -38,10 +32,10 @@ export const ResumeChat: FC<{
{
+export const McpTab: FC<{ projectId: string }> = ({ projectId }) => {
const queryClient = useQueryClient();
const {
@@ -14,12 +14,14 @@ export const McpTab: FC = () => {
isLoading,
error,
} = useQuery({
- queryKey: mcpListQuery.queryKey,
- queryFn: mcpListQuery.queryFn,
+ queryKey: mcpListQuery(projectId).queryKey,
+ queryFn: mcpListQuery(projectId).queryFn,
});
const handleReload = () => {
- queryClient.invalidateQueries({ queryKey: mcpListQuery.queryKey });
+ queryClient.invalidateQueries({
+ queryKey: mcpListQuery(projectId).queryKey,
+ });
};
return (
diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx
index d49066f..dca0e3c 100644
--- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx
+++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx
@@ -87,7 +87,7 @@ export const MobileSidebar: FC = ({
/>
);
case "mcp":
- return ;
+ return ;
case "settings":
return ;
default:
diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx
index 5b4a9f0..4e08949 100644
--- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx
+++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx
@@ -69,7 +69,7 @@ export const SessionSidebar: FC<{
/>
);
case "mcp":
- return ;
+ return ;
case "settings":
return ;
default:
diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx
index 221090b..fd99fbd 100644
--- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx
+++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx
@@ -10,7 +10,7 @@ import { cn } from "@/lib/utils";
import type { Session } from "../../../../../../../server/service/types";
import { NewChatModal } from "../../../../components/newChat/NewChatModal";
import { firstCommandToTitle } from "../../../../services/firstCommandToTitle";
-import { aliveTasksAtom } from "../../store/aliveTasksAtom";
+import { sessionProcessesAtom } from "../../store/sessionProcessesAtom";
export const SessionsTab: FC<{
sessions: Session[];
@@ -27,18 +27,22 @@ export const SessionsTab: FC<{
isFetchingNextPage,
onLoadMore,
}) => {
- const aliveTasks = useAtomValue(aliveTasksAtom);
+ const sessionProcesses = useAtomValue(sessionProcessesAtom);
// Sort sessions: Running > Paused > Others, then by lastModifiedAt (newest first)
const sortedSessions = [...sessions].sort((a, b) => {
- const aTask = aliveTasks.find((task) => task.sessionId === a.id);
- const bTask = aliveTasks.find((task) => task.sessionId === b.id);
+ const aProcess = sessionProcesses.find(
+ (process) => process.sessionId === a.id,
+ );
+ const bProcess = sessionProcesses.find(
+ (process) => process.sessionId === b.id,
+ );
- const aStatus = aTask?.status;
- const bStatus = bTask?.status;
+ const aStatus = aProcess?.status;
+ const bStatus = bProcess?.status;
// Define priority: running = 0, paused = 1, others = 2
- const getPriority = (status: string | undefined) => {
+ const getPriority = (status: "paused" | "running" | undefined) => {
if (status === "running") return 0;
if (status === "paused") return 1;
return 2;
@@ -86,11 +90,11 @@ export const SessionsTab: FC<{
? firstCommandToTitle(session.meta.firstCommand)
: session.id;
- const aliveTask = aliveTasks.find(
+ const sessionProcess = sessionProcesses.find(
(task) => task.sessionId === session.id,
);
- const isRunning = aliveTask?.status === "running";
- const isPaused = aliveTask?.status === "paused";
+ const isRunning = sessionProcess?.status === "running";
+ const isPaused = sessionProcess?.status === "paused";
return (
{
- const [aliveTasks, setAliveTasks] = useAtom(aliveTasksAtom);
-
- useQuery({
- queryKey: aliveTasksQuery.queryKey,
- queryFn: async () => {
- const { aliveTasks } = await aliveTasksQuery.queryFn();
- setAliveTasks(aliveTasks);
- return aliveTasks;
- },
- refetchOnReconnect: true,
- });
-
- const taskInfo = useMemo(() => {
- const aliveTask = aliveTasks.find((task) => task.sessionId === sessionId);
-
- return {
- aliveTask: aliveTasks.find((task) => task.sessionId === sessionId),
- isRunningTask: aliveTask?.status === "running",
- isPausedTask: aliveTask?.status === "paused",
- } as const;
- }, [aliveTasks, sessionId]);
-
- return taskInfo;
-};
diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSession.ts b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSession.ts
index 961de3a..88295a2 100644
--- a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSession.ts
+++ b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSession.ts
@@ -3,9 +3,13 @@ import { useSessionQuery } from "./useSessionQuery";
export const useSession = (projectId: string, sessionId: string) => {
const query = useSessionQuery(projectId, sessionId);
+ const session = query.data?.session;
+ if (session === undefined || session === null) {
+ throw new Error("Session not found");
+ }
const toolResultMap = useMemo(() => {
- const entries = query.data.session.conversations.flatMap((conversation) => {
+ const entries = session.conversations.flatMap((conversation) => {
if (conversation.type !== "user") {
return [];
}
@@ -28,7 +32,7 @@ export const useSession = (projectId: string, sessionId: string) => {
});
return new Map(entries);
- }, [query.data.session.conversations]);
+ }, [session.conversations]);
const getToolResult = useCallback(
(toolUseId: string) => {
@@ -38,8 +42,8 @@ export const useSession = (projectId: string, sessionId: string) => {
);
return {
- session: query.data.session,
- conversations: query.data.session.conversations,
+ session,
+ conversations: session.conversations,
getToolResult,
};
};
diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionProcess.ts b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionProcess.ts
new file mode 100644
index 0000000..52203bd
--- /dev/null
+++ b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionProcess.ts
@@ -0,0 +1,23 @@
+import { useAtomValue } from "jotai";
+import { useCallback } from "react";
+import { sessionProcessesAtom } from "../store/sessionProcessesAtom";
+
+export const useSessionProcess = () => {
+ const sessionProcesses = useAtomValue(sessionProcessesAtom);
+
+ const getSessionProcess = useCallback(
+ (sessionId: string) => {
+ const targetProcess = sessionProcesses.find(
+ (process) => process.sessionId === sessionId,
+ );
+
+ return targetProcess;
+ },
+ [sessionProcesses],
+ );
+
+ return {
+ sessionProcesses,
+ getSessionProcess,
+ };
+};
diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/store/aliveTasksAtom.ts b/src/app/projects/[projectId]/sessions/[sessionId]/store/aliveTasksAtom.ts
deleted file mode 100644
index 90dd9de..0000000
--- a/src/app/projects/[projectId]/sessions/[sessionId]/store/aliveTasksAtom.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import { atom } from "jotai";
-import type { SerializableAliveTask } from "../../../../../../server/service/claude-code/types";
-
-export const aliveTasksAtom = atom([]);
diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/store/sessionProcessesAtom.ts b/src/app/projects/[projectId]/sessions/[sessionId]/store/sessionProcessesAtom.ts
new file mode 100644
index 0000000..7cefa5c
--- /dev/null
+++ b/src/app/projects/[projectId]/sessions/[sessionId]/store/sessionProcessesAtom.ts
@@ -0,0 +1,4 @@
+import { atom } from "jotai";
+import type { PublicSessionProcess } from "../../../../../../types/session-process";
+
+export const sessionProcessesAtom = atom([]);
diff --git a/src/hooks/usePermissionRequests.ts b/src/hooks/usePermissionRequests.ts
index 17a4cb9..d28f485 100644
--- a/src/hooks/usePermissionRequests.ts
+++ b/src/hooks/usePermissionRequests.ts
@@ -25,7 +25,7 @@ export const usePermissionRequests = () => {
const handlePermissionResponse = useCallback(
async (response: PermissionResponse) => {
try {
- const apiResponse = await honoClient.api.tasks[
+ const apiResponse = await honoClient.api.cc[
"permission-response"
].$post({
json: response,
diff --git a/src/lib/api/queries.ts b/src/lib/api/queries.ts
index 5d754d5..2a75bdc 100644
--- a/src/lib/api/queries.ts
+++ b/src/lib/api/queries.ts
@@ -74,10 +74,10 @@ export const claudeCommandsQuery = (projectId: string) =>
},
}) as const;
-export const aliveTasksQuery = {
- queryKey: ["aliveTasks"],
+export const sessionProcessesQuery = {
+ queryKey: ["sessionProcesses"],
queryFn: async () => {
- const response = await honoClient.api.tasks.alive.$get({});
+ const response = await honoClient.api.cc["session-processes"].$get({});
if (!response.ok) {
throw new Error(`Failed to fetch alive tasks: ${response.statusText}`);
@@ -123,18 +123,23 @@ export const gitCommitsQuery = (projectId: string) =>
},
}) as const;
-export const mcpListQuery = {
- queryKey: ["mcp", "list"],
- queryFn: async () => {
- const response = await honoClient.api.mcp.list.$get();
+export const mcpListQuery = (projectId: string) =>
+ ({
+ queryKey: ["mcp", "list", projectId],
+ queryFn: async () => {
+ const response = await honoClient.api.projects[
+ ":projectId"
+ ].mcp.list.$get({
+ param: { projectId },
+ });
- if (!response.ok) {
- throw new Error(`Failed to fetch MCP list: ${response.statusText}`);
- }
+ if (!response.ok) {
+ throw new Error(`Failed to fetch MCP list: ${response.statusText}`);
+ }
- return await response.json();
- },
-} as const;
+ return await response.json();
+ },
+ }) as const;
export const fileCompletionQuery = (projectId: string, basePath: string) =>
({
@@ -151,7 +156,7 @@ export const fileCompletionQuery = (projectId: string, basePath: string) =>
throw new Error("Failed to fetch file completion");
}
- return response.json();
+ return await response.json();
},
}) as const;
diff --git a/src/lib/controllablePromise.ts b/src/lib/controllablePromise.ts
new file mode 100644
index 0000000..98d44b0
--- /dev/null
+++ b/src/lib/controllablePromise.ts
@@ -0,0 +1,25 @@
+export type ControllablePromise = {
+ readonly promise: Promise;
+ readonly resolve: (value: T) => void;
+ readonly reject: (reason?: unknown) => void;
+};
+
+export const controllablePromise = (): ControllablePromise => {
+ let promiseResolve: ((value: T) => void) | undefined;
+ let promiseReject: ((reason?: unknown) => void) | undefined;
+
+ const promise = new Promise((resolve, reject) => {
+ promiseResolve = resolve;
+ promiseReject = reject;
+ });
+
+ if (!promiseResolve || !promiseReject) {
+ throw new Error("Illegal state: Promise not created");
+ }
+
+ return {
+ promise,
+ resolve: promiseResolve,
+ reject: promiseReject,
+ } as const;
+};
diff --git a/src/lib/conversation-schema/entry/UserEntrySchema.ts b/src/lib/conversation-schema/entry/UserEntrySchema.ts
index 6cc7b77..99c86c5 100644
--- a/src/lib/conversation-schema/entry/UserEntrySchema.ts
+++ b/src/lib/conversation-schema/entry/UserEntrySchema.ts
@@ -9,3 +9,5 @@ export const UserEntrySchema = BaseEntrySchema.extend({
// required
message: UserMessageSchema,
});
+
+export type UserEntry = z.infer;
diff --git a/src/server/hono/initialize.test.ts b/src/server/hono/initialize.test.ts
new file mode 100644
index 0000000..e3515a7
--- /dev/null
+++ b/src/server/hono/initialize.test.ts
@@ -0,0 +1,362 @@
+import { Effect, Layer, Ref } from "effect";
+import { describe, expect, it, vi } from "vitest";
+import { EventBus } from "../service/events/EventBus";
+import { FileWatcherService } from "../service/events/fileWatcher";
+import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration";
+import { ProjectMetaService } from "../service/project/ProjectMetaService";
+import { ProjectRepository } from "../service/project/ProjectRepository";
+import { VirtualConversationDatabase } from "../service/session/PredictSessionsDatabase";
+import { SessionMetaService } from "../service/session/SessionMetaService";
+import { SessionRepository } from "../service/session/SessionRepository";
+import { InitializeService } from "./initialize";
+
+describe("InitializeService", () => {
+ const createMockProjectRepository = (
+ projects: Array<{
+ id: string;
+ claudeProjectPath: string;
+ lastModifiedAt: Date;
+ meta: {
+ projectName: string | null;
+ projectPath: string | null;
+ sessionCount: number;
+ };
+ }> = [],
+ ) =>
+ Layer.succeed(ProjectRepository, {
+ getProjects: () => Effect.succeed({ projects }),
+ getProject: () => Effect.fail(new Error("Not implemented in mock")),
+ });
+
+ const createMockSessionRepository = (
+ sessions: Array<{
+ id: string;
+ jsonlFilePath: string;
+ lastModifiedAt: Date;
+ meta: {
+ messageCount: number;
+ firstCommand: {
+ kind: "command";
+ commandName: string;
+ commandArgs?: string;
+ commandMessage?: string;
+ } | null;
+ };
+ }> = [],
+ getSessionsCb?: (projectId: string) => void,
+ ) =>
+ Layer.succeed(SessionRepository, {
+ getSessions: (projectId: string) => {
+ if (getSessionsCb) getSessionsCb(projectId);
+ return Effect.succeed({ sessions });
+ },
+ getSession: () => Effect.fail(new Error("Not implemented in mock")),
+ });
+
+ const createMockProjectMetaService = () =>
+ Layer.succeed(ProjectMetaService, {
+ getProjectMeta: () =>
+ Effect.succeed({
+ projectName: "Test Project",
+ projectPath: "/path/to/project",
+ sessionCount: 0,
+ }),
+ invalidateProject: () => Effect.void,
+ });
+
+ const createMockSessionMetaService = () =>
+ Layer.succeed(SessionMetaService, {
+ getSessionMeta: () =>
+ Effect.succeed({
+ messageCount: 0,
+ firstCommand: null,
+ }),
+ invalidateSession: () => Effect.void,
+ });
+
+ const createTestLayer = (
+ mockProjectRepositoryLayer: Layer.Layer<
+ ProjectRepository,
+ never,
+ never
+ > = createMockProjectRepository(),
+ mockSessionRepositoryLayer: Layer.Layer<
+ SessionRepository,
+ never,
+ never
+ > = createMockSessionRepository(),
+ ) => {
+ // Provide EventBus first since FileWatcherService depends on it
+ const fileWatcherWithEventBus = FileWatcherService.Live.pipe(
+ Layer.provide(EventBus.Live),
+ );
+
+ // Merge all dependencies
+ const allDependencies = Layer.mergeAll(
+ EventBus.Live,
+ fileWatcherWithEventBus,
+ mockProjectRepositoryLayer,
+ mockSessionRepositoryLayer,
+ createMockProjectMetaService(),
+ createMockSessionMetaService(),
+ VirtualConversationDatabase.Live,
+ );
+
+ // Provide dependencies to InitializeService.Live and expose all services
+ return Layer.provide(InitializeService.Live, allDependencies).pipe(
+ Layer.merge(allDependencies),
+ );
+ };
+
+ describe("basic initialization process", () => {
+ it("service initialization succeeds", async () => {
+ const mockProjectRepositoryLayer = createMockProjectRepository([
+ {
+ id: "project-1",
+ claudeProjectPath: "/path/to/project-1",
+ lastModifiedAt: new Date(),
+ meta: {
+ projectName: "Project 1",
+ projectPath: "/path/to/project-1",
+ sessionCount: 2,
+ },
+ },
+ ]);
+
+ const mockSessionRepositoryLayer = createMockSessionRepository([
+ {
+ id: "session-1",
+ jsonlFilePath: "/path/to/session-1.jsonl",
+ lastModifiedAt: new Date(),
+ meta: {
+ messageCount: 5,
+ firstCommand: {
+ kind: "command",
+ commandName: "test",
+ },
+ },
+ },
+ {
+ id: "session-2",
+ jsonlFilePath: "/path/to/session-2.jsonl",
+ lastModifiedAt: new Date(),
+ meta: {
+ messageCount: 3,
+ firstCommand: null,
+ },
+ },
+ ]);
+
+ const program = Effect.gen(function* () {
+ const initialize = yield* InitializeService;
+ return yield* initialize.startInitialization();
+ });
+
+ const testLayer = createTestLayer(
+ mockProjectRepositoryLayer,
+ mockSessionRepositoryLayer,
+ );
+
+ const result = await Effect.runPromise(
+ program.pipe(Effect.provide(testLayer)),
+ );
+
+ expect(result).toBeUndefined();
+ });
+
+ it("file watcher is started", async () => {
+ const program = Effect.gen(function* () {
+ const initialize = yield* InitializeService;
+
+ yield* initialize.startInitialization();
+
+ // Verify file watcher is started
+ // (In actual implementation, verify that startWatching is called)
+ return "file watcher started";
+ });
+
+ const testLayer = createTestLayer();
+
+ const result = await Effect.runPromise(
+ program.pipe(Effect.provide(testLayer)),
+ );
+
+ expect(result).toBe("file watcher started");
+ });
+ });
+
+ describe("event processing", () => {
+ it("receives sessionChanged event", async () => {
+ const program = Effect.gen(function* () {
+ const initialize = yield* InitializeService;
+ const eventBus = yield* EventBus;
+ const eventsRef = yield* Ref.make<
+ Array
+ >([]);
+
+ // Set up listener for sessionChanged event
+ yield* eventBus.on("sessionChanged", (event) => {
+ Effect.runSync(Ref.update(eventsRef, (events) => [...events, event]));
+ });
+
+ yield* initialize.startInitialization();
+
+ // Emit event
+ yield* eventBus.emit("sessionChanged", {
+ projectId: "project-1",
+ sessionId: "session-1",
+ });
+
+ // Wait a bit for event to be processed
+ yield* Effect.sleep("50 millis");
+
+ const events = yield* Ref.get(eventsRef);
+ return events;
+ });
+
+ const testLayer = createTestLayer();
+
+ const result = await Effect.runPromise(
+ program.pipe(Effect.provide(testLayer)),
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual({
+ projectId: "project-1",
+ sessionId: "session-1",
+ });
+ });
+
+ it("heartbeat event is emitted periodically", async () => {
+ const program = Effect.gen(function* () {
+ const initialize = yield* InitializeService;
+ const eventBus = yield* EventBus;
+ const heartbeatCountRef = yield* Ref.make(0);
+
+ // Set up listener for heartbeat event
+ yield* eventBus.on("heartbeat", () =>
+ Effect.runSync(Ref.update(heartbeatCountRef, (count) => count + 1)),
+ );
+
+ yield* initialize.startInitialization();
+
+ // Wait a bit to verify heartbeat is emitted
+ // (In actual tests, should use mock to shorten time)
+ yield* Effect.sleep("100 millis");
+
+ const count = yield* Ref.get(heartbeatCountRef);
+ return count;
+ });
+
+ const testLayer = createTestLayer();
+
+ const result = await Effect.runPromise(
+ program.pipe(Effect.provide(testLayer)),
+ );
+
+ // heartbeat is emitted immediately once first, then every 10 seconds
+ // At 100ms, only the first one is emitted
+ expect(result).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ describe("cache initialization", () => {
+ it("project and session caches are initialized", async () => {
+ const getProjectsCalled = vi.fn();
+ const getSessionsCalled = vi.fn();
+
+ const mockProjectRepositoryLayer = Layer.succeed(ProjectRepository, {
+ getProjects: () => {
+ getProjectsCalled();
+ return Effect.succeed({
+ projects: [
+ {
+ id: "project-1",
+ claudeProjectPath: "/path/to/project-1",
+ lastModifiedAt: new Date(),
+ meta: {
+ projectName: "Project 1",
+ projectPath: "/path/to/project-1",
+ sessionCount: 2,
+ },
+ },
+ ],
+ });
+ },
+ getProject: () => Effect.fail(new Error("Not implemented in mock")),
+ });
+
+ const mockSessionRepositoryLayer = createMockSessionRepository(
+ [
+ {
+ id: "session-1",
+ jsonlFilePath: "/path/to/session-1.jsonl",
+ lastModifiedAt: new Date(),
+ meta: {
+ messageCount: 5,
+ firstCommand: {
+ kind: "command",
+ commandName: "test",
+ },
+ },
+ },
+ ],
+ getSessionsCalled,
+ );
+
+ const program = Effect.gen(function* () {
+ const initialize = yield* InitializeService;
+ yield* initialize.startInitialization();
+ });
+
+ const testLayer = createTestLayer(
+ mockProjectRepositoryLayer,
+ mockSessionRepositoryLayer,
+ );
+
+ await Effect.runPromise(program.pipe(Effect.provide(testLayer)));
+
+ expect(getProjectsCalled).toHaveBeenCalledTimes(1);
+ expect(getSessionsCalled).toHaveBeenCalledTimes(1);
+ expect(getSessionsCalled).toHaveBeenCalledWith("project-1");
+ });
+
+ it("doesn't throw error even if cache initialization fails", async () => {
+ const mockProjectRepositoryLayer = Layer.succeed(ProjectRepository, {
+ getProjects: () => Effect.fail(new Error("Failed to get projects")),
+ getProject: () => Effect.fail(new Error("Not implemented in mock")),
+ });
+
+ const program = Effect.gen(function* () {
+ const initialize = yield* InitializeService;
+ return yield* initialize.startInitialization();
+ });
+
+ const testLayer = createTestLayer(mockProjectRepositoryLayer);
+
+ // Completes without throwing error
+ await expect(
+ Effect.runPromise(program.pipe(Effect.provide(testLayer))),
+ ).resolves.toBeUndefined();
+ });
+ });
+
+ describe("cleanup", () => {
+ it("resources are cleaned up with stopCleanup", async () => {
+ const program = Effect.gen(function* () {
+ const initialize = yield* InitializeService;
+ yield* initialize.startInitialization();
+ yield* initialize.stopCleanup();
+ return "cleaned up";
+ });
+
+ const testLayer = createTestLayer();
+
+ const result = await Effect.runPromise(
+ program.pipe(Effect.provide(testLayer)),
+ );
+
+ expect(result).toBe("cleaned up");
+ });
+ });
+});
diff --git a/src/server/hono/initialize.ts b/src/server/hono/initialize.ts
index 5e992b2..ab11261 100644
--- a/src/server/hono/initialize.ts
+++ b/src/server/hono/initialize.ts
@@ -1,55 +1,144 @@
-import prexit from "prexit";
-import { claudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController";
-import { eventBus } from "../service/events/EventBus";
-import { fileWatcher } from "../service/events/fileWatcher";
+import { Context, Effect, Layer, Ref, Schedule } from "effect";
+import { EventBus } from "../service/events/EventBus";
+import { FileWatcherService } from "../service/events/fileWatcher";
import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration";
-import type { ProjectRepository } from "../service/project/ProjectRepository";
-import { projectMetaStorage } from "../service/project/projectMetaStorage";
-import type { SessionRepository } from "../service/session/SessionRepository";
-import { sessionMetaStorage } from "../service/session/sessionMetaStorage";
+import { ProjectMetaService } from "../service/project/ProjectMetaService";
+import { ProjectRepository } from "../service/project/ProjectRepository";
+import { VirtualConversationDatabase } from "../service/session/PredictSessionsDatabase";
+import { SessionMetaService } from "../service/session/SessionMetaService";
+import { SessionRepository } from "../service/session/SessionRepository";
-export const initialize = async (deps: {
- sessionRepository: SessionRepository;
- projectRepository: ProjectRepository;
-}): Promise => {
- fileWatcher.startWatching();
+interface InitializeServiceInterface {
+ readonly startInitialization: () => Effect.Effect;
+ readonly stopCleanup: () => Effect.Effect;
+}
- const intervalId = setInterval(() => {
- eventBus.emit("heartbeat", {});
- }, 10 * 1000);
+export class InitializeService extends Context.Tag("InitializeService")<
+ InitializeService,
+ InitializeServiceInterface
+>() {
+ static Live = Layer.effect(
+ this,
+ Effect.gen(function* () {
+ const eventBus = yield* EventBus;
+ const fileWatcher = yield* FileWatcherService;
+ const projectRepository = yield* ProjectRepository;
+ const sessionRepository = yield* SessionRepository;
+ const projectMetaService = yield* ProjectMetaService;
+ const sessionMetaService = yield* SessionMetaService;
+ const virtualConversationDatabase = yield* VirtualConversationDatabase;
- const onSessionChanged = (
- event: InternalEventDeclaration["sessionChanged"],
- ) => {
- projectMetaStorage.invalidateProject(event.projectId);
- sessionMetaStorage.invalidateSession(event.projectId, event.sessionId);
- };
+ // 状態管理用の Ref
+ const listenersRef = yield* Ref.make<{
+ sessionProcessChanged?:
+ | ((event: InternalEventDeclaration["sessionProcessChanged"]) => void)
+ | null;
+ sessionChanged?:
+ | ((event: InternalEventDeclaration["sessionChanged"]) => void)
+ | null;
+ }>({});
- eventBus.on("sessionChanged", onSessionChanged);
+ const startInitialization = (): Effect.Effect => {
+ return Effect.gen(function* () {
+ // ファイルウォッチャーを開始
+ yield* fileWatcher.startWatching();
- try {
- console.log("Initializing projects cache");
- const { projects } = await deps.projectRepository.getProjects();
- console.log(`${projects.length} projects cache initialized`);
+ // ハートビートを定期的に送信
+ const daemon = Effect.repeat(
+ eventBus.emit("heartbeat", {}),
+ Schedule.fixed("10 seconds"),
+ );
- console.log("Initializing sessions cache");
- const results = await Promise.all(
- projects.map((project) => deps.sessionRepository.getSessions(project.id)),
- );
- console.log(
- `${results.reduce(
- (s, { sessions }) => s + sessions.length,
- 0,
- )} sessions cache initialized`,
- );
- } catch {
- // do nothing
- }
+ console.log("start heartbeat");
+ yield* Effect.forkDaemon(daemon);
+ console.log("after starting heartbeat fork");
- prexit(() => {
- clearInterval(intervalId);
- eventBus.off("sessionChanged", onSessionChanged);
- fileWatcher.stop();
- claudeCodeTaskController.abortAllTasks();
- });
-};
+ // sessionChanged イベントのリスナーを登録
+ const onSessionChanged = (
+ event: InternalEventDeclaration["sessionChanged"],
+ ) => {
+ Effect.runFork(
+ projectMetaService.invalidateProject(event.projectId),
+ );
+
+ Effect.runFork(
+ sessionMetaService.invalidateSession(
+ event.projectId,
+ event.sessionId,
+ ),
+ );
+ };
+
+ const onSessionProcessChanged = (
+ event: InternalEventDeclaration["sessionProcessChanged"],
+ ) => {
+ if (
+ (event.changed.type === "completed" ||
+ event.changed.type === "paused") &&
+ event.changed.sessionId !== undefined
+ ) {
+ Effect.runFork(
+ virtualConversationDatabase.deleteVirtualConversations(
+ event.changed.sessionId,
+ ),
+ );
+ return;
+ }
+ };
+
+ yield* Ref.set(listenersRef, {
+ sessionChanged: onSessionChanged,
+ sessionProcessChanged: onSessionProcessChanged,
+ });
+ yield* eventBus.on("sessionChanged", onSessionChanged);
+ yield* eventBus.on("sessionProcessChanged", onSessionProcessChanged);
+
+ yield* Effect.gen(function* () {
+ console.log("Initializing projects cache");
+ const { projects } = yield* projectRepository.getProjects();
+ console.log(`${projects.length} projects cache initialized`);
+
+ console.log("Initializing sessions cache");
+ const results = yield* Effect.all(
+ projects.map((project) =>
+ sessionRepository.getSessions(project.id),
+ ),
+ { concurrency: "unbounded" },
+ );
+ const totalSessions = results.reduce(
+ (s, { sessions }) => s + sessions.length,
+ 0,
+ );
+ console.log(`${totalSessions} sessions cache initialized`);
+ }).pipe(
+ Effect.catchAll(() => Effect.void),
+ Effect.withSpan("initialize-cache"),
+ );
+ }).pipe(Effect.withSpan("start-initialization")) as Effect.Effect;
+ };
+
+ const stopCleanup = (): Effect.Effect =>
+ Effect.gen(function* () {
+ const listeners = yield* Ref.get(listenersRef);
+ if (listeners.sessionChanged) {
+ yield* eventBus.off("sessionChanged", listeners.sessionChanged);
+ }
+
+ if (listeners.sessionProcessChanged) {
+ yield* eventBus.off(
+ "sessionProcessChanged",
+ listeners.sessionProcessChanged,
+ );
+ }
+
+ yield* Ref.set(listenersRef, {});
+ yield* fileWatcher.stop();
+ });
+
+ return {
+ startInitialization,
+ stopCleanup,
+ } satisfies InitializeServiceInterface;
+ }),
+ );
+}
diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts
index 9521f74..3a809ee 100644
--- a/src/server/hono/route.ts
+++ b/src/server/hono/route.ts
@@ -1,486 +1,692 @@
import { readdir } from "node:fs/promises";
import { resolve } from "node:path";
+import type { CommandExecutor, FileSystem, Path } from "@effect/platform";
import { zValidator } from "@hono/zod-validator";
+import { Effect, Runtime } from "effect";
import { setCookie } from "hono/cookie";
import { streamSSE } from "hono/streaming";
+import prexit from "prexit";
import { z } from "zod";
-import { type Config, configSchema } from "../config/config";
+import type { PublicSessionProcess } from "../../types/session-process";
+import { configSchema } from "../config/config";
import { env } from "../lib/env";
-import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController";
-import type { SerializableAliveTask } from "../service/claude-code/types";
+import { ClaudeCodeLifeCycleService } from "../service/claude-code/ClaudeCodeLifeCycleService";
+import { ClaudeCodePermissionService } from "../service/claude-code/ClaudeCodePermissionService";
import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE";
-import { eventBus } from "../service/events/EventBus";
+import { EventBus } from "../service/events/EventBus";
import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration";
-import { writeTypeSafeSSE } from "../service/events/typeSafeSSE";
+import { TypeSafeSSE } from "../service/events/typeSafeSSE";
import { getFileCompletion } from "../service/file-completion/getFileCompletion";
import { getBranches } from "../service/git/getBranches";
import { getCommits } from "../service/git/getCommits";
import { getDiff } from "../service/git/getDiff";
import { getMcpList } from "../service/mcp/getMcpList";
import { claudeCommandsDirPath } from "../service/paths";
+import type { ProjectMetaService } from "../service/project/ProjectMetaService";
import { ProjectRepository } from "../service/project/ProjectRepository";
+import type { VirtualConversationDatabase } from "../service/session/PredictSessionsDatabase";
+import type { SessionMetaService } from "../service/session/SessionMetaService";
import { SessionRepository } from "../service/session/SessionRepository";
import type { HonoAppType } from "./app";
-import { initialize } from "./initialize";
+import { InitializeService } from "./initialize";
import { configMiddleware } from "./middleware/config.middleware";
-export const routes = async (app: HonoAppType) => {
- const sessionRepository = new SessionRepository();
- const projectRepository = new ProjectRepository();
+export const routes = (app: HonoAppType) =>
+ Effect.gen(function* () {
+ const sessionRepository = yield* SessionRepository;
+ const projectRepository = yield* ProjectRepository;
+ const claudeCodeLifeCycleService = yield* ClaudeCodeLifeCycleService;
+ const claudeCodePermissionService = yield* ClaudeCodePermissionService;
+ const initializeService = yield* InitializeService;
+ const eventBus = yield* EventBus;
- const fileWatcher = getFileWatcher();
- const eventBus = getEventBus();
+ const runtime = yield* Effect.runtime<
+ | ProjectMetaService
+ | SessionMetaService
+ | VirtualConversationDatabase
+ | FileSystem.FileSystem
+ | Path.Path
+ | CommandExecutor.CommandExecutor
+ >();
- if (env.get("NEXT_PHASE") !== "phase-production-build") {
- fileWatcher.startWatching();
+ if (env.get("NEXT_PHASE") !== "phase-production-build") {
+ yield* initializeService.startInitialization();
- setInterval(() => {
- eventBus.emit("heartbeat", {});
- }, 10 * 1000);
- }
+ prexit(async () => {
+ await Runtime.runPromise(runtime)(initializeService.stopCleanup());
+ });
+ }
- return (
- app
- // middleware
- .use(configMiddleware)
- .use(async (c, next) => {
- claudeCodeTaskController.updateConfig(c.get("config"));
- await next();
- })
+ return (
+ app
+ // middleware
+ .use(configMiddleware)
+ .use(async (_c, next) => {
+ await next();
+ })
- // routes
- .get("/config", async (c) => {
- return c.json({
- config: c.get("config"),
- });
- })
+ // routes
+ .get("/config", async (c) => {
+ return c.json({
+ config: c.get("config"),
+ });
+ })
- .put("/config", zValidator("json", configSchema), async (c) => {
- const { ...config } = c.req.valid("json");
+ .put("/config", zValidator("json", configSchema), async (c) => {
+ const { ...config } = c.req.valid("json");
- setCookie(c, "ccv-config", JSON.stringify(config));
+ setCookie(c, "ccv-config", JSON.stringify(config));
- return c.json({
- config,
- });
- })
+ return c.json({
+ config,
+ });
+ })
- .get("/projects", async (c) => {
- const { projects } = await projectRepository.getProjects();
- return c.json({ projects });
- })
+ .get("/projects", async (c) => {
+ const program = Effect.gen(function* () {
+ return yield* projectRepository.getProjects();
+ });
- .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 { projects } = await Runtime.runPromise(runtime)(program);
- const [{ project }, { sessions, nextCursor }] = await Promise.all([
- projectRepository.getProject(projectId),
- sessionRepository
- .getSessions(projectId, { cursor })
- .then(({ sessions }) => {
- let filteredSessions = sessions;
+ return c.json({ projects });
+ })
- // Filter sessions based on hideNoUserMessageSession setting
- if (c.get("config").hideNoUserMessageSession) {
- filteredSessions = filteredSessions.filter((session) => {
- return session.meta.firstCommand !== null;
- });
- }
+ .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");
- // Unify sessions with same title if unifySameTitleSession is enabled
- if (c.get("config").unifySameTitleSession) {
- const sessionMap = new Map<
- string,
- (typeof filteredSessions)[0]
- >();
+ const program = Effect.gen(function* () {
+ const { project } =
+ yield* projectRepository.getProject(projectId);
+ const { sessions } = yield* sessionRepository.getSessions(
+ projectId,
+ { cursor },
+ );
- 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;
+ let filteredSessions = sessions;
- const existingSession = sessionMap.get(title);
- if (existingSession) {
- // Keep the session with the latest modification date
+ // 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
- ) {
- if (
- session.lastModifiedAt >
- existingSession.lastModifiedAt
- ) {
- sessionMap.set(title, session);
- }
- } else if (
- session.lastModifiedAt &&
- !existingSession.lastModifiedAt
+ session.lastModifiedAt > existingSession.lastModifiedAt
) {
sessionMap.set(title, session);
}
- // If no modification dates, keep the existing one
- } else {
+ } 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());
}
- return {
- sessions: filteredSessions,
- nextCursor: sessions.at(-1)?.id,
- };
- }),
- ] as const);
-
- return c.json({ project, sessions, nextCursor });
- },
- )
-
- .get("/projects/:projectId/sessions/:sessionId", async (c) => {
- const { projectId, sessionId } = c.req.param();
- const { session } = await sessionRepository.getSession(
- projectId,
- sessionId,
- );
- return c.json({ session });
- })
-
- .get(
- "/projects/:projectId/file-completion",
- zValidator(
- "query",
- z.object({
- basePath: z.string().optional().default("/"),
- }),
- ),
- async (c) => {
- const { projectId } = c.req.param();
- const { basePath } = c.req.valid("query");
-
- const { project } = await projectRepository.getProject(projectId);
-
- if (project.meta.projectPath === null) {
- return c.json({ error: "Project path not found" }, 400);
- }
-
- try {
- const result = await getFileCompletion(
- project.meta.projectPath,
- basePath,
- );
- return c.json(result);
- } catch (error) {
- console.error("File completion error:", error);
- return c.json({ error: "Failed to get file completion" }, 500);
- }
- },
- )
-
- .get("/projects/:projectId/claude-commands", async (c) => {
- const { projectId } = c.req.param();
- const { project } = await projectRepository.getProject(projectId);
-
- const [globalCommands, projectCommands] = await 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 c.json({
- globalCommands:
- globalCommands.status === "fulfilled" ? globalCommands.value : [],
- projectCommands:
- projectCommands.status === "fulfilled" ? projectCommands.value : [],
- defaultCommands: ["init", "compact"],
- });
- })
-
- .get("/projects/:projectId/git/branches", async (c) => {
- const { projectId } = c.req.param();
- const { project } = await projectRepository.getProject(projectId);
-
- if (project.meta.projectPath === null) {
- return c.json({ error: "Project path not found" }, 400);
- }
-
- try {
- const result = await getBranches(project.meta.projectPath);
- return c.json(result);
- } catch (error) {
- console.error("Get branches error:", error);
- if (error instanceof Error) {
- return c.json({ error: error.message }, 400);
- }
- return c.json({ error: "Failed to get branches" }, 500);
- }
- })
-
- .get("/projects/:projectId/git/commits", async (c) => {
- const { projectId } = c.req.param();
- const { project } = await projectRepository.getProject(projectId);
-
- if (project.meta.projectPath === null) {
- return c.json({ error: "Project path not found" }, 400);
- }
-
- try {
- const result = await getCommits(project.meta.projectPath);
- return c.json(result);
- } catch (error) {
- console.error("Get commits error:", error);
- if (error instanceof Error) {
- return c.json({ error: error.message }, 400);
- }
- return c.json({ error: "Failed to get commits" }, 500);
- }
- })
-
- .post(
- "/projects/:projectId/git/diff",
- zValidator(
- "json",
- z.object({
- fromRef: z.string().min(1, "fromRef is required"),
- toRef: z.string().min(1, "toRef is required"),
- }),
- ),
- async (c) => {
- const { projectId } = c.req.param();
- const { fromRef, toRef } = c.req.valid("json");
- const { project } = await projectRepository.getProject(projectId);
-
- if (project.meta.projectPath === null) {
- return c.json({ error: "Project path not found" }, 400);
- }
-
- try {
- const result = await getDiff(
- project.meta.projectPath,
- fromRef,
- toRef,
- );
- return c.json(result);
- } catch (error) {
- console.error("Get diff error:", error);
- if (error instanceof Error) {
- return c.json({ error: error.message }, 400);
- }
- return c.json({ error: "Failed to get diff" }, 500);
- }
- },
- )
-
- .get("/mcp/list", async (c) => {
- const { servers } = await getMcpList();
- return c.json({ servers });
- })
-
- .post(
- "/projects/:projectId/new-session",
- zValidator(
- "json",
- z.object({
- message: z.string(),
- }),
- ),
- async (c) => {
- const { projectId } = c.req.param();
- const { message } = c.req.valid("json");
- const { project } = await projectRepository.getProject(projectId);
-
- if (project.meta.projectPath === null) {
- return c.json({ error: "Project path not found" }, 400);
- }
-
- const task = await claudeCodeTaskController.startOrContinueTask(
- {
- projectId,
- cwd: project.meta.projectPath,
- },
- message,
- );
-
- return c.json({
- taskId: task.id,
- sessionId: task.sessionId,
- });
- },
- )
-
- .post(
- "/projects/:projectId/sessions/:sessionId/resume",
- zValidator(
- "json",
- z.object({
- resumeMessage: z.string(),
- }),
- ),
- async (c) => {
- const { projectId, sessionId } = c.req.param();
- const { resumeMessage } = c.req.valid("json");
- const { project } = await projectRepository.getProject(projectId);
-
- if (project.meta.projectPath === null) {
- return c.json({ error: "Project path not found" }, 400);
- }
-
- const task = await claudeCodeTaskController.startOrContinueTask(
- {
- projectId,
- sessionId,
- cwd: project.meta.projectPath,
- },
- resumeMessage,
- );
-
- return c.json({
- taskId: task.id,
- sessionId: task.sessionId,
- });
- },
- )
-
- .get("/tasks/alive", async (c) => {
- return c.json({
- aliveTasks: claudeCodeTaskController.aliveTasks.map(
- (task): SerializableAliveTask => ({
- id: task.id,
- status: task.status,
- sessionId: task.sessionId,
- }),
- ),
- });
- })
-
- .post(
- "/tasks/abort",
- zValidator("json", z.object({ sessionId: z.string() })),
- async (c) => {
- const { sessionId } = c.req.valid("json");
- claudeCodeTaskController.abortTask(sessionId);
- return c.json({ message: "Task aborted" });
- },
- )
-
- .post(
- "/tasks/permission-response",
- zValidator(
- "json",
- z.object({
- permissionRequestId: z.string(),
- decision: z.enum(["allow", "deny"]),
- }),
- ),
- async (c) => {
- const permissionResponse = c.req.valid("json");
- claudeCodeTaskController.respondToPermissionRequest(
- permissionResponse,
- );
- return c.json({ message: "Permission response received" });
- },
- )
-
- .get("/sse", async (c) => {
- return streamSSE(
- c,
- async (rawStream) => {
- const stream = writeTypeSafeSSE(rawStream);
-
- const onSessionListChanged = (
- event: InternalEventDeclaration["sessionListChanged"],
- ) => {
- stream.writeSSE("sessionListChanged", {
- projectId: event.projectId,
- });
- };
-
- const onSessionChanged = (
- event: InternalEventDeclaration["sessionChanged"],
- ) => {
- stream.writeSSE("sessionChanged", {
- projectId: event.projectId,
- sessionId: event.sessionId,
- });
- };
-
- const onTaskChanged = (
- event: InternalEventDeclaration["taskChanged"],
- ) => {
- stream.writeSSE("taskChanged", {
- aliveTasks: event.aliveTasks,
- changed: {
- status: event.changed.status,
- sessionId: event.changed.sessionId,
- projectId: event.changed.projectId,
- },
- });
-
- if (event.changed.sessionId !== undefined) {
- stream.writeSSE("sessionChanged", {
- projectId: event.changed.projectId,
- sessionId: event.changed.sessionId,
- });
+ filteredSessions = Array.from(sessionMap.values());
}
- };
- eventBus.on("sessionListChanged", onSessionListChanged);
- eventBus.on("sessionChanged", onSessionChanged);
- eventBus.on("taskChanged", onTaskChanged);
- const { connectionPromise } = adaptInternalEventToSSE(rawStream, {
- timeout: 5 /* min */ * 60 /* sec */ * 1000,
- cleanUp: () => {
- eventBus.off("sessionListChanged", onSessionListChanged);
- eventBus.off("sessionChanged", onSessionChanged);
- eventBus.off("taskChanged", onTaskChanged);
- },
+ return {
+ project,
+ sessions: filteredSessions,
+ nextCursor: sessions.at(-1)?.id,
+ };
});
- await connectionPromise;
+ const result = await Runtime.runPromise(runtime)(program);
+ return c.json(result);
},
- async (err) => {
- console.error("Streaming error:", err);
- },
- );
- })
- );
-};
+ )
-export type RouteType = Awaited>;
+ .get("/projects/:projectId/sessions/:sessionId", async (c) => {
+ const { projectId, sessionId } = c.req.param();
+
+ const program = Effect.gen(function* () {
+ const { session } = yield* sessionRepository.getSession(
+ projectId,
+ sessionId,
+ );
+ return { session };
+ });
+
+ const result = await Runtime.runPromise(runtime)(program);
+ return c.json(result);
+ })
+
+ .get(
+ "/projects/:projectId/file-completion",
+ 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) => {
+ const { projectId } = c.req.param();
+
+ 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(() =>
+ 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) => {
+ const { projectId } = c.req.param();
+
+ 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(() =>
+ 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(
+ "/projects/:projectId/git/diff",
+ zValidator(
+ "json",
+ z.object({
+ fromRef: z.string().min(1, "fromRef is required"),
+ toRef: z.string().min(1, "toRef is required"),
+ }),
+ ),
+ async (c) => {
+ const { projectId } = c.req.param();
+ const { fromRef, toRef } = c.req.valid("json");
+
+ const program = Effect.gen(function* () {
+ const { project } =
+ yield* projectRepository.getProject(projectId);
+
+ try {
+ if (project.meta.projectPath === null) {
+ return {
+ 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();
+ const { servers } = await getMcpList(projectId);
+ return c.json({ servers });
+ })
+
+ .get("/cc/session-processes", async (c) => {
+ const publicProcesses = await Runtime.runPromise(runtime)(
+ claudeCodeLifeCycleService.getPublicSessionProcesses(),
+ );
+ return c.json({
+ processes: publicProcesses.map(
+ (process): PublicSessionProcess => ({
+ id: process.def.sessionProcessId,
+ projectId: process.def.projectId,
+ sessionId: process.sessionId,
+ status: process.type === "paused" ? "paused" : "running",
+ }),
+ ),
+ });
+ })
+
+ // new or resume
+ .post(
+ "/cc/session-processes",
+ zValidator(
+ "json",
+ z.object({
+ projectId: z.string(),
+ message: z.string(),
+ baseSessionId: z.string().optional(),
+ }),
+ ),
+ async (c) => {
+ const { projectId, message, baseSessionId } = c.req.valid("json");
+
+ 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 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) {
+ return c.json({
+ sessionProcess: {
+ id: result.result.sessionProcess.def.sessionProcessId,
+ projectId: result.result.sessionProcess.def.projectId,
+ sessionId: await result.result.awaitSessionInitialized(),
+ },
+ });
+ }
+
+ return c.json({ error: result.error }, result.status);
+ },
+ )
+
+ // continue
+ .post(
+ "/cc/session-processes/:sessionProcessId/continue",
+ zValidator(
+ "json",
+ z.object({
+ projectId: z.string(),
+ continueMessage: z.string(),
+ baseSessionId: z.string(),
+ }),
+ ),
+ async (c) => {
+ const { sessionProcessId } = c.req.param();
+ const { projectId, continueMessage, baseSessionId } =
+ c.req.valid("json");
+
+ 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 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);
+ },
+ )
+
+ .post(
+ "/cc/session-processes/:sessionProcessId/abort",
+ zValidator("json", z.object({ projectId: z.string() })),
+ async (c) => {
+ const { sessionProcessId } = c.req.param();
+ void Effect.runFork(
+ claudeCodeLifeCycleService.abortTask(sessionProcessId),
+ );
+ return c.json({ message: "Task aborted" });
+ },
+ )
+
+ .post(
+ "/cc/permission-response",
+ zValidator(
+ "json",
+ z.object({
+ permissionRequestId: z.string(),
+ decision: z.enum(["allow", "deny"]),
+ }),
+ ),
+ async (c) => {
+ const permissionResponse = c.req.valid("json");
+ Effect.runFork(
+ claudeCodePermissionService.respondToPermissionRequest(
+ permissionResponse,
+ ),
+ );
+ return c.json({ message: "Permission response received" });
+ },
+ )
+
+ .get("/sse", async (c) => {
+ return streamSSE(
+ c,
+ async (rawStream) => {
+ const handleSSE = 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,
+ }),
+ );
+ };
+
+ yield* eventBus.on("sessionListChanged", onSessionListChanged);
+ yield* eventBus.on("sessionChanged", onSessionChanged);
+ yield* eventBus.on(
+ "sessionProcessChanged",
+ onSessionProcessChanged,
+ );
+ yield* eventBus.on("heartbeat", onHeartbeat);
+
+ 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);
+ }),
+ );
+ },
+ },
+ );
+
+ return {
+ connectionPromise,
+ };
+ });
+
+ const { connectionPromise } = await Runtime.runPromise(runtime)(
+ handleSSE.pipe(Effect.provide(TypeSafeSSE.make(rawStream))),
+ );
+
+ await connectionPromise;
+ },
+ async (err) => {
+ console.error("Streaming error:", err);
+ },
+ );
+ })
+ );
+ });
+
+export type RouteType = ReturnType extends Effect.Effect<
+ infer A,
+ unknown,
+ unknown
+>
+ ? A
+ : never;
diff --git a/src/server/lib/effect/types.ts b/src/server/lib/effect/types.ts
new file mode 100644
index 0000000..1da6522
--- /dev/null
+++ b/src/server/lib/effect/types.ts
@@ -0,0 +1,6 @@
+import type { Effect } from "effect";
+
+// biome-ignore lint/suspicious/noExplicitAny: for type restriction
+export type InferEffect = T extends Effect.Effect
+ ? U
+ : never;
diff --git a/src/server/lib/env/schema.ts b/src/server/lib/env/schema.ts
index 13ac4e2..b60a9ff 100644
--- a/src/server/lib/env/schema.ts
+++ b/src/server/lib/env/schema.ts
@@ -13,6 +13,7 @@ export const envSchema = z.object({
.optional()
.default("3000")
.transform((val) => parseInt(val, 10)),
+ PATH: z.string().optional(),
});
export type EnvSchema = z.infer;
diff --git a/src/server/lib/storage/FileCacheStorage.ts b/src/server/lib/storage/FileCacheStorage.ts
deleted file mode 100644
index ed333e9..0000000
--- a/src/server/lib/storage/FileCacheStorage.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
-import { writeFile } from "node:fs/promises";
-import { resolve } from "node:path";
-import { z } from "zod";
-import { claudeCodeViewerCacheDirPath } from "../../service/paths";
-
-const saveSchema = z.array(z.tuple([z.string(), z.unknown()]));
-
-export class FileCacheStorage {
- private storage = new Map();
-
- private constructor(private readonly key: string) {}
-
- public static load(
- key: string,
- schema: z.ZodType,
- ) {
- const instance = new FileCacheStorage(key);
-
- if (!existsSync(claudeCodeViewerCacheDirPath)) {
- mkdirSync(claudeCodeViewerCacheDirPath, { recursive: true });
- }
-
- if (!existsSync(instance.cacheFilePath)) {
- writeFileSync(instance.cacheFilePath, "[]");
- } else {
- const content = readFileSync(instance.cacheFilePath, "utf-8");
- const parsed = saveSchema.safeParse(JSON.parse(content));
-
- if (!parsed.success) {
- writeFileSync(instance.cacheFilePath, "[]");
- } else {
- for (const [key, value] of parsed.data) {
- const parsedValue = schema.safeParse(value);
- if (!parsedValue.success) {
- continue;
- }
-
- instance.storage.set(key, parsedValue.data);
- }
- }
- }
-
- return instance;
- }
-
- private get cacheFilePath() {
- return resolve(claudeCodeViewerCacheDirPath, `${this.key}.json`);
- }
-
- private asSaveFormat() {
- return JSON.stringify(Array.from(this.storage.entries()));
- }
-
- private async syncToFile() {
- await writeFile(this.cacheFilePath, this.asSaveFormat());
- }
-
- public get(key: string) {
- return this.storage.get(key);
- }
-
- public save(key: string, value: T) {
- const previous = this.asSaveFormat();
- this.storage.set(key, value);
-
- if (previous === this.asSaveFormat()) {
- return;
- }
-
- void this.syncToFile();
- }
-
- public invalidate(key: string) {
- if (!this.storage.has(key)) {
- return;
- }
-
- this.storage.delete(key);
- void this.syncToFile();
- }
-}
diff --git a/src/server/lib/storage/FileCacheStorage/PersistantService.ts b/src/server/lib/storage/FileCacheStorage/PersistantService.ts
new file mode 100644
index 0000000..f59445c
--- /dev/null
+++ b/src/server/lib/storage/FileCacheStorage/PersistantService.ts
@@ -0,0 +1,64 @@
+import { resolve } from "node:path";
+import { FileSystem } from "@effect/platform";
+import { Context, Effect, Layer } from "effect";
+import { z } from "zod";
+import { claudeCodeViewerCacheDirPath } from "../../../service/paths";
+
+const saveSchema = z.array(z.tuple([z.string(), z.unknown()]));
+
+const getCacheFilePath = (key: string) =>
+ resolve(claudeCodeViewerCacheDirPath, `${key}.json`);
+
+const load = (key: string) => {
+ const cacheFilePath = getCacheFilePath(key);
+
+ return Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+
+ if (!(yield* fs.exists(claudeCodeViewerCacheDirPath))) {
+ yield* fs.makeDirectory(claudeCodeViewerCacheDirPath, {
+ recursive: true,
+ });
+ }
+
+ if (!(yield* fs.exists(cacheFilePath))) {
+ yield* fs.writeFileString(cacheFilePath, "[]");
+ } else {
+ const content = yield* fs.readFileString(cacheFilePath);
+ const parsed = saveSchema.safeParse(JSON.parse(content));
+
+ if (!parsed.success) {
+ yield* fs.writeFileString(cacheFilePath, "[]");
+ } else {
+ parsed.data;
+ return parsed.data;
+ }
+ }
+
+ return [];
+ });
+};
+
+const save = (key: string, entries: readonly [string, unknown][]) => {
+ const cacheFilePath = getCacheFilePath(key);
+
+ return Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ yield* fs.writeFileString(cacheFilePath, JSON.stringify(entries));
+ });
+};
+
+export class PersistentService extends Context.Tag("PersistentService")<
+ PersistentService,
+ {
+ readonly load: typeof load;
+ readonly save: typeof save;
+ }
+>() {
+ static Live = Layer.succeed(this, {
+ load,
+ save,
+ });
+}
+
+export type IPersistentService = Context.Tag.Service;
diff --git a/src/server/lib/storage/FileCacheStorage/index.test.ts b/src/server/lib/storage/FileCacheStorage/index.test.ts
new file mode 100644
index 0000000..9f289f2
--- /dev/null
+++ b/src/server/lib/storage/FileCacheStorage/index.test.ts
@@ -0,0 +1,516 @@
+import { FileSystem } from "@effect/platform";
+import { Effect, Layer, Ref } from "effect";
+import { z } from "zod";
+import { FileCacheStorage, makeFileCacheStorageLayer } from "./index";
+import { PersistentService } from "./PersistantService";
+
+// Schema for testing
+const UserSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ email: z.string().email(),
+});
+
+type User = z.infer;
+
+const FileSystemMock = FileSystem.layerNoop({});
+
+describe("FileCacheStorage", () => {
+ describe("basic operations", () => {
+ it("can save and retrieve data with set and get", async () => {
+ // PersistentService mock (empty data)
+ const PersistentServiceMock = Layer.succeed(PersistentService, {
+ load: () => Effect.succeed([]),
+ save: () => Effect.void,
+ });
+
+ const program = Effect.gen(function* () {
+ const cache = yield* FileCacheStorage();
+
+ // Save data
+ yield* cache.set("user-1", {
+ id: "user-1",
+ name: "Alice",
+ email: "alice@example.com",
+ });
+
+ // Retrieve data
+ const user = yield* cache.get("user-1");
+ return user;
+ });
+
+ const result = await Effect.runPromise(
+ program.pipe(
+ Effect.provide(
+ makeFileCacheStorageLayer("test-users", UserSchema).pipe(
+ Layer.provide(PersistentServiceMock),
+ Layer.provide(FileSystemMock),
+ ),
+ ),
+ ),
+ );
+
+ expect(result).toEqual({
+ id: "user-1",
+ name: "Alice",
+ email: "alice@example.com",
+ });
+ });
+
+ it("returns undefined when retrieving non-existent key", async () => {
+ const PersistentServiceMock = Layer.succeed(PersistentService, {
+ load: () => Effect.succeed([]),
+ save: () => Effect.void,
+ });
+
+ const program = Effect.gen(function* () {
+ const cache = yield* FileCacheStorage();
+ return yield* cache.get("non-existent");
+ });
+
+ const result = await Effect.runPromise(
+ program.pipe(
+ Effect.provide(
+ makeFileCacheStorageLayer("test-users", UserSchema).pipe(
+ Layer.provide(PersistentServiceMock),
+ Layer.provide(FileSystemMock),
+ ),
+ ),
+ ),
+ );
+
+ expect(result).toBeUndefined();
+ });
+
+ it("can delete data with invalidate", async () => {
+ const PersistentServiceMock = Layer.succeed(PersistentService, {
+ load: () => Effect.succeed([]),
+ save: () => Effect.void,
+ });
+
+ const program = Effect.gen(function* () {
+ const cache = yield* FileCacheStorage();
+
+ // Save data
+ yield* cache.set("user-1", {
+ id: "user-1",
+ name: "Alice",
+ email: "alice@example.com",
+ });
+
+ // Delete data
+ yield* cache.invalidate("user-1");
+
+ // Returns undefined after deletion
+ return yield* cache.get("user-1");
+ });
+
+ const result = await Effect.runPromise(
+ program.pipe(
+ Effect.provide(
+ makeFileCacheStorageLayer("test-users", UserSchema).pipe(
+ Layer.provide(PersistentServiceMock),
+ Layer.provide(FileSystemMock),
+ ),
+ ),
+ ),
+ );
+
+ expect(result).toBeUndefined();
+ });
+
+ it("getAll ですべてのデータを取得できる", async () => {
+ const PersistentServiceMock = Layer.succeed(PersistentService, {
+ load: () => Effect.succeed([]),
+ save: () => Effect.void,
+ });
+
+ const program = Effect.gen(function* () {
+ const cache = yield* FileCacheStorage();
+
+ // 複数のデータを保存
+ yield* cache.set("user-1", {
+ id: "user-1",
+ name: "Alice",
+ email: "alice@example.com",
+ });
+ yield* cache.set("user-2", {
+ id: "user-2",
+ name: "Bob",
+ email: "bob@example.com",
+ });
+
+ // すべてのデータを取得
+ return yield* cache.getAll();
+ });
+
+ const result = await Effect.runPromise(
+ program.pipe(
+ Effect.provide(
+ makeFileCacheStorageLayer("test-users", UserSchema).pipe(
+ Layer.provide(PersistentServiceMock),
+ Layer.provide(FileSystemMock),
+ ),
+ ),
+ ),
+ );
+
+ expect(result.size).toBe(2);
+ expect(result.get("user-1")).toEqual({
+ id: "user-1",
+ name: "Alice",
+ email: "alice@example.com",
+ });
+ expect(result.get("user-2")).toEqual({
+ id: "user-2",
+ name: "Bob",
+ email: "bob@example.com",
+ });
+ });
+ });
+
+ describe("永続化データの読み込み", () => {
+ it("初期化時に永続化データを読み込む", async () => {
+ // 永続化データを返すモック
+ const PersistentServiceMock = Layer.succeed(PersistentService, {
+ load: () =>
+ Effect.succeed([
+ [
+ "user-1",
+ {
+ id: "user-1",
+ name: "Alice",
+ email: "alice@example.com",
+ },
+ ],
+ [
+ "user-2",
+ {
+ id: "user-2",
+ name: "Bob",
+ email: "bob@example.com",
+ },
+ ],
+ ] as const),
+ save: () => Effect.void,
+ });
+
+ const program = Effect.gen(function* () {
+ const cache = yield* FileCacheStorage();
+ return yield* cache.getAll();
+ });
+
+ const result = await Effect.runPromise(
+ program.pipe(
+ Effect.provide(
+ makeFileCacheStorageLayer("test-users", UserSchema).pipe(
+ Layer.provide(PersistentServiceMock),
+ Layer.provide(FileSystemMock),
+ ),
+ ),
+ ),
+ );
+
+ expect(result.size).toBe(2);
+ expect(result.get("user-1")?.name).toBe("Alice");
+ expect(result.get("user-2")?.name).toBe("Bob");
+ });
+
+ it("スキーマバリデーションに失敗したデータは無視される", async () => {
+ // 不正なデータを含む永続化データ
+ const PersistentServiceMock = Layer.succeed(PersistentService, {
+ load: () =>
+ Effect.succeed([
+ [
+ "user-1",
+ {
+ id: "user-1",
+ name: "Alice",
+ email: "alice@example.com",
+ },
+ ],
+ [
+ "user-invalid",
+ {
+ id: "invalid",
+ name: "Invalid",
+ // email が無い(バリデーションエラー)
+ },
+ ],
+ [
+ "user-2",
+ {
+ id: "user-2",
+ name: "Bob",
+ email: "invalid-email", // 不正なメールアドレス
+ },
+ ],
+ ] as const),
+ save: () => Effect.void,
+ });
+
+ const program = Effect.gen(function* () {
+ const cache = yield* FileCacheStorage();
+ return yield* cache.getAll();
+ });
+
+ const result = await Effect.runPromise(
+ program.pipe(
+ Effect.provide(
+ makeFileCacheStorageLayer("test-users", UserSchema).pipe(
+ Layer.provide(PersistentServiceMock),
+ Layer.provide(FileSystemMock),
+ ),
+ ),
+ ),
+ );
+
+ // 有効なデータのみ読み込まれる
+ expect(result.size).toBe(1);
+ expect(result.get("user-1")?.name).toBe("Alice");
+ expect(result.get("user-invalid")).toBeUndefined();
+ expect(result.get("user-2")).toBeUndefined();
+ });
+ });
+
+ describe("永続化への同期", () => {
+ it("set でデータを保存すると save が呼ばれる", async () => {
+ const saveCallsRef = await Effect.runPromise(Ref.make(0));
+
+ const PersistentServiceMock = Layer.succeed(PersistentService, {
+ load: () => Effect.succeed([]),
+ save: () =>
+ Effect.gen(function* () {
+ yield* Ref.update(saveCallsRef, (n) => n + 1);
+ }),
+ });
+
+ const program = Effect.gen(function* () {
+ const cache = yield* FileCacheStorage();
+
+ yield* cache.set("user-1", {
+ id: "user-1",
+ name: "Alice",
+ email: "alice@example.com",
+ });
+
+ // バックグラウンド実行を待つために少し待機
+ yield* Effect.sleep("10 millis");
+ });
+
+ await Effect.runPromise(
+ program.pipe(
+ Effect.provide(
+ makeFileCacheStorageLayer("test-users", UserSchema).pipe(
+ Layer.provide(PersistentServiceMock),
+ Layer.provide(FileSystemMock),
+ ),
+ ),
+ ),
+ );
+
+ const saveCalls = await Effect.runPromise(Ref.get(saveCallsRef));
+ expect(saveCalls).toBeGreaterThan(0);
+ });
+
+ it("同じ値を set しても save は呼ばれない(差分検出)", async () => {
+ const saveCallsRef = await Effect.runPromise(Ref.make(0));
+
+ const PersistentServiceMock = Layer.succeed(PersistentService, {
+ load: () =>
+ Effect.succeed([
+ [
+ "user-1",
+ {
+ id: "user-1",
+ name: "Alice",
+ email: "alice@example.com",
+ },
+ ],
+ ] as const),
+ save: () =>
+ Effect.gen(function* () {
+ yield* Ref.update(saveCallsRef, (n) => n + 1);
+ }),
+ });
+
+ const program = Effect.gen(function* () {
+ const cache = yield* FileCacheStorage();
+
+ // 既に存在する同じ値を set
+ yield* cache.set("user-1", {
+ id: "user-1",
+ name: "Alice",
+ email: "alice@example.com",
+ });
+
+ // バックグラウンド実行を待つために少し待機
+ yield* Effect.sleep("10 millis");
+ });
+
+ await Effect.runPromise(
+ program.pipe(
+ Effect.provide(
+ makeFileCacheStorageLayer("test-users", UserSchema).pipe(
+ Layer.provide(PersistentServiceMock),
+ Layer.provide(FileSystemMock),
+ ),
+ ),
+ ),
+ );
+
+ const saveCalls = await Effect.runPromise(Ref.get(saveCallsRef));
+ // 差分がないので save は呼ばれない
+ expect(saveCalls).toBe(0);
+ });
+
+ it("invalidate でデータを削除すると save が呼ばれる", async () => {
+ const saveCallsRef = await Effect.runPromise(Ref.make(0));
+
+ const PersistentServiceMock = Layer.succeed(PersistentService, {
+ load: () =>
+ Effect.succeed([
+ [
+ "user-1",
+ {
+ id: "user-1",
+ name: "Alice",
+ email: "alice@example.com",
+ },
+ ],
+ ] as const),
+ save: () =>
+ Effect.gen(function* () {
+ yield* Ref.update(saveCallsRef, (n) => n + 1);
+ }),
+ });
+
+ const program = Effect.gen(function* () {
+ const cache = yield* FileCacheStorage();
+
+ yield* cache.invalidate("user-1");
+
+ // バックグラウンド実行を待つために少し待機
+ yield* Effect.sleep("10 millis");
+ });
+
+ await Effect.runPromise(
+ program.pipe(
+ Effect.provide(
+ makeFileCacheStorageLayer("test-users", UserSchema).pipe(
+ Layer.provide(PersistentServiceMock),
+ Layer.provide(FileSystemMock),
+ ),
+ ),
+ ),
+ );
+
+ const saveCalls = await Effect.runPromise(Ref.get(saveCallsRef));
+ expect(saveCalls).toBeGreaterThan(0);
+ });
+
+ it("存在しないキーを invalidate しても save は呼ばれない", async () => {
+ const saveCallsRef = await Effect.runPromise(Ref.make(0));
+
+ const PersistentServiceMock = Layer.succeed(PersistentService, {
+ load: () => Effect.succeed([]),
+ save: () =>
+ Effect.gen(function* () {
+ yield* Ref.update(saveCallsRef, (n) => n + 1);
+ }),
+ });
+
+ const program = Effect.gen(function* () {
+ const cache = yield* FileCacheStorage();
+
+ // 存在しないキーを invalidate
+ yield* cache.invalidate("non-existent");
+
+ // バックグラウンド実行を待つために少し待機
+ yield* Effect.sleep("10 millis");
+ });
+
+ await Effect.runPromise(
+ program.pipe(
+ Effect.provide(
+ makeFileCacheStorageLayer("test-users", UserSchema).pipe(
+ Layer.provide(PersistentServiceMock),
+ Layer.provide(FileSystemMock),
+ ),
+ ),
+ ),
+ );
+
+ const saveCalls = await Effect.runPromise(Ref.get(saveCallsRef));
+ // 存在しないキーなので save は呼ばれない
+ expect(saveCalls).toBe(0);
+ });
+ });
+
+ describe("複雑なシナリオ", () => {
+ it("複数の操作を順次実行できる", async () => {
+ const PersistentServiceMock = Layer.succeed(PersistentService, {
+ load: () =>
+ Effect.succeed([
+ [
+ "user-1",
+ {
+ id: "user-1",
+ name: "Alice",
+ email: "alice@example.com",
+ },
+ ],
+ ] as const),
+ save: () => Effect.void,
+ });
+
+ const program = Effect.gen(function* () {
+ const cache = yield* FileCacheStorage();
+
+ // 初期データの確認
+ const initial = yield* cache.getAll();
+ expect(initial.size).toBe(1);
+
+ // 新しいユーザーを追加
+ yield* cache.set("user-2", {
+ id: "user-2",
+ name: "Bob",
+ email: "bob@example.com",
+ });
+
+ // 既存のユーザーを更新
+ yield* cache.set("user-1", {
+ id: "user-1",
+ name: "Alice Updated",
+ email: "alice.updated@example.com",
+ });
+
+ // すべてのデータを取得
+ const afterUpdate = yield* cache.getAll();
+ expect(afterUpdate.size).toBe(2);
+ expect(afterUpdate.get("user-1")?.name).toBe("Alice Updated");
+ expect(afterUpdate.get("user-2")?.name).toBe("Bob");
+
+ // ユーザーを削除
+ yield* cache.invalidate("user-1");
+
+ // 削除後の確認
+ const afterDelete = yield* cache.getAll();
+ expect(afterDelete.size).toBe(1);
+ expect(afterDelete.get("user-1")).toBeUndefined();
+ expect(afterDelete.get("user-2")?.name).toBe("Bob");
+ });
+
+ await Effect.runPromise(
+ program.pipe(
+ Effect.provide(
+ makeFileCacheStorageLayer("test-users", UserSchema).pipe(
+ Layer.provide(PersistentServiceMock),
+ Layer.provide(FileSystemMock),
+ ),
+ ),
+ ),
+ );
+ });
+ });
+});
diff --git a/src/server/lib/storage/FileCacheStorage/index.ts b/src/server/lib/storage/FileCacheStorage/index.ts
new file mode 100644
index 0000000..71656ae
--- /dev/null
+++ b/src/server/lib/storage/FileCacheStorage/index.ts
@@ -0,0 +1,94 @@
+import type { FileSystem } from "@effect/platform";
+import { Context, Effect, Layer, Ref, Runtime } from "effect";
+import type { z } from "zod";
+import { PersistentService } from "./PersistantService";
+
+export interface FileCacheStorageService {
+ readonly get: (key: string) => Effect.Effect;
+ readonly set: (key: string, value: T) => Effect.Effect;
+ readonly invalidate: (key: string) => Effect.Effect;
+ readonly getAll: () => Effect.Effect