Skip to content

Commit 7d052e8

Browse files
grichaclaude
andcommitted
Add session deletion and search functionality
Session Deletion: - Add deleteSession to AgentSessionProvider interface - Implement hard delete for Claude Code, Codex, and OpenCode sessions - Add perry worker sessions delete subcommand for OpenCode - Add sessions.delete API endpoint with host session support - Add delete button and confirmation dialog to session list UI Session Search: - Add search input above session list with debounced filtering - Filter sessions by name, first prompt, ID, and project path - Show filtered count in header - Display "No sessions match" message when search returns empty 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8750e82 commit 7d052e8

11 files changed

Lines changed: 499 additions & 49 deletions

File tree

src/agent/router.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
getSessionDetails as getAgentSessionDetails,
2222
getSessionMessages,
2323
findSessionMessages,
24+
deleteSession as deleteSessionFromProvider,
2425
} from '../sessions/agents';
2526
import type { SessionsCacheManager } from '../sessions/cache';
2627
import type { ModelCacheManager } from '../models/cache';
@@ -32,6 +33,7 @@ import {
3233
import {
3334
listOpencodeSessions,
3435
getOpencodeSessionMessages,
36+
deleteOpencodeSession,
3537
} from '../sessions/agents/opencode-storage';
3638

3739
const WorkspaceStatusSchema = z.enum(['running', 'stopped', 'creating', 'error']);
@@ -708,6 +710,127 @@ export function createRouter(ctx: RouterContext) {
708710
return { success: true };
709711
});
710712

