Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 244 additions & 0 deletions src/agent/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
getSessionDetails as getAgentSessionDetails,
getSessionMessages,
findSessionMessages,
deleteSession as deleteSessionFromProvider,
searchSessions as searchSessionsInContainer,
} from '../sessions/agents';
import type { SessionsCacheManager } from '../sessions/cache';
import type { ModelCacheManager } from '../models/cache';
Expand All @@ -32,6 +34,7 @@ import {
import {
listOpencodeSessions,
getOpencodeSessionMessages,
deleteOpencodeSession,
} from '../sessions/agents/opencode-storage';

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

const deleteSession = os
.input(
z.object({
workspaceName: z.string(),
sessionId: z.string(),
agentType: z.enum(['claude-code', 'opencode', 'codex']),
})
)
.handler(async ({ input }) => {
const isHost = input.workspaceName === HOST_WORKSPACE_NAME;

if (isHost) {
const config = ctx.config.get();
if (!config.allowHostAccess) {
throw new ORPCError('PRECONDITION_FAILED', { message: 'Host access is disabled' });
}

const result = await deleteHostSession(input.sessionId, input.agentType);
if (!result.success) {
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: result.error || 'Failed to delete session',
});
}

await deleteSessionName(ctx.stateDir, input.workspaceName, input.sessionId);
await ctx.sessionsCache.removeSession(input.workspaceName, input.sessionId);

return { success: true };
}

const workspace = await ctx.workspaces.get(input.workspaceName);
if (!workspace) {
throw new ORPCError('NOT_FOUND', { message: 'Workspace not found' });
}
if (workspace.status !== 'running') {
throw new ORPCError('PRECONDITION_FAILED', { message: 'Workspace is not running' });
}

const containerName = `workspace-${input.workspaceName}`;

const result = await deleteSessionFromProvider(
containerName,
input.sessionId,
input.agentType,
execInContainer
);

if (!result.success) {
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: result.error || 'Failed to delete session',
});
}

await deleteSessionName(ctx.stateDir, input.workspaceName, input.sessionId);
await ctx.sessionsCache.removeSession(input.workspaceName, input.sessionId);

return { success: true };
});

const searchSessions = os
.input(
z.object({
workspaceName: z.string(),
query: z.string().min(1).max(500),
})
)
.handler(async ({ input }) => {
const isHost = input.workspaceName === HOST_WORKSPACE_NAME;

if (isHost) {
const config = ctx.config.get();
if (!config.allowHostAccess) {
throw new ORPCError('PRECONDITION_FAILED', { message: 'Host access is disabled' });
}

const results = await searchHostSessions(input.query);
return { results };
}

const workspace = await ctx.workspaces.get(input.workspaceName);
if (!workspace) {
throw new ORPCError('NOT_FOUND', { message: 'Workspace not found' });
}
if (workspace.status !== 'running') {
throw new ORPCError('PRECONDITION_FAILED', { message: 'Workspace is not running' });
}

const containerName = `workspace-${input.workspaceName}`;
const results = await searchSessionsInContainer(containerName, input.query, execInContainer);

return { results };
});

