feat: import existing Claude Code sessions (+ fork-resume for live sessions)#942
feat: import existing Claude Code sessions (+ fork-resume for live sessions)#942dzshzx wants to merge 9 commits into
Conversation
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>
There was a problem hiding this comment.
Findings
- [Major] Preserve transcript timestamps during direct import —
importSingleSessionwrites every imported row throughaddMessage, socreated_at/invoked_atare stamped with the import time rather than the Claude row timestamp. The hub then sorts sessions byupdatedAtand message pages byinvokedAt ?? createdAt, so importing an old Claude transcript makes it look active today and collapses historical message timing. Claude JSONL rows already exposetimestamp(see existingRawJSONLinesBaseSchema), 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
timestampvalues and assert persistedcreatedAt/sessionupdatedAtmatch the transcript timeline, notDate.now().
HAPI Bot
|
|
||
| const comparablePrefixCount = sessionId ? target.comparablePrefixCount : 0 | ||
| const messagesToAppend = transcript.messages.slice(comparablePrefixCount) | ||
| const appendedMessages = messagesToAppend.map((message) => options.store.messages.addMessage(sessionId!, message)) |
There was a problem hiding this comment.
[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>
There was a problem hiding this comment.
Findings
- [Major] Query the live-agent roster with the same Claude environment used for launch — the new fork decision runs
getLiveAgentKind(startFrom)beforeopts.claudeEnvVarsare applied, while the actual SDK launch below uses those env overrides. If a runner/session setsHAPI_CLAUDE_PATHorCLAUDE_CONFIG_DIRthroughclaudeEnvVars, the roster probe can query the default Claude binary/home, miss the live agent, setforkSession=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
claudeRemotefork test whereclaudeEnvVarssuppliesCLAUDE_CONFIG_DIRorHAPI_CLAUDE_PATH, and assertgetLiveAgentKindobserves that override beforequery()is called.
HAPI Bot
| // daemon roster is unavailable, so this never blocks the resume path. | ||
| let forkSession = false; | ||
| if (startFrom) { | ||
| const liveKind = getLiveAgentKind(startFrom); |
There was a problem hiding this comment.
[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>
There was a problem hiding this comment.
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/readsgetCodexImportedAtand 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 withmetadata.flavor === 'claude'and a stored Claude imported timestamp.
HAPI Bot
| url: '' | ||
| }) | ||
| // 中文注释:导入成功后先在浏览器侧记住这些 Claude session 的导入时间,供左侧会话列表显示特殊时间文案。 | ||
| markClaudeSessionsImported(sessionIds) |
There was a problem hiding this comment.
[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)
}|
Heads-up from the fork: we have a cursor flavor of the multi-agent session import picker in flight ( Plan is to rebase onto this PR and land cursor behind the Claude importer — same Will avoid duplicating the picker logic you extracted here. |
|
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 Separate track: MCP |
|
Draft cursor follow-up is #945 (blocked on this PR merging first). |
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>
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>
There was a problem hiding this comment.
Findings
- [Minor] Wire the Claude import marker into the session list —
markClaudeSessionsImported(sessionIds)now writes a browser-side import timestamp after Claude sync, butSessionListstill 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. Evidenceweb/src/router.tsx:534and existing readerweb/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
| await refetch() | ||
| } catch (syncError) { | ||
| addToast({ | ||
| title: t('claudeSync.failed.title'), |
There was a problem hiding this comment.
[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)
: nullThere was a problem hiding this comment.
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 contextweb/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
| await refetch() | ||
| } catch (syncError) { | ||
| addToast({ | ||
| title: t('claudeSync.failed.title'), |
There was a problem hiding this comment.
[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)
: nullCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Nz6WoBbN7ydDFsLt4R96X
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, mapsuser/assistant(text/thinking/tool_use) /tool_resultrecords to imported messages, and persists through a shared engine.hub/src/web/routes/transcriptImport.ts(target selection / dedupe / persist / event emit, keyed onmetadata.<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.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.claude agents --json(getLiveAgentKind) and branch a copy with--fork-session(recordingmetadata.forkedFrom) instead of taking over the live session.MAX_LAUNCH_RETRIES).Errorto{}(now{name,message,stack}).--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 testgreen across cli/hub/web/shared;bun run buildclean.