Skip to content

Commit 69e5cf0

Browse files
committed
feat: add OpenCode chat support
Read OpenCode sessions from ~/.local/share/opencode/opencode.db (SQLite, v1.2.0+) and surface them alongside Claude Code sessions throughout the UI. Changes: - lib/opencode-fs.ts — new module; reads session/message/part tables and converts to AgentTower's ParsedMessage/SessionInfo/ProjectInfo types - typings/node-sqlite.d.ts — minimal type shim for node:sqlite built-in - lib/types.ts — add `source?: 'claude' | 'opencode'` to ProjectInfo, SessionInfo - lib/claude-fs.ts — add `source: 'claude'` to RecentSession type and all returned objects - API routes (projects, sessions, recent-sessions, session, tail) — merge Claude and OpenCode data; opencode: prefix routes to the new module - app/projects/page.tsx — merge both project lists, sort by mtime - app/project/page.tsx — OpenCode branch: read-only session list with source badge; Claude branch unchanged - app/session/page.tsx — detect opencode: filepath prefix, parse from SQLite instead of JSONL - components/LiveSession.tsx — new `source` prop; OpenCode sessions skip SSE tail, process polling, fork buttons, and the input bar (replaced by a read-only notice)
1 parent 225fce4 commit 69e5cf0

16 files changed

Lines changed: 765 additions & 166 deletions

File tree

app/api/projects/route.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
import { NextRequest, NextResponse } from 'next/server'
22
import { requireAuth } from '@/lib/auth'
33
import { discoverProjects } from '@/lib/claude-fs'
4+
import { discoverOpenCodeProjects } from '@/lib/opencode-fs'
5+
import type { ProjectInfo } from '@/lib/types'
46

57
export async function GET(req: NextRequest) {
68
const authErr = await requireAuth(req)
79
if (authErr) return authErr
8-
return NextResponse.json(discoverProjects())
10+
11+
const claude: ProjectInfo[] = discoverProjects()
12+
const opencode: ProjectInfo[] = discoverOpenCodeProjects()
13+
14+
// Merge and sort by most recently active, Claude projects first when tied
15+
const all = [
16+
...claude.map(p => ({ ...p, source: 'claude' as const })),
17+
...opencode,
18+
].sort((a, b) => b.latestMtime - a.latestMtime)
19+
20+
return NextResponse.json(all)
921
}

app/api/recent-sessions/route.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
import { NextRequest, NextResponse } from 'next/server'
22
import { requireAuth } from '@/lib/auth'
33
import { getRecentSessions } from '@/lib/claude-fs'
4+
import { getOpenCodeRecentSessions } from '@/lib/opencode-fs'
45

56
export async function GET(req: NextRequest) {
67
const authErr = await requireAuth(req)
78
if (authErr) return authErr
89

910
const limit = Math.min(parseInt(req.nextUrl.searchParams.get('limit') ?? '20', 10), 50)
10-
return NextResponse.json(getRecentSessions(limit))
11+
12+
const claude = getRecentSessions(limit)
13+
const opencode = getOpenCodeRecentSessions(limit)
14+
15+
// Merge and return the `limit` most recent across both sources
16+
const merged = [...claude, ...opencode]
17+
.sort((a, b) => b.mtime - a.mtime)
18+
.slice(0, limit)
19+
20+
return NextResponse.json(merged)
1121
}

app/api/session/route.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { NextRequest, NextResponse } from 'next/server'
22
import { requireAuth } from '@/lib/auth'
33
import { parseJsonlFilePaginated, decodeB64, safePath, getClaudeDir } from '@/lib/claude-fs'
4+
import { parseOpenCodeSession, isOpenCodePath, openCodeSessionId } from '@/lib/opencode-fs'
5+
import type { PaginatedSession } from '@/lib/types'
46

