Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
07cfaa1
feat: reduce voice swipe threshold for easier activation
chriswritescode-dev Apr 30, 2026
afd2c18
feat: set horizontal swipe thresholds to 60px
chriswritescode-dev Apr 30, 2026
5abaaa5
feat: display current git branch in MoreDrawer header
chriswritescode-dev Apr 30, 2026
568d448
refactor: remove proxy service and add opencode client implementation
chriswritescode-dev May 1, 2026
f2887b6
refactor: skills service and UI components with SSE improvements
chriswritescode-dev May 1, 2026
3aee66b
Add swipe-to-close and repo display to MoreDrawer
chriswritescode-dev May 1, 2026
07b0e15
Simplify Load button and remove skill error toast
chriswritescode-dev May 1, 2026
fba7c62
Improve scroll detection and add scroll-based header show/hide
chriswritescode-dev May 1, 2026
c2adc59
Change header background to transparent
chriswritescode-dev May 1, 2026
b0fcb6e
Style repo name in MoreDrawer header to match other locations and rem…
chriswritescode-dev May 1, 2026
94a6f2d
refactor(frontend): improve voice status overlay and scroll button vi…
chriswritescode-dev May 2, 2026
63f6f44
Restore in-flow header/todo layout and fix collapse animation
chriswritescode-dev May 2, 2026
7fbdf7f
Add dismiss button to todo panel that resets on todo changes
chriswritescode-dev May 2, 2026
6c75180
Suppress todo tool status messages in chat — panel already shows state
chriswritescode-dev May 2, 2026
ea556fb
Add internal token authentication and assistant mode improvements
chriswritescode-dev May 2, 2026
27f9c35
Add internal token authentication with tests and migration
chriswritescode-dev May 2, 2026
d77e545
Improve schedule skill URL rewriting and session detail header animation
chriswritescode-dev May 2, 2026
829d4af
Simplify useAutoScroll and add tests
chriswritescode-dev May 2, 2026
348319d
fix(MoreDrawer): show Assistant name on assistant routes
chriswritescode-dev May 2, 2026
becb16a
refactor: opencode event stream integration and SSE aggregator update…
chriswritescode-dev May 2, 2026
cd567f8
feat: add OpenCode server authentication settings and config
chriswritescode-dev May 2, 2026
bba3113
feat: add SSE password resolver and server health monitoring
chriswritescode-dev May 2, 2026
c669834
fix(assistant-mode): use ENV.SERVER.PORT for internal API URL in sche…
chriswritescode-dev May 2, 2026
42f3251
feat: add assistant mode, notifications, and internal API routes
chriswritescode-dev May 3, 2026
dfcf2bf
bump version to 0.10.0
chriswritescode-dev May 3, 2026
7e07f74
docs: update README and project documentation
chriswritescode-dev May 3, 2026
fbee6bc
loop: remove-memory-plugin-1 completed after 1 iterations (#213)
chriswritescode-dev May 3, 2026
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: 8 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@ LOG_LEVEL=info
OPENCODE_SERVER_PORT=5551
OPENCODE_HOST=127.0.0.1

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

# Optional - Basic Auth username (default: opencode)
# OPENCODE_SERVER_USERNAME=opencode

# Optional - import an existing standalone OpenCode install on first startup
# Useful for Docker when your host OpenCode data is bind-mounted into the container
# OPENCODE_IMPORT_CONFIG_PATH=/import/opencode-config/opencode.json
Expand Down
38 changes: 30 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,37 @@ For local development setup, see the [Development Guide](https://chriswritescode

## Features

- **Git** — Multi-repo support, SSH authentication, worktrees, unified diffs with line numbers, PR creation
- **Repositories & Git** — Multi-repo management with local discovery, SSH auth, worktrees, unified diffs, branch/commit management
- **Chat & Sessions** — Real-time SSE streaming, slash commands, `@file` mentions, Plan/Build modes, Mermaid diagrams
- **Files** — Directory browser with tree view, syntax highlighting, create/rename/delete, ZIP download
- **Chat** — Real-time streaming (SSE), slash commands, `@file` mentions, Plan/Build modes, Mermaid diagrams
- **Schedules** — Recurring repo jobs with reusable prompts, run history, linked sessions, and markdown-rendered output
- **Audio** — Text-to-speech (browser + OpenAI-compatible), speech-to-text (browser + OpenAI-compatible)
- **AI** — Model selection, provider config, OAuth for Anthropic/GitHub Copilot, custom agents with system prompts
- **MCP** — Local and remote MCP server support with pre-built templates
- **Memory** — Persistent project knowledge with semantic search ([plugin repo](https://github.com/chriswritescode-dev/opencode-memory)) and compaction awareness
- **Mobile** — Responsive UI, PWA installable, iOS-optimized with proper keyboard handling and swipe navigation
- **Schedules** — Recurring repo jobs with reusable prompts, run history, linked sessions, markdown-rendered output
- **AI & OpenCode** — Model/provider configuration, OAuth for Anthropic/GitHub Copilot, custom agents, OpenCode server supervision and proxying
- **Audio** — Text-to-speech and speech-to-text (browser + OpenAI-compatible)
- **Mobile & Notifications** — Responsive PWA, mobile-first navigation, push notification support

## Architecture

OpenCode Manager is a pnpm workspace with three TypeScript packages:

- `backend/` — Bun + Hono API server with Better Auth, SQLite migrations, OpenCode process management, SSE, schedules, and push notifications.
- `frontend/` — React + Vite SPA using React Router, TanStack Query, Radix UI/Tailwind, service worker support, and mobile-first navigation.
- `shared/` — shared Zod schemas, config helpers, types, and utilities consumed by both backend and frontend.

A MkDocs Material site (`docs/`) provides guides, feature docs, configuration, and troubleshooting.

## Development

This repo uses pnpm workspaces for `shared`, `backend`, and `frontend`.

```bash
pnpm install
pnpm dev
pnpm lint
pnpm typecheck
pnpm test
```

See the [Development Guide](https://chriswritescode-dev.github.io/opencode-manager/development/setup/) for local setup, scripts, database notes, and testing.

## Configuration

Expand Down
6 changes: 3 additions & 3 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
"start": "bun src/index.ts",
"build": "bun build src/index.ts --outdir=dist --target=bun",
"typecheck": "tsc --noEmit",
"test": "bun test src/",
"test:vitest": "vitest",
"test:all": "bun test src/ && vitest test/",
"test": "pnpm run test:bun && pnpm run test:vitest",
"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",
"test:vitest": "vitest run",
"test:ui": "vitest --ui",
"test:watch": "vitest --watch",
"lint": "eslint . --ext .ts",
Expand Down
19 changes: 19 additions & 0 deletions backend/src/auth/internal-token-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createMiddleware } from 'hono/factory'
import { timingSafeEqual } from 'node:crypto'
import type { Database } from 'bun:sqlite'
import { getOrCreateInternalToken } from '../services/internal-token'

export function createInternalTokenMiddleware(db: Database) {
return createMiddleware(async (c, next) => {
const header = c.req.header('authorization') ?? c.req.header('Authorization')
if (!header || !header.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized' }, 401)
}
const provided = Buffer.from(header.slice(7))
const expected = Buffer.from(getOrCreateInternalToken(db))
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
return c.json({ error: 'Unauthorized' }, 401)
}
await next()
})
}
21 changes: 21 additions & 0 deletions backend/src/db/migrations/013-app-secrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Migration } from '../migration-runner'

const migration: Migration = {
version: 13,
name: 'app-secrets',
up(db) {
db.run(`
CREATE TABLE IF NOT EXISTS app_secrets (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`)
},
down(db) {
db.run('DROP TABLE IF EXISTS app_secrets')
},
}

export default migration
2 changes: 2 additions & 0 deletions backend/src/db/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import migration009 from './009-repo-source-path'
import migration010 from './009-prompt-templates'
import migration011 from './011-repo-last-accessed'
import migration012 from './012-opencode-model-state'
import migration013 from './013-app-secrets'

export const allMigrations: Migration[] = [
migration001,
Expand All @@ -25,4 +26,5 @@ export const allMigrations: Migration[] = [
migration010,
migration011,
migration012,
migration013,
]
35 changes: 21 additions & 14 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,17 @@ import { createOAuthRoutes } from './routes/oauth'
import { createSSERoutes } from './routes/sse'
import { createSSHRoutes } from './routes/ssh'
import { createNotificationRoutes } from './routes/notifications'
import { createMemoryRoutes } from './routes/memory'
import { createMcpOauthProxyRoutes } from './routes/mcp-oauth-proxy'
import { createAuthRoutes, createAuthInfoRoutes, syncAdminFromEnv } from './routes/auth'
import { createAuth } from './auth'
import { createAuthMiddleware } from './auth/middleware'
import { createPromptTemplateRoutes } from './routes/prompt-templates'
import { createInternalRoutes } from './routes/internal'
import { sseAggregator } from './services/sse-aggregator'
import { ensureDirectoryExists, writeFileContent, fileExists, readFileContent } from './services/file-operations'
import { SettingsService } from './services/settings'
import { opencodeServerManager } from './services/opencode-single-server'
import { proxyRequest, proxyMcpAuthStart, proxyMcpAuthAuthenticate } from './services/proxy'
import { createOpenCodeClient } from './services/opencode/client'
import { NotificationService } from './services/notification'
import { ScheduleRunner, ScheduleService } from './services/schedules'
import { migrateGlobalSkills } from './services/skills'
Expand Down Expand Up @@ -85,6 +85,7 @@ app.use('/*', cors({
const db = initializeDatabase(DB_PATH)
const auth = createAuth(db)
const requireAuth = createAuthMiddleware(auth)
const openCodeClient = createOpenCodeClient(() => new SettingsService(db).getOpenCodeServerPassword())

import { DEFAULT_AGENTS_MD } from './constants'

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

await syncAdminFromEnv(auth, db)

opencodeServerManager.setDatabase(db)
const openCodeStatus = await openCodeSupervisor.start()
if (openCodeStatus.healthy) {
Expand All @@ -270,12 +273,11 @@ try {
logger.warn(`OpenCode server unavailable after startup recovery: ${openCodeStatus.lastError ?? openCodeStatus.state}`)
}

await syncAdminFromEnv(auth, db)
} catch (error) {
logger.error('Failed to initialize workspace:', error)
}

const scheduleService = new ScheduleService(db)
const scheduleService = new ScheduleService(db, openCodeClient)
const scheduleRunnerInstance = new ScheduleRunner(scheduleService)

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

sseAggregator.setPendingActionsFetcher(openCodeClient)
sseAggregator.setPasswordResolver(() => new SettingsService(db).getOpenCodeServerPassword())
sseAggregator.start()

void scheduleRunnerInstance.start()

const settingsService = new SettingsService(db)

app.route('/api/auth', createAuthRoutes(auth))
app.route('/api/auth-info', createAuthInfoRoutes(auth, db))
app.route('/api/health', createHealthRoutes(db, openCodeSupervisor))

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

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

protectedApi.route('/repos', createRepoRoutes(db, gitAuthService, scheduleService, openCodeSupervisor))
protectedApi.route('/settings', createSettingsRoutes(db, gitAuthService, openCodeSupervisor))
protectedApi.route('/repos', createRepoRoutes(db, gitAuthService, scheduleService, openCodeClient, openCodeSupervisor))
protectedApi.route('/settings', createSettingsRoutes(db, gitAuthService, openCodeClient, openCodeSupervisor))
protectedApi.route('/files', createFileRoutes())
protectedApi.route('/providers', createProvidersRoutes(db, openCodeSupervisor))
protectedApi.route('/oauth', createOAuthRoutes(openCodeSupervisor))
protectedApi.route('/providers', createProvidersRoutes(db, openCodeClient, openCodeSupervisor))
protectedApi.route('/oauth', createOAuthRoutes(openCodeClient, openCodeSupervisor))
protectedApi.route('/tts', createTTSRoutes(db))
protectedApi.route('/stt', createSTTRoutes(db))
protectedApi.route('/sse', createSSERoutes())
protectedApi.route('/ssh', createSSHRoutes(gitAuthService))
protectedApi.route('/notifications', createNotificationRoutes(notificationService))
protectedApi.route('/memory', createMemoryRoutes(db))
protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db))
protectedApi.route('/schedules', createScheduleRoutes(scheduleService))

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

app.post('/api/opencode/mcp/:name/auth/authenticate', requireAuth, async (c) => {
const serverName = c.req.param('name')
const directory = c.req.query('directory')
return proxyMcpAuthAuthenticate(serverName, directory)
return openCodeClient.authenticateMcp(serverName, directory)
})

app.all('/api/opencode/*', requireAuth, async (c) => {
const request = c.req.raw
return proxyRequest(request)
return openCodeClient.forwardRaw(c.req.raw)
})

const isProduction = ENV.SERVER.NODE_ENV === 'production'
Expand Down
26 changes: 26 additions & 0 deletions backend/src/routes/internal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Hono } from 'hono'
import type { Database } from 'bun:sqlite'
import type { ScheduleService } from '../../services/schedules'
import type { NotificationService } from '../../services/notification'
import type { SettingsService } from '../../services/settings'
import { createScheduleRoutes } from '../schedules'
import { createInternalTokenMiddleware } from '../../auth/internal-token-middleware'
import { createInternalNotificationRoutes } from './notifications'
import { createInternalSettingsRoutes } from './settings'

export function createInternalRoutes(
db: Database,
scheduleService: ScheduleService,
notificationService: NotificationService,
settingsService: SettingsService,
) {
const app = new Hono()
app.use('/*', createInternalTokenMiddleware(db))
app.route('/schedules', createScheduleRoutes(scheduleService))
app.route('/notifications', createInternalNotificationRoutes(notificationService))
app.route('/settings', createInternalSettingsRoutes(settingsService))
const repos = new Hono()
repos.route('/:id/schedules', createScheduleRoutes(scheduleService))
app.route('/repos', repos)
return app
}
57 changes: 57 additions & 0 deletions backend/src/routes/internal/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Hono } from 'hono'
import { AssistantNotificationRequestSchema } from '@opencode-manager/shared/schemas'
import type { NotificationService } from '../../services/notification'
import { TokenBucketRateLimiter } from '../../utils/rate-limit'

export function createInternalNotificationRoutes(notificationService: NotificationService) {
const app = new Hono()
const limiter = new TokenBucketRateLimiter({ capacity: 10, refillPerMs: 60_000 })

app.post('/send', async (c) => {
if (!notificationService.isConfigured()) {
return c.json({ error: 'Push notifications are not configured (missing VAPID env)' }, 503)
}

const token = (c.req.header('authorization') ?? '').slice('Bearer '.length)
const limit = limiter.tryConsume(token || 'anon')
if (!limit.allowed) {
c.header('Retry-After', String(Math.ceil(limit.retryAfterMs / 1000)))
return c.json({ error: 'Rate limit exceeded' }, 429)
}

let body: unknown
try {
body = await c.req.json()
} catch {
return c.json({ error: 'Invalid JSON' }, 400)
}

const parsed = AssistantNotificationRequestSchema.safeParse(body)
if (!parsed.success) {
return c.json({ error: 'Invalid request body', details: parsed.error.issues }, 400)
}

const userId = c.req.query('userId') ?? 'default'

const payload = {
title: parsed.data.title,
body: parsed.data.body,
tag: parsed.data.tag ?? `assistant-${Date.now()}`,
data: {
eventType: 'assistant.message',
url: parsed.data.url ?? '/',
priority: parsed.data.priority,
},
}

const stats = await notificationService.sendToUser(userId, payload)
return c.json({
delivered: stats.delivered,
expired: stats.expired,
failed: stats.failed,
noSubscriptions: stats.total === 0,
})
})

return app
}
35 changes: 35 additions & 0 deletions backend/src/routes/internal/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Hono } from 'hono'
import { AssistantSettingsPatchSchema } from '@opencode-manager/shared/schemas'
import type { SettingsService } from '../../services/settings'
import type { UserPreferences } from '@opencode-manager/shared/types'

export function createInternalSettingsRoutes(settingsService: SettingsService) {
const app = new Hono()

app.get('/', (c) => {
const userId = c.req.query('userId') ?? 'default'
const settings = settingsService.getSettings(userId)
return c.json(settings)
})

app.patch('/', async (c) => {
const userId = c.req.query('userId') ?? 'default'

let body: unknown
try {
body = await c.req.json()
} catch {
return c.json({ error: 'Invalid JSON' }, 400)
}

const parsed = AssistantSettingsPatchSchema.safeParse(body)
if (!parsed.success) {
return c.json({ error: 'Invalid request body', details: parsed.error.issues }, 400)
}

const updated = settingsService.updateSettings(parsed.data as Partial<UserPreferences>, userId)
return c.json(updated)
})

return app
}
Loading
Loading