Skip to content

Commit edb2ca8

Browse files
grichaclaude
andauthored
Add session deletion and search functionality (#25)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5f26a47 commit edb2ca8

File tree

12 files changed

+733
-78
lines changed

12 files changed

+733
-78
lines changed

src/agent/router.ts

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import {
2121
getSessionDetails as getAgentSessionDetails,
2222
getSessionMessages,
2323
findSessionMessages,
24+
deleteSession as deleteSessionFromProvider,
25+
searchSessions as searchSessionsInContainer,
2426
} from '../sessions/agents';
2527
import type { SessionsCacheManager } from '../sessions/cache';
2628
import type { ModelCacheManager } from '../models/cache';
@@ -32,6 +34,7 @@ import {
3234
import {
3335
listOpencodeSessions,
3436
getOpencodeSessionMessages,
37+
deleteOpencodeSession,
3538
} from '../sessions/agents/opencode-storage';
3639

3740
const WorkspaceStatusSchema = z.enum(['running', 'stopped', 'creating', 'error']);
@@ -708,6 +711,245 @@ export function createRouter(ctx: RouterContext) {
708711
return { success: true };
709712
});
710713

714+
const deleteSession = os
715+
.input(
716+
z.object({
717+
workspaceName: z.string(),
718+
sessionId: z.string(),
719+
agentType: z.enum(['claude-code', 'opencode', 'codex']),
720+
})
721+
)
722+
.handler(async ({ input }) => {
723+
const isHost = input.workspaceName === HOST_WORKSPACE_NAME;
724+
725+
if (isHost) {
726+
const config = ctx.config.get();
727+
if (!config.allowHostAccess) {
728+
throw new ORPCError('PRECONDITION_FAILED', { message: 'Host access is disabled' });
729+
}
730+
731+
const result = await deleteHostSession(input.sessionId, input.agentType);
732+
if (!result.success) {
733+
throw new ORPCError('INTERNAL_SERVER_ERROR', {
734+
message: result.error || 'Failed to delete session',
735+
});
736+
}
737+
738+
await deleteSessionName(ctx.stateDir, input.workspaceName, input.sessionId);
739+
await ctx.sessionsCache.removeSession(input.workspaceName, input.sessionId);
740+
741+
return { success: true };
742+
}
743+
744+
const workspace = await ctx.workspaces.get(input.workspaceName);
745+
if (!workspace) {
746+
throw new ORPCError('NOT_FOUND', { message: 'Workspace not found' });
747+
}
748+
if (workspace.status !== 'running') {
749+
throw new ORPCError('PRECONDITION_FAILED', { message: 'Workspace is not running' });
750+
}
751+
752+
const containerName = `workspace-${input.workspaceName}`;
753+
754+
const result = await deleteSessionFromProvider(
755+
containerName,
756+
input.sessionId,
757+
input.agentType,
758+
execInContainer
759+
);
760+
761+
if (!result.success) {
762+
throw new ORPCError('INTERNAL_SERVER_ERROR', {
763+
message: result.error || 'Failed to delete session',
764+
});
765+
}
766+
767+
await deleteSessionName(ctx.stateDir, input.workspaceName, input.sessionId);
768+
await ctx.sessionsCache.removeSession(input.workspaceName, input.sessionId);
769+
770+
return { success: true };
771+
});
772+
773+
const searchSessions = os
774+
.input(
775+
z.object({
776+
workspaceName: z.string(),
777+
query: z.string().min(1).max(500),
778+
})
779+
)
780+
.handler(async ({ input }) => {
781+
const isHost = input.workspaceName === HOST_WORKSPACE_NAME;
782+
783+
if (isHost) {
784+
const config = ctx.config.get();
785+
if (!config.allowHostAccess) {
786+
throw new ORPCError('PRECONDITION_FAILED', { message: 'Host access is disabled' });
787+
}
788+
789+
const results = await searchHostSessions(input.query);
790+
return { results };
791+
}
792+
793+
const workspace = await ctx.workspaces.get(input.workspaceName);
794+
if (!workspace) {
795+
throw new ORPCError('NOT_FOUND', { message: 'Workspace not found' });
796+
}
797+
if (workspace.status !== 'running') {
798+
throw new ORPCError('PRECONDITION_FAILED', { message: 'Workspace is not running' });
799+
}
800+
801+
const containerName = `workspace-${input.workspaceName}`;
802+
const results = await searchSessionsInContainer(containerName, input.query, execInContainer);
803+
804+
return { results };
805+
});
806+
807+
async function searchHostSessions(query: string): Promise<
808+
Array<{
809+
sessionId: string;
810+
agentType: 'claude-code' | 'opencode' | 'codex';
811+
matchCount: number;
812+
}>
813+
> {
814+
const homeDir = os_module.homedir();
815+
const safeQuery = query.replace(/['"\\]/g, '\\$&');
816+
const searchPaths = [
817+
path.join(homeDir, '.claude', 'projects'),
818+
path.join(homeDir, '.local', 'share', 'opencode', 'storage'),
819+
path.join(homeDir, '.codex', 'sessions'),
820+
].filter((p) => {
821+
try {
822+
require('fs').accessSync(p);
823+
return true;
824+
} catch {
825+
return false;
826+
}
827+
});
828+
829+
if (searchPaths.length === 0) {
830+
return [];
831+
}
832+
833+
const { execSync } = await import('child_process');
834+
try {
835+
const output = execSync(
836+
`rg -l -i --no-messages "${safeQuery}" ${searchPaths.join(' ')} 2>/dev/null | head -100`,
837+
{
838+
encoding: 'utf-8',
839+
timeout: 30000,
840+
}
841+
);
842+
843+
const files = output.trim().split('\n').filter(Boolean);
844+
const results: Array<{
845+
sessionId: string;
846+
agentType: 'claude-code' | 'opencode' | 'codex';
847+
matchCount: number;
848+
}> = [];
849+
850+
for (const file of files) {
851+
let sessionId: string | null = null;
852+
let agentType: 'claude-code' | 'opencode' | 'codex' | null = null;
853+
854+
if (file.includes('/.claude/projects/')) {
855+
const match = file.match(/\/([^/]+)\.jsonl$/);
856+
if (match && !match[1].startsWith('agent-')) {
857+
sessionId = match[1];
858+
agentType = 'claude-code';
859+
}
860+
} else if (file.includes('/.local/share/opencode/storage/')) {
861+
if (file.includes('/session/') && file.endsWith('.json')) {
862+
const match = file.match(/\/(ses_[^/]+)\.json$/);
863+
if (match) {
864+
sessionId = match[1];
865+
agentType = 'opencode';
866+
}
867+
}
868+
} else if (file.includes('/.codex/sessions/')) {
869+
const match = file.match(/\/([^/]+)\.jsonl$/);
870+
if (match) {
871+
sessionId = match[1];
872+
agentType = 'codex';
873+
}
874+
}
875+
876+
if (sessionId && agentType) {
877+
results.push({ sessionId, agentType, matchCount: 1 });
878+
}
879+
}
880+
881+
return results;
882+
} catch {
883+
return [];
884+
}
885+
}
886+
887+
async function deleteHostSession(
888+
sessionId: string,
889+
agentType: 'claude-code' | 'opencode' | 'codex'
890+
): Promise<{ success: boolean; error?: string }> {
891+
const homeDir = os_module.homedir();
892+
893+
if (agentType === 'claude-code') {
894+
const safeSessionId = sessionId.replace(/[^a-zA-Z0-9_-]/g, '');
895+
const claudeProjectsDir = path.join(homeDir, '.claude', 'projects');
896+
897+
try {
898+
const projectDirs = await fs.readdir(claudeProjectsDir);
899+
for (const projectDir of projectDirs) {
900+
const sessionFile = path.join(claudeProjectsDir, projectDir, `${safeSessionId}.jsonl`);
901+
try {
902+
await fs.unlink(sessionFile);
903+
return { success: true };
904+
} catch {
905+
continue;
906+
}
907+
}
908+
} catch {
909+
return { success: false, error: 'Session not found' };
910+
}
911+
return { success: false, error: 'Session not found' };
912+
}
913+
914+
if (agentType === 'opencode') {
915+
return deleteOpencodeSession(sessionId);
916+
}
917+
918+
if (agentType === 'codex') {
919+
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
920+
try {
921+
const files = await fs.readdir(codexSessionsDir);
922+
for (const file of files) {
923+
if (!file.endsWith('.jsonl')) continue;
924+
const filePath = path.join(codexSessionsDir, file);
925+
const fileId = file.replace('.jsonl', '');
926+
927+
if (fileId === sessionId) {
928+
await fs.unlink(filePath);
929+
return { success: true };
930+
}
931+
932+
try {
933+
const content = await fs.readFile(filePath, 'utf-8');
934+
const firstLine = content.split('\n')[0];
935+
const meta = JSON.parse(firstLine) as { session_id?: string };
936+
if (meta.session_id === sessionId) {
937+
await fs.unlink(filePath);
938+
return { success: true };
939+
}
940+
} catch {
941+
continue;
942+
}
943+
}
944+
} catch {
945+
return { success: false, error: 'Session not found' };
946+
}
947+
return { success: false, error: 'Session not found' };
948+
}
949+
950+
return { success: false, error: 'Unsupported agent type' };
951+
}
952+
711953
const getHostInfo = os.handler(async () => {
712954
const config = ctx.config.get();
713955
return {
@@ -813,6 +1055,8 @@ export function createRouter(ctx: RouterContext) {
8131055
clearName: clearSessionName,
8141056
getRecent: getRecentSessions,
8151057
recordAccess: recordSessionAccess,
1058+
delete: deleteSession,
1059+
search: searchSessions,
8161060
},
8171061
models: {
8181062
list: listModels,

src/index.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -673,11 +673,11 @@ const workerCmd = program
673673

674674
workerCmd
675675
.command('sessions')
676-
.argument('<subcommand>', 'Subcommand: list or messages')
677-
.argument('[sessionId]', 'Session ID (required for messages)')
676+
.argument('<subcommand>', 'Subcommand: list, messages, or delete')
677+
.argument('[sessionId]', 'Session ID (required for messages and delete)')
678678
.description('Manage OpenCode sessions')
679679
.action(async (subcommand: string, sessionId?: string) => {
680-
const { listOpencodeSessions, getOpencodeSessionMessages } =
680+
const { listOpencodeSessions, getOpencodeSessionMessages, deleteOpencodeSession } =
681681
await import('./sessions/agents/opencode-storage');
682682

683683
if (subcommand === 'list') {
@@ -690,9 +690,16 @@ workerCmd
690690
}
691691
const result = await getOpencodeSessionMessages(sessionId);
692692
console.log(JSON.stringify(result));
693+
} else if (subcommand === 'delete') {
694+
if (!sessionId) {
695+
console.error('Usage: perry worker sessions delete <session_id>');
696+
process.exit(1);
697+
}
698+
const result = await deleteOpencodeSession(sessionId);
699+
console.log(JSON.stringify(result));
693700
} else {
694701
console.error(`Unknown subcommand: ${subcommand}`);
695-
console.error('Available: list, messages');
702+
console.error('Available: list, messages, delete');
696703
process.exit(1);
697704
}
698705
});

src/sessions/agents/claude.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,36 @@ export const claudeProvider: AgentSessionProvider = {
121121
);
122122
return { id: sessionId, messages };
123123
},
124+
125+
async deleteSession(
126+
containerName: string,
127+
sessionId: string,
128+
exec: ExecInContainer
129+
): Promise<{ success: boolean; error?: string }> {
130+
const safeSessionId = sessionId.replace(/[^a-zA-Z0-9_-]/g, '');
131+
const findResult = await exec(
132+
containerName,
133+
[
134+
'bash',
135+
'-c',
136+
`find /home/workspace/.claude/projects -name "${safeSessionId}.jsonl" -type f 2>/dev/null | head -1`,
137+
],
138+
{ user: 'workspace' }
139+
);
140+
141+
if (findResult.exitCode !== 0 || !findResult.stdout.trim()) {
142+
return { success: false, error: 'Session not found' };
143+
}
144+
145+
const filePath = findResult.stdout.trim();
146+
const rmResult = await exec(containerName, ['rm', '-f', filePath], {
147+
user: 'workspace',
148+
});
149+
150+
if (rmResult.exitCode !== 0) {
151+
return { success: false, error: rmResult.stderr || 'Failed to delete session file' };
152+
}
153+
154+
return { success: true };
155+
},
124156
};

0 commit comments

Comments
 (0)