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
2 changes: 1 addition & 1 deletion server/coding-cli/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface CodingCliProvider {
readonly homeDir: string

listSessionsDirect?(): Promise<CodingCliSession[]>
getSessionGlob(): string
getSessionGlob(): string | string[]
getSessionRoots(): string[]
getSessionWatchBases?(): string[]
listSessionFiles(): Promise<string[]>
Expand Down
40 changes: 36 additions & 4 deletions server/coding-cli/providers/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type OpencodeSessionRow = {
createdAt: number
lastActivityAt: number
projectPath: string | null
hasThreeViewsMarker?: number | null
}

function defaultOpencodeDataHome(): string {
Expand All @@ -30,6 +31,12 @@ function toValidTimestamp(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
}

const THREE_VIEWS_MARKER_SQL_PATTERN = '%<freshell-session-metadata origin=3-views%'

function toSqliteBoolean(value: unknown): boolean {
return value === true || value === 1
}

export class OpencodeProvider implements CodingCliProvider {
readonly name = 'opencode' as const
readonly displayName = 'OpenCode'
Expand All @@ -40,6 +47,11 @@ export class OpencodeProvider implements CodingCliProvider {
return path.join(this.homeDir, 'opencode.db')
}

private getWatchedDatabasePaths(): [string, string] {
const dbPath = this.getDatabasePath()
return [dbPath, `${dbPath}-wal`]
}

async listSessionsDirect(): Promise<CodingCliSession[]> {
const dbPath = this.getDatabasePath()
try {
Expand All @@ -66,19 +78,37 @@ export class OpencodeProvider implements CodingCliProvider {
s.title AS title,
s.time_created AS createdAt,
s.time_updated AS lastActivityAt,
p.worktree AS projectPath
p.worktree AS projectPath,
(
EXISTS (
SELECT 1
FROM part pa
WHERE pa.session_id = s.id
AND pa.data LIKE ?
)
OR EXISTS (
SELECT 1
FROM message m
WHERE m.session_id = s.id
AND m.data LIKE ?
)
) AS hasThreeViewsMarker
FROM session s
LEFT JOIN project p
ON p.id = s.project_id
WHERE s.parent_id IS NULL
AND s.time_archived IS NULL
ORDER BY s.time_updated DESC
`).all() as OpencodeSessionRow[]
`).all(
THREE_VIEWS_MARKER_SQL_PATTERN,
THREE_VIEWS_MARKER_SQL_PATTERN,
) as OpencodeSessionRow[]

const sessions: CodingCliSession[] = []
for (const row of rows) {
if (typeof row.cwd !== 'string' || !row.cwd) continue
const projectPath = row.projectPath || await resolveGitRepoRoot(row.cwd)
const isThreeViewsSession = toSqliteBoolean(row.hasThreeViewsMarker)
sessions.push({
provider: this.name,
sessionId: row.sessionId,
Expand All @@ -87,6 +117,8 @@ export class OpencodeProvider implements CodingCliProvider {
title: typeof row.title === 'string' ? row.title : undefined,
lastActivityAt: toValidTimestamp(row.lastActivityAt) ?? Date.now(),
createdAt: toValidTimestamp(row.createdAt),
isSubagent: isThreeViewsSession || undefined,
isNonInteractive: isThreeViewsSession || undefined,
})
}
return sessions
Expand All @@ -98,8 +130,8 @@ export class OpencodeProvider implements CodingCliProvider {
}
}

getSessionGlob(): string {
return this.getDatabasePath()
getSessionGlob(): string[] {
return this.getWatchedDatabasePaths()
}

getSessionRoots(): string[] {
Expand Down
15 changes: 14 additions & 1 deletion server/coding-cli/session-indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ function resolveSessionTitle(
return normalizeTitle(parsedTitle) || normalizeTitle(previousTitle) || normalizeTitle(storedTitle)
}

function getSessionWatchGlobs(providers: CodingCliProvider[]): string[] {
const globs: string[] = []
for (const provider of providers) {
const providerGlobs = provider.getSessionGlob()
if (Array.isArray(providerGlobs)) {
globs.push(...providerGlobs)
} else {
globs.push(providerGlobs)
}
}
return Array.from(new Set(globs))
}

// Byte pattern for a text user message (content is a string, not a tool_result array).
const USER_TEXT_PATTERN = Buffer.from('"role":"user","content":"')

Expand Down Expand Up @@ -396,7 +409,7 @@ export class CodingCliSessionIndexer {
}

private startSessionWatcher(providers: CodingCliProvider[]) {
const globs = providers.map((p) => p.getSessionGlob())
const globs = getSessionWatchGlobs(providers)
logger.info({ globs, debounceMs: this.debounceMs, throttleMs: this.throttleMs }, 'Starting coding CLI sessions watcher')

this.watcher = chokidar.watch(globs, {
Expand Down
4 changes: 2 additions & 2 deletions server/mcp/freshell-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ FRESHELL_URL and FRESHELL_TOKEN are already set in your environment.
## Key gotchas

- **Tab and pane IDs are ephemeral.** IDs from open-browser, new-tab, and split-pane are valid only within the current session. If the Freshell server restarts or the agent conversation resumes after a disconnect, previously returned IDs may no longer exist. Always call open-browser or list-tabs fresh rather than reusing stale IDs.
- **Always screenshot with `screenshot({ scope: "tab", target: tabId })` after open-browser.** Network errors, CORS issues, or server problems can cause blank pages. open-browser returns a tabId — use it immediately to screenshot and confirm the page rendered before proceeding.
- **Always screenshot with \`screenshot({ scope: "tab", target: tabId })\` after open-browser.** Network errors, CORS issues, or server problems can cause blank pages. open-browser returns a tabId — use it immediately to screenshot and confirm the page rendered before proceeding.
- send-keys: use literal mode (literal: true + keys as a string) for natural-language prompts or multi-word text. Do NOT append "ENTER" as literal text -- send the command with literal:true, then send ["ENTER"] as a separate call in token mode.
- wait-for with stable (seconds of no output) is more reliable than pattern matching across different CLI providers.
- Editor panes show "Loading..." until the tab is visited in the browser. When screenshotting multiple tabs, visit each tab first (select-tab), then loop back for screenshots.
Expand Down Expand Up @@ -469,7 +469,7 @@ Meta:

## Screenshot guidance

- **Always screenshot with `screenshot({ scope: "tab", target: tabId })` after open-browser.** Network errors, blank pages, and CORS failures are silent unless you look. open-browser returns a tabId — use it immediately to confirm the page rendered before acting on it.
- **Always screenshot with \`screenshot({ scope: "tab", target: tabId })\` after open-browser.** Network errors, blank pages, and CORS failures are silent unless you look. open-browser returns a tabId — use it immediately to confirm the page rendered before acting on it.
- Tab and pane IDs from earlier in a session may become stale after reconnections or server restarts. If screenshot fails to find a tab/pane, call list-tabs or list-panes to get fresh IDs rather than reusing old ones.
- Use a dedicated canary tab when validating screenshot behavior so live project panes are not contaminated.
- Close temporary tabs/panes after verification unless user asked to keep them open.
Expand Down
183 changes: 183 additions & 0 deletions test/unit/server/coding-cli/opencode-provider.sqlite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import path from 'path'
import os from 'os'
import fsp from 'fs/promises'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { OpencodeProvider } from '../../../../server/coding-cli/providers/opencode'

vi.unmock('node:sqlite')

type SqliteModule = typeof import('node:sqlite')
type DatabaseSyncConstructor = SqliteModule['DatabaseSync']
type DatabaseSyncInstance = InstanceType<DatabaseSyncConstructor>

const threeViewsMarker = '<freshell-session-metadata origin=3-views noninteractive=true>'

describe('OpencodeProvider SQLite marker detection', () => {
let tempDir: string
let DatabaseSync: DatabaseSyncConstructor

beforeAll(async () => {
vi.resetModules()
try {
const sqlite = await import('node:sqlite')
DatabaseSync = sqlite.DatabaseSync
} catch (error) {
throw new Error(
`OpencodeProvider SQLite marker detection tests require Node.js with node:sqlite support. Current Node: ${process.version}`,
{ cause: error },
)
}
})

beforeEach(async () => {
tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-opencode-sqlite-'))
})

afterEach(async () => {
await fsp.rm(tempDir, { recursive: true, force: true })
})

function createOpencodeSchema(db: DatabaseSyncInstance): void {
db.exec(`
CREATE TABLE project (
id text PRIMARY KEY,
worktree text NOT NULL,
time_created integer NOT NULL,
time_updated integer NOT NULL,
sandboxes text NOT NULL
);

CREATE TABLE session (
id text PRIMARY KEY,
project_id text NOT NULL,
parent_id text,
slug text NOT NULL,
directory text NOT NULL,
title text NOT NULL,
version text NOT NULL,
time_created integer NOT NULL,
time_updated integer NOT NULL,
time_archived integer
);

CREATE TABLE message (
id text PRIMARY KEY,
session_id text NOT NULL,
time_created integer NOT NULL,
time_updated integer NOT NULL,
data text NOT NULL
);

CREATE TABLE part (
id text PRIMARY KEY,
message_id text NOT NULL,
session_id text NOT NULL,
time_created integer NOT NULL,
time_updated integer NOT NULL,
data text NOT NULL
);
`)
}

function insertSession(db: DatabaseSyncInstance, id: string, title: string, timeUpdated: number): void {
db.prepare(`
INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated, time_archived)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, 'project-1', null, id, '/repo/root', title, 'test', 1000, timeUpdated, null)
}

function insertMessage(db: DatabaseSyncInstance, id: string, sessionId: string, data: string): void {
db.prepare(`
INSERT INTO message (id, session_id, time_created, time_updated, data)
VALUES (?, ?, ?, ?, ?)
`).run(id, sessionId, 1100, 1100, data)
}

function insertPart(db: DatabaseSyncInstance, id: string, messageId: string, sessionId: string, data: string): void {
db.prepare(`
INSERT INTO part (id, message_id, session_id, time_created, time_updated, data)
VALUES (?, ?, ?, ?, ?, ?)
`).run(id, messageId, sessionId, 1100, 1100, data)
}

it('marks 3-views OpenCode sessions as subagent and non-interactive without changing the title', async () => {
const dbPath = path.join(tempDir, 'opencode.db')
const db = new DatabaseSync(dbPath)
try {
createOpencodeSchema(db)
db.prepare(`
INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES (?, ?, ?, ?, ?)
`).run('project-1', '/repo/root', 900, 4000, '[]')

insertSession(db, 'session-part-marker', 'Review OpenCode session state restoration', 3000)
insertSession(db, 'session-message-marker', 'Review marker from message payload', 2500)
insertSession(db, 'session-normal', 'Normal OpenCode session', 2000)

insertMessage(db, 'message-part-marker', 'session-part-marker', JSON.stringify({ role: 'user' }))
insertPart(
db,
'part-marked',
'message-part-marker',
'session-part-marker',
JSON.stringify({
type: 'text',
synthetic: true,
text: `attached prompt\n${threeViewsMarker}`,
}),
)

insertMessage(
db,
'message-message-marker',
'session-message-marker',
JSON.stringify({ role: 'user', text: `attached prompt\n${threeViewsMarker}` }),
)
insertMessage(
db,
'message-normal',
'session-normal',
JSON.stringify({ role: 'user', text: 'ordinary OpenCode prompt' }),
)
} finally {
db.close()
}

const provider = new OpencodeProvider(tempDir)
const sessions = await provider.listSessionsDirect()

expect(sessions).toEqual([
{
provider: 'opencode',
sessionId: 'session-part-marker',
projectPath: '/repo/root',
cwd: '/repo/root',
title: 'Review OpenCode session state restoration',
createdAt: 1000,
lastActivityAt: 3000,
isSubagent: true,
isNonInteractive: true,
},
{
provider: 'opencode',
sessionId: 'session-message-marker',
projectPath: '/repo/root',
cwd: '/repo/root',
title: 'Review marker from message payload',
createdAt: 1000,
lastActivityAt: 2500,
isSubagent: true,
isNonInteractive: true,
},
{
provider: 'opencode',
sessionId: 'session-normal',
projectPath: '/repo/root',
cwd: '/repo/root',
title: 'Normal OpenCode session',
createdAt: 1000,
lastActivityAt: 2000,
},
])
})
})
2 changes: 2 additions & 0 deletions test/unit/server/coding-cli/opencode-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ describe('OpencodeProvider', () => {

it('lists root sessions from the OpenCode database', async () => {
const dbPath = path.join(tempDir, 'opencode.db')
const walPath = `${dbPath}-wal`
await fsp.writeFile(dbPath, 'fake sqlite file', 'utf8')
FakeDatabaseSync.seed(dbPath, {
projects: [
Expand Down Expand Up @@ -122,6 +123,7 @@ describe('OpencodeProvider', () => {
const provider = new OpencodeProvider(tempDir)
const sessions = await provider.listSessionsDirect()

expect(provider.getSessionGlob()).toEqual([dbPath, walPath])
expect(provider.getSessionRoots()).toEqual([dbPath])
expect(provider.supportsSessionResume()).toBe(true)
expect(sessions).toEqual([
Expand Down
Loading
Loading