Skip to content
Open
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
11 changes: 10 additions & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import { createAuth } from './auth'
import { createAuthMiddleware } from './auth/middleware'
import { createPromptTemplateRoutes } from './routes/prompt-templates'
import { createSessionPinRoutes } from './routes/session-pins'
import { createChatRoutes } from './routes/chats'
import { createChatTerminalRoutes, createChatTerminalSocketRoutes } from './routes/chat-terminals'
import { createSessionPermissionModeRoutes } from './routes/session-permission-modes'
import { createSessionSandboxRoutes } from './routes/session-sandbox'
import { createMultiRunRoutes } from './routes/multi-runs'
Expand Down Expand Up @@ -69,6 +71,7 @@ import { SessionAncestry } from './services/session-ancestry'
import { GoalOutcomeTracker } from './services/goal-outcomes'
import { MultiRunService } from './services/multi-runs'
import { ChangeWalkthroughService } from './services/change-walkthroughs'
import { ChatService } from './services/chats'
import { opencodeServerManager } from './services/opencode-single-server'
import { createOpenCodeClient } from './services/opencode/client'
import { OpenCodeServerRegistry } from './services/opencode-server-registry'
Expand All @@ -95,6 +98,7 @@ import { logger } from './utils/logger'
import {
getWorkspacePath,
getReposPath,
getChatsPath,
getConfigPath,
getAgentsMdPath,
getDatabasePath,
Expand Down Expand Up @@ -220,6 +224,7 @@ try {

await ensureDirectoryExists(getWorkspacePath())
await ensureDirectoryExists(getReposPath())
await ensureDirectoryExists(getChatsPath())
await ensureDirectoryExists(getConfigPath())
logger.info('Workspace directories initialized')

Expand Down Expand Up @@ -299,7 +304,8 @@ scheduleService.setWorktreeRemovedHandler(async (directory) => {
const repoWorkspaces = new RepoWorkspaceService(db, openCodeClient, gitAuthService, projectConfigService, terminalService, scheduleService)
const scheduleRunnerInstance = new ScheduleRunner(scheduleService)

const notificationService = new NotificationService(db, opencodeServerRegistry)
const chatService = new ChatService(openCodeClient, terminalService)
const notificationService = new NotificationService(db, opencodeServerRegistry, chatService)
const sessionPermissionModeService = new SessionPermissionModeService(db, opencodeServerRegistry, sessionSettingsService, sessionAncestry)
const goalOutcomes = new GoalOutcomeTracker()

Expand Down Expand Up @@ -420,6 +426,9 @@ protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db))
protectedApi.route('/secrets', createGlobalSecretRoutes(secretsService, 'secret'))
protectedApi.route('/variables', createGlobalSecretRoutes(secretsService, 'variable'))
protectedApi.route('/session-pins', createSessionPinRoutes(db))
protectedApi.route('/chats', createChatTerminalSocketRoutes(chatService, terminalService, upgradeWebSocket))
protectedApi.route('/chats', createChatRoutes(chatService))
protectedApi.route('/chats', createChatTerminalRoutes(chatService, terminalService))
protectedApi.route('/opencode-servers/:serverId/schedules', createServerScheduleRoutes(scheduleService))
protectedApi.route('/opencode-servers', createOpenCodeServerTerminalSocketRoutes(resolveServerTerminalService, upgradeWebSocket))
protectedApi.route('/opencode-servers', createOpenCodeServerTerminalRoutes(resolveServerTerminalService))
Expand Down
144 changes: 144 additions & 0 deletions backend/src/routes/chat-terminals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { Hono } from 'hono'
import type { MiddlewareHandler } from 'hono'
import type { UpgradeWebSocket } from 'hono/ws'
import { getTrustedOrigins } from '@opencode-manager/shared/config/env'
import { CreateTerminalRequestSchema, ResizeTerminalRequestSchema } from '@opencode-manager/shared/schemas'
import type { ChatService } from '../services/chats'
import { TerminalNotFoundError, type TerminalService } from '../services/terminal'
import { createBridgedSocketEvents, isAllowedUpgradeOrigin } from '../utils/websocket-bridge'
import { parseTerminalCursor } from '../utils/terminal-cursor'
import { handleOpenCodeError } from '../utils/route-helpers'
import type { Session } from '../auth'

export function createChatTerminalRoutes(chatService: ChatService, terminalService: TerminalService) {
const app = new Hono()

app.get('/:chatId/terminals', async (c) => {
const chat = await chatService.resolve(c.req.param('chatId'))
if (!chat) return c.json({ error: 'Chat not found' }, 404)

try {
const terminals = await terminalService.list(chat.directory)
return c.json({ terminals })
} catch (error: unknown) {
return handleOpenCodeError(c, error, 'Terminal request failed')
}
})

app.post('/:chatId/terminals', async (c) => {
const body = await c.req.json().catch(() => ({}))
const parsed = CreateTerminalRequestSchema.safeParse(body)
if (!parsed.success) {
return c.json({ error: 'Invalid request' }, 400)
}

const chat = await chatService.resolve(c.req.param('chatId'))
if (!chat) return c.json({ error: 'Chat not found' }, 404)

try {
const terminal = await terminalService.create(chat.directory, {
kind: 'shell',
name: parsed.data.title ?? 'Terminal',
})
return c.json(terminal)
} catch (error: unknown) {
return handleOpenCodeError(c, error, 'Terminal request failed')
}
})

app.patch('/:chatId/terminals/:ptyID', async (c) => {
const body = await c.req.json().catch(() => ({}))
const parsed = ResizeTerminalRequestSchema.safeParse(body)
if (!parsed.success) {
return c.json({ error: 'Invalid request' }, 400)
}

const chat = await chatService.resolve(c.req.param('chatId'))
if (!chat) return c.json({ error: 'Chat not found' }, 404)

const ptyID = c.req.param('ptyID')

try {
await terminalService.requireTerminal(chat.directory, ptyID)
await terminalService.resize(chat.directory, ptyID, { cols: parsed.data.cols, rows: parsed.data.rows })
return c.json({ success: true })
} catch (error: unknown) {
if (error instanceof TerminalNotFoundError) {
return c.json({ error: error.message }, 404)
}
return handleOpenCodeError(c, error, 'Terminal request failed')
}
})

app.delete('/:chatId/terminals/:ptyID', async (c) => {
const chat = await chatService.resolve(c.req.param('chatId'))
if (!chat) return c.json({ error: 'Chat not found' }, 404)

const ptyID = c.req.param('ptyID')

try {
await terminalService.requireTerminal(chat.directory, ptyID)
await terminalService.remove(chat.directory, ptyID)
return c.json({ success: true })
} catch (error: unknown) {
if (error instanceof TerminalNotFoundError) {
return c.json({ error: error.message }, 404)
}
return handleOpenCodeError(c, error, 'Terminal request failed')
}
})

return app
}

interface ChatTerminalSocketVariables {
terminalDirectory: string
terminalCursor: number | undefined
session?: Session['session']
}

export function createChatTerminalSocketRoutes(
chatService: ChatService,
terminalService: TerminalService,
upgradeWebSocket: UpgradeWebSocket,
trustedOrigins: readonly string[] = getTrustedOrigins(),
) {
const app = new Hono<{ Variables: ChatTerminalSocketVariables }>()

const validate: MiddlewareHandler<{ Variables: ChatTerminalSocketVariables }> = async (c, next) => {
if (!isAllowedUpgradeOrigin(c.req.header('origin'), trustedOrigins)) {
return c.json({ error: 'Origin not allowed' }, 403)
}

const cursor = parseTerminalCursor(c.req.query('cursor'))
if (cursor === null) {
return c.json({ error: 'Invalid cursor' }, 400)
}

const chat = await chatService.resolve(c.req.param('chatId') ?? '')
if (!chat) {
return c.json({ error: 'Chat not found' }, 404)
}

c.set('terminalDirectory', chat.directory)
c.set('terminalCursor', cursor)
await next()
}

app.get(
'/:chatId/terminals/:ptyID/connect',
validate,
upgradeWebSocket((c) => {
const directory = c.get('terminalDirectory')
const cursor = c.get('terminalCursor')
const ptyID = c.req.param('ptyID') ?? ''

return createBridgedSocketEvents(
(peer) => terminalService.connect(directory, ptyID, cursor, peer),
{ sessionId: c.get('session')?.id },
)
}),
)

return app
}
63 changes: 63 additions & 0 deletions backend/src/routes/chats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Hono } from 'hono'
import { CreateChatRequestSchema, ListChatsQuerySchema } from '@opencode-manager/shared/schemas'
import { ChatNotFoundError, ChatSessionMismatchError, type ChatService } from '../services/chats'
import { getErrorMessage } from '../utils/error-utils'
import { logger } from '../utils/logger'
import { handleOpenCodeError, parseJsonBody } from '../utils/route-helpers'

export function createChatRoutes(chatService: ChatService) {
const app = new Hono()

app.get('/', async (c) => {
const parsedQuery = ListChatsQuerySchema.safeParse(c.req.query())
if (!parsedQuery.success) {
return c.json({ error: 'Invalid query', details: parsedQuery.error.issues }, 400)
}

try {
return c.json(await chatService.list(parsedQuery.data))
} catch (error) {
logger.error('Failed to list chats', error)
return c.json({ error: getErrorMessage(error) }, 500)
}
})

app.get('/:chatId', async (c) => {
try {
const chat = await chatService.resolve(c.req.param('chatId'))
return chat ? c.json(chat) : c.json({ error: 'Chat not found' }, 404)
} catch (error) {
logger.error('Failed to read chat', error)
return c.json({ error: getErrorMessage(error) }, 500)
}
})

app.post('/', async (c) => {
const body = await parseJsonBody(c, CreateChatRequestSchema, { allowEmpty: true })
if (!body.ok) {
return body.response
}

try {
return c.json(await chatService.create(body.data), 201)
} catch (error) {
return handleOpenCodeError(c, error, 'Chat request failed')
}
})

app.delete('/:chatId/sessions/:sessionId', async (c) => {
try {
return c.json(await chatService.deleteSession(c.req.param('chatId'), c.req.param('sessionId')))
} catch (error) {
if (error instanceof ChatNotFoundError) {
return c.json({ error: error.message }, 404)
}
if (error instanceof ChatSessionMismatchError) {
return c.json({ error: error.message }, 400)
}
return handleOpenCodeError(c, error, 'Chat session delete failed')
}
})

return app
}
Loading
Loading