Skip to content

Commit c3d6549

Browse files
feat: add assistant mode, SSE streaming, and OpenCode server integration (#214)
1 parent 3cc2428 commit c3d6549

133 files changed

Lines changed: 7278 additions & 6287 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,16 @@ LOG_LEVEL=info
1717
OPENCODE_SERVER_PORT=5551
1818
OPENCODE_HOST=127.0.0.1
1919

20-
# Optional - bearer password required to talk to the spawned OpenCode server.
21-
# When set, the backend spawns OpenCode with this password and attaches it to
22-
# every proxied request. Leave unset to disable OpenCode-level auth.
20+
# Optional - bearer password required when OPENCODE_HOST=0.0.0.0 (external exposure).
21+
# The managed OpenCode server will refuse to start if OPENCODE_HOST is not
22+
# localhost/127.0.0.1 and no password is configured (either here or via
23+
# Settings → OpenCode → Server Auth).
24+
# DB-stored passwords (set via UI) override this env var.
2325
# OPENCODE_SERVER_PASSWORD=
2426

27+
# Optional - Basic Auth username (default: opencode)
28+
# OPENCODE_SERVER_USERNAME=opencode
29+
2530
# Optional - import an existing standalone OpenCode install on first startup
2631
# Useful for Docker when your host OpenCode data is bind-mounted into the container
2732
# OPENCODE_IMPORT_CONFIG_PATH=/import/opencode-config/opencode.json

README.md

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,15 +53,37 @@ For local development setup, see the [Development Guide](https://chriswritescode
5353

5454
## Features
5555

56-
- **Git** — Multi-repo support, SSH authentication, worktrees, unified diffs with line numbers, PR creation
56+
- **Repositories & Git** — Multi-repo management with local discovery, SSH auth, worktrees, unified diffs, branch/commit management
57+
- **Chat & Sessions** — Real-time SSE streaming, slash commands, `@file` mentions, Plan/Build modes, Mermaid diagrams
5758
- **Files** — Directory browser with tree view, syntax highlighting, create/rename/delete, ZIP download
58-
- **Chat** — Real-time streaming (SSE), slash commands, `@file` mentions, Plan/Build modes, Mermaid diagrams
59-
- **Schedules** — Recurring repo jobs with reusable prompts, run history, linked sessions, and markdown-rendered output
60-
- **Audio** — Text-to-speech (browser + OpenAI-compatible), speech-to-text (browser + OpenAI-compatible)
61-
- **AI** — Model selection, provider config, OAuth for Anthropic/GitHub Copilot, custom agents with system prompts
62-
- **MCP** — Local and remote MCP server support with pre-built templates
63-
- **Memory** — Persistent project knowledge with semantic search ([plugin repo](https://github.com/chriswritescode-dev/opencode-memory)) and compaction awareness
64-
- **Mobile** — Responsive UI, PWA installable, iOS-optimized with proper keyboard handling and swipe navigation
59+
- **Schedules** — Recurring repo jobs with reusable prompts, run history, linked sessions, markdown-rendered output
60+
- **AI & OpenCode** — Model/provider configuration, OAuth for Anthropic/GitHub Copilot, custom agents, OpenCode server supervision and proxying
61+
- **Audio** — Text-to-speech and speech-to-text (browser + OpenAI-compatible)
62+
- **Mobile & Notifications** — Responsive PWA, mobile-first navigation, push notification support
63+
64+
## Architecture
65+
66+
OpenCode Manager is a pnpm workspace with three TypeScript packages:
67+
68+
- `backend/` — Bun + Hono API server with Better Auth, SQLite migrations, OpenCode process management, SSE, schedules, and push notifications.
69+
- `frontend/` — React + Vite SPA using React Router, TanStack Query, Radix UI/Tailwind, service worker support, and mobile-first navigation.
70+
- `shared/` — shared Zod schemas, config helpers, types, and utilities consumed by both backend and frontend.
71+
72+
A MkDocs Material site (`docs/`) provides guides, feature docs, configuration, and troubleshooting.
73+
74+
## Development
75+
76+
This repo uses pnpm workspaces for `shared`, `backend`, and `frontend`.
77+
78+
```bash
79+
pnpm install
80+
pnpm dev
81+
pnpm lint
82+
pnpm typecheck
83+
pnpm test
84+
```
85+
86+
See the [Development Guide](https://chriswritescode-dev.github.io/opencode-manager/development/setup/) for local setup, scripts, database notes, and testing.
6587

6688
## Configuration
6789

backend/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
"start": "bun src/index.ts",
99
"build": "bun build src/index.ts --outdir=dist --target=bun",
1010
"typecheck": "tsc --noEmit",
11-
"test": "bun test src/",
12-
"test:vitest": "vitest",
13-
"test:all": "bun test src/ && vitest test/",
11+
"test": "pnpm run test:bun && pnpm run test:vitest",
12+
"test:bun": "bun test test/services/assistant-mode.test.ts test/services/internal-token.test.ts test/auth/internal-token-middleware.test.ts test/routes/internal-schedules.test.ts test/routes/internal-notifications.test.ts test/routes/internal-settings.test.ts src/db/model-state.test.ts src/routes/providers.test.ts",
13+
"test:vitest": "vitest run",
1414
"test:ui": "vitest --ui",
1515
"test:watch": "vitest --watch",
1616
"lint": "eslint . --ext .ts",
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { createMiddleware } from 'hono/factory'
2+
import { timingSafeEqual } from 'node:crypto'
3+
import type { Database } from 'bun:sqlite'
4+
import { getOrCreateInternalToken } from '../services/internal-token'
5+
6+
export function createInternalTokenMiddleware(db: Database) {
7+
return createMiddleware(async (c, next) => {
8+
const header = c.req.header('authorization') ?? c.req.header('Authorization')
9+
if (!header || !header.startsWith('Bearer ')) {
10+
return c.json({ error: 'Unauthorized' }, 401)
11+
}
12+
const provided = Buffer.from(header.slice(7))
13+
const expected = Buffer.from(getOrCreateInternalToken(db))
14+
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
15+
return c.json({ error: 'Unauthorized' }, 401)
16+
}
17+
await next()
18+
})
19+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { Migration } from '../migration-runner'
2+
3+
const migration: Migration = {
4+
version: 13,
5+
name: 'app-secrets',
6+
up(db) {
7+
db.run(`
8+
CREATE TABLE IF NOT EXISTS app_secrets (
9+
key TEXT PRIMARY KEY,
10+
value TEXT NOT NULL,
11+
created_at INTEGER NOT NULL,
12+
updated_at INTEGER NOT NULL
13+
)
14+
`)
15+
},
16+
down(db) {
17+
db.run('DROP TABLE IF EXISTS app_secrets')
18+
},
19+
}
20+
21+
export default migration

backend/src/db/migrations/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import migration009 from './009-repo-source-path'
1111
import migration010 from './009-prompt-templates'
1212
import migration011 from './011-repo-last-accessed'
1313
import migration012 from './012-opencode-model-state'
14+
import migration013 from './013-app-secrets'
1415

1516
export const allMigrations: Migration[] = [
1617
migration001,
@@ -25,4 +26,5 @@ export const allMigrations: Migration[] = [
2526
migration010,
2627
migration011,
2728
migration012,
29+
migration013,
2830
]

backend/src/index.ts

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,17 @@ import { createOAuthRoutes } from './routes/oauth'
2929
import { createSSERoutes } from './routes/sse'
3030
import { createSSHRoutes } from './routes/ssh'
3131
import { createNotificationRoutes } from './routes/notifications'
32-
import { createMemoryRoutes } from './routes/memory'
3332
import { createMcpOauthProxyRoutes } from './routes/mcp-oauth-proxy'
3433
import { createAuthRoutes, createAuthInfoRoutes, syncAdminFromEnv } from './routes/auth'
3534
import { createAuth } from './auth'
3635
import { createAuthMiddleware } from './auth/middleware'
3736
import { createPromptTemplateRoutes } from './routes/prompt-templates'
37+
import { createInternalRoutes } from './routes/internal'
3838
import { sseAggregator } from './services/sse-aggregator'
3939
import { ensureDirectoryExists, writeFileContent, fileExists, readFileContent } from './services/file-operations'
4040
import { SettingsService } from './services/settings'
4141
import { opencodeServerManager } from './services/opencode-single-server'
42-
import { proxyRequest, proxyMcpAuthStart, proxyMcpAuthAuthenticate } from './services/proxy'
42+
import { createOpenCodeClient } from './services/opencode/client'
4343
import { NotificationService } from './services/notification'
4444
import { ScheduleRunner, ScheduleService } from './services/schedules'
4545
import { migrateGlobalSkills } from './services/skills'
@@ -85,6 +85,7 @@ app.use('/*', cors({
8585
const db = initializeDatabase(DB_PATH)
8686
const auth = createAuth(db)
8787
const requireAuth = createAuthMiddleware(auth)
88+
const openCodeClient = createOpenCodeClient(() => new SettingsService(db).getOpenCodeServerPassword())
8889

8990
import { DEFAULT_AGENTS_MD } from './constants'
9091

@@ -262,6 +263,8 @@ try {
262263
await gitAuthService.initialize(ipcServer, db)
263264
logger.info(`Git IPC server running at ${ipcServer.ipcHandlePath}`)
264265

266+
await syncAdminFromEnv(auth, db)
267+
265268
opencodeServerManager.setDatabase(db)
266269
const openCodeStatus = await openCodeSupervisor.start()
267270
if (openCodeStatus.healthy) {
@@ -270,12 +273,11 @@ try {
270273
logger.warn(`OpenCode server unavailable after startup recovery: ${openCodeStatus.lastError ?? openCodeStatus.state}`)
271274
}
272275

273-
await syncAdminFromEnv(auth, db)
274276
} catch (error) {
275277
logger.error('Failed to initialize workspace:', error)
276278
}
277279

278-
const scheduleService = new ScheduleService(db)
280+
const scheduleService = new ScheduleService(db, openCodeClient)
279281
const scheduleRunnerInstance = new ScheduleRunner(scheduleService)
280282

281283
const notificationService = new NotificationService(db)
@@ -299,28 +301,34 @@ if (ENV.VAPID.PUBLIC_KEY && ENV.VAPID.PRIVATE_KEY) {
299301
})
300302
}
301303

304+
sseAggregator.setPendingActionsFetcher(openCodeClient)
305+
sseAggregator.setPasswordResolver(() => new SettingsService(db).getOpenCodeServerPassword())
306+
sseAggregator.start()
307+
302308
void scheduleRunnerInstance.start()
303309

310+
const settingsService = new SettingsService(db)
311+
304312
app.route('/api/auth', createAuthRoutes(auth))
305313
app.route('/api/auth-info', createAuthInfoRoutes(auth, db))
306314
app.route('/api/health', createHealthRoutes(db, openCodeSupervisor))
307315

308-
app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(requireAuth))
316+
app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(openCodeClient, requireAuth))
317+
app.route('/api/internal', createInternalRoutes(db, scheduleService, notificationService, settingsService))
309318

310319
const protectedApi = new Hono()
311320
protectedApi.use('/*', requireAuth)
312321

313-
protectedApi.route('/repos', createRepoRoutes(db, gitAuthService, scheduleService, openCodeSupervisor))
314-
protectedApi.route('/settings', createSettingsRoutes(db, gitAuthService, openCodeSupervisor))
322+
protectedApi.route('/repos', createRepoRoutes(db, gitAuthService, scheduleService, openCodeClient, openCodeSupervisor))
323+
protectedApi.route('/settings', createSettingsRoutes(db, gitAuthService, openCodeClient, openCodeSupervisor))
315324
protectedApi.route('/files', createFileRoutes())
316-
protectedApi.route('/providers', createProvidersRoutes(db, openCodeSupervisor))
317-
protectedApi.route('/oauth', createOAuthRoutes(openCodeSupervisor))
325+
protectedApi.route('/providers', createProvidersRoutes(db, openCodeClient, openCodeSupervisor))
326+
protectedApi.route('/oauth', createOAuthRoutes(openCodeClient, openCodeSupervisor))
318327
protectedApi.route('/tts', createTTSRoutes(db))
319328
protectedApi.route('/stt', createSTTRoutes(db))
320329
protectedApi.route('/sse', createSSERoutes())
321330
protectedApi.route('/ssh', createSSHRoutes(gitAuthService))
322331
protectedApi.route('/notifications', createNotificationRoutes(notificationService))
323-
protectedApi.route('/memory', createMemoryRoutes(db))
324332
protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db))
325333
protectedApi.route('/schedules', createScheduleRoutes(scheduleService))
326334

@@ -329,18 +337,17 @@ app.route('/api', protectedApi)
329337
app.post('/api/opencode/mcp/:name/auth', requireAuth, async (c) => {
330338
const serverName = c.req.param('name')
331339
const directory = c.req.query('directory')
332-
return proxyMcpAuthStart(serverName, directory)
340+
return openCodeClient.startMcpAuth(serverName, directory)
333341
})
334342

335343
app.post('/api/opencode/mcp/:name/auth/authenticate', requireAuth, async (c) => {
336344
const serverName = c.req.param('name')
337345
const directory = c.req.query('directory')
338-
return proxyMcpAuthAuthenticate(serverName, directory)
346+
return openCodeClient.authenticateMcp(serverName, directory)
339347
})
340348

341349
app.all('/api/opencode/*', requireAuth, async (c) => {
342-
const request = c.req.raw
343-
return proxyRequest(request)
350+
return openCodeClient.forwardRaw(c.req.raw)
344351
})
345352

346353
const isProduction = ENV.SERVER.NODE_ENV === 'production'
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Hono } from 'hono'
2+
import type { Database } from 'bun:sqlite'
3+
import type { ScheduleService } from '../../services/schedules'
4+
import type { NotificationService } from '../../services/notification'
5+
import type { SettingsService } from '../../services/settings'
6+
import { createScheduleRoutes } from '../schedules'
7+
import { createInternalTokenMiddleware } from '../../auth/internal-token-middleware'
8+
import { createInternalNotificationRoutes } from './notifications'
9+
import { createInternalSettingsRoutes } from './settings'
10+
11+
export function createInternalRoutes(
12+
db: Database,
13+
scheduleService: ScheduleService,
14+
notificationService: NotificationService,
15+
settingsService: SettingsService,
16+
) {
17+
const app = new Hono()
18+
app.use('/*', createInternalTokenMiddleware(db))
19+
app.route('/schedules', createScheduleRoutes(scheduleService))
20+
app.route('/notifications', createInternalNotificationRoutes(notificationService))
21+
app.route('/settings', createInternalSettingsRoutes(settingsService))
22+
const repos = new Hono()
23+
repos.route('/:id/schedules', createScheduleRoutes(scheduleService))
24+
app.route('/repos', repos)
25+
return app
26+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Hono } from 'hono'
2+
import { AssistantNotificationRequestSchema } from '@opencode-manager/shared/schemas'
3+
import type { NotificationService } from '../../services/notification'
4+
import { TokenBucketRateLimiter } from '../../utils/rate-limit'
5+
6+
export function createInternalNotificationRoutes(notificationService: NotificationService) {
7+
const app = new Hono()
8+
const limiter = new TokenBucketRateLimiter({ capacity: 10, refillPerMs: 60_000 })
9+
10+
app.post('/send', async (c) => {
11+
if (!notificationService.isConfigured()) {
12+
return c.json({ error: 'Push notifications are not configured (missing VAPID env)' }, 503)
13+
}
14+
15+
const token = (c.req.header('authorization') ?? '').slice('Bearer '.length)
16+
const limit = limiter.tryConsume(token || 'anon')
17+
if (!limit.allowed) {
18+
c.header('Retry-After', String(Math.ceil(limit.retryAfterMs / 1000)))
19+
return c.json({ error: 'Rate limit exceeded' }, 429)
20+
}
21+
22+
let body: unknown
23+
try {
24+
body = await c.req.json()
25+
} catch {
26+
return c.json({ error: 'Invalid JSON' }, 400)
27+
}
28+
29+
const parsed = AssistantNotificationRequestSchema.safeParse(body)
30+
if (!parsed.success) {
31+
return c.json({ error: 'Invalid request body', details: parsed.error.issues }, 400)
32+
}
33+
34+
const userId = c.req.query('userId') ?? 'default'
35+
36+
const payload = {
37+
title: parsed.data.title,
38+
body: parsed.data.body,
39+
tag: parsed.data.tag ?? `assistant-${Date.now()}`,
40+
data: {
41+
eventType: 'assistant.message',
42+
url: parsed.data.url ?? '/',
43+
priority: parsed.data.priority,
44+
},
45+
}
46+
47+
const stats = await notificationService.sendToUser(userId, payload)
48+
return c.json({
49+
delivered: stats.delivered,
50+
expired: stats.expired,
51+
failed: stats.failed,
52+
noSubscriptions: stats.total === 0,
53+
})
54+
})
55+
56+
return app
57+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Hono } from 'hono'
2+
import { AssistantSettingsPatchSchema } from '@opencode-manager/shared/schemas'
3+
import type { SettingsService } from '../../services/settings'
4+
import type { UserPreferences } from '@opencode-manager/shared/types'
5+
6+
export function createInternalSettingsRoutes(settingsService: SettingsService) {
7+
const app = new Hono()
8+
9+
app.get('/', (c) => {
10+
const userId = c.req.query('userId') ?? 'default'
11+
const settings = settingsService.getSettings(userId)
12+
return c.json(settings)
13+
})
14+
15+
app.patch('/', async (c) => {
16+
const userId = c.req.query('userId') ?? 'default'
17+
18+
let body: unknown
19+
try {
20+
body = await c.req.json()
21+
} catch {
22+
return c.json({ error: 'Invalid JSON' }, 400)
23+
}
24+
25+
const parsed = AssistantSettingsPatchSchema.safeParse(body)
26+
if (!parsed.success) {
27+
return c.json({ error: 'Invalid request body', details: parsed.error.issues }, 400)
28+
}
29+
30+
const updated = settingsService.updateSettings(parsed.data as Partial<UserPreferences>, userId)
31+
return c.json(updated)
32+
})
33+
34+
return app
35+
}

0 commit comments

Comments
 (0)