diff --git a/src/main/core/agent-hooks/agent-hook-service.ts b/src/main/core/agent-hooks/agent-hook-service.ts index dd5ac8b93..53b9fae45 100644 --- a/src/main/core/agent-hooks/agent-hook-service.ts +++ b/src/main/core/agent-hooks/agent-hook-service.ts @@ -6,6 +6,7 @@ import { telemetryService } from '@main/lib/telemetry'; import { agentEventChannel, type AgentEvent } from '@shared/events/agentEvents'; import { conversationChangedChannel } from '@shared/events/conversationEvents'; import { enrichEvent } from './event-enricher'; +import { handleProviderSessionHook } from './handle-provider-session-hook'; import { HookServer } from './hook-server'; import { isAppFocused, maybeShowNotification } from './notification'; @@ -14,6 +15,11 @@ class AgentHookService implements IInitializable, IDisposable { async initialize(): Promise { await this.server.start(async (raw) => { + if (raw.type === 'session') { + await handleProviderSessionHook(raw); + return; + } + const event = await enrichEvent(raw); event.source = 'hook'; const appFocused = isAppFocused(); diff --git a/src/main/core/agent-hooks/agent-notify-command.test.ts b/src/main/core/agent-hooks/agent-notify-command.test.ts index f78d8e375..e02e85530 100644 --- a/src/main/core/agent-hooks/agent-notify-command.test.ts +++ b/src/main/core/agent-hooks/agent-notify-command.test.ts @@ -66,6 +66,9 @@ describe('makeOpenCodePluginContent', () => { const content = makeOpenCodePluginContent(); expect(content).toContain('EMDASH_HOOK_PORT'); + expect(content).toContain("event.type === 'session.created'"); + expect(content).toContain('event.properties?.info?.parentID'); + expect(content).toContain('event.properties?.info?.id'); expect(content).toContain("event.type === 'session.idle'"); expect(content).toContain("event.type === 'session.error'"); expect(content).toContain("'X-Emdash-Event-Type': payload.type"); diff --git a/src/main/core/agent-hooks/handle-provider-session-hook.ts b/src/main/core/agent-hooks/handle-provider-session-hook.ts new file mode 100644 index 000000000..a22df6f99 --- /dev/null +++ b/src/main/core/agent-hooks/handle-provider-session-hook.ts @@ -0,0 +1,27 @@ +import { saveProviderSessionId } from '@main/core/conversations/save-provider-session-id'; +import { log } from '@main/lib/logger'; +import { parsePtyId } from '@shared/ptyId'; +import type { RawHookRequest } from './hook-server'; + +export async function handleProviderSessionHook(raw: RawHookRequest): Promise { + const parsed = parsePtyId(raw.ptyId); + if (!parsed || parsed.providerId !== 'opencode') return; + + let body: Record = {}; + if (raw.body) { + try { + const value: unknown = JSON.parse(raw.body); + if (typeof value === 'object' && value !== null) { + body = value as Record; + } + } catch { + log.warn('handleProviderSessionHook: invalid JSON body', { ptyId: raw.ptyId }); + return; + } + } + + const providerSessionId = body.provider_session_id ?? body.providerSessionId; + if (typeof providerSessionId !== 'string' || providerSessionId.length === 0) return; + + await saveProviderSessionId(parsed.conversationId, providerSessionId); +} diff --git a/src/main/core/agent-hooks/opencode-notifications-plugin.js b/src/main/core/agent-hooks/opencode-notifications-plugin.js index c1315f6f6..c779bfa12 100644 --- a/src/main/core/agent-hooks/opencode-notifications-plugin.js +++ b/src/main/core/agent-hooks/opencode-notifications-plugin.js @@ -28,6 +28,18 @@ export const EmdashNotifications = async () => ({ }); function toEmdashPayload(event) { + if (event.type === 'session.created') { + if (event.properties?.info?.parentID) return; + const sessionID = event.properties?.info?.id; + if (typeof sessionID !== 'string' || sessionID.length === 0) return; + return { + type: 'session', + body: { + provider_session_id: sessionID, + }, + }; + } + if (event.type === 'session.idle') { return { type: 'notification', diff --git a/src/main/core/conversations/createConversation.ts b/src/main/core/conversations/createConversation.ts index 6c37e34af..c1865dd7f 100644 --- a/src/main/core/conversations/createConversation.ts +++ b/src/main/core/conversations/createConversation.ts @@ -5,6 +5,7 @@ import { db } from '@main/db/client'; import { conversations } from '@main/db/schema'; import { log } from '@main/lib/logger'; import { telemetryService } from '@main/lib/telemetry'; +import { serializeConversationConfig } from '@shared/conversation-config'; import { type Conversation, type CreateConversationParams } from '@shared/conversations'; import { resolveTask } from '../projects/utils'; import { conversationEvents } from './conversation-events'; @@ -21,7 +22,7 @@ export async function createConversation(params: CreateConversationParams): Prom const config = params.autoApprove === undefined ? undefined - : JSON.stringify({ autoApprove: params.autoApprove }); + : serializeConversationConfig({ autoApprove: params.autoApprove }); const [row] = await db .insert(conversations) diff --git a/src/main/core/conversations/impl/agent-command.test.ts b/src/main/core/conversations/impl/agent-command.test.ts index 53c8eb2bb..015ac88ac 100644 --- a/src/main/core/conversations/impl/agent-command.test.ts +++ b/src/main/core/conversations/impl/agent-command.test.ts @@ -169,7 +169,7 @@ describe('buildAgentCommand', () => { { providerId: 'opencode', freshArgs: ['--prompt', 'Fix the bug'], - resumeArgs: ['--continue'], + resumeArgs: ['--session', 'conv-1'], }, { providerId: 'grok', freshArgs: [], resumeArgs: ['-r'] }, { providerId: 'copilot', freshArgs: ['Fix the bug'], resumeArgs: ['--resume'] }, diff --git a/src/main/core/conversations/impl/local-conversation.ts b/src/main/core/conversations/impl/local-conversation.ts index 53e7b6730..784633b48 100644 --- a/src/main/core/conversations/impl/local-conversation.ts +++ b/src/main/core/conversations/impl/local-conversation.ts @@ -22,6 +22,7 @@ import type { Conversation } from '@shared/conversations'; import { agentSessionExitedChannel } from '@shared/events/agentEvents'; import { makePtyId } from '@shared/ptyId'; import { makePtySessionId } from '@shared/ptySessionId'; +import { resolveAgentSessionCommandArgs } from '../resolve-agent-session-command'; import { buildAgentSessionCommand } from './agent-command'; import { scheduleInitialPromptInjection } from './keystroke-injection'; import { resolveProviderEnv } from './provider-env'; @@ -97,12 +98,13 @@ export class LocalConversationProvider implements ConversationProvider { const providerConfig = await providerOverrideSettings.getItem(conversation.providerId); const providerDef = getProvider(conversation.providerId); + const agentSession = resolveAgentSessionCommandArgs(conversation, isResuming); const { command, args } = buildAgentSessionCommand({ providerId: conversation.providerId, providerConfig, autoApprove: conversation.autoApprove, - sessionId: conversation.id, - isResuming, + sessionId: agentSession.sessionId, + isResuming: agentSession.isResuming, initialPrompt, }); const providerEnv = resolveProviderEnv(providerConfig, { diff --git a/src/main/core/conversations/impl/ssh-conversation.ts b/src/main/core/conversations/impl/ssh-conversation.ts index 492e71d5a..e25415039 100644 --- a/src/main/core/conversations/impl/ssh-conversation.ts +++ b/src/main/core/conversations/impl/ssh-conversation.ts @@ -17,6 +17,7 @@ import type { AgentSessionConfig } from '@shared/agent-session'; import type { Conversation } from '@shared/conversations'; import { agentSessionExitedChannel } from '@shared/events/agentEvents'; import { makePtySessionId } from '@shared/ptySessionId'; +import { resolveAgentSessionCommandArgs } from '../resolve-agent-session-command'; import { buildAgentSessionCommand } from './agent-command'; import { scheduleInitialPromptInjection } from './keystroke-injection'; import { resolveProviderEnv } from './provider-env'; @@ -90,12 +91,13 @@ export class SshConversationProvider implements ConversationProvider { }); const providerConfig = await providerOverrideSettings.getItem(conversation.providerId); + const agentSession = resolveAgentSessionCommandArgs(conversation, isResuming); const { command, args } = buildAgentSessionCommand({ providerId: conversation.providerId, providerConfig, autoApprove: conversation.autoApprove, - sessionId: conversation.id, - isResuming, + sessionId: agentSession.sessionId, + isResuming: agentSession.isResuming, initialPrompt, }); const providerEnv = resolveProviderEnv(providerConfig, { @@ -115,7 +117,7 @@ export class SshConversationProvider implements ConversationProvider { shellSetup: this.shellSetup, tmuxSessionName, autoApprove: conversation.autoApprove ?? false, - resume: isResuming, + resume: agentSession.isResuming, }; const profile = await this.proxy.getRemoteShellProfile(); diff --git a/src/main/core/conversations/resolve-agent-session-command.test.ts b/src/main/core/conversations/resolve-agent-session-command.test.ts new file mode 100644 index 000000000..0178356fc --- /dev/null +++ b/src/main/core/conversations/resolve-agent-session-command.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import type { Conversation } from '@shared/conversations'; +import { resolveAgentSessionCommandArgs } from './resolve-agent-session-command'; + +function makeConversation(overrides: Partial = {}): Conversation { + return { + id: 'conv-1', + projectId: 'proj-1', + taskId: 'task-1', + providerId: 'opencode', + title: 'Test', + lastInteractedAt: null, + isInitialConversation: false, + ...overrides, + }; +} + +describe('resolveAgentSessionCommandArgs', () => { + it('uses stored OpenCode session id when resuming', () => { + expect( + resolveAgentSessionCommandArgs(makeConversation({ providerSessionId: 'ses_abc123' }), true) + ).toEqual({ sessionId: 'ses_abc123', isResuming: true }); + }); + + it('starts fresh when resuming OpenCode without a stored session id', () => { + expect(resolveAgentSessionCommandArgs(makeConversation(), true)).toEqual({ + sessionId: 'conv-1', + isResuming: false, + }); + }); + + it('passes through for non-OpenCode providers', () => { + expect( + resolveAgentSessionCommandArgs( + makeConversation({ providerId: 'claude', providerSessionId: 'ses_ignored' }), + true + ) + ).toEqual({ sessionId: 'conv-1', isResuming: true }); + }); +}); diff --git a/src/main/core/conversations/resolve-agent-session-command.ts b/src/main/core/conversations/resolve-agent-session-command.ts new file mode 100644 index 000000000..234d92125 --- /dev/null +++ b/src/main/core/conversations/resolve-agent-session-command.ts @@ -0,0 +1,22 @@ +import { log } from '@main/lib/logger'; +import type { Conversation } from '@shared/conversations'; + +/** OpenCode `--continue` resumes the last global session, not per Emdash chat. */ +export function resolveAgentSessionCommandArgs( + conversation: Conversation, + isResuming: boolean +): { sessionId: string; isResuming: boolean } { + if (conversation.providerId === 'opencode' && isResuming) { + if (conversation.providerSessionId) { + return { sessionId: conversation.providerSessionId, isResuming: true }; + } + log.warn('resolveAgentSessionCommandArgs: OpenCode resume skipped — no providerSessionId', { + conversationId: conversation.id, + taskId: conversation.taskId, + projectId: conversation.projectId, + }); + return { sessionId: conversation.id, isResuming: false }; + } + + return { sessionId: conversation.id, isResuming }; +} diff --git a/src/main/core/conversations/save-provider-session-id.ts b/src/main/core/conversations/save-provider-session-id.ts new file mode 100644 index 000000000..b6d0a46f8 --- /dev/null +++ b/src/main/core/conversations/save-provider-session-id.ts @@ -0,0 +1,56 @@ +import { eq } from 'drizzle-orm'; +import { db } from '@main/db/client'; +import { conversations } from '@main/db/schema'; +import { events } from '@main/lib/events'; +import { log } from '@main/lib/logger'; +import { + isOpenCodeProviderSessionId, + parseConversationConfig, + serializeConversationConfig, +} from '@shared/conversation-config'; +import { conversationChangedChannel } from '@shared/events/conversationEvents'; + +export async function saveProviderSessionId( + conversationId: string, + providerSessionId: string +): Promise { + if (!isOpenCodeProviderSessionId(providerSessionId)) { + log.warn('saveProviderSessionId: ignored invalid OpenCode session id', { + conversationId, + providerSessionId, + }); + return; + } + + const [row] = await db + .select({ + config: conversations.config, + projectId: conversations.projectId, + taskId: conversations.taskId, + }) + .from(conversations) + .where(eq(conversations.id, conversationId)) + .limit(1); + + if (!row) return; + + const config = parseConversationConfig(row.config); + if (config.providerSessionId === providerSessionId) return; + + const nextConfig = serializeConversationConfig({ + ...config, + providerSessionId, + }); + + await db + .update(conversations) + .set({ config: nextConfig, updatedAt: new Date().toISOString() }) + .where(eq(conversations.id, conversationId)); + + events.emit(conversationChangedChannel, { + conversationId, + taskId: row.taskId, + projectId: row.projectId, + changes: { providerSessionId }, + }); +} diff --git a/src/main/core/conversations/utils.ts b/src/main/core/conversations/utils.ts index 7296ae4bb..df07e9ba4 100644 --- a/src/main/core/conversations/utils.ts +++ b/src/main/core/conversations/utils.ts @@ -1,18 +1,21 @@ import { type ConversationRow } from '@main/db/schema'; import { type AgentProviderId } from '@shared/agent-provider-registry'; +import { parseConversationConfig } from '@shared/conversation-config'; import { type Conversation } from '@shared/conversations'; export function mapConversationRowToConversation( row: ConversationRow, resume: boolean = false ): Conversation { + const config = parseConversationConfig(row.config); return { id: row.id, title: row.title, taskId: row.taskId, projectId: row.projectId, providerId: row.provider as AgentProviderId, - autoApprove: row.config ? JSON.parse(row.config).autoApprove : undefined, + autoApprove: config.autoApprove, + providerSessionId: config.providerSessionId, resume: resume, lastInteractedAt: row.lastInteractedAt ?? null, isInitialConversation: row.isInitialConversation, diff --git a/src/shared/agent-provider-registry.ts b/src/shared/agent-provider-registry.ts index 329440b92..91b5e79bf 100644 --- a/src/shared/agent-provider-registry.ts +++ b/src/shared/agent-provider-registry.ts @@ -270,7 +270,9 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [ versionArgs: ['--version'], cli: 'opencode', initialPromptFlag: '--prompt', - resumeFlag: '--continue', + resumeFlag: '--session', + sessionIdFlag: '--session', + sessionIdOnResumeOnly: true, icon: 'opencode.png', alt: 'OpenCode CLI', invertInDark: true, diff --git a/src/shared/conversation-config.test.ts b/src/shared/conversation-config.test.ts new file mode 100644 index 000000000..34f320341 --- /dev/null +++ b/src/shared/conversation-config.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { + isOpenCodeProviderSessionId, + parseConversationConfig, + serializeConversationConfig, +} from './conversation-config'; + +describe('parseConversationConfig', () => { + it('parses autoApprove and providerSessionId', () => { + expect( + parseConversationConfig(JSON.stringify({ autoApprove: true, providerSessionId: 'ses_abc' })) + ).toEqual({ autoApprove: true, providerSessionId: 'ses_abc' }); + }); + + it('returns empty config for invalid JSON', () => { + expect(parseConversationConfig('not-json')).toEqual({}); + }); +}); + +describe('isOpenCodeProviderSessionId', () => { + it('accepts OpenCode session ids', () => { + expect(isOpenCodeProviderSessionId('ses_0123456789abcdef')).toBe(true); + }); + + it('rejects non-OpenCode ids', () => { + expect(isOpenCodeProviderSessionId('conv-1')).toBe(false); + expect(isOpenCodeProviderSessionId('ses_')).toBe(false); + }); +}); + +describe('serializeConversationConfig', () => { + it('round-trips known fields', () => { + const raw = serializeConversationConfig({ + autoApprove: false, + providerSessionId: 'ses_xyz', + }); + expect(parseConversationConfig(raw)).toEqual({ + autoApprove: false, + providerSessionId: 'ses_xyz', + }); + }); +}); diff --git a/src/shared/conversation-config.ts b/src/shared/conversation-config.ts new file mode 100644 index 000000000..2a5527860 --- /dev/null +++ b/src/shared/conversation-config.ts @@ -0,0 +1,32 @@ +const OPENCODE_SESSION_ID_PATTERN = /^ses_.+/; + +export function isOpenCodeProviderSessionId(value: string): boolean { + return OPENCODE_SESSION_ID_PATTERN.test(value); +} + +export type ConversationConfig = { + autoApprove?: boolean; + /** Provider-native session id (e.g. OpenCode `ses_*`) for resuming the correct chat. */ + providerSessionId?: string; +}; + +export function parseConversationConfig(raw: string | null | undefined): ConversationConfig { + if (!raw) return {}; + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null) return {}; + const record = parsed as Record; + return { + ...(typeof record.autoApprove === 'boolean' ? { autoApprove: record.autoApprove } : {}), + ...(typeof record.providerSessionId === 'string' + ? { providerSessionId: record.providerSessionId } + : {}), + }; + } catch { + return {}; + } +} + +export function serializeConversationConfig(config: ConversationConfig): string { + return JSON.stringify(config); +} diff --git a/src/shared/conversations.ts b/src/shared/conversations.ts index 840a74e97..5991d282e 100644 --- a/src/shared/conversations.ts +++ b/src/shared/conversations.ts @@ -9,6 +9,8 @@ export type Conversation = { lastInteractedAt: string | null; resume?: boolean; autoApprove?: boolean; + /** OpenCode `ses_*` id captured from the provider for per-chat resume. */ + providerSessionId?: string; isInitialConversation: boolean | null; }; diff --git a/src/shared/events/conversationEvents.ts b/src/shared/events/conversationEvents.ts index 1c9194004..cd7b2279d 100644 --- a/src/shared/events/conversationEvents.ts +++ b/src/shared/events/conversationEvents.ts @@ -5,5 +5,5 @@ export const conversationChangedChannel = defineEvent<{ conversationId: string; taskId: string; projectId: string; - changes: Partial>; + changes: Partial>; }>('conversation:changed');