57
export async function GET(req: NextRequest) {
68
const authErr = await requireAuth(req)
@@ -10,13 +12,29 @@ export async function GET(req: NextRequest) {
1012
if (!encoded) return NextResponse.json({ error: 'Missing f param' }, { status: 400 })
1113

1214
const filepath = decodeB64(encoded)
15+
16+
// ── OpenCode session ───────────────────────────────────────────────────────
17+
if (isOpenCodePath(filepath)) {
18+
const sessionId = openCodeSessionId(filepath)
19+
const messages = parseOpenCodeSession(sessionId)
20+
const response: PaginatedSession = {
21+
firstMessage: messages[0] ?? null,
22+
messages,
23+
total: messages.length,
24+
hiddenCount: 0,
25+
hasMore: false,
26+
}
27+
return NextResponse.json(response)
28+
}
29+
30+
// ── Claude session ─────────────────────────────────────────────────────────
1331
if (!safePath(filepath, getClaudeDir())) {
1432
return NextResponse.json({ error: 'Invalid path' }, { status: 403 })
1533
}
1634

17-
const limit = Math.min(parseInt(req.nextUrl.searchParams.get('limit') ?? '50', 10), 200)
35+
const limit = Math.min(parseInt(req.nextUrl.searchParams.get('limit') ?? '50', 10), 200)
1836
const olderThan = req.nextUrl.searchParams.get('before') ?? undefined
19-
const around = req.nextUrl.searchParams.get('around') ?? undefined
37+
const around = req.nextUrl.searchParams.get('around') ?? undefined
2038

2139
return NextResponse.json(parseJsonlFilePaginated(filepath, limit, olderThan, around))
2240
}

app/api/sessions/route.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextRequest, NextResponse } from 'next/server'
22
import { requireAuth } from '@/lib/auth'
33
import { listSessions, decodeB64 } from '@/lib/claude-fs'
4+
import { listOpenCodeSessions, openCodeDirectory, isOpenCodePath } from '@/lib/opencode-fs'
45

