Skip to content

feat: import existing Claude Code sessions (+ fork-resume for live sessions)#942

Open
dzshzx wants to merge 9 commits into
tiann:mainfrom
dzshzx:feat/claude-session-import
Open

feat: import existing Claude Code sessions (+ fork-resume for live sessions)#942
dzshzx wants to merge 9 commits into
tiann:mainfrom
dzshzx:feat/claude-session-import

Conversation

@dzshzx

@dzshzx dzshzx commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

What

Two related additions for Claude Code, mirroring the existing Codex importer/session machinery.

1. Import existing Claude Code sessions — feat(hub,web)

A Claude counterpart to the Codex desktop importer. Scans ~/.claude/projects/**/*.jsonl, maps user / assistant(text/thinking/tool_use) / tool_result records to imported messages, and persists through a shared engine.

  • New flavor-agnostic engine hub/src/web/routes/transcriptImport.ts (target selection / dedupe / persist / event emit, keyed on metadata.<flavor>SessionId); Codex & Claude each keep only their scanner + parser + adapter.
  • hub/src/web/routes/claudeDesktop.ts: /api/claude/status|sessions|sync-session (default namespace only), idempotent re-import.
  • Web: an "Import Claude sessions" entry mirroring the Codex dialog.

2. Fork-resume sessions that are still live as a background agent — feat(cli)

When resuming a Claude session that is still held open by a running background/interactive agent, claude --resume <id> is rejected (currently running as a background agent), the error was swallowed to {}, and the launcher retried forever.

  • Detect liveness via claude agents --json (getLiveAgentKind) and branch a copy with --fork-session (recording metadata.forkedFrom) instead of taking over the live session.
  • Stop the launcher's infinite retry on unrecoverable resume errors (MAX_LAUNCH_RETRIES).
  • Fix the logger swallowing Error to {} (now {name,message,stack}).
  • Dead sessions fall through to plain --resume (unchanged); Codex and other flavors untouched.

Tests

New unit tests for liveness detection, error classification, fork-arg passthrough, launcher retry, and logger serialization. bun run test green across cli/hub/web/shared; bun run build clean.

dzshzx and others added 2 commits June 17, 2026 14:46
Mirror the Codex session importer for Claude Code: scan ~/.claude/projects
transcripts, map user/assistant(text/thinking/tool_use)/tool_result records
to Hapi imported messages, persist via the shared store/sync engine, and
expose /api/claude/status|sessions|sync-session plus a web import dialog.

Extract the flavor-agnostic import engine into transcriptImport.ts so Codex
and Claude share session-target selection, dedupe, persistence and event
emission (single source of truth); each flavor keeps only its scanner,
parser and adapter. Codex behavior and routes are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When resuming an imported Claude session still held open by a running background/interactive agent, 'claude --resume' is rejected (currently running as a background agent), the error was swallowed to {}, and the launcher retried forever. Now: detect liveness via 'claude agents --json' and branch a copy with --fork-session (recording forkedFrom) instead of taking over; logger serializes Error to {name,message,stack}; launcher classifies unrecoverable resume errors and stops retry (MAX_LAUNCH_RETRIES=3), surfacing the real reason. Dead sessions fall through to plain --resume (unchanged); codex and other flavors untouched.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Preserve transcript timestamps during direct import — importSingleSession writes every imported row through addMessage, so created_at/invoked_at are stamped with the import time rather than the Claude row timestamp. The hub then sorts sessions by updatedAt and message pages by invokedAt ?? createdAt, so importing an old Claude transcript makes it look active today and collapses historical message timing. Claude JSONL rows already expose timestamp (see existing RawJSONLinesBaseSchema), but the new Claude importer drops it before persistence. Evidence: hub/src/web/routes/transcriptImport.ts:805.
    Suggested fix:
    type ImportedTranscriptMessage = {
        content: ImportedMessageContent
        createdAt?: number
    }
    
    function parseClaudeTimestamp(record: Record<string, unknown>): number | undefined {
        if (typeof record.timestamp !== 'string') return undefined
        const value = Date.parse(record.timestamp)
        return Number.isFinite(value) ? value : undefined
    }
    
    const createdAt = message.createdAt ?? transcript.modifiedAt
    const appendedMessages = messagesToAppend.map((message) =>
        options.store.messages.copyMessageToSession(sessionId!, {
            content: message.content,
            createdAt,
            invokedAt: createdAt,
            localId: undefined,
            scheduledAt: null
        })
    )

Summary

  • Review mode: initial
  • Found one issue: imported Claude transcripts lose original timestamps, which changes session recency and message chronology in HAPI.

Testing

  • Not run (automation). Add a Claude import test with two JSONL rows carrying old ISO timestamp values and assert persisted createdAt/session updatedAt match the transcript timeline, not Date.now().

