diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx
index 3f53fafa7f..0aefa90e30 100644
--- a/apps/desktop/src/app/chat/sidebar/index.tsx
+++ b/apps/desktop/src/app/chat/sidebar/index.tsx
@@ -3,9 +3,10 @@ import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { useLocation } from 'react-router'
+import { useLocation, useNavigate } from 'react-router'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
+import { SESSION_IMPORT_ROUTE } from '@/app/routes'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from '@/components/ui/context-menu'
@@ -333,6 +334,7 @@ export function ChatSidebar({
const { t } = useI18n()
const s = t.sidebar
const { pathname } = useLocation()
+ const navigate = useNavigate()
// Contributed nav rows (plugins pairing a page with a sidebar entry) render
// below the built-ins with the same chrome; active = at their route.
const navContributions = useContributions(SIDEBAR_NAV_AREA)
@@ -1617,6 +1619,13 @@ export function ChatSidebar({
+
+
+
+
{showSessionSections && (
void }) {
label: cc.sections.sessions,
run: go(`${COMMAND_CENTER_ROUTE}?section=sessions`)
},
+ {
+ icon: Download,
+ id: 'session-import',
+ keywords: ['import', 'claude', 'codex', 'conversation'],
+ label: t.sessionImport.action,
+ run: go(SESSION_IMPORT_ROUTE)
+ },
{
icon: Activity,
id: 'cc-system',
diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx
index 0783e243ac..2e7881845e 100644
--- a/apps/desktop/src/app/contrib/wiring.tsx
+++ b/apps/desktop/src/app/contrib/wiring.tsx
@@ -113,6 +113,7 @@ import {
SETTINGS_ROUTE,
syncWorkspaceRoute
} from '../routes'
+import { SessionImportView } from '../session-import'
import { SessionPickerOverlay } from '../session-picker-overlay'
import { SessionSwitcher } from '../session-switcher'
import { useBackgroundQueueDrain } from '../session/hooks/use-background-queue-drain'
@@ -1246,6 +1247,18 @@ export function ContribWiring({ children }: { children: ReactNode }) {
)}
+ {currentView === 'session-import' && (
+ {
+ closeOverlayToPreviousRoute()
+ openSession(sessionId, navigate, 'stack')
+ }}
+ owner={{ connectionId: activeConnectionId || 'local', profile: activeGatewayProfile }}
+ />
+ )}
+
{commandCenterOpen && (
= new Set([
+ 'session-import',
'agents',
'command-center',
'cron',
diff --git a/apps/desktop/src/app/session-import/api.ts b/apps/desktop/src/app/session-import/api.ts
new file mode 100644
index 0000000000..13fe71464b
--- /dev/null
+++ b/apps/desktop/src/app/session-import/api.ts
@@ -0,0 +1,49 @@
+import { requestGatewayForAgent } from '@/store/gateway'
+import type { SessionOwnerRoute } from '@/store/session-request-router'
+
+export interface ForeignSession {
+ id: string
+ source: 'claude' | 'codex'
+ label: string
+ title: string
+ cwd: string | null
+ mtime: number
+ turn_count: number
+ excerpt: string
+}
+
+export interface ForeignPage {
+ sessions: ForeignSession[]
+ next_offset: number | null
+ host: string
+ unreadable: number
+}
+
+export interface ForeignPreview {
+ truncated: boolean
+ messages: { role: string; content: string }[]
+ total: number
+ already_imported: string | null
+ cwd: string | null
+}
+
+export interface ForeignImportResult {
+ session_id: string
+ already_imported: boolean
+}
+
+export function foreignRequest(
+ owner: SessionOwnerRoute,
+ method: 'list' | 'preview' | 'import',
+ params: Record,
+ signal?: AbortSignal
+) {
+ return requestGatewayForAgent(
+ owner.connectionId,
+ owner.profile,
+ `session.foreign.${method}`,
+ params,
+ 60_000,
+ signal
+ )
+}
diff --git a/apps/desktop/src/app/session-import/index.test.tsx b/apps/desktop/src/app/session-import/index.test.tsx
new file mode 100644
index 0000000000..2656245bd5
--- /dev/null
+++ b/apps/desktop/src/app/session-import/index.test.tsx
@@ -0,0 +1,102 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+
+import { setSessionOwnerHint } from '@/store/session'
+
+import { type ForeignImportResult, foreignRequest } from './api'
+
+import { SessionImportView } from './index'
+
+vi.mock('./api', () => ({ foreignRequest: vi.fn() }))
+vi.mock('@/store/session', () => ({ setSessionOwnerHint: vi.fn() }))
+vi.mock('@/components/assistant-ui/markdown-text', () => ({
+ MarkdownTextContent: ({ text }: { text: string }) => {text}
+}))
+vi.mock('../overlays/overlay-view', () => ({
+ OverlayView: ({ children }: { children: React.ReactNode }) => {children}
+}))
+
+afterEach(() => {
+ cleanup()
+ vi.clearAllMocks()
+})
+
+const owner = { connectionId: 'workstation', profile: 'research' }
+
+const session = {
+ id: 'foreign-one',
+ source: 'claude',
+ label: 'Claude Code',
+ title: 'Repair imports',
+ cwd: '/work/project',
+ mtime: 1000,
+ turn_count: 2,
+ excerpt: 'Help with imports'
+}
+
+function mount(onOpenSession = vi.fn()) {
+ return {
+ onOpenSession,
+ ...render(
+
+
+
+ )
+ }
+}
+
+it('browses without importing, then retries a failed import on the captured owner before opening', async () => {
+ let attempts = 0
+ vi.mocked(foreignRequest).mockImplementation(async (_owner, method) => {
+ if (method === 'list') {
+ return { sessions: [session], next_offset: null, host: 'studio', unreadable: 0 }
+ }
+
+ if (method === 'preview') {
+ return { messages: [{ role: 'user', content: 'Please repair this' }], total: 1, already_imported: null }
+ }
+
+ if (++attempts === 1) {
+ throw new Error('Connection interrupted')
+ }
+
+ return { session_id: 'durable-one', already_imported: false }
+ })
+ const { onOpenSession } = mount()
+ fireEvent.click(await screen.findByRole('button', { name: /Repair imports/ }))
+ await screen.findByText('Please repair this')
+ expect(attempts).toBe(0)
+ fireEvent.click(screen.getByRole('button', { name: 'Continue in Hermes' }))
+ await screen.findByRole('alert')
+ expect(onOpenSession).not.toHaveBeenCalled()
+ fireEvent.click(screen.getByRole('button', { name: 'Continue in Hermes' }))
+ await waitFor(() => expect(onOpenSession).toHaveBeenCalledWith('durable-one'))
+ expect(setSessionOwnerHint).toHaveBeenCalledWith('durable-one', owner)
+ expect(foreignRequest).toHaveBeenCalledWith(owner, 'import', { id: 'foreign-one' }, expect.any(AbortSignal))
+})
+
+it('does not navigate when an import finishes after the view has closed', async () => {
+ let finish!: (result: ForeignImportResult) => void
+ vi.mocked(foreignRequest).mockImplementation(async (_owner, method) => {
+ if (method === 'list') {
+ return { sessions: [session], next_offset: null, host: 'studio', unreadable: 0 }
+ }
+
+ if (method === 'preview') {
+ return { messages: [], total: 0, already_imported: 'existing' }
+ }
+
+ return new Promise(resolve => {
+ finish = resolve
+ })
+ })
+ const { onOpenSession, unmount } = mount()
+ fireEvent.click(await screen.findByRole('button', { name: /Repair imports/ }))
+ fireEvent.click(await screen.findByRole('button', { name: 'Open in Hermes' }))
+ await waitFor(() => expect(finish).toBeDefined())
+ unmount()
+ finish({ session_id: 'existing', already_imported: true })
+ await waitFor(() => expect(setSessionOwnerHint).toHaveBeenCalledWith('existing', owner))
+ expect(onOpenSession).not.toHaveBeenCalled()
+})
diff --git a/apps/desktop/src/app/session-import/index.tsx b/apps/desktop/src/app/session-import/index.tsx
new file mode 100644
index 0000000000..47eef212b4
--- /dev/null
+++ b/apps/desktop/src/app/session-import/index.tsx
@@ -0,0 +1,320 @@
+import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'
+import { useEffect, useRef, useState } from 'react'
+
+import { MarkdownTextContent } from '@/components/assistant-ui/markdown-text'
+import { Button } from '@/components/ui/button'
+import { Codicon } from '@/components/ui/codicon'
+import { EmptyState } from '@/components/ui/empty-state'
+import { ErrorState } from '@/components/ui/error-state'
+import { Loader } from '@/components/ui/loader'
+import { SearchField } from '@/components/ui/search-field'
+import { SegmentedControl } from '@/components/ui/segmented-control'
+import { useI18n } from '@/i18n'
+import { cn } from '@/lib/utils'
+import { setSessionOwnerHint } from '@/store/session'
+import type { SessionOwnerRoute } from '@/store/session-request-router'
+
+import { OverlayView } from '../overlays/overlay-view'
+import { PanelEmpty } from '../overlays/panel'
+
+import { type ForeignImportResult, type ForeignPage, type ForeignPreview, foreignRequest } from './api'
+
+interface SessionImportViewProps {
+ owner: SessionOwnerRoute
+ onClose: () => void
+ onOpenSession: (id: string) => void
+}
+
+export function SessionImportView({ owner, onClose, onOpenSession }: SessionImportViewProps) {
+ const { t, locale } = useI18n()
+ const copy = t.sessionImport
+ const queryClient = useQueryClient()
+ const [source, setSource] = useState<'all' | 'claude' | 'codex'>('all')
+ const [search, setSearch] = useState('')
+ const [selected, setSelected] = useState(null)
+ const [pending, setPending] = useState(false)
+ const [error, setError] = useState('')
+ const lifetime = useRef(null)
+ // eslint-disable-next-line no-restricted-syntax -- lifetime cancellation, not a mirrored reactive value
+ useEffect(() => {
+ const controller = new AbortController()
+ lifetime.current = controller
+
+ return () => controller.abort()
+ }, [])
+ const scope = [owner.connectionId, owner.profile]
+
+ const sessions = useInfiniteQuery({
+ queryKey: ['foreign-sessions', ...scope, source],
+ initialPageParam: 0,
+ queryFn: ({ pageParam, signal }) =>
+ foreignRequest(
+ owner,
+ 'list',
+ {
+ source: source === 'all' ? null : source,
+ offset: pageParam
+ },
+ signal
+ ),
+ getNextPageParam: page => page.next_offset,
+ retry: false
+ })
+
+ const rows = [
+ ...new Map((sessions.data?.pages.flatMap(page => page.sessions) ?? []).map(row => [row.id, row])).values()
+ ]
+
+ const visible = rows.filter(row =>
+ `${row.title} ${row.cwd ?? ''} ${row.excerpt}`.toLocaleLowerCase().includes(search.toLocaleLowerCase())
+ )
+
+ const current = rows.find(row => row.id === selected)
+
+ const preview = useQuery({
+ queryKey: ['foreign-preview', ...scope, selected],
+ queryFn: ({ signal }) => foreignRequest(owner, 'preview', { id: selected }, signal),
+ enabled: Boolean(current),
+ retry: false
+ })
+
+ const host = sessions.data?.pages[0]?.host
+ const unreadable = sessions.data?.pages.reduce((sum, page) => sum + page.unreadable, 0) ?? 0
+
+ async function continueSession() {
+ if (!current || pending) {
+ return
+ }
+
+ const signal = lifetime.current?.signal
+ setPending(true)
+ setError('')
+
+ try {
+ const result = await foreignRequest(owner, 'import', { id: current.id }, signal)
+ setSessionOwnerHint(result.session_id, owner)
+ void queryClient.invalidateQueries({ queryKey: ['foreign-preview', ...scope] })
+ void queryClient.invalidateQueries({ queryKey: ['sessions'] })
+
+ if (!signal?.aborted) {
+ onOpenSession(result.session_id)
+ }
+ } catch (cause) {
+ if (!signal?.aborted) {
+ setError(cause instanceof Error ? cause.message : copy.importError)
+ }
+ } finally {
+ if (!signal?.aborted) {
+ setPending(false)
+ }
+ }
+ }
+
+ return (
+ {
+ void sessions.refetch()
+
+ if (current) {
+ void preview.refetch()
+ }
+ }}
+ size="icon-titlebar"
+ variant="ghost"
+ >
+
+
+ }
+ >
+
+
+
+
+
+ {!current ? (
+
+ ) : (
+ <>
+
+
+
+
+
{current.title}
+
+ {current.label} · {current.turn_count} {copy.messages}
+
+
+
+ {preview.isPending &&
}
+ {preview.isError && (
+
+
+
+ )}
+ {preview.data && (
+
+ {preview.data.truncated && (
+
{copy.previewLimit}
+ )}
+ {preview.data.messages.map((message, index) => (
+
+
+ {message.role === 'user' ? copy.you : current.label}
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ >
+ )}
+
+
+
+
+ )
+}
diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx
index f0671fb1a8..23031e6514 100644
--- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx
+++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx
@@ -449,6 +449,8 @@ interface MarkdownTextSurfaceProps {
/** Disable artifact-card promotion for fenced blocks (reasoning text — a
* model's scratchpad draft must not register artifact versions). */
disableArtifacts?: boolean
+ /** Foreign history must not load images or mount live transcript directives. */
+ previewOnly?: boolean
}
// Headings shrink to chat scale rather than the prose default (h1≈xl). Kept
@@ -537,7 +539,8 @@ function MarkdownTextSurface({
containerClassName,
containerProps,
defer,
- disableArtifacts
+ disableArtifacts,
+ previewOnly
}: MarkdownTextSurfaceProps) {
const { status, text } = useMessagePartText()
const isStreaming = status.type === 'running'
@@ -564,8 +567,9 @@ function MarkdownTextSurface({
h4: ({ className, ...props }: ComponentProps<'h4'>) => (
),
- p: (props: ComponentProps<'p'>) => ,
- a: MarkdownLink,
+ p: (props: ComponentProps<'p'>) =>
+ previewOnly ? : ,
+ a: previewOnly ? ({ children }: ComponentProps<'a'>) => {children} : MarkdownLink,
// Inline code must not vote when an ancestor resolves `dir="auto"`
// (HTML's algorithm skips descendants that carry their own dir),
// mirroring the CSS isolate that already keeps it out of the
@@ -624,13 +628,13 @@ function MarkdownTextSurface({
td: ({ className, ...props }: ComponentProps<'td'>) => (
|
),
- img: MarkdownImage,
+ img: previewOnly ? ({ alt }: ComponentProps<'img'>) => {alt} : MarkdownImage,
// ```mermaid / ```svg fences route to their lazy renderers; substantial
// html/svg/code fences promote to an artifact card that opens in the
// right rail; every other language falls back to the Shiki-highlighted
// code block.
SyntaxHighlighter: (props: SyntaxHighlighterProps) => {
- const artifact = disableArtifacts ? null : detectArtifact(props.language, props.code)
+ const artifact = disableArtifacts || previewOnly ? null : detectArtifact(props.language, props.code)
if (artifact) {
return
@@ -646,7 +650,7 @@ function MarkdownTextSurface({
)
}
}) as StreamdownTextComponents,
- [disableArtifacts, isStreaming]
+ [disableArtifacts, isStreaming, previewOnly]
)
if (text.length > MAX_MARKDOWN_CHARS) {
diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts
index c99c0fbdce..007e631917 100644
--- a/apps/desktop/src/i18n/ar.ts
+++ b/apps/desktop/src/i18n/ar.ts
@@ -1,6 +1,42 @@
import { defineLocale } from './define-locale'
export const ar = defineLocale({
+ sessionImport: {
+ refresh: 'تحديث الجلسات',
+ title: 'المتابعة من تطبيق آخر',
+ subtitle: 'انقل محادثة إلى Hermes وتابع من حيث توقفت.',
+ action: 'استيراد جلسة',
+ readingFrom: 'القراءة من',
+ connectedComputer: 'الكمبيوتر المتصل',
+ destination: 'الاستيراد إلى',
+ all: 'الكل',
+ search: 'البحث في الجلسات المحملة',
+ scanning: 'جارٍ البحث عن المحادثات',
+ retry: 'حاول مرة أخرى',
+ scanError: 'تعذر العثور على الجلسات',
+ scanHelp: 'تحقق من اتصال الخادم ثم أعد المحاولة. قد تحتاج الخوادم القديمة إلى تحديث.',
+ empty: 'لا توجد محادثات',
+ emptyHelp: 'ستظهر هنا جلسات Claude Code وCodex الموجودة على هذا الخادم.',
+ noMatches: 'لا توجد محادثات مطابقة',
+ searchHelp: 'جرّب عنوانًا أو مجلدًا آخر، أو حمّل المزيد من الجلسات.',
+ skipped: 'تم تجاوز بعض السجلات الفارغة أو غير المقروءة أو الكبيرة جدًا.',
+ more: 'تحميل المزيد من الجلسات',
+ messages: 'رسائل',
+ choose: 'محادثة تستحق المتابعة',
+ chooseHelp: 'اختر جلسة لقراءة سجلها قبل نقلها إلى Hermes.',
+ back: 'العودة إلى الجلسات',
+ previewLoading: 'جارٍ فتح المعاينة',
+ previewError: 'المعاينة غير متاحة',
+ previewHelp: 'ربما تم نقل الملف الأصلي أو تغييره. حدّث القائمة وحاول مرة أخرى.',
+ previewLimit: 'تم اختصار المعاينة لتسهيل القراءة. يتم استيراد المحادثة كاملة.',
+ you: 'أنت',
+ snapshot: 'هذه المحادثة موجودة بالفعل في Hermes. افتح نسختك الحالية للمتابعة.',
+ copyNotice: 'ينسخ نص المحادثة دون تغيير الملفات الأصلية. لا يشمل مخرجات الأدوات أو الاستدلال.',
+ importing: 'جارٍ الاستيراد…',
+ open: 'فتح في Hermes',
+ continue: 'المتابعة في Hermes',
+ importError: 'تعذر استيراد هذه المحادثة.'
+ },
sendDiagnostics: {
title: 'إرسال التشخيصات إلى Nous',
privacyNotice:
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 6275a42ff7..7a1eef6084 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -3,6 +3,43 @@ import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
import type { Translations } from './types'
export const en: Translations = {
+ sessionImport: {
+ refresh: 'Refresh sessions',
+ title: 'Continue from another app',
+ subtitle: 'Bring a conversation into Hermes and pick up where you left off.',
+ action: 'Import session',
+ readingFrom: 'Reading from',
+ connectedComputer: 'the connected computer',
+ destination: 'Import into',
+ all: 'All',
+ search: 'Search loaded sessions',
+ scanning: 'Finding conversations',
+ retry: 'Try again',
+ scanError: 'Could not find sessions',
+ scanHelp: 'Check your backend connection, then try again. Older backends may need an update.',
+ empty: 'No conversations found',
+ emptyHelp: 'Claude Code and Codex sessions on this backend will appear here.',
+ noMatches: 'No matching conversations',
+ searchHelp: 'Try another title or folder, or load more sessions.',
+ skipped: 'Some logs were empty, unreadable, or too large to preview.',
+ more: 'Load more sessions',
+ messages: 'messages',
+ choose: 'A conversation worth continuing',
+ chooseHelp: 'Choose a session to read its history before bringing it into Hermes.',
+ back: 'Back to sessions',
+ previewLoading: 'Opening preview',
+ previewError: 'Preview unavailable',
+ previewHelp: 'The source may have moved or changed. Refresh the list and try again.',
+ previewLimit: 'Preview shortened for readability. The complete conversation is imported.',
+ you: 'You',
+ snapshot: 'This conversation is already in Hermes. Open your existing copy to continue.',
+ copyNotice:
+ 'Copies conversation text. Source files stay unchanged. Tool output and reasoning are not carried over.',
+ importing: 'Importing…',
+ open: 'Open in Hermes',
+ continue: 'Continue in Hermes',
+ importError: 'Could not import this conversation.'
+ },
common: {
apply: 'Apply',
back: 'Back',
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index 36d3408d9c..e7f5c7cbf4 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -3,6 +3,42 @@ import { defineFieldCopy } from '@/app/settings/field-copy'
import { defineLocale } from './define-locale'
export const ja = defineLocale({
+ sessionImport: {
+ refresh: '一覧を更新',
+ title: '別のアプリから続ける',
+ subtitle: '会話をHermesに取り込み、続きを始めましょう。',
+ action: 'セッションを取り込む',
+ readingFrom: '読み込み元',
+ connectedComputer: '接続先のコンピューター',
+ destination: '取り込み先',
+ all: 'すべて',
+ search: '読み込み済みのセッションを検索',
+ scanning: '会話を検索中',
+ retry: '再試行',
+ scanError: 'セッションを取得できません',
+ scanHelp: 'バックエンドの接続を確認して再試行してください。古いバックエンドは更新が必要な場合があります。',
+ empty: '会話が見つかりません',
+ emptyHelp: 'このバックエンドのClaude CodeとCodexのセッションがここに表示されます。',
+ noMatches: '一致する会話がありません',
+ searchHelp: '別のタイトルやフォルダーを検索するか、セッションを追加で読み込んでください。',
+ skipped: '空、読み込み不可、または大きすぎるログをスキップしました。',
+ more: 'さらに読み込む',
+ messages: 'メッセージ',
+ choose: '会話の続きを始めましょう',
+ chooseHelp: 'セッションを選び、取り込む前に履歴を確認できます。',
+ back: 'セッションに戻る',
+ previewLoading: 'プレビューを開いています',
+ previewError: 'プレビューできません',
+ previewHelp: '元のファイルが移動または変更された可能性があります。一覧を更新してください。',
+ previewLimit: '読みやすいようにプレビューを省略しています。取り込み時は会話全体をコピーします。',
+ you: 'あなた',
+ snapshot: 'この会話は取り込み済みです。既存のコピーを開いて続けられます。',
+ copyNotice: '会話のテキストをコピーします。元のファイルは変更されません。ツール出力と推論は含まれません。',
+ importing: '取り込み中…',
+ open: 'Hermesで開く',
+ continue: 'Hermesで続ける',
+ importError: '会話を取り込めませんでした。'
+ },
common: {
apply: '適用',
back: '戻る',
diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts
index fbeb912a24..a82750482a 100644
--- a/apps/desktop/src/i18n/ru.ts
+++ b/apps/desktop/src/i18n/ru.ts
@@ -24,6 +24,43 @@ const RU_NOUN = (count: number | string, one: string, few: string, many: string)
}
export const ru = defineLocale({
+ sessionImport: {
+ refresh: 'Обновить сессии',
+ title: 'Продолжить из другого приложения',
+ subtitle: 'Перенесите разговор в Hermes и продолжите с того места, где остановились.',
+ action: 'Импортировать сессию',
+ readingFrom: 'Читаем с',
+ connectedComputer: 'подключённого компьютера',
+ destination: 'Импорт в',
+ all: 'Все',
+ search: 'Поиск по загруженным сессиям',
+ scanning: 'Поиск разговоров',
+ retry: 'Повторить',
+ scanError: 'Не удалось найти сессии',
+ scanHelp: 'Проверьте подключение к серверу и повторите попытку. Старому серверу может требоваться обновление.',
+ empty: 'Разговоров пока нет',
+ emptyHelp: 'Здесь появятся сессии Claude Code и Codex с этого сервера.',
+ noMatches: 'Совпадений нет',
+ searchHelp: 'Попробуйте другой заголовок или папку либо загрузите ещё сессии.',
+ skipped: 'Некоторые журналы пусты, недоступны или слишком велики для просмотра.',
+ more: 'Загрузить ещё сессии',
+ messages: 'сообщений',
+ choose: 'Разговор, который стоит продолжить',
+ chooseHelp: 'Выберите сессию, чтобы прочитать историю перед импортом в Hermes.',
+ back: 'Назад к сессиям',
+ previewLoading: 'Открываем просмотр',
+ previewError: 'Просмотр недоступен',
+ previewHelp: 'Исходный файл мог переместиться или измениться. Обновите список и повторите попытку.',
+ previewLimit: 'Просмотр сокращён для удобства чтения. Импортируется весь разговор.',
+ you: 'Вы',
+ snapshot: 'Этот разговор уже есть в Hermes. Откройте существующую копию, чтобы продолжить.',
+ copyNotice:
+ 'Копируется текст разговора. Исходные файлы не меняются. Вывод инструментов и рассуждения не переносятся.',
+ importing: 'Импорт…',
+ open: 'Открыть в Hermes',
+ continue: 'Продолжить в Hermes',
+ importError: 'Не удалось импортировать разговор.'
+ },
common: {
apply: 'Применить',
back: 'Назад',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index bacb24a5c7..b8dcc14adf 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -51,6 +51,42 @@ interface AuxTaskCopy {
}
export interface Translations {
+ sessionImport: {
+ refresh: string
+ title: string
+ subtitle: string
+ action: string
+ readingFrom: string
+ connectedComputer: string
+ destination: string
+ all: string
+ search: string
+ scanning: string
+ retry: string
+ scanError: string
+ scanHelp: string
+ empty: string
+ emptyHelp: string
+ noMatches: string
+ searchHelp: string
+ skipped: string
+ more: string
+ messages: string
+ choose: string
+ chooseHelp: string
+ back: string
+ previewLoading: string
+ previewError: string
+ previewHelp: string
+ previewLimit: string
+ you: string
+ snapshot: string
+ copyNotice: string
+ importing: string
+ open: string
+ continue: string
+ importError: string
+ }
common: {
apply: string
back: string
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index 9d6c61ba71..50a5a8b689 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -3,6 +3,42 @@ import { defineFieldCopy } from '@/app/settings/field-copy'
import { defineLocale } from './define-locale'
export const zhHant = defineLocale({
+ sessionImport: {
+ refresh: '重新整理工作階段',
+ title: '從其他應用程式繼續',
+ subtitle: '將對話匯入 Hermes,接著上次的進度繼續。',
+ action: '匯入工作階段',
+ readingFrom: '讀取自',
+ connectedComputer: '已連線的電腦',
+ destination: '匯入至',
+ all: '全部',
+ search: '搜尋已載入的工作階段',
+ scanning: '正在尋找對話',
+ retry: '重試',
+ scanError: '無法尋找工作階段',
+ scanHelp: '請檢查後端連線並重試。舊版後端可能需要更新。',
+ empty: '找不到對話',
+ emptyHelp: '此後端上的 Claude Code 和 Codex 工作階段將顯示在這裡。',
+ noMatches: '沒有符合的對話',
+ searchHelp: '嘗試其他標題或資料夾,或載入更多工作階段。',
+ skipped: '部分記錄為空白、無法讀取或過大,已略過。',
+ more: '載入更多工作階段',
+ messages: '則訊息',
+ choose: '繼續一段對話',
+ chooseHelp: '選擇工作階段,在匯入 Hermes 前查看歷程記錄。',
+ back: '返回工作階段',
+ previewLoading: '正在開啟預覽',
+ previewError: '無法預覽',
+ previewHelp: '來源檔案可能已移動或變更。請重新整理清單後重試。',
+ previewLimit: '預覽已縮短,方便閱讀。匯入時會複製完整對話。',
+ you: '你',
+ snapshot: '此對話已匯入 Hermes。開啟現有副本即可繼續。',
+ copyNotice: '複製對話文字,不變更來源檔案。不包含工具輸出和推理內容。',
+ importing: '正在匯入…',
+ open: '在 Hermes 中開啟',
+ continue: '在 Hermes 中繼續',
+ importError: '無法匯入此對話。'
+ },
common: {
apply: '套用',
back: '返回',
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 1c94cd2019..9ca3534e73 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -3,6 +3,42 @@ import { defineFieldCopy } from '@/app/settings/field-copy'
import type { Translations } from './types'
export const zh: Translations = {
+ sessionImport: {
+ refresh: '刷新会话',
+ title: '从其他应用继续',
+ subtitle: '将对话导入 Hermes,接着上次的进度继续。',
+ action: '导入会话',
+ readingFrom: '读取自',
+ connectedComputer: '已连接的计算机',
+ destination: '导入到',
+ all: '全部',
+ search: '搜索已加载的会话',
+ scanning: '正在查找对话',
+ retry: '重试',
+ scanError: '无法查找会话',
+ scanHelp: '请检查后端连接并重试。旧版后端可能需要更新。',
+ empty: '未找到对话',
+ emptyHelp: '此后端上的 Claude Code 和 Codex 会话将显示在这里。',
+ noMatches: '没有匹配的对话',
+ searchHelp: '尝试其他标题或文件夹,或加载更多会话。',
+ skipped: '部分日志为空、无法读取或过大,已跳过。',
+ more: '加载更多会话',
+ messages: '条消息',
+ choose: '继续一段对话',
+ chooseHelp: '选择会话,在导入 Hermes 前查看历史记录。',
+ back: '返回会话列表',
+ previewLoading: '正在打开预览',
+ previewError: '预览不可用',
+ previewHelp: '源文件可能已移动或更改。请刷新列表后重试。',
+ previewLimit: '预览已缩短,便于阅读。导入时会复制完整对话。',
+ you: '你',
+ snapshot: '此对话已导入 Hermes。打开现有副本即可继续。',
+ copyNotice: '复制对话文本,不更改源文件。不包含工具输出和推理内容。',
+ importing: '正在导入…',
+ open: '在 Hermes 中打开',
+ continue: '在 Hermes 中继续',
+ importError: '无法导入此对话。'
+ },
common: {
apply: '应用',
back: '返回',
diff --git a/hermes_cli/foreign_session_browser.py b/hermes_cli/foreign_session_browser.py
new file mode 100644
index 0000000000..cc194c6098
--- /dev/null
+++ b/hermes_cli/foreign_session_browser.py
@@ -0,0 +1,111 @@
+"""Backend-local discovery and previews for the desktop session importer."""
+
+import hashlib
+import re
+import socket
+from pathlib import Path
+
+from hermes_cli.foreign_sessions import _SOURCES, _SOURCE_LABELS, _SOURCE_DB_NAMES
+
+MAX_LOG_BYTES = 32 * 1024 * 1024
+
+
+def _display_title(parsed, source):
+ # Codex attachment envelopes put the actual request after a file listing.
+ # Keep the history intact; only the browser's display title skips that header.
+ first_user = next((turn["content"] for turn in parsed["turns"] if turn["role"] == "user"), "")
+ request = re.split(r"(?im)^#{1,6}\s*My request:\s*$", first_user, maxsplit=1)
+ title = request[-1].strip().splitlines()[0] if len(request) > 1 and request[-1].strip() else parsed["title_guess"]
+ return (title or _SOURCE_LABELS[source]).lstrip("# ")[:180]
+
+
+def _candidates(source=None):
+ if source is not None and source not in _SOURCES:
+ raise ValueError("Unknown session source")
+ rows = []
+ for name, (parts, pattern, recursive, _) in _SOURCES.items():
+ if source and source != name:
+ continue
+ root = Path.home().joinpath(*parts).resolve()
+ if not root.is_dir():
+ continue
+ for path in root.rglob(pattern) if recursive else root.glob(pattern):
+ resolved = path.resolve()
+ if not resolved.is_relative_to(root):
+ continue
+ try:
+ stat = resolved.stat()
+ except FileNotFoundError:
+ continue # A running source may rotate a log during discovery.
+ if not resolved.is_file():
+ continue
+ handle = hashlib.sha256(f"{name}:{resolved}".encode()).hexdigest()
+ rows.append((stat.st_mtime, handle, name, resolved, stat.st_size))
+ return sorted(rows, key=lambda row: (row[0], row[1]), reverse=True)
+
+
+def _parse(candidate):
+ _, _, source, path, size = candidate
+ if size > MAX_LOG_BYTES:
+ raise ValueError("This log exceeds the 32 MB preview and import limit")
+ parsed = _SOURCES[source][3](path)
+ if not parsed["turns"]:
+ raise ValueError("This session has no readable conversation messages")
+ return parsed
+
+
+def resolve_foreign_session(handle):
+ if not isinstance(handle, str) or len(handle) != 64:
+ raise ValueError("Unknown session. Refresh the list and try again")
+ for candidate in _candidates():
+ if candidate[1] == handle:
+ return candidate, _parse(candidate)
+ raise ValueError("Session no longer available. Refresh the list and try again")
+
+
+def list_foreign_sessions(source=None, offset=0, limit=25):
+ if not isinstance(offset, int) or offset < 0 or not isinstance(limit, int) or not 1 <= limit <= 50:
+ raise ValueError("Invalid session page")
+ candidates = _candidates(source)
+ rows, unreadable = [], 0
+ # Page candidates before parsing. Empty or oversized logs cannot turn a
+ # request for 25 rows into an unbounded transcript scan.
+ for candidate in candidates[offset:offset + limit]:
+ mtime, handle, name, _, _ = candidate
+ try:
+ parsed = _parse(candidate)
+ except (ValueError, OSError):
+ unreadable += 1
+ continue
+ rows.append({"id": handle, "source": name, "label": _SOURCE_LABELS[name],
+ "title": _display_title(parsed, name),
+ "cwd": parsed["cwd"], "mtime": mtime, "turn_count": len(parsed["turns"]),
+ "excerpt": parsed["turns"][0]["content"][:200]})
+ next_offset = offset + limit
+ return {"sessions": rows, "next_offset": next_offset if next_offset < len(candidates) else None,
+ "host": socket.gethostname(), "unreadable": unreadable}
+
+
+def foreign_origin(candidate, parsed):
+ return {"tool": _SOURCE_DB_NAMES[candidate[2]], "path": str(candidate[3]),
+ "foreign_session_id": parsed["session_id"]}
+
+
+def preview_foreign_session(handle, db):
+ candidate, parsed = resolve_foreign_session(handle)
+ origin = foreign_origin(candidate, parsed)
+ existing = db.find_foreign_import(origin)
+ # A bounded preview avoids mounting thousands of messages in the renderer.
+ messages = [{**turn, "content": turn["content"][:8000]} for turn in parsed["turns"][-40:]]
+ return {"messages": messages, "total": len(parsed["turns"]),
+ "truncated": len(parsed["turns"]) > 40 or any(len(turn["content"]) > 8000 for turn in parsed["turns"][-40:]),
+ "already_imported": existing, "cwd": parsed["cwd"]}
+
+
+def import_browser_session(handle, db, profile):
+ candidate, parsed = resolve_foreign_session(handle)
+ origin = foreign_origin(candidate, parsed)
+ cwd = parsed["cwd"]
+ return db.import_foreign_history(origin, parsed["turns"],
+ title=_display_title(parsed, candidate[2]),
+ cwd=cwd if cwd and Path(cwd).is_dir() else None, profile=profile)
diff --git a/hermes_state_portability.py b/hermes_state_portability.py
index d2da283c50..3858ccd9f5 100644
--- a/hermes_state_portability.py
+++ b/hermes_state_portability.py
@@ -80,6 +80,55 @@ _PROMPT_RESOLVED_SQL = "COALESCE(sp.prompt, s.system_prompt) AS _system_prompt_r
class SessionPortabilityMixin:
"""See module docstring — mixin for SessionDB (Port cluster)."""
+ @staticmethod
+ def _find_foreign_import_on_conn(conn, origin):
+ rows = conn.execute("SELECT id, origin_json FROM sessions WHERE source = ? AND origin_json IS NOT NULL",
+ (origin["tool"],)).fetchall()
+ for row in rows:
+ imported = (safe_json_loads(row["origin_json"], default={}) or {}).get("imported_from", {})
+ if imported.get("tool") != origin["tool"]:
+ continue
+ foreign_id = origin.get("foreign_session_id")
+ if ((foreign_id and foreign_id == imported.get("foreign_session_id"))
+ or (not foreign_id and imported.get("path") == origin["path"])):
+ return row["id"]
+ return None
+
+ def find_foreign_import(self, origin):
+ with self._read_ctx() as conn:
+ return self._find_foreign_import_on_conn(conn, origin)
+
+ def import_foreign_history(self, origin, messages, *, title, cwd, profile):
+ """Adopt or mint a foreign snapshot in one transaction, including its provenance.
+
+ BEGIN IMMEDIATE serializes duplicate clicks across connections/processes.
+ Reuse the portability validator and message writer so counters and FTS
+ obey the same contract as ordinary transcript imports.
+ """
+ import uuid
+ session_id = f"{time.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:12]}"
+ normalized, errors = self._validate_import_payload([
+ {"id": session_id, "source": origin["tool"], "title": title,
+ "cwd": cwd, "messages": messages}])
+ if errors:
+ raise ValueError(errors[0]["error"])
+
+ def _do(conn):
+ existing = self._find_foreign_import_on_conn(conn, origin)
+ if existing:
+ return {"session_id": existing, "already_imported": True}
+ # Titles are globally unique within a profile. Preserve a readable
+ # title while giving unrelated conversations with the same text room.
+ item = normalized[0]
+ if conn.execute("SELECT 1 FROM sessions WHERE title = ?", (title,)).fetchone():
+ item["session"]["title"] = f"{title} ({session_id[-12:]})"
+ self._import_session_row(conn, item["session"], item["messages"], session_id)
+ conn.execute("UPDATE sessions SET origin_json = ?, profile_name = ? WHERE id = ?",
+ (json.dumps({"imported_from": origin}), profile, session_id))
+ return {"session_id": session_id, "already_imported": False}
+
+ return self._execute_write(_do)
+
@classmethod
def _compact_session_cols(cls) -> str:
"""``s.``-prefixed SELECT list of every SCHEMA_SQL ``sessions`` column except
diff --git a/tests/tui_gateway/test_session_foreign.py b/tests/tui_gateway/test_session_foreign.py
new file mode 100644
index 0000000000..fa8b4b93df
--- /dev/null
+++ b/tests/tui_gateway/test_session_foreign.py
@@ -0,0 +1,97 @@
+"""Real foreign logs through the registered desktop RPCs and profile stores."""
+
+import json
+from concurrent.futures import ThreadPoolExecutor
+from pathlib import Path
+
+import pytest
+
+
+def test_foreign_rpc_preview_import_and_profile_isolation(tmp_path, monkeypatch):
+ from hermes_state import SessionDB
+ from tui_gateway import server
+
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
+ monkeypatch.delenv("HERMES_DESKTOP", raising=False)
+ folder = tmp_path / ".claude" / "projects" / "project"
+ folder.mkdir(parents=True)
+ log = folder / "session.jsonl"
+ lines = [{"type": role, "sessionId": "foreign-one", "cwd": str(tmp_path),
+ "message": {"role": role, "content": content}}
+ for role, content in [("user", "# Files mentioned by the user:\nplan.md\n\n## My request:\nFix the import"), ("assistant", "Here is the fix."),
+ ("assistant", "And its test."), ("user", "Continue")]]
+ log.write_text("\n".join(map(json.dumps, lines)), encoding="utf-8")
+ original = log.read_bytes()
+ db = SessionDB(tmp_path / ".hermes" / "state.db")
+ monkeypatch.setattr(server, "_get_db", lambda: db)
+ monkeypatch.setattr(server, "_profile_home", lambda profile: None)
+
+ def rpc(method, **params):
+ result = server._methods[f"session.foreign.{method}"](1, params)
+ assert "error" not in result, result
+ return result["result"]
+
+ try:
+ page = rpc("list", limit=1)
+ handle = page["sessions"][0]["id"]
+ assert page["sessions"][0]["title"] == "Fix the import"
+ assert "path" not in page["sessions"][0]
+ preview = rpc("preview", id=handle)
+ assert preview["already_imported"] is None
+ assert db.session_count() == 0
+ with ThreadPoolExecutor(max_workers=2) as executor:
+ results = list(executor.map(lambda _: rpc("import", id=handle), range(2)))
+ assert results[0]["session_id"] == results[1]["session_id"]
+ sid = results[0]["session_id"]
+ stored = db.get_session(sid)
+ assert json.loads(stored["origin_json"])["imported_from"]["foreign_session_id"] == "foreign-one"
+ history = db.get_messages(sid)
+ assert [message["role"] for message in history] == ["user", "assistant", "user"]
+ assert len(history) == preview["total"] == stored["message_count"]
+ assert rpc("preview", id=handle)["already_imported"] == sid
+ assert log.read_bytes() == original
+ with SessionDB(tmp_path / "other" / "state.db") as other:
+ monkeypatch.setattr(server, "_get_db", lambda: other)
+ assert rpc("preview", id=handle)["already_imported"] is None
+ assert rpc("import", id=handle)["session_id"] != sid
+ finally:
+ db.close()
+
+
+def test_foreign_pages_confine_handles_and_failed_import_rolls_back(tmp_path, monkeypatch):
+ from hermes_cli import foreign_session_browser as browser
+ from hermes_state import SessionDB
+
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
+ folder = tmp_path / ".codex" / "sessions"
+ folder.mkdir(parents=True)
+ for index in range(3):
+ (folder / f"rollout-{index}.jsonl").write_text(json.dumps({
+ "type": "response_item", "payload": {"type": "message", "role": "user",
+ "content": [{"type": "input_text", "text": f"Question {index}"}]}}), encoding="utf-8")
+ calls = []
+ parse = browser._parse
+ monkeypatch.setattr(browser, "_parse", lambda row: (calls.append(row[1]), parse(row))[1])
+ page = browser.list_foreign_sessions(limit=1)
+ assert len(calls) == 1
+ next_page = browser.list_foreign_sessions(offset=page["next_offset"], limit=1)
+ assert page["sessions"][0]["id"] != next_page["sessions"][0]["id"]
+ with pytest.raises(ValueError):
+ browser.resolve_foreign_session(str(tmp_path / "secret.jsonl"))
+ # A fabricated well-shaped handle cannot become a file-read request.
+ with pytest.raises(ValueError):
+ browser.resolve_foreign_session("0" * 64)
+ db = SessionDB(tmp_path / ".hermes" / "state.db")
+ insert = db._insert_message_rows
+ def failing_insert(*args):
+ insert(*args)
+ raise RuntimeError("interrupted write")
+ monkeypatch.setattr(db, "_insert_message_rows", failing_insert)
+ try:
+ with pytest.raises(RuntimeError, match="interrupted write"):
+ browser.import_browser_session(page["sessions"][0]["id"], db, "default")
+ assert db.session_count() == 0
+ finally:
+ db.close()
diff --git a/tui_gateway/methods_session_foreign.py b/tui_gateway/methods_session_foreign.py
new file mode 100644
index 0000000000..82fc7edf15
--- /dev/null
+++ b/tui_gateway/methods_session_foreign.py
@@ -0,0 +1,46 @@
+"""Desktop foreign-history browsing, scoped to the serving backend and profile."""
+
+from .method_ctx import HandlerRegistry, bind_module
+
+_registry = HandlerRegistry()
+method = _registry.method
+
+
+@method("session.foreign.list")
+def _foreign_list(rid, params):
+ from hermes_cli.foreign_session_browser import list_foreign_sessions
+ try:
+ return _ok(rid, list_foreign_sessions(params.get("source"), params.get("offset", 0), params.get("limit", 25)))
+ except ValueError as exc:
+ return _err(rid, -32602, str(exc))
+ except OSError:
+ return _err(rid, -32000, "Could not read session folders on this backend")
+
+
+def _foreign_history_request(rid, params, importing):
+ from hermes_cli.foreign_session_browser import import_browser_session, preview_foreign_session
+ try:
+ with _profile_db(params) as db:
+ if db is None:
+ return _db_unavailable_error(rid, code=-32000)
+ result = (import_browser_session(params.get("id"), db, _response_profile_name(params.get("profile")))
+ if importing else preview_foreign_session(params.get("id"), db))
+ return _ok(rid, result)
+ except ValueError as exc:
+ return _err(rid, -32602, str(exc))
+ except OSError:
+ return _err(rid, -32000, "Could not read this session on the backend")
+
+
+@method("session.foreign.preview")
+def _foreign_preview(rid, params):
+ return _foreign_history_request(rid, params, False)
+
+
+@method("session.foreign.import")
+def _foreign_import(rid, params):
+ return _foreign_history_request(rid, params, True)
+
+
+def register(server):
+ bind_module(globals(), server)
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index 671def454a..33ebf7ba25 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -158,6 +158,7 @@ _DETAIL_MODES = frozenset({"hidden", "collapsed", "expanded"})
# interrupts); voice.*/wake.* = SYNCHRONOUS faster-whisper install (300s); session.workspace.move =
# git subprocess probes on an arbitrary (maybe slow) mount.
_LONG_HANDLERS = frozenset({
+ "session.foreign.list", "session.foreign.preview", "session.foreign.import",
"billing.state", "subscription.state", "subscription.preview", "subscription.change",
"subscription.resume", "subscription.upgrade", "usage.bars", "session.usage", "billing.step_up",
"browser.manage", "cli.exec", "complete.path", "complete.slash", "llm.oneshot", "model.options",
@@ -3206,7 +3207,7 @@ from . import ( # noqa: E402
methods_config_set as _methods_config_set, methods_images as _methods_images,
methods_profiles as _methods_profiles, methods_prompt as _methods_prompt, methods_session as _methods_session,
methods_tools as _methods_tools, prompt_turn as _prompt_turn, billing_view as _billing_view,
- methods_projects as _methods_projects)
+ methods_projects as _methods_projects, methods_session_foreign as _methods_session_foreign)
for _m in (
_session_reaper, _session_lifecycle, _session_workdir, _compute_host_bridge, _model_switch,
@@ -3215,6 +3216,6 @@ for _m in (
_methods_complete_helpers, _methods_slash, _methods_voice, _methods_browser,
_methods_browser_control, _methods_session, _methods_prompt, _methods_config,
_methods_config_set, _methods_complete, _methods_tools, _methods_profiles, _methods_images,
- _methods_bot_relay, _prompt_turn, _billing_view, _methods_projects):
+ _methods_bot_relay, _prompt_turn, _billing_view, _methods_projects, _methods_session_foreign):
_m.register(sys.modules[__name__])
del _m