56
export async function GET(req: NextRequest) {
67
const authErr = await requireAuth(req)
@@ -10,5 +11,10 @@ export async function GET(req: NextRequest) {
1011
if (!encoded) return NextResponse.json({ error: 'Missing p param' }, { status: 400 })
1112

1213
const dirName = decodeB64(encoded)
14+
15+
if (isOpenCodePath(dirName)) {
16+
return NextResponse.json(listOpenCodeSessions(openCodeDirectory(dirName)))
17+
}
18+
1319
return NextResponse.json(listSessions(dirName))
1420
}

app/api/tail/route.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ export async function GET(req: NextRequest) {
1515

1616
const encoded = req.nextUrl.searchParams.get('f') ?? ''
1717
const filepath = decodeB64(encoded)
18+
19+
// OpenCode sessions are stored in SQLite and have no live updates
20+
if (filepath.startsWith('opencode:')) {
21+
return new Response(': opencode session — no live tail\n\n', {
22+
headers: {
23+
'Content-Type': 'text/event-stream',
24+
'Cache-Control': 'no-cache',
25+
},
26+
})
27+
}
28+
1829
if (!safePath(filepath, getClaudeDir())) {
1930
return new Response('Forbidden', { status: 403 })
2031
}

app/project/page.tsx

Lines changed: 104 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { redirect } from 'next/navigation'
22
import Link from 'next/link'
33
import { getSessionToken, validateSession } from '@/lib/auth'
44
import { listSessions, decodeB64, encodeB64, resolveProjectPath, detectContinuationChains } from '@/lib/claude-fs'
5+
import { listOpenCodeSessions, openCodeDirectory, isOpenCodePath } from '@/lib/opencode-fs'
56
import { getProjectMeta } from '@/lib/project-meta'
67
import { loadSessionTags } from '@/lib/session-tags'
78
import Nav from '@/components/Nav'
89
import ProcessControls from '@/components/ProcessControls'
910
import NewSessionForm from '@/components/NewSessionForm'
1011
import SessionTagsButton from '@/components/SessionTagsButton'
12+
import type { SessionInfo } from '@/lib/types'
1113

1214
export const dynamic = 'force-dynamic'
1315

@@ -24,20 +26,69 @@ export default async function ProjectPage({ searchParams }: Props) {
2426
if (!encoded) redirect('/projects')
2527

2628
const dirName = decodeB64(encoded)
27-
const sessions = listSessions(dirName)
29+
30+
// ── OpenCode project ────────────────────────────────────────────────────
31+
if (isOpenCodePath(dirName)) {
32+
const directory = openCodeDirectory(dirName)
33+
const sessions = listOpenCodeSessions(directory)
34+
const title = directory.split('/').pop() || directory
35+
36+
return (
37+
<>
38+
<Nav />
39+
<main style={{ padding: '32px 28px', maxWidth: 1100, margin: '0 auto', width: '100%' }}>
40+
<div style={{ marginBottom: 28 }}>
41+
<Link href="/projects" style={{ color: 'var(--text2)', fontSize: 13, textDecoration: 'none' }}>
42+
← Projects
43+
</Link>
44+
<div style={{ marginTop: 10 }}>
45+
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
46+
<h1 style={{ margin: '0 0 3px', fontSize: 22, fontWeight: 700, letterSpacing: '-0.02em' }}>
47+
{title}
48+
</h1>
49+
<span className="chip" style={{ fontSize: 11, background: 'rgba(99,102,241,0.15)', color: 'var(--text2)' }}>
50+
opencode
51+
</span>
52+
</div>
53+
<p style={{ margin: 0, color: 'var(--text3)', fontSize: 12, fontFamily: 'ui-monospace, monospace' }}>
54+
{directory}
55+
</p>
56+
</div>
57+
</div>
58+
59+
<section>
60+
<h2 style={{ fontSize: 12, fontWeight: 600, color: 'var(--text2)', margin: '0 0 12px', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
61+
Sessions · {sessions.length}
62+
</h2>
63+
{sessions.length === 0 ? (
64+
<p style={{ color: 'var(--text2)', fontSize: 14 }}>No sessions found.</p>
65+
) : (
66+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
67+
{sessions.map(s => (
68+
<OpenCodeSessionRow key={s.sessionId} session={s} />
69+
))}
70+
</div>
71+
)}
72+
</section>
73+
</main>
74+
</>
75+
)
76+
}
77+
78+
// ── Claude project ───────────────────────────────────────────────────────
79+
const sessions = listSessions(dirName)
2880
const projectPath = resolveProjectPath(dirName)
29-
const meta = getProjectMeta(projectPath)
30-
const title = meta?.displayName || projectPath.split('/').pop() || projectPath
31-
const chains = detectContinuationChains(dirName)
32-
const tagStore = loadSessionTags()
81+
const meta = getProjectMeta(projectPath)
82+
const title = meta?.displayName || projectPath.split('/').pop() || projectPath
83+
const chains = detectContinuationChains(dirName)
84+
const tagStore = loadSessionTags()
3385

34-
// Build reverse map: parentId → childId
3586
const childOf = new Map<string, string>()
3687
for (const [child, parent] of chains.entries()) {
3788
childOf.set(parent, child)
3889
}
3990

40-
const active = sessions.filter(s => s.processState === 'running' || s.processState === 'paused')
91+
const active = sessions.filter(s => s.processState === 'running' || s.processState === 'paused')
4192
const history = sessions.filter(s => s.processState !== 'running' && s.processState !== 'paused')
4293

4394
return (
@@ -60,7 +111,6 @@ export default async function ProjectPage({ searchParams }: Props) {
60111
</div>
61112
</div>
62113

63-
{/* New session — full-width block below header */}
64114
<div style={{ marginTop: 16 }}>
65115
<NewSessionForm projectPath={projectPath} />
66116
</div>
@@ -77,7 +127,7 @@ export default async function ProjectPage({ searchParams }: Props) {
77127
</div>
78128
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
79129
{active.map(s => (
80-
<SessionRow
130+
<ClaudeSessionRow
81131
key={s.sessionId}
82132
session={s}
83133
parentId={chains.get(s.sessionId)}
@@ -101,7 +151,7 @@ export default async function ProjectPage({ searchParams }: Props) {
101151
) : (
102152
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
103153
{history.map(s => (
104-
<SessionRow
154+
<ClaudeSessionRow
105155
key={s.sessionId}
106156
session={s}
107157
parentId={chains.get(s.sessionId)}
@@ -119,6 +169,43 @@ export default async function ProjectPage({ searchParams }: Props) {
119169
)
120170
}
121171

172+
// ── OpenCode session row (read-only, no process controls) ─────────────────
173+
174+
function OpenCodeSessionRow({ session: s }: { session: SessionInfo }) {
175+
return (
176+
<div className="glass" style={{ borderRadius: 12, padding: '14px 18px', display: 'flex', alignItems: 'center', gap: 14 }}>
177+
<div style={{ flex: 1, minWidth: 0 }}>
178+
<Link
179+
href={`/session?f=${encodeB64(s.filepath)}`}
180+
style={{
181+
color: 'var(--text)', fontWeight: 500, fontSize: 14, textDecoration: 'none',
182+
display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginBottom: 7,
183+
}}
184+
>
185+
{s.firstPrompt}
186+
</Link>
187+
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
188+
<span className="chip">Finished</span>
189+
{s.estimatedCostUsd != null && s.estimatedCostUsd > 0 && (
190+
<span className="chip" title="Cost">{formatCost(s.estimatedCostUsd)}</span>
191+
)}
192+
<span className="chip">{formatRelative(s.mtime)}</span>
193+
<span className="chip" style={{ fontFamily: 'ui-monospace, monospace' }}>{s.sessionId.slice(0, 8)}</span>
194+
</div>
195+
</div>
196+
<Link
197+
href={`/session?f=${encodeB64(s.filepath)}`}
198+
className="glass-btn"
199+
style={{ fontSize: 13, padding: '6px 14px', flexShrink: 0 }}
200+
>
201+
Open →
202+
</Link>
203+
</div>
204+
)
205+
}
206+
207+
// ── Claude session row (full-featured) ────────────────────────────────────
208+
122209
function activityLabel(processState: string, currentActivity?: string | null): string {
123210
if (processState === 'paused') return 'Paused'
124211
if (processState !== 'running') return 'Finished'
@@ -134,7 +221,7 @@ function formatCost(usd: number): string {
134221
return `$${usd.toFixed(2)}`
135222
}
136223

137-
function SessionRow({
224+
function ClaudeSessionRow({
138225
session: s,
139226
parentId,
140227
childId,
@@ -158,26 +245,15 @@ function SessionRow({
158245

159246
return (
160247
<div className="glass" style={{
161-
borderRadius: 12,
162-
padding: '14px 18px',
163-
display: 'flex',
164-
alignItems: 'center',
165-
gap: 14,
248+
borderRadius: 12, padding: '14px 18px', display: 'flex', alignItems: 'center', gap: 14,
166249
borderColor: s.processState === 'running' ? 'rgba(61,214,140,0.20)' : undefined,
167250
}}>
168251
<div style={{ flex: 1, minWidth: 0 }}>
169252
<Link
170253
href={`/session?f=${encodeB64(s.filepath)}`}
171254
style={{
172-
color: 'var(--text)',
173-
fontWeight: 500,
174-
fontSize: 14,
175-
textDecoration: 'none',
176-
display: 'block',
177-
overflow: 'hidden',
178-
textOverflow: 'ellipsis',
179-
whiteSpace: 'nowrap',
180-
marginBottom: 7,
255+
color: 'var(--text)', fontWeight: 500, fontSize: 14, textDecoration: 'none',
256+
display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginBottom: 7,
181257
}}
182258
>
183259
{s.firstPrompt}
@@ -231,8 +307,8 @@ function SessionRow({
231307

232308
function formatRelative(ms: number): string {
233309
const diff = Date.now() - ms
234-
if (diff < 60_000) return 'just now'
235-
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
236-
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
310+
if (diff < 60_000) return 'just now'
311+
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
312+
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
237313
return `${Math.floor(diff / 86_400_000)}d ago`
238314
}

app/projects/page.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
import { redirect } from 'next/navigation'
22
import { getSessionToken, validateSession } from '@/lib/auth'
33
import { discoverProjects } from '@/lib/claude-fs'
4+
import { discoverOpenCodeProjects } from '@/lib/opencode-fs'
45
import Nav from '@/components/Nav'
56
import ProjectsView from '@/components/ProjectsView'
7+
import type { ProjectInfo } from '@/lib/types'
68

79
export const dynamic = 'force-dynamic'
810

911
export default async function ProjectsPage() {
1012
const token = await getSessionToken()
1113
if (!validateSession(token)) redirect('/login')
1214

13-
const projects = discoverProjects()
15+
const claude: ProjectInfo[] = discoverProjects().map(p => ({ ...p, source: 'claude' as const }))
16+
const opencode: ProjectInfo[] = discoverOpenCodeProjects()
17+
18+
const projects = [...claude, ...opencode].sort((a, b) => b.latestMtime - a.latestMtime)
1419

1520
return (
1621
<>

0 commit comments

Comments
 (0)