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;
};