HAPI Bot

Comment thread hub/src/web/routes/transcriptImport.ts Outdated

const comparablePrefixCount = sessionId ? target.comparablePrefixCount : 0
const messagesToAppend = transcript.messages.slice(comparablePrefixCount)
const appendedMessages = messagesToAppend.map((message) => options.store.messages.addMessage(sessionId!, message))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] addMessage always stamps created_at/invoked_at with Date.now(), so direct-imported Claude rows lose their JSONL timestamp. Because /api/sessions sorts by session updatedAt and message pagination sorts by invokedAt ?? createdAt, importing an old transcript makes it look active at import time and collapses the original message timeline. Claude records already have a timestamp field in the existing raw schema; carry that through the import data and insert with copyMessageToSession (or an equivalent timestamp-aware insert).

Suggested fix:

type ImportedTranscriptMessage = {
    content: ImportedMessageContent
    createdAt?: number
}

function parseClaudeTimestamp(record: Record<string, unknown>): number | undefined {
    if (typeof record.timestamp !== 'string') return undefined
    const value = Date.parse(record.timestamp)
    return Number.isFinite(value) ? value : undefined
}

const createdAt = message.createdAt ?? transcript.modifiedAt
const appendedMessages = messagesToAppend.map((message) =>
    options.store.messages.copyMessageToSession(sessionId!, {
        content: message.content,
        createdAt,
        invokedAt: createdAt,
        localId: undefined,
        scheduledAt: null
    })
)

The direct-import path (importSingleSession) wrote rows via addMessage, stamping created_at/invoked_at with import time, so imported Claude/Codex sessions sorted as "active today" and lost their original message timeline (review finding on tiann#942).

- Carry each JSONL record's top-level timestamp through import data (new ImportedMessage wrapper + shared parseImportedTimestamp) for both Claude and Codex parsers.
- Insert via copyMessageToSession with createdAt/invokedAt = original timestamp (fallback to file mtime).
- Back-fill the newly-created imported session's updatedAt to real last activity (setImportedSessionActivity, new-session path only); existing/online sessions keep forward-only activity semantics untouched.
- New unit tests (Claude + Codex): timestamp preservation + mtime fallback.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Query the live-agent roster with the same Claude environment used for launch — the new fork decision runs getLiveAgentKind(startFrom) before opts.claudeEnvVars are applied, while the actual SDK launch below uses those env overrides. If a runner/session sets HAPI_CLAUDE_PATH or CLAUDE_CONFIG_DIR through claudeEnvVars, the roster probe can query the default Claude binary/home, miss the live agent, set forkSession=false, and then the real launch resumes against the overridden Claude config and hits the occupied-session failure path instead of forking. Evidence: cli/src/claude/claudeRemote.ts:84.
    Suggested fix:
    if (opts.claudeEnvVars) {
        Object.entries(opts.claudeEnvVars).forEach(([key, value]) => {
            process.env[key] = value;
        });
    }
    process.env.DISABLE_AUTOUPDATER = '1';
    
    let forkSession = false;
    if (startFrom) {
        const liveKind = getLiveAgentKind(startFrom);
        if (liveKind) {
            forkSession = true;
        }
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The previous timestamp finding appears addressed in the follow-up diff, including tests. Found one remaining issue in the live-session fork path when Claude env overrides are supplied.

Testing

  • Not run (automation). Add a claudeRemote fork test where claudeEnvVars supplies CLAUDE_CONFIG_DIR or HAPI_CLAUDE_PATH, and assert getLiveAgentKind observes that override before query() is called.

HAPI Bot

// daemon roster is unavailable, so this never blocks the resume path.
let forkSession = false;
if (startFrom) {
const liveKind = getLiveAgentKind(startFrom);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] This roster probe runs before opts.claudeEnvVars are copied into process.env, but the actual launch below uses those env overrides. If the session/runner provides HAPI_CLAUDE_PATH or CLAUDE_CONFIG_DIR, getLiveAgentKind() can query the default Claude binary/home, return null, and then the real launch resumes against the overridden Claude config without --fork-session, re-triggering the occupied-session failure path.

Suggested fix:

if (opts.claudeEnvVars) {
    Object.entries(opts.claudeEnvVars).forEach(([key, value]) => {
        process.env[key] = value;
    });
}
process.env.DISABLE_AUTOUPDATER = '1';

let forkSession = false;
if (startFrom) {
    const liveKind = getLiveAgentKind(startFrom);
    if (liveKind) {
        forkSession = true;
    }
}

getLiveAgentKind() runs 'claude agents --json', which honors CLAUDE_CONFIG_DIR / HAPI_CLAUDE_PATH. It ran before opts.claudeEnvVars were applied to process.env, so when a session supplies those overrides the liveness probe queried the default Claude config, missed the live agent, and the launch then resumed against the overridden config and hit the occupied-session path instead of forking (review finding on tiann#942).

Move the env application above the fork decision so probe and launch share the same Claude env. Adds a regression test asserting the override is visible to getLiveAgentKind.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Minor] Wire the Claude import marker into the session list — markClaudeSessionsImported(sessionIds) now records a Claude import timestamp, and the new helper comment says the session list should switch to a “from Claude” time label, but the list still only imports/reads getCodexImportedAt and only has Codex-specific time strings (web/src/components/SessionList.tsx:576). After a successful Claude import the localStorage marker is written, but no subscriber/formatter reads it, so the imported session just shows the normal updated time. Evidence: web/src/router.tsx:529.
    Suggested fix:
    import { getClaudeImportedAt, subscribeClaudeImportedSessions } from '@/lib/claudeImportedSessions'
    
    function getSessionTimeLabel(session: SessionSummary, t: TFunction): string | null {
        const agentSessionId = session.metadata?.agentSessionId
        const importedAt = session.metadata?.flavor === 'codex'
            ? getCodexImportedAt(agentSessionId)
            : session.metadata?.flavor === 'claude'
              ? getClaudeImportedAt(agentSessionId)
              : null
    
        if (importedAt !== null) {
            return session.metadata?.flavor === 'claude'
                ? formatClaudeImportedRelativeTime(importedAt, t)
                : formatCodexImportedRelativeTime(importedAt, t)
        }
        return formatRelativeTime(session.updatedAt, t)
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The previous live-agent env ordering finding appears addressed in this head. One remaining UI wiring issue: Claude import writes a marker that is never rendered by the session list.

Testing

  • Not run (automation). Add a SessionList/import-marker test covering a Claude summary with metadata.flavor === 'claude' and a stored Claude imported timestamp.

HAPI Bot

Comment thread web/src/router.tsx
url: ''
})
// 中文注释:导入成功后先在浏览器侧记住这些 Claude session 的导入时间,供左侧会话列表显示特殊时间文案。
markClaudeSessionsImported(sessionIds)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] This writes the new Claude import marker, but the session list never reads it. SessionList still only imports/uses getCodexImportedAt in getSessionTimeLabel, so a successful Claude import stores hapi.claudeImportedSessions and then renders the normal relative updatedAt label instead of the intended “imported from Claude” label.

