feat(desktop): session import view for foreign coding-agent transcripts

Browse the backend host's foreign CLI session logs, preview a bounded read-only
transcript, and continue a copy in Hermes under the selected profile. Reuses the
hermes_cli.foreign_sessions parsers and the portability validator/writer;
imports are transactional and deduplicated on the recorded origin.
This commit is contained in:
Adolanium
2026-09-05 20:20:40 +03:00
committed by Teknium
parent 8d4b7f874d
commit 9186e3ebc5
20 changed files with 1077 additions and 9 deletions
+10 -1
View File
@@ -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({
</SidebarGroupContent>
</SidebarGroup>
<div className="shrink-0 px-3 py-2">
<Button onClick={() => navigate(SESSION_IMPORT_ROUTE)} size="sm" variant="text">
<Codicon name="cloud-download" />
{t.sessionImport.action}
</Button>
</div>
{showSessionSections && (
<div className="shrink-0 px-2 pb-1 pt-1">
<SearchField
@@ -13,6 +13,7 @@ import {
HUD_SURFACE,
HUD_TEXT
} from '@/app/floating-hud'
import { SESSION_IMPORT_ROUTE } from '@/app/routes'
import { codiconIcon } from '@/components/ui/codicon'
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { HighlightMatches } from '@/components/ui/highlight-matches'
@@ -898,6 +899,13 @@ function CommandPaletteBody({ onExited }: { onExited: () => 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',
+13
View File
@@ -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 }) {
</Suspense>
)}
{currentView === 'session-import' && (
<SessionImportView
key={`${activeConnectionId}:${activeGatewayProfile}`}
onClose={closeOverlayToPreviousRoute}
onOpenSession={sessionId => {
closeOverlayToPreviousRoute()
openSession(sessionId, navigate, 'stack')
}}
owner={{ connectionId: activeConnectionId || 'local', profile: activeGatewayProfile }}
/>
)}
{commandCenterOpen && (
<Suspense fallback={null}>
<CommandCenterView
+5
View File
@@ -10,6 +10,7 @@ export const SESSION_ROUTE_PREFIX = '/'
export const NEW_CHAT_ROUTE = '/'
export const SETTINGS_ROUTE = '/settings'
export const COMMAND_CENTER_ROUTE = '/command-center'
export const SESSION_IMPORT_ROUTE = '/session-import'
export const SKILLS_ROUTE = '/skills'
export const MESSAGING_ROUTE = '/messaging'
export const WEBHOOKS_ROUTE = '/webhooks'
@@ -20,6 +21,7 @@ export const AGENTS_ROUTE = '/agents'
export const STARMAP_ROUTE = '/starmap'
export type AppView =
| 'session-import'
| 'agents'
| 'artifacts'
| 'chat'
@@ -38,6 +40,7 @@ export type AppView =
| 'webhooks'
export type AppRouteId =
| 'session-import'
| 'agents'
| 'artifacts'
| 'command-center'
@@ -57,6 +60,7 @@ export interface AppRoute {
}
export const APP_ROUTES = [
{ id: 'session-import', path: SESSION_IMPORT_ROUTE, view: 'session-import' },
{ id: 'new', path: NEW_CHAT_ROUTE, view: 'chat' },
{ id: 'settings', path: SETTINGS_ROUTE, view: 'settings' },
{ id: 'command-center', path: COMMAND_CENTER_ROUTE, view: 'command-center' },
@@ -123,6 +127,7 @@ export interface SidebarNavContribution {
// While one is open the app's titlebar control clusters must hide so they don't
// bleed over the overlay (they sit at a higher z-index than the overlay card).
export const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set([
'session-import',
'agents',
'command-center',
'cron',
@@ -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<T>(
owner: SessionOwnerRoute,
method: 'list' | 'preview' | 'import',
params: Record<string, unknown>,
signal?: AbortSignal
) {
return requestGatewayForAgent<T>(
owner.connectionId,
owner.profile,
`session.foreign.${method}`,
params,
60_000,
signal
)
}
@@ -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 }) => <p>{text}</p>
}))
vi.mock('../overlays/overlay-view', () => ({
OverlayView: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
}))
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(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<SessionImportView onClose={vi.fn()} onOpenSession={onOpenSession} owner={owner} />
</QueryClientProvider>
)
}
}
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<ForeignImportResult>(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()
})
@@ -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<string | null>(null)
const [pending, setPending] = useState(false)
const [error, setError] = useState('')
const lifetime = useRef<AbortController | null>(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<ForeignPage>(
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<ForeignPreview>(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<ForeignImportResult>(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 (
<OverlayView
onClose={onClose}
rootClassName="bg-(--ui-bg-elevated)"
titlebarActions={
<Button
aria-label={copy.refresh}
disabled={sessions.isFetching}
onClick={() => {
void sessions.refetch()
if (current) {
void preview.refetch()
}
}}
size="icon-titlebar"
variant="ghost"
>
<Codicon name="refresh" />
</Button>
}
>
<section aria-label={copy.title} className="session-import flex h-full min-h-0 flex-col">
<header className="px-8 pb-6 pt-12">
<h1 className="text-3xl font-semibold tracking-tight text-(--ui-text-primary)">{copy.title}</h1>
<p className="mt-2 text-sm text-(--ui-text-secondary)">{copy.subtitle}</p>
<div className="mt-5 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-(--ui-text-secondary)">
<span className="inline-flex items-center gap-2">
<Codicon name="device-desktop" />
{copy.readingFrom} {host ?? copy.connectedComputer}
</span>
<span className="inline-flex items-center gap-2">
<Codicon name="account" />
{copy.destination} {owner.targetProfile ?? owner.profile}
</span>
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-[minmax(17rem,0.85fr)_minmax(0,1.6fr)] border-t border-(--ui-stroke-tertiary) max-[760px]:grid-cols-1">
<aside
className={cn(
'flex min-h-0 flex-col bg-(--ui-sidebar-surface-background)',
selected && 'max-[760px]:hidden'
)}
>
<div className="flex flex-col gap-5 px-6 pb-4 pt-6">
<SegmentedControl
onChange={value => {
setSource(value)
setSelected(null)
setError('')
}}
options={[
{ id: 'all', label: copy.all },
{ id: 'claude', label: 'Claude Code' },
{ id: 'codex', label: 'Codex' }
]}
value={source}
/>
{rows.length > 0 && (
<SearchField
aria-label={copy.search}
containerClassName="opacity-100"
onChange={setSearch}
placeholder={copy.search}
value={search}
/>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-5">
{sessions.isPending && <Loader className="mx-auto my-12" label={copy.scanning} />}
{sessions.isError && (
<ErrorState className="p-5" description={copy.scanHelp} title={copy.scanError}>
<Button onClick={() => void sessions.refetch()} variant="secondary">
{copy.retry}
</Button>
</ErrorState>
)}
{!sessions.isPending && !sessions.isError && visible.length === 0 && (
<div className="p-5">
<EmptyState
description={search ? copy.searchHelp : copy.emptyHelp}
title={search ? copy.noMatches : copy.empty}
/>
</div>
)}
{visible.map((row, index) => {
const date = new Date(row.mtime * 1000).toLocaleDateString(locale, {
month: 'short',
day: 'numeric'
})
const previous = visible[index - 1]
const newDate =
!previous ||
new Date(previous.mtime * 1000).toDateString() !== new Date(row.mtime * 1000).toDateString()
return (
<div key={row.id}>
{newDate && <p className="px-3 pb-2 pt-5 text-xs font-medium text-(--ui-text-secondary)">{date}</p>}
<button
aria-pressed={selected === row.id}
className={cn(
'w-full cursor-pointer rounded-md px-3 py-4 text-start transition-colors hover:bg-(--chrome-action-hover) focus-visible:outline-2 focus-visible:outline-(--ui-accent)',
selected === row.id && 'bg-(--chrome-action-hover)'
)}
onClick={() => {
setSelected(row.id)
setError('')
}}
type="button"
>
<span className="mb-2 flex items-center gap-2 text-xs text-(--ui-text-secondary)">
<Codicon name="comment-discussion" />
{row.label}
<span className="ms-auto tabular-nums">
{row.turn_count} {copy.messages}
</span>
</span>
<span className="line-clamp-2 text-sm font-medium leading-5 text-(--ui-text-primary)">
{row.title}
</span>
<span className="mt-1 block truncate text-xs leading-5 text-(--ui-text-secondary)">
{row.cwd || row.excerpt}
</span>
</button>
</div>
)
})}
{unreadable > 0 && <p className="px-3 py-4 text-xs text-(--ui-text-secondary)">{copy.skipped}</p>}
{sessions.hasNextPage && (
<div className="flex justify-center pt-4">
<Button
disabled={sessions.isFetchingNextPage}
onClick={() => void sessions.fetchNextPage()}
variant="text"
>
{copy.more}
</Button>
</div>
)}
</div>
</aside>
<main className={cn('flex min-h-0 min-w-0 flex-col', !selected && 'max-[760px]:hidden')}>
{!current ? (
<PanelEmpty description={copy.chooseHelp} icon="comment-discussion" title={copy.choose} />
) : (
<>
<div className="px-8 pb-5 pt-6">
<div className="mb-4 hidden max-[760px]:block">
<Button onClick={() => setSelected(null)} variant="text">
{copy.back}
</Button>
</div>
<h2 className="text-xl font-medium leading-7 tracking-tight">{current.title}</h2>
<p className="mt-2 text-xs text-(--ui-text-secondary)">
{current.label} · {current.turn_count} {copy.messages}
</p>
</div>
<div aria-live="polite" className="min-h-0 flex-1 overflow-y-auto px-8 pb-8" key={current.id}>
{preview.isPending && <Loader className="mx-auto my-12" label={copy.previewLoading} />}
{preview.isError && (
<ErrorState description={copy.previewHelp} title={copy.previewError}>
<Button onClick={() => void preview.refetch()} variant="secondary">
{copy.retry}
</Button>
</ErrorState>
)}
{preview.data && (
<div className="max-w-prose space-y-7">
{preview.data.truncated && (
<p className="text-xs text-(--ui-text-secondary)">{copy.previewLimit}</p>
)}
{preview.data.messages.map((message, index) => (
<article className="min-w-0" key={index}>
<p className="mb-2 text-xs font-semibold text-(--ui-text-secondary)">
{message.role === 'user' ? copy.you : current.label}
</p>
<div className="text-sm leading-6 wrap-anywhere">
<MarkdownTextContent isRunning={false} previewOnly text={message.content} />
</div>
</article>
))}
</div>
)}
</div>
<footer className="flex flex-wrap items-center justify-between gap-4 border-t border-(--ui-stroke-tertiary) px-8 py-5">
<p className="max-w-xs text-xs leading-5 text-(--ui-text-secondary)">
{preview.data?.already_imported ? copy.snapshot : copy.copyNotice}
</p>
<Button disabled={pending || !preview.data || preview.isError} onClick={() => void continueSession()}>
{pending ? copy.importing : preview.data?.already_imported ? copy.open : copy.continue}
<Codicon name="arrow-right" />
</Button>
{error && (
<div className="w-full text-sm text-destructive" role="alert">
{copy.importError} {error}
</div>
)}
</footer>
</>
)}
</main>
</div>
</section>
</OverlayView>
)
}
@@ -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'>) => (
<h4 className={cn('my-1 font-semibold', HEADING_SIZES.h4, className)} {...props} />
),
p: (props: ComponentProps<'p'>) => <MarkdownParagraph {...props} streaming={isStreaming} />,
a: MarkdownLink,
p: (props: ComponentProps<'p'>) =>
previewOnly ? <p {...props} /> : <MarkdownParagraph {...props} streaming={isStreaming} />,
a: previewOnly ? ({ children }: ComponentProps<'a'>) => <span>{children}</span> : 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'>) => (
<td className={cn('px-2.5 py-1.5 align-top text-[0.8125rem] leading-snug', className)} {...props} />
),
img: MarkdownImage,
img: previewOnly ? ({ alt }: ComponentProps<'img'>) => <span>{alt}</span> : 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 <ArtifactCard code={props.code} detection={artifact} streaming={isStreaming} />
@@ -646,7 +650,7 @@ function MarkdownTextSurface({
)
}
}) as StreamdownTextComponents,
[disableArtifacts, isStreaming]
[disableArtifacts, isStreaming, previewOnly]
)
if (text.length > MAX_MARKDOWN_CHARS) {
+36
View File
@@ -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:
+37
View File
@@ -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',
+36
View File
@@ -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: '戻る',
+37
View File
@@ -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: 'Назад',
+36
View File
@@ -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
+36
View File
@@ -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: '返回',
+36
View File
@@ -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: '返回',
+111
View File
@@ -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)
+49
View File
@@ -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
+97
View File
@@ -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()
+46
View File
@@ -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)
+3 -2
View File
@@ -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