713+
const deleteSession = os
714+
.input(
715+
z.object({
716+
workspaceName: z.string(),
717+
sessionId: z.string(),
718+
agentType: z.enum(['claude-code', 'opencode', 'codex']),
719+
})
720+
)
721+
.handler(async ({ input }) => {
722+
const isHost = input.workspaceName === HOST_WORKSPACE_NAME;
723+
724+
if (isHost) {
725+
const config = ctx.config.get();
726+
if (!config.allowHostAccess) {
727+
throw new ORPCError('PRECONDITION_FAILED', { message: 'Host access is disabled' });
728+
}
729+
730+
const result = await deleteHostSession(input.sessionId, input.agentType);
731+
if (!result.success) {
732+
throw new ORPCError('INTERNAL_SERVER_ERROR', { message: result.error || 'Failed to delete session' });
733+
}
734+
735+
await deleteSessionName(ctx.stateDir, input.workspaceName, input.sessionId);
736+
await ctx.sessionsCache.removeSession(input.workspaceName, input.sessionId);
737+
738+
return { success: true };
739+
}
740+
741+
const workspace = await ctx.workspaces.get(input.workspaceName);
742+
if (!workspace) {
743+
throw new ORPCError('NOT_FOUND', { message: 'Workspace not found' });
744+
}
745+
if (workspace.status !== 'running') {
746+
throw new ORPCError('PRECONDITION_FAILED', { message: 'Workspace is not running' });
747+
}
748+
749+
const containerName = `workspace-${input.workspaceName}`;
750+
751+
const result = await deleteSessionFromProvider(
752+
containerName,
753+
input.sessionId,
754+
input.agentType,
755+
execInContainer
756+
);
757+
758+
if (!result.success) {
759+
throw new ORPCError('INTERNAL_SERVER_ERROR', { message: result.error || 'Failed to delete session' });
760+
}
761+
762+
await deleteSessionName(ctx.stateDir, input.workspaceName, input.sessionId);
763+
await ctx.sessionsCache.removeSession(input.workspaceName, input.sessionId);
764+
765+
return { success: true };
766+
});
767+
768+
async function deleteHostSession(
769+
sessionId: string,
770+
agentType: 'claude-code' | 'opencode' | 'codex'
771+
): Promise<{ success: boolean; error?: string }> {
772+
const homeDir = os_module.homedir();
773+
774+
if (agentType === 'claude-code') {
775+
const safeSessionId = sessionId.replace(/[^a-zA-Z0-9_-]/g, '');
776+
const claudeProjectsDir = path.join(homeDir, '.claude', 'projects');
777+
778+
try {
779+
const projectDirs = await fs.readdir(claudeProjectsDir);
780+
for (const projectDir of projectDirs) {
781+
const sessionFile = path.join(claudeProjectsDir, projectDir, `${safeSessionId}.jsonl`);
782+
try {
783+
await fs.unlink(sessionFile);
784+
return { success: true };
785+
} catch {
786+
continue;
787+
}
788+
}
789+
} catch {
790+
return { success: false, error: 'Session not found' };
791+
}
792+
return { success: false, error: 'Session not found' };
793+
}
794+
795+
if (agentType === 'opencode') {
796+
return deleteOpencodeSession(sessionId);
797+
}
798+
799+
if (agentType === 'codex') {
800+
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
801+
try {
802+
const files = await fs.readdir(codexSessionsDir);
803+
for (const file of files) {
804+
if (!file.endsWith('.jsonl')) continue;
805+
const filePath = path.join(codexSessionsDir, file);
806+
const fileId = file.replace('.jsonl', '');
807+
808+
if (fileId === sessionId) {
809+
await fs.unlink(filePath);
810+
return { success: true };
811+
}
812+
813+
try {
814+
const content = await fs.readFile(filePath, 'utf-8');
815+
const firstLine = content.split('\n')[0];
816+
const meta = JSON.parse(firstLine) as { session_id?: string };
817+
if (meta.session_id === sessionId) {
818+
await fs.unlink(filePath);
819+
return { success: true };
820+
}
821+
} catch {
822+
continue;
823+
}
824+
}
825+
} catch {
826+
return { success: false, error: 'Session not found' };
827+
}
828+
return { success: false, error: 'Session not found' };
829+
}
830+
831+
return { success: false, error: 'Unsupported agent type' };
832+
}
833+
711834
const getHostInfo = os.handler(async () => {
712835
const config = ctx.config.get();
713836
return {
@@ -813,6 +936,7 @@ export function createRouter(ctx: RouterContext) {
813936
clearName: clearSessionName,
814937
getRecent: getRecentSessions,
815938
recordAccess: recordSessionAccess,
939+
delete: deleteSession,
816940
},
817941
models: {
818942
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
};

src/sessions/agents/codex.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,4 +157,58 @@ export const codexProvider: AgentSessionProvider = {
157157

158158
return null;
159159
},
160+
161+
async deleteSession(
162+
containerName: string,
163+
sessionId: string,
164+
exec: ExecInContainer
165+
): Promise<{ success: boolean; error?: string }> {
166+
const findResult = await exec(
167+
containerName,
168+
['bash', '-c', `find /home/workspace/.codex/sessions -name "*.jsonl" -type f 2>/dev/null`],
169+
{ user: 'workspace' }
170+
);
171+
172+
if (findResult.exitCode !== 0 || !findResult.stdout.trim()) {
173+
return { success: false, error: 'No session files found' };
174+
}
175+
176+
const files = findResult.stdout.trim().split('\n').filter(Boolean);
177+
178+
for (const file of files) {
179+
const fileId = file.split('/').pop()?.replace('.jsonl', '') || '';
180+
181+
if (fileId === sessionId) {
182+
const rmResult = await exec(containerName, ['rm', '-f', file], {
183+
user: 'workspace',
184+
});
185+
if (rmResult.exitCode !== 0) {
186+
return { success: false, error: rmResult.stderr || 'Failed to delete session file' };
187+
}
188+
return { success: true };
189+
}
190+
191+
const headResult = await exec(containerName, ['head', '-1', file], {
192+
user: 'workspace',
193+
});
194+
if (headResult.exitCode === 0) {
195+
try {
196+
const meta = JSON.parse(headResult.stdout) as { session_id?: string };
197+
if (meta.session_id === sessionId) {
198+
const rmResult = await exec(containerName, ['rm', '-f', file], {
199+
user: 'workspace',
200+
});
201+
if (rmResult.exitCode !== 0) {
202+
return { success: false, error: rmResult.stderr || 'Failed to delete session file' };
203+
}
204+
return { success: true };
205+
}
206+
} catch {
207+
continue;
208+
}
209+
}
210+
}
211+
212+
return { success: false, error: 'Session not found' };
213+
},
160214
};

src/sessions/agents/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,16 @@ export async function findSessionMessages(
6565
}
6666
return null;
6767
}
68+
69+
export async function deleteSession(
70+
containerName: string,
71+
sessionId: string,
72+
agentType: AgentType,
73+
exec: ExecInContainer
74+
): Promise<{ success: boolean; error?: string }> {
75+
const provider = providers[agentType];
76+
if (!provider) {
77+
return { success: false, error: 'Unknown agent type' };
78+
}
79+
return provider.deleteSession(containerName, sessionId, exec);
80+
}

src/sessions/agents/opencode-storage.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,3 +196,63 @@ async function findSessionFile(sessionDir: string, sessionId: string): Promise<s
196196

197197
return null;
198198
}
199+
200+
export async function deleteOpencodeSession(
201+
sessionId: string,
202+
homeDir?: string
203+
): Promise<{ success: boolean; error?: string }> {
204+
const storageBase = getStorageBase(homeDir);
205+
const sessionDir = path.join(storageBase, 'session');
206+
const messageDir = path.join(storageBase, 'message');
207+
const partDir = path.join(storageBase, 'part');
208+
209+
const sessionFile = await findSessionFile(sessionDir, sessionId);
210+
if (!sessionFile) {
211+
return { success: false, error: 'Session not found' };
212+
}
213+
214+
let internalId: string | null = null;
215+
try {
216+
const content = await fs.readFile(sessionFile, 'utf-8');
217+
const data = JSON.parse(content);
218+
internalId = data.id;
219+
} catch {
220+
// Continue with session file deletion only
221+
}
222+
223+
try {
224+
await fs.unlink(sessionFile);
225+
} catch (err) {
226+
return { success: false, error: `Failed to delete session file: ${err}` };
227+
}
228+
229+
if (internalId) {
230+
const msgDir = path.join(messageDir, internalId);
231+
try {
232+
const msgFiles = await fs.readdir(msgDir);
233+
for (const msgFile of msgFiles) {
234+
if (!msgFile.startsWith('msg_') || !msgFile.endsWith('.json')) continue;
235+
const msgPath = path.join(msgDir, msgFile);
236+
try {
237+
const content = await fs.readFile(msgPath, 'utf-8');
238+
const msg = JSON.parse(content);
239+
if (msg.id) {
240+
const partMsgDir = path.join(partDir, msg.id);
241+
try {
242+
await fs.rm(partMsgDir, { recursive: true });
243+
} catch {
244+
// Parts may not exist
245+
}
246+
}
247+
} catch {
248+
// Skip malformed messages
249+
}
250+
}
251+
await fs.rm(msgDir, { recursive: true });
252+
} catch {
253+
// Messages directory may not exist
254+
}
255+
}
256+
257+
return { success: true };
258+
}

src/sessions/agents/opencode.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,28 @@ export const opencodeProvider: AgentSessionProvider = {
120120
return null;
121121
}
122122
},
123+
124+
async deleteSession(
125+
containerName: string,
126+
sessionId: string,
127+
exec: ExecInContainer
128+
): Promise<{ success: boolean; error?: string }> {
129+
const result = await exec(
130+
containerName,
131+
['perry', 'worker', 'sessions', 'delete', sessionId],
132+
{
133+
user: 'workspace',
134+
}
135+
);
136+
137+
if (result.exitCode !== 0) {
138+
return { success: false, error: result.stderr || 'Failed to delete session' };
139+
}
140+
141+
try {
142+
return JSON.parse(result.stdout) as { success: boolean; error?: string };
143+
} catch {
144+
return { success: false, error: 'Invalid response from worker' };
145+
}
146+
},
123147
};

0 commit comments

Comments
 (0)