async function searchHostSessions(query: string): Promise<
Array<{
sessionId: string;
agentType: 'claude-code' | 'opencode' | 'codex';
matchCount: number;
}>
> {
const homeDir = os_module.homedir();
const safeQuery = query.replace(/['"\\]/g, '\\$&');
const searchPaths = [
path.join(homeDir, '.claude', 'projects'),
path.join(homeDir, '.local', 'share', 'opencode', 'storage'),
path.join(homeDir, '.codex', 'sessions'),
].filter((p) => {
try {
require('fs').accessSync(p);
return true;
} catch {
return false;
}
});

if (searchPaths.length === 0) {
return [];
}

const { execSync } = await import('child_process');
try {
const output = execSync(
`rg -l -i --no-messages "${safeQuery}" ${searchPaths.join(' ')} 2>/dev/null | head -100`,
{
encoding: 'utf-8',
timeout: 30000,
}
);

const files = output.trim().split('\n').filter(Boolean);
const results: Array<{
sessionId: string;
agentType: 'claude-code' | 'opencode' | 'codex';
matchCount: number;
}> = [];

for (const file of files) {
let sessionId: string | null = null;
let agentType: 'claude-code' | 'opencode' | 'codex' | null = null;

if (file.includes('/.claude/projects/')) {
const match = file.match(/\/([^/]+)\.jsonl$/);
if (match && !match[1].startsWith('agent-')) {
sessionId = match[1];
agentType = 'claude-code';
}
} else if (file.includes('/.local/share/opencode/storage/')) {
if (file.includes('/session/') && file.endsWith('.json')) {
const match = file.match(/\/(ses_[^/]+)\.json$/);
if (match) {
sessionId = match[1];
agentType = 'opencode';
}
}
} else if (file.includes('/.codex/sessions/')) {
const match = file.match(/\/([^/]+)\.jsonl$/);
if (match) {
sessionId = match[1];
agentType = 'codex';
}
}

if (sessionId && agentType) {
results.push({ sessionId, agentType, matchCount: 1 });
}
}

return results;
} catch {
return [];
}
}

async function deleteHostSession(
sessionId: string,
agentType: 'claude-code' | 'opencode' | 'codex'
): Promise<{ success: boolean; error?: string }> {
const homeDir = os_module.homedir();

if (agentType === 'claude-code') {
const safeSessionId = sessionId.replace(/[^a-zA-Z0-9_-]/g, '');
const claudeProjectsDir = path.join(homeDir, '.claude', 'projects');

try {
const projectDirs = await fs.readdir(claudeProjectsDir);
for (const projectDir of projectDirs) {
const sessionFile = path.join(claudeProjectsDir, projectDir, `${safeSessionId}.jsonl`);
try {
await fs.unlink(sessionFile);
return { success: true };
} catch {
continue;
}
}
} catch {
return { success: false, error: 'Session not found' };
}
return { success: false, error: 'Session not found' };
}

if (agentType === 'opencode') {
return deleteOpencodeSession(sessionId);
}

if (agentType === 'codex') {
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
try {
const files = await fs.readdir(codexSessionsDir);
for (const file of files) {
if (!file.endsWith('.jsonl')) continue;
const filePath = path.join(codexSessionsDir, file);
const fileId = file.replace('.jsonl', '');

if (fileId === sessionId) {
await fs.unlink(filePath);
return { success: true };
}

try {
const content = await fs.readFile(filePath, 'utf-8');
const firstLine = content.split('\n')[0];
const meta = JSON.parse(firstLine) as { session_id?: string };
if (meta.session_id === sessionId) {
await fs.unlink(filePath);
return { success: true };
}
} catch {
continue;
}
}
} catch {
return { success: false, error: 'Session not found' };
}
return { success: false, error: 'Session not found' };
}

return { success: false, error: 'Unsupported agent type' };
}

const getHostInfo = os.handler(async () => {
const config = ctx.config.get();
return {
Expand Down Expand Up @@ -813,6 +1055,8 @@ export function createRouter(ctx: RouterContext) {
clearName: clearSessionName,
getRecent: getRecentSessions,
recordAccess: recordSessionAccess,
delete: deleteSession,
search: searchSessions,
},
models: {
list: listModels,
Expand Down
15 changes: 11 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,11 +673,11 @@ const workerCmd = program

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

if (subcommand === 'list') {
Expand All @@ -690,9 +690,16 @@ workerCmd
}
const result = await getOpencodeSessionMessages(sessionId);
console.log(JSON.stringify(result));
} else if (subcommand === 'delete') {
if (!sessionId) {
console.error('Usage: perry worker sessions delete <session_id>');
process.exit(1);
}
const result = await deleteOpencodeSession(sessionId);
console.log(JSON.stringify(result));
} else {
console.error(`Unknown subcommand: ${subcommand}`);
console.error('Available: list, messages');
console.error('Available: list, messages, delete');
process.exit(1);
}
});
Expand Down
32 changes: 32 additions & 0 deletions src/sessions/agents/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,36 @@ export const claudeProvider: AgentSessionProvider = {
);
return { id: sessionId, messages };
},

async deleteSession(
containerName: string,
sessionId: string,
exec: ExecInContainer
): Promise<{ success: boolean; error?: string }> {
const safeSessionId = sessionId.replace(/[^a-zA-Z0-9_-]/g, '');
const findResult = await exec(
containerName,
[
'bash',
'-c',
`find /home/workspace/.claude/projects -name "${safeSessionId}.jsonl" -type f 2>/dev/null | head -1`,
],
{ user: 'workspace' }
);

if (findResult.exitCode !== 0 || !findResult.stdout.trim()) {
return { success: false, error: 'Session not found' };
}

const filePath = findResult.stdout.trim();
const rmResult = await exec(containerName, ['rm', '-f', filePath], {
user: 'workspace',
});

if (rmResult.exitCode !== 0) {
return { success: false, error: rmResult.stderr || 'Failed to delete session file' };
}

return { success: true };
},
};
Loading