diff --git a/server/coding-cli/provider.ts b/server/coding-cli/provider.ts index 758df34da..cb58873ec 100644 --- a/server/coding-cli/provider.ts +++ b/server/coding-cli/provider.ts @@ -18,7 +18,7 @@ export interface CodingCliProvider { readonly homeDir: string listSessionsDirect?(): Promise - getSessionGlob(): string + getSessionGlob(): string | string[] getSessionRoots(): string[] getSessionWatchBases?(): string[] listSessionFiles(): Promise diff --git a/server/coding-cli/providers/opencode.ts b/server/coding-cli/providers/opencode.ts index c1a2c64c7..777aa572b 100644 --- a/server/coding-cli/providers/opencode.ts +++ b/server/coding-cli/providers/opencode.ts @@ -13,6 +13,7 @@ type OpencodeSessionRow = { createdAt: number lastActivityAt: number projectPath: string | null + hasThreeViewsMarker?: number | null } function defaultOpencodeDataHome(): string { @@ -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 = '% { const dbPath = this.getDatabasePath() try { @@ -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, @@ -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 @@ -98,8 +130,8 @@ export class OpencodeProvider implements CodingCliProvider { } } - getSessionGlob(): string { - return this.getDatabasePath() + getSessionGlob(): string[] { + return this.getWatchedDatabasePaths() } getSessionRoots(): string[] { diff --git a/server/coding-cli/session-indexer.ts b/server/coding-cli/session-indexer.ts index 72f676cf8..e56e78cb1 100644 --- a/server/coding-cli/session-indexer.ts +++ b/server/coding-cli/session-indexer.ts @@ -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":"') @@ -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, { diff --git a/server/mcp/freshell-tool.ts b/server/mcp/freshell-tool.ts index 085dfad00..bc7c358be 100644 --- a/server/mcp/freshell-tool.ts +++ b/server/mcp/freshell-tool.ts @@ -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. @@ -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. diff --git a/test/unit/server/coding-cli/opencode-provider.sqlite.test.ts b/test/unit/server/coding-cli/opencode-provider.sqlite.test.ts new file mode 100644 index 000000000..0cfb82714 --- /dev/null +++ b/test/unit/server/coding-cli/opencode-provider.sqlite.test.ts @@ -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 + +const threeViewsMarker = '' + +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, + }, + ]) + }) +}) diff --git a/test/unit/server/coding-cli/opencode-provider.test.ts b/test/unit/server/coding-cli/opencode-provider.test.ts index 11963659f..25872fd7b 100644 --- a/test/unit/server/coding-cli/opencode-provider.test.ts +++ b/test/unit/server/coding-cli/opencode-provider.test.ts @@ -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: [ @@ -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([ diff --git a/test/unit/server/coding-cli/session-indexer.test.ts b/test/unit/server/coding-cli/session-indexer.test.ts index be1fb3c34..1937b52d4 100644 --- a/test/unit/server/coding-cli/session-indexer.test.ts +++ b/test/unit/server/coding-cli/session-indexer.test.ts @@ -260,6 +260,91 @@ describe('CodingCliSessionIndexer', () => { ]) }) + it('refreshes direct-provider sessions when any returned watch path changes', async () => { + const opencodeDir = path.join(tempDir, 'opencode') + const dbPath = path.join(opencodeDir, 'opencode.db') + const walPath = `${dbPath}-wal` + await fsp.mkdir(opencodeDir, { recursive: true }) + await fsp.writeFile(dbPath, 'stub') + + let directSessions = [{ + provider: 'opencode' as const, + sessionId: 'initial-opencode-session', + projectPath: '/project/a', + cwd: '/project/a', + title: 'Initial OpenCode Session', + lastActivityAt: 1_700_000_000_000, + createdAt: 1_699_999_000_000, + }] + + const provider = makeProvider([], { + name: 'opencode', + displayName: 'OpenCode', + homeDir: opencodeDir, + getSessionGlob: () => [dbPath, walPath], + getSessionRoots: () => [dbPath], + listSessionFiles: async () => [], + listSessionsDirect: async () => directSessions, + }) + + vi.mocked(configStore.snapshot).mockResolvedValue({ + sessionOverrides: {}, + settings: { + codingCli: { + enabledProviders: ['opencode'], + providers: {}, + }, + }, + }) + + const indexer = new CodingCliSessionIndexer([provider], { + debounceMs: 50, + throttleMs: 0, + fullScanIntervalMs: 0, + }) + + await indexer.start() + + try { + expect(vi.mocked(chokidar.watch)).toHaveBeenCalledWith([dbPath, walPath], { + ignoreInitial: true, + }) + + expect(indexer.getProjects()[0].sessions.map((session) => session.sessionId)).toEqual([ + 'initial-opencode-session', + ]) + + directSessions = [ + { + provider: 'opencode' as const, + sessionId: 'new-opencode-session', + projectPath: '/project/a', + cwd: '/project/a', + title: 'New OpenCode Session', + lastActivityAt: 1_700_000_010_000, + createdAt: 1_700_000_005_000, + }, + ...directSessions, + ] + + ;((indexer as unknown as { + watcher: { emit: (event: string, payload: unknown) => boolean } | null + }).watcher)?.emit('change', walPath) + + await vi.waitFor( + () => { + expect(indexer.getProjects()[0].sessions.map((session) => session.sessionId)).toEqual([ + 'new-opencode-session', + 'initial-opencode-session', + ]) + }, + { timeout: 5000, interval: 100 }, + ) + } finally { + await indexer.stop() + } + }) + it('preserves parsed codex task event snapshots from bounded snippets without extra reads', async () => { const sessionFile = path.join(tempDir, 'sessions', 'rollout-task-events.jsonl') await fsp.mkdir(path.dirname(sessionFile), { recursive: true })