diff --git a/src/main/ai-vault/session-handoff-snapshot.ts b/src/main/ai-vault/session-handoff-snapshot.ts new file mode 100644 index 00000000000..2849ea58bac --- /dev/null +++ b/src/main/ai-vault/session-handoff-snapshot.ts @@ -0,0 +1,73 @@ +import { + buildOpenCodeSqliteCandidatePath, + splitOpenCodeSqliteCandidate +} from '../../shared/opencode-sqlite-session-path' +import { + AI_VAULT_SESSION_HANDOFF_TIMEOUT_MS, + isAiVaultDatabaseAgent, + type AiVaultSessionHandoffArgs, + type AiVaultSessionHandoffResult +} from '../../shared/ai-vault/contracts/ai-vault-session-handoff' +import { formatWorkerTranscriptMessage } from '../../shared/worker-transcript-text' +import { isWslUncPath, toLinuxPath } from '../../shared/wsl-paths' +import { readOpenCodeNativeChatTranscriptFull } from '../native-chat/transcript-opencode' +import { writeAgentSessionContextSnapshot } from '../native-chat/agent-session-context-snapshots' +import { wakeNativeChatTranscriptDistro } from '../native-chat/native-chat-open-wake' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { wslDistroForPath } from '../wsl/filesystem/wsl-unc-access-gate' +import { getAiVaultWslScanHomeDirs } from './ai-vault-scan-roots' +import { openCodeWslPath } from './scanners/opencode/reader-transport/session-scanner-opencode-wsl-client' +import { configureOpenCodeTranscriptWslReaders } from '../native-chat/transcript-opencode-database' + +/** Copies one database-backed conversation into its own hand-off snapshot on this host, + * so the new agent never gets a path to the account-wide database. */ +export async function writeAiVaultSessionHandoffSnapshot( + args: Pick +): Promise { + if (!isAiVaultDatabaseAgent(args.agent)) { + return {} + } + // Rows carry `#` or the bare database plus sessionId; both must name a database file. + const candidate = + splitOpenCodeSqliteCandidate(args.filePath, args.agent) || !args.sessionId + ? args.filePath + : buildOpenCodeSqliteCandidatePath(args.filePath, args.sessionId) + const row = splitOpenCodeSqliteCandidate(candidate, args.agent) + if (!row) { + return {} + } + try { + // Hand-off is a user act: start a stopped distro before the gated database read. + await wakeNativeChatTranscriptDistro(wslDistroForPath(row.dbPath)) + // No one waits past the caller's budget, so stop reading then too. + const signal = AbortSignal.timeout(AI_VAULT_SESSION_HANDOFF_TIMEOUT_MS) + if (openCodeWslPath(row.dbPath)) { + // This process reads WSL databases through per-distro readers; keep every known distro's. + const homes = (await getAiVaultWslScanHomeDirs()).slice(0, 32) + await configureOpenCodeTranscriptWslReaders([...homes, row.dbPath], signal) + } + const read = await readOpenCodeNativeChatTranscriptFull( + row.sessionId, + { resolveDbPath: async () => row.dbPath }, + signal, + args.agent === 'zcode' ? 'zcode' : undefined + ) + if ('error' in read) { + return {} + } + const text = read.messages.map(formatWorkerTranscriptMessage).join('\n\n') + if (!text.trim()) { + return {} + } + const path = await writeAgentSessionContextSnapshot( + getProfileUserDataPath(), + `ai-vault:${candidate}`, + text + ) + // A WSL row hands off to a WSL workspace, which reads the Windows temp file through /mnt. + return { transcriptPath: isWslUncPath(row.dbPath) ? toLinuxPath(path) : path } + } catch (error) { + console.warn('[ai-vault] could not save the session for hand-off', error) + return {} + } +} diff --git a/src/main/ipc/ai-vault/ai-vault-handoff.ts b/src/main/ipc/ai-vault/ai-vault-handoff.ts new file mode 100644 index 00000000000..ac9f7f53b27 --- /dev/null +++ b/src/main/ipc/ai-vault/ai-vault-handoff.ts @@ -0,0 +1,35 @@ +import { isUnresolvedOwnerHostId, parseExecutionHostId } from '../../../shared/execution-host' +import { + AI_VAULT_SESSION_HANDOFF_TIMEOUT_MS, + readAiVaultSessionHandoffResult, + type AiVaultSessionHandoffArgs, + type AiVaultSessionHandoffResult +} from '../../../shared/ai-vault/contracts/ai-vault-session-handoff' +import { writeAiVaultSessionHandoffSnapshot } from '../../ai-vault/session-handoff-snapshot' +import { callRuntimeEnvironment } from '../runtime-environments/runtime-environment-transport-routing' +import { getRuntimeEnvironmentRegistryRoot } from '../runtime-environments/runtime-environment-registry-root' + +/** The snapshot is written on the session's own host, where the hand-off agent starts. */ +export async function prepareAiVaultSessionHandoff( + args: AiVaultSessionHandoffArgs +): Promise { + const host = parseExecutionHostId(args.executionHostId) + if (host?.kind === 'ssh') { + return {} + } + if (host?.kind !== 'runtime') { + return writeAiVaultSessionHandoffSnapshot(args) + } + if (isUnresolvedOwnerHostId(host.id)) { + return {} + } + // An older host answers method_not_found; any failure leaves the preview hand-off. + const response = await callRuntimeEnvironment( + getRuntimeEnvironmentRegistryRoot(), + host.environmentId, + 'aiVault.prepareSessionHandoff', + { agent: args.agent, filePath: args.filePath, sessionId: args.sessionId }, + AI_VAULT_SESSION_HANDOFF_TIMEOUT_MS + ).catch(() => null) + return readAiVaultSessionHandoffResult(response?.ok === true ? response.result : null) +} diff --git a/src/main/ipc/ai-vault/ai-vault-resume.ts b/src/main/ipc/ai-vault/ai-vault-resume.ts index 90ca4c7365e..3d556fbe14a 100644 --- a/src/main/ipc/ai-vault/ai-vault-resume.ts +++ b/src/main/ipc/ai-vault/ai-vault-resume.ts @@ -13,6 +13,8 @@ import { tryEnsureStructuredAgentSessionHostForResume } from '../../runtime/agen import { copyStructuredChatConversation } from '../../ai-vault/structured-chat-conversation-copy' import { wakeNativeChatTranscriptDistro } from '../../native-chat/native-chat-open-wake' import { wslDistroForPath } from '../../wsl/filesystem/wsl-unc-access-gate' +import { prepareAiVaultSessionHandoff } from './ai-vault-handoff' +import type { AiVaultSessionHandoffArgs } from '../../../shared/ai-vault/contracts/ai-vault-session-handoff' export type AiVaultResumeHandlerOptions = { ensureStructuredSessionOwnership?: () => Promise @@ -28,6 +30,9 @@ export function registerAiVaultResumeHandler(options: AiVaultResumeHandlerOption 'aiVault:prepareSessionResume', (_event, args: AiVaultPrepareSessionResumeRequest) => prepareAiVaultSessionResume(args, options) ) + ipcMain.handle('aiVault:prepareSessionHandoff', (_event, args: AiVaultSessionHandoffArgs) => + prepareAiVaultSessionHandoff(args) + ) } export async function prepareAiVaultSessionResume( diff --git a/src/main/native-chat/transcript-opencode-database.ts b/src/main/native-chat/transcript-opencode-database.ts index a896bc16db6..c063ad325f2 100644 --- a/src/main/native-chat/transcript-opencode-database.ts +++ b/src/main/native-chat/transcript-opencode-database.ts @@ -20,6 +20,16 @@ function zcodeTranscriptDatabasePaths(wslHomeDirs: readonly string[]): string[] return [homedir(), ...wslHomeDirs].map((home) => join(home, '.zcode', 'cli', 'db', 'db.sqlite')) } +/** Readers for every listed WSL root; the registry drops any distro left out. */ +export async function configureOpenCodeTranscriptWslReaders( + roots: readonly string[], + signal: AbortSignal +): Promise { + const readers = await waitForPromiseWithSignal(prepareOpenCodeWslReaders(roots), signal) + signal.throwIfAborted() + configureOpenCodeWslReaders(readers) +} + export async function discoverOpenCodeTranscriptDatabase( sessionId?: string, signal?: AbortSignal, @@ -98,12 +108,7 @@ export async function discoverOpenCodeTranscriptDatabase( await waitForPromiseWithSignal(getAiVaultWslScanHomeDirs(), boundedSignal) ).slice(0, 32) if (homes.length > 0) { - const readers = await waitForPromiseWithSignal( - prepareOpenCodeWslReaders(homes), - boundedSignal - ) - boundedSignal.throwIfAborted() - configureOpenCodeWslReaders(readers) + await configureOpenCodeTranscriptWslReaders(homes, boundedSignal) } const wsl = await findSession(zcodeTranscriptDatabasePaths(homes)) if (wsl) { @@ -141,12 +146,7 @@ export async function discoverOpenCodeTranscriptDatabase( const primaryWsl = primary.filter((path) => openCodeWslPath(path)) const readerRoots = [...new Set([...homes, ...primaryWsl])] if (readerRoots.length > 0) { - const readers = await waitForPromiseWithSignal( - prepareOpenCodeWslReaders(readerRoots), - boundedSignal - ) - boundedSignal.throwIfAborted() - configureOpenCodeWslReaders(readers) + await configureOpenCodeTranscriptWslReaders(readerRoots, boundedSignal) } const wsl = await findSession([...primaryWsl, ...sources.flat()]) if (wsl) { diff --git a/src/main/runtime/rpc/methods/history/ai-vault.ts b/src/main/runtime/rpc/methods/history/ai-vault.ts index 0ec0a41183c..0354adef06d 100644 --- a/src/main/runtime/rpc/methods/history/ai-vault.ts +++ b/src/main/runtime/rpc/methods/history/ai-vault.ts @@ -1,4 +1,5 @@ import { prepareStructuredChatCliFork } from '../../../../ai-vault/structured-chat-cli-fork' +import { writeAiVaultSessionHandoffSnapshot } from '../../../../ai-vault/session-handoff-snapshot' import { AiVaultSearchRequestSchema, AiVaultSearchStatusRequestSchema, @@ -33,6 +34,7 @@ import { import { AiVaultDeleteSessionParams, AiVaultListSessionsParams, + AiVaultPrepareSessionHandoffParams, AiVaultPrepareSessionResumeParams, AiVaultSessionTitlesParams } from '../../../../../shared/rpc-contract/ai-vault-params' @@ -164,6 +166,12 @@ export const AI_VAULT_METHODS = [ ) } }), + defineMethod({ + name: 'aiVault.prepareSessionHandoff', + permission: 'workspace', + params: AiVaultPrepareSessionHandoffParams, + handler: (params) => writeAiVaultSessionHandoffSnapshot(params) + }), defineMethod({ name: 'aiVault.deleteSession', permission: 'workspace', diff --git a/src/preload/api/ai-vault-api.ts b/src/preload/api/ai-vault-api.ts index 0df2a880e40..76716433c64 100644 --- a/src/preload/api/ai-vault-api.ts +++ b/src/preload/api/ai-vault-api.ts @@ -7,6 +7,10 @@ import type { AiVaultDeleteSessionArgs, AiVaultDeleteSessionResult } from '../../shared/ai-vault/contracts/ai-vault-session-deletion' +import type { + AiVaultSessionHandoffArgs, + AiVaultSessionHandoffResult +} from '../../shared/ai-vault/contracts/ai-vault-session-handoff' import type { AiVaultFirstUserPromptArgs, AiVaultFirstUserPromptResult, @@ -50,6 +54,8 @@ export type AiVaultApi = { prepareSessionResume: ( args: AiVaultPrepareSessionResumeRequest ) => Promise + /** Saves a database-backed session's full conversation on its own host for a hand-off. */ + prepareSessionHandoff: (args: AiVaultSessionHandoffArgs) => Promise listSubagentSessions: (args: AiVaultSubagentListArgs) => Promise getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs) => Promise /** Moves a deletable session's transcript to the OS trash; local sessions only. */ diff --git a/src/preload/api/ai-vault-bridge.ts b/src/preload/api/ai-vault-bridge.ts index e12c807db8c..b772715009c 100644 --- a/src/preload/api/ai-vault-bridge.ts +++ b/src/preload/api/ai-vault-bridge.ts @@ -16,6 +16,7 @@ import type { } from '../../shared/ai-vault/contracts/ai-vault-types' import type { AiVaultSessionTitlesArgs } from '../../shared/ai-vault/contracts/ai-vault-session-title' import type { AiVaultPrepareSessionResumeRequest } from '../../shared/ai-vault/resume/ai-vault-resume-preparation' +import type { AiVaultSessionHandoffArgs } from '../../shared/ai-vault/contracts/ai-vault-session-handoff' import type { PreloadApi } from '../api-types' import { AI_VAULT_SCAN_PROGRESS_CHANNEL, @@ -54,6 +55,8 @@ export const aiVaultApi = { ipcRenderer.invoke('aiVault:cancelListSessions', args), prepareSessionResume: (args: AiVaultPrepareSessionResumeRequest) => ipcRenderer.invoke('aiVault:prepareSessionResume', args), + prepareSessionHandoff: (args: AiVaultSessionHandoffArgs) => + ipcRenderer.invoke('aiVault:prepareSessionHandoff', args), listSubagentSessions: (args: AiVaultSubagentListArgs) => ipcRenderer.invoke('aiVault:listSubagentSessions', args), getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs) => diff --git a/src/renderer/src/components/right-sidebar/ai-vault/panel/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/ai-vault/panel/AiVaultPanel.tsx index ccd8e6de7c6..cf75c4cdc29 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault/panel/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault/panel/AiVaultPanel.tsx @@ -367,7 +367,7 @@ export default function AiVaultPanel({ /> )} - {launchActions.continuationRequest && ( + {launchActions.continuationOpen && ( message.text.trim())) + // A database row's host saves its conversation when Hand Off opens, even with no preview text. + (isAiVaultDatabaseAgent(session.agent) || + vaultTranscriptPath(session) || + session.previewMessages.some((message) => message.text.trim())) ) } -export function prepareAiVaultSessionContinuation(args: { +type AiVaultSessionContinuationTarget = { session: AiVaultSession targetWorktreeId: string targetWorkspacePath: string -}): AgentSessionContinuationRequest { +} + +/** A database row's host saves this one conversation first; null when nothing usable came back. */ +export async function loadDatabaseAiVaultSessionContinuation( + target: AiVaultSessionContinuationTarget +): Promise { + const { session } = target + const saved = await withTimeout( + window.api.aiVault.prepareSessionHandoff({ + agent: session.agent, + filePath: session.filePath, + sessionId: session.sessionId, + executionHostId: session.executionHostId + }), + AI_VAULT_SESSION_HANDOFF_TIMEOUT_MS, + {} + ) + const request = prepareAiVaultSessionContinuation({ + ...target, + savedTranscriptPath: saved.transcriptPath + }) + return buildAgentSessionContinuationPrompt(request.source, 'focused') ? request : null +} + +export function prepareAiVaultSessionContinuation( + args: AiVaultSessionContinuationTarget & { + /** This conversation's own hand-off snapshot, for database-backed rows. */ + savedTranscriptPath?: string + } +): AgentSessionContinuationRequest { const { session, targetWorktreeId, targetWorkspacePath } = args return { source: { @@ -23,7 +63,7 @@ export function prepareAiVaultSessionContinuation(args: { sourceAgent: session.agent, sourceTitle: session.title, sourceWorkingDirectory: session.cwd, - transcriptPath: vaultTranscriptPath(session), + transcriptPath: args.savedTranscriptPath ?? vaultTranscriptPath(session), // Use provider-authenticated prompts; preview user entries may contain tool results or injected skills. lastPrompt: session.lastUserPrompt ?? null, lastAssistantMessage: latestAssistantPreview(session) @@ -48,8 +88,8 @@ function previewTranscript(session: AiVaultSession): string { } function vaultTranscriptPath(session: AiVaultSession): string | null { - // OpenCode vault paths identify its database or storage metadata, not a full transcript. - if (session.agent === 'opencode' || session.agent === 'opencode2') { + // These rows name an account-wide database; handing that path over would expose every conversation in it. + if (isAiVaultDatabaseAgent(session.agent)) { return null } return session.filePath.trim() || null diff --git a/src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-launch-actions.ts index e195184b22c..c5050e3b60b 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-launch-actions.ts @@ -1,8 +1,9 @@ import { + notifyAiVaultSessionNoContinuationContext, notifyAiVaultSessionPreparationFailure, resolveAiVaultSessionLaunchTargetOrNotify } from './ai-vault-session-launch-feedback' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { toast } from 'sonner' import { buildAiVaultResumeCopyCommandForWorktree, @@ -23,7 +24,11 @@ import { } from '@/lib/agent-session/resume/history/ai-vault-session-resume-preparation' import { translate } from '@/i18n/i18n' import { describeAiVaultCliForkFailure } from './ai-vault-session-cli-fork' -import { prepareAiVaultSessionContinuation } from './ai-vault-session-continuation' +import { + loadDatabaseAiVaultSessionContinuation, + prepareAiVaultSessionContinuation +} from './ai-vault-session-continuation' +import { isAiVaultDatabaseAgent } from '../../../../../../shared/ai-vault/contracts/ai-vault-session-handoff' import { activateAiVaultStructuredSession } from '@/lib/agent-session/open/activate-ai-vault-structured-session' import { adoptsCliConversations } from '../../../../../../shared/structured-agent-cli-conversation-adoption' @@ -54,8 +59,12 @@ export function useAiVaultSessionLaunchActions({ targetState: AiVaultSessionResumeTargetState agentCmdOverrides?: Partial> }) { - const [continuationRequest, setContinuationRequest] = - useState(null) + // An open dialog whose request is null is still saving the conversation on its host. + const [continuation, setContinuation] = useState<{ + request: AgentSessionContinuationRequest | null + } | null>(null) + // Only the latest Hand Off may fill or close the dialog. + const continuationAttempt = useRef(null) const buildResumeCommand = useCallback( (session: AiVaultSession, worktreeId?: string | null): string => @@ -245,20 +254,33 @@ export function useAiVaultSessionLaunchActions({ ) return } - setContinuationRequest( - prepareAiVaultSessionContinuation({ - session, - targetWorktreeId: targetId.worktreeId, - targetWorkspacePath - }) - ) + const target = { session, targetWorktreeId: targetId.worktreeId, targetWorkspacePath } + const attempt = {} + continuationAttempt.current = attempt + if (!isAiVaultDatabaseAgent(session.agent)) { + setContinuation({ request: prepareAiVaultSessionContinuation(target) }) + return + } + setContinuation({ request: null }) + void loadDatabaseAiVaultSessionContinuation(target).then((request) => { + if (continuationAttempt.current !== attempt) { + return + } + if (request) { + setContinuation({ request }) + return + } + setContinuation(null) + notifyAiVaultSessionNoContinuationContext() + }) }, [activeWorktree?.id, activeWorktreeId, targetState] ) const handleContinuationDialogOpenChange = useCallback((open: boolean): void => { if (!open) { - setContinuationRequest(null) + continuationAttempt.current = null + setContinuation(null) } }, []) @@ -269,7 +291,8 @@ export function useAiVaultSessionLaunchActions({ handleResumeInNewCli, handleResumeInNewChat, handleContinueInNewSession, - continuationRequest, + continuationOpen: continuation !== null, + continuationRequest: continuation?.request ?? null, handleContinuationDialogOpenChange } } diff --git a/src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-launch-feedback.ts b/src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-launch-feedback.ts index 8f881d4738b..3c581cd7dea 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-launch-feedback.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault/session-actions/ai-vault-session-launch-feedback.ts @@ -19,6 +19,15 @@ export function notifyAiVaultSessionQueued(agent: AiVaultAgent): void { ) } +export function notifyAiVaultSessionNoContinuationContext(): void { + toast.error( + translate( + 'components.agentSessionContinuation.noContext', + 'No session context is available to continue in a new session.' + ) + ) +} + export function notifyAiVaultSessionPreparationFailure( error: unknown, agent: AiVaultAgent, diff --git a/src/renderer/src/web/preload-api/web-ai-vault-api.ts b/src/renderer/src/web/preload-api/web-ai-vault-api.ts index a2359550941..c6ff4eaafdb 100644 --- a/src/renderer/src/web/preload-api/web-ai-vault-api.ts +++ b/src/renderer/src/web/preload-api/web-ai-vault-api.ts @@ -8,6 +8,11 @@ import type { AiVaultPrepareSessionResumeResult } from '../../../../shared/ai-vault/resume/ai-vault-resume-preparation' import type { AiVaultDeleteSessionArgs } from '../../../../shared/ai-vault/contracts/ai-vault-session-deletion' +import { + AI_VAULT_SESSION_HANDOFF_TIMEOUT_MS, + readAiVaultSessionHandoffResult, + type AiVaultSessionHandoffArgs +} from '../../../../shared/ai-vault/contracts/ai-vault-session-handoff' import type { AiVaultSessionTitlesArgs, AiVaultSessionTitlesResult @@ -105,6 +110,18 @@ export function createWebAiVaultApi(): NonNullable['aiVault' cancelListSessions: () => Promise.resolve(), prepareSessionResume: (args: AiVaultPrepareSessionResumeRequest) => callRuntimeResult('aiVault.prepareSessionResume', args), + // A browser reaches only its own paired runtime; an older one leaves the preview hand-off. + prepareSessionHandoff: async (args: AiVaultSessionHandoffArgs) => { + if (!addressesOwnRuntime(args.executionHostId)) { + return {} + } + const result = await callRuntimeResult( + 'aiVault.prepareSessionHandoff', + { agent: args.agent, filePath: args.filePath, sessionId: args.sessionId }, + AI_VAULT_SESSION_HANDOFF_TIMEOUT_MS + ).catch(() => null) + return readAiVaultSessionHandoffResult(result) + }, // Why: no server-side RPC for subagent transcript listing yet, so report an empty (not erroring) result. listSubagentSessions: () => Promise.resolve({ sessions: [], issues: [] }), // Why: full first-prompt re-parse is local-FS only; web/runtime falls back to preview text. diff --git a/src/shared/ai-vault/contracts/ai-vault-session-handoff.ts b/src/shared/ai-vault/contracts/ai-vault-session-handoff.ts new file mode 100644 index 00000000000..851b5121c30 --- /dev/null +++ b/src/shared/ai-vault/contracts/ai-vault-session-handoff.ts @@ -0,0 +1,31 @@ +import type { ExecutionHostId } from '../../execution-host' +import { resolveNativeChatTranscriptAgent } from '../../native-chat/native-chat-agent-support' +import type { AiVaultAgent } from './ai-vault-types' + +/** Agents whose rows name one conversation inside an account-wide database. */ +export function isAiVaultDatabaseAgent( + agent: AiVaultAgent +): agent is Extract { + return resolveNativeChatTranscriptAgent(agent) === 'opencode' +} + +/** How long a hand-off waits for the host's saved copy before using the preview. */ +export const AI_VAULT_SESSION_HANDOFF_TIMEOUT_MS = 30_000 + +export type AiVaultSessionHandoffArgs = { + agent: AiVaultAgent + /** The database path, or `#`. */ + filePath: string + sessionId?: string + executionHostId?: ExecutionHostId +} + +/** Absent `transcriptPath`: no saved copy (older host, SSH row, unreadable database); use the preview. */ +export type AiVaultSessionHandoffResult = { transcriptPath?: string } + +/** Reads a host's answer; anything but a non-empty path keeps the preview hand-off. */ +export function readAiVaultSessionHandoffResult(value: unknown): AiVaultSessionHandoffResult { + const transcriptPath = + value && typeof value === 'object' && 'transcriptPath' in value ? value.transcriptPath : null + return typeof transcriptPath === 'string' && transcriptPath ? { transcriptPath } : {} +} diff --git a/src/shared/rpc-contract/ai-vault-params.ts b/src/shared/rpc-contract/ai-vault-params.ts index 0c113761afb..4ef3f897ad1 100644 --- a/src/shared/rpc-contract/ai-vault-params.ts +++ b/src/shared/rpc-contract/ai-vault-params.ts @@ -99,3 +99,10 @@ export const AiVaultDeleteSessionParams = z.object({ sessionId: z.string().min(1).max(512).optional(), filePath: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH) }) + +// Only the row identity travels; the host reads its own database and writes the snapshot locally. +export const AiVaultPrepareSessionHandoffParams = z.object({ + agent: z.enum(AI_VAULT_AGENTS), + sessionId: z.string().min(1).max(512).optional(), + filePath: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH) +}) diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 789ed39392e..3d1414c1347 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -87,6 +87,7 @@ import { ReadVisualParams } from './agent-session-visual-params' import { AiVaultDeleteSessionParams, AiVaultListSessionsParams, + AiVaultPrepareSessionHandoffParams, AiVaultPrepareSessionResumeParams, AiVaultSessionTitlesParams } from './ai-vault-params' @@ -887,6 +888,7 @@ export const RPC_PARAMS_BY_METHOD = { 'agentTeams.tmuxCompat': AgentTeamsTmuxCompat, 'aiVault.deleteSession': AiVaultDeleteSessionParams, 'aiVault.listSessions': AiVaultListSessionsParams, + 'aiVault.prepareSessionHandoff': AiVaultPrepareSessionHandoffParams, 'aiVault.prepareSessionResume': AiVaultPrepareSessionResumeParams, 'aiVault.resolveSessionTitles': AiVaultSessionTitlesParams, 'aiVault.searchSessions': AiVaultSearchRequestSchema,