Suggested fix:

import { getClaudeImportedAt, subscribeClaudeImportedSessions } from '@/lib/claudeImportedSessions'

function getSessionTimeLabel(session: SessionSummary, t: TFunction): string | null {
    const agentSessionId = session.metadata?.agentSessionId
    const importedAt = session.metadata?.flavor === 'codex'
        ? getCodexImportedAt(agentSessionId)
        : session.metadata?.flavor === 'claude'
          ? getClaudeImportedAt(agentSessionId)
          : null

    if (importedAt !== null) {
        return session.metadata?.flavor === 'claude'
            ? formatClaudeImportedRelativeTime(importedAt, t)
            : formatCodexImportedRelativeTime(importedAt, t)
    }
    return formatRelativeTime(session.updatedAt, t)
}

@heavygee

Copy link
Copy Markdown
Collaborator

Heads-up from the fork: we have a cursor flavor of the multi-agent session import picker in flight (feat/agent-session-import-picker, targets Fixes #732).

Plan is to rebase onto this PR and land cursor behind the Claude importer — same SessionImportPicker shell, thin CursorSessionSyncDialog, parallel /api/cursor/* hub routes (ACP verify-probe; not transcript JSONL).

Will avoid duplicating the picker logic you extracted here.

@heavygee

Copy link
Copy Markdown
Collaborator

Follow-up from the fork: cursor flavor for the multi-agent import picker is in a draft PR stacked on this branch:

Plan: merge #942 first, then rebase cursor import onto main. Same SessionImportPicker shell; parallel /api/cursor/* hub routes (strict ACP verify-probe).

Separate track: MCP display_image stateful-transport fix is #944 (cli-only, no dependency on this PR).

@heavygee

Copy link
Copy Markdown
Collaborator

Draft cursor follow-up is #945 (blocked on this PR merging first).

iridite pushed a commit to iridite/hapi that referenced this pull request Jun 20, 2026
The direct-import path (importSingleSession) wrote rows via addMessage, stamping created_at/invoked_at with import time, so imported Claude/Codex sessions sorted as "active today" and lost their original message timeline (review finding on tiann#942).

- Carry each JSONL record's top-level timestamp through import data (new ImportedMessage wrapper + shared parseImportedTimestamp) for both Claude and Codex parsers.
- Insert via copyMessageToSession with createdAt/invokedAt = original timestamp (fallback to file mtime).
- Back-fill the newly-created imported session's updatedAt to real last activity (setImportedSessionActivity, new-session path only); existing/online sessions keep forward-only activity semantics untouched.
- New unit tests (Claude + Codex): timestamp preservation + mtime fallback.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
iridite pushed a commit to iridite/hapi that referenced this pull request Jun 20, 2026
getLiveAgentKind() runs 'claude agents --json', which honors CLAUDE_CONFIG_DIR / HAPI_CLAUDE_PATH. It ran before opts.claudeEnvVars were applied to process.env, so when a session supplies those overrides the liveness probe queried the default Claude config, missed the live agent, and the launch then resumed against the overridden config and hit the occupied-session path instead of forking (review finding on tiann#942).

Move the env application above the fork decision so probe and launch share the same Claude env. Adds a regression test asserting the override is visible to getLiveAgentKind.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Minor] Wire the Claude import marker into the session list — markClaudeSessionsImported(sessionIds) now writes a browser-side import timestamp after Claude sync, but SessionList still only reads the Codex marker and Codex time strings, so imported Claude sessions render the normal updated time instead of the intended “imported from Claude” state. Evidence web/src/router.tsx:534 and existing reader web/src/components/SessionList.tsx:581.
    Suggested fix:
    import { getClaudeImportedAt, subscribeClaudeImportedSessions } from '@/lib/claudeImportedSessions'
    
    function formatClaudeImportedRelativeTime(value: number, t: TFunction): string | null {
        // mirror formatCodexImportedRelativeTime with Claude locale keys
    }
    
    function getSessionTimeLabel(session: SessionSummary, t: TFunction): string | null {
        const agentSessionId = session.metadata?.agentSessionId
        const importedAt = session.metadata?.flavor === 'codex'
            ? getCodexImportedAt(agentSessionId)
            : session.metadata?.flavor === 'claude'
              ? getClaudeImportedAt(agentSessionId)
              : null
    
        if (importedAt !== null) {
            return session.metadata?.flavor === 'claude'
                ? formatClaudeImportedRelativeTime(importedAt, t)
                : formatCodexImportedRelativeTime(importedAt, t)
        }
        return formatRelativeTime(session.updatedAt, t)
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • One remaining UI wiring issue: the Claude import flow records an import timestamp that no rendered session-list code consumes. Residual risk: I did not run the full test suite in this review pass.

Testing

  • Not run (automation).

HAPI Bot

Comment thread web/src/router.tsx
await refetch()
} catch (syncError) {
addToast({
title: t('claudeSync.failed.title'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] markClaudeSessionsImported(sessionIds) persists a Claude import timestamp, but the only session-list reader still checks Codex imports (web/src/components/SessionList.tsx:581), so this new marker is never displayed.

Suggested fix:

const importedAt = session.metadata?.flavor === 'codex'
    ? getCodexImportedAt(agentSessionId)
    : session.metadata?.flavor === 'claude'
      ? getClaudeImportedAt(agentSessionId)
      : null

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Minor] Wire Claude import markers into the session time label — the new Claude import flow records browser-side import timestamps after a successful sync, but the session list still only reads Codex import markers. As a result, imported Claude sessions keep showing the normal updated time instead of the intended imported-from-Claude state. Evidence web/src/router.tsx:534; unchanged reader context web/src/components/SessionList.tsx:581.
    Suggested fix:
    import { getClaudeImportedAt, subscribeClaudeImportedSessions } from '@/lib/claudeImportedSessions'
    
    function formatClaudeImportedRelativeTime(value: number, t: TFunction): string | null {
        // mirror formatCodexImportedRelativeTime with Claude locale keys
    }
    
    const agentSessionId = session.metadata?.agentSessionId
    const importedAt = session.metadata?.flavor === 'codex'
        ? getCodexImportedAt(agentSessionId)
        : session.metadata?.flavor === 'claude'
          ? getClaudeImportedAt(agentSessionId)
          : null

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The previous Claude import timestamp wiring issue is still present at the latest head. Residual risk: I reviewed source context and did not run the full test suite in this automation pass.

Testing

  • Not run (automation).

HAPI Bot

Comment thread web/src/router.tsx
await refetch()
} catch (syncError) {
addToast({
title: t('claudeSync.failed.title'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] markClaudeSessionsImported(sessionIds) records the Claude import timestamp, but SessionList still only reads the Codex marker (web/src/components/SessionList.tsx:581). Imported Claude sessions therefore never render the new imported-from-Claude time label.

Suggested fix:

const agentSessionId = session.metadata?.agentSessionId
const importedAt = session.metadata?.flavor === 'codex'
    ? getCodexImportedAt(agentSessionId)
    : session.metadata?.flavor === 'claude'
      ? getClaudeImportedAt(agentSessionId)
